Skip to content

Commit da2a935

Browse files
committed
refactor(database): seed preloaded translations before fetched
Models that override the loader or table getter already skip batching, so the hydration snapshot no longer needs to route through the loader. Keep only tests that fail when a batching guarantee breaks.
1 parent c2518fe commit da2a935

4 files changed

Lines changed: 222 additions & 504 deletions

File tree

‎src/Database/Builder.php‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,10 @@ public function hydrate(array $items)
2828
return parent::hydrate($items);
2929
}
3030

31-
// Create the prototype before preloading. Its construction callbacks may
32-
// run independent queries and must not inherit a hydration snapshot.
3331
$instance = $this->newModelInstance();
3432

33+
// Same as the parent method, with the batch held by this prototype
3534
return $instance->hydrateWithTranslatableBatch($items, function () use ($items, $instance) {
36-
// Match Laravel's hydration and lazy-loading behavior, keeping the
37-
// snapshot on this prototype instead of sharing it across models.
3835
return $instance->newCollection(array_map(function ($item) use ($items, $instance) {
3936
$model = $instance->newFromBuilder($item);
4037

‎src/Database/Model.php‎

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -166,19 +166,6 @@ public function newInstance($attributes = [], $exists = false)
166166
* @return \Illuminate\Database\Eloquent\Model|static
167167
*/
168168
public function newFromBuilder($attributes = [], $connection = null)
169-
{
170-
if (method_exists($this, 'newFromBuilderWithTranslatableBatch')) {
171-
return $this->newFromBuilderWithTranslatableBatch($attributes, $connection);
172-
}
173-
174-
return $this->newFromBuilderInstance($attributes, $connection);
175-
}
176-
177-
/**
178-
* newFromBuilderInstance runs the hydration lifecycle, optionally preparing
179-
* instance data before fetched callbacks observe its attributes.
180-
*/
181-
protected function newFromBuilderInstance($attributes, $connection, ?callable $prepare = null)
182169
{
183170
$instance = $this->newInstance([], true);
184171

@@ -188,8 +175,9 @@ protected function newFromBuilderInstance($attributes, $connection, ?callable $p
188175

189176
$instance->setRawAttributes((array) $attributes, true);
190177

191-
if ($prepare !== null) {
192-
$prepare($instance);
178+
// Hand over translations preloaded for this hydration batch
179+
if (method_exists($this, 'applyTranslatableBatch')) {
180+
$this->applyTranslatableBatch($instance);
193181
}
194182

195183
$instance->fireModelEvent('fetched', false);

‎src/Database/Traits/Translatable.php‎

Lines changed: 38 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use Db;
55
use Site;
66
use Exception;
7+
use ReflectionMethod;
78

89
/**
910
* Translatable trait provides per-row model translation using a single
@@ -47,17 +48,11 @@ trait Translatable
4748
protected $translatableBaseValues = [];
4849

4950
/**
50-
* @var array|null translatableBatch holds translations preloaded for the hydration
51-
* batch on this hydration prototype, keyed by model_id then attribute
51+
* @var array|null translatableBatch holds the locale and translations preloaded
52+
* while this model hydrates a batch of rows, keyed by model key then attribute
5253
*/
5354
protected $translatableBatch;
5455

55-
/**
56-
* @var array|null translatableHydrationData supplies raw translations only while
57-
* this instance runs its fetched lifecycle, without bypassing its loader
58-
*/
59-
protected $translatableHydrationData;
60-
6156
/**
6257
* initializeTranslatable trait for a model
6358
*/
@@ -693,35 +688,24 @@ public function getTranslatableOriginals($locale = null)
693688
//
694689

695690
/**
696-
* hydrateWithTranslatableBatch loads the active locale translations for all rows
697-
* in one query before hydration, so each fetched event can promote translated values
698-
* without its own lookup. Rows or locales outside the batch fall back to a lookup.
691+
* hydrateWithTranslatableBatch preloads the active locale translations for the rows
692+
* in chunked queries, so fetched events can promote translated values without a
693+
* query per row. Models with their own loader or table getter load per row.
699694
* @internal Called by the database builder.
700695
*/
701696
public function hydrateWithTranslatableBatch(array $items, callable $hydrate)
702697
{
703-
$keyName = $this->getKeyName();
704-
$ids = array_column($items, $keyName);
698+
$ids = array_column($items, $this->getKeyName());
705699

706-
if (!$ids || !$this->shouldTranslate()) {
700+
if (!$ids || !$this->shouldTranslate() || !$this->usesDefaultTranslatableStorage()) {
707701
return $hydrate();
708702
}
709703

710-
// Custom loaders or table getters may depend on hydrated attributes or
711-
// external storage. Only preload this trait's default storage behavior.
712-
foreach (['loadTranslatableData', 'getTranslateAttributeTable'] as $method) {
713-
$implementation = new \ReflectionMethod($this, $method);
714-
$default = new \ReflectionMethod(__TRAIT__, $method);
715-
if ($implementation->getFileName() !== __FILE__ || $implementation->getStartLine() !== $default->getStartLine()) {
716-
return $hydrate();
717-
}
718-
}
719-
720704
$locale = $this->getTranslatableContext();
721-
$connection = Db::connection();
722-
$rows = [];
705+
$rows = array_fill_keys($ids, []);
706+
723707
foreach (array_chunk($ids, 500) as $chunk) {
724-
$translations = $connection->table($this->getTranslateAttributeTable())
708+
$translations = Db::table($this->getTranslateAttributeTable())
725709
->where('model_type', $this->getMorphClass())
726710
->whereIn('model_id', $chunk)
727711
->where('locale', $locale)
@@ -732,69 +716,57 @@ public function hydrateWithTranslatableBatch(array $items, callable $hydrate)
732716
}
733717
}
734718

735-
$previous = $this->translatableBatch;
736719
$this->translatableBatch = [
737-
'connection' => $connection,
738-
'table' => $this->getTranslateAttributeTable(),
739-
'morph' => $this->getMorphClass(),
740720
'locale' => $locale,
741-
'ids' => array_fill_keys($ids, true),
721+
'morph' => $this->getMorphClass(),
742722
'rows' => $rows,
743723
];
744724

745725
try {
746726
return $hydrate();
747727
}
748728
finally {
749-
$this->translatableBatch = $previous;
729+
$this->translatableBatch = null;
750730
}
751731
}
752732

753733
/**
754-
* newFromBuilderWithTranslatableBatch keeps the snapshot local to one model
755-
* while lifecycle callbacks may independently hydrate other models.
756-
* @internal Called by the database model during hydration.
734+
* applyTranslatableBatch gives a model hydrated from this one the translations
735+
* preloaded for its row, as the default loader would have read them.
736+
* @internal Called by the database model before the fetched event.
757737
*/
758-
public function newFromBuilderWithTranslatableBatch($attributes, $connection)
738+
public function applyTranslatableBatch($model)
759739
{
760-
$batch = $this->translatableBatch;
761-
if ($batch === null) {
762-
return $this->newFromBuilderInstance($attributes, $connection);
763-
}
740+
$rows = $this->translatableBatch['rows'] ?? null;
741+
$key = $model->getKey();
764742

765-
$this->translatableBatch = null;
766-
$hydrated = null;
767-
try {
768-
return $this->newFromBuilderInstance($attributes, $connection, function ($instance) use ($batch, &$hydrated) {
769-
$hydrated = $instance;
770-
$instance->translatableHydrationData = $batch;
771-
});
743+
if ($rows === null || $key === null || !array_key_exists($key, $rows)) {
744+
return;
772745
}
773-
finally {
774-
if ($hydrated !== null) {
775-
$hydrated->translatableHydrationData = null;
776-
}
777-
$this->translatableBatch = $batch;
746+
747+
// A morph class that depends on the row is stored under another type
748+
if ($model->getMorphClass() !== $this->translatableBatch['morph']) {
749+
return;
778750
}
751+
752+
$locale = $this->translatableBatch['locale'];
753+
$model->translatableAttributes[$locale] = $rows[$key];
754+
$model->translatableOriginals[$locale] = $rows[$key];
779755
}
780756

781757
/**
782-
* getTranslatableBatchRows returns preloaded translations for this model, or null
783-
* when the model is not part of the batch in progress.
758+
* usesDefaultTranslatableStorage checks that the model keeps this trait's loader
759+
* and table getter, since overrides may read other storage or row attributes.
784760
*/
785-
protected function getTranslatableBatchRows($locale, array $batch)
761+
protected function usesDefaultTranslatableStorage(): bool
786762
{
787-
if (
788-
$batch['connection'] !== Db::connection() ||
789-
$batch['locale'] !== $locale ||
790-
$batch['morph'] !== $this->getMorphClass() ||
791-
$batch['table'] !== $this->getTranslateAttributeTable() ||
792-
!isset($batch['ids'][$this->getKey()])
793-
) {
794-
return null;
763+
foreach (['loadTranslatableData', 'getTranslateAttributeTable'] as $method) {
764+
if ((new ReflectionMethod($this, $method))->getFileName() !== __FILE__) {
765+
return false;
766+
}
795767
}
796768

797-
return $batch['rows'][$this->getKey()] ?? [];
769+
return true;
798770
}
799771

800772
/**
@@ -893,12 +865,7 @@ protected function loadTranslatableData($locale)
893865
$rows = [];
894866
}
895867
else {
896-
// Model overrides still run through this loader when rows are preloaded.
897-
$rows = $this->translatableHydrationData !== null
898-
? $this->getTranslatableBatchRows($locale, $this->translatableHydrationData)
899-
: null;
900-
901-
$rows ??= Db::table($this->getTranslateAttributeTable())
868+
$rows = Db::table($this->getTranslateAttributeTable())
902869
->where('model_type', $this->getMorphClass())
903870
->where('model_id', $this->getKey())
904871
->where('locale', $locale)

0 commit comments

Comments
 (0)