diff --git a/src/Concerns/HasSnapshot.php b/src/Concerns/HasSnapshot.php index 319e7dd..0d1aee1 100644 --- a/src/Concerns/HasSnapshot.php +++ b/src/Concerns/HasSnapshot.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Oobook\Snapshot\Models\Snapshot; +use Oobook\Snapshot\Relations\SnapshotSyncedRelationFactory; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -103,20 +104,41 @@ public static function bootHasSnapshot() // $model->fillable = $model->originalFillableForSnapshot; }); + static::addGlobalScope('snapshot_required_for_synced', function ($builder) { + if (empty(static::getSourceRelationshipsToSync())) { + return; + } + $eagerLoads = $builder->getEagerLoads(); + if (! array_key_exists('snapshot', $eagerLoads)) { + $builder->with('snapshot'); + } + }); + $sourceClass = static::getSnapshotSourceClass(); $sourceInstance = new $sourceClass(); $syncedSourceRelationships = static::getSourceRelationshipsToSync(); foreach ($syncedSourceRelationships as $relationship) { - if(!method_exists(static::class, $relationship)){ - static::resolveRelationUsing($relationship, function ($snapshotable) use ($sourceInstance, $relationship) { - $source = $snapshotable->snapshotSource; - - if($source){ - return $source->{$relationship}(); + if (! method_exists(static::class, $relationship)) { + static::resolveRelationUsing($relationship, function ($snapshotable) use ($sourceInstance, $relationship, $sourceClass) { + $sourceRelation = $sourceInstance->{$relationship}(); + + if (SnapshotSyncedRelationFactory::supports($sourceRelation)) { + return SnapshotSyncedRelationFactory::make( + $sourceRelation, + $snapshotable, + $sourceClass, + $relationship + ); } - return $sourceInstance->{$relationship}(); + throw new \InvalidArgumentException( + sprintf( + 'Snapshot relation [%s] on [%s] is not supported. Use BelongsTo, MorphTo, HasOne, HasMany, HasOneThrough, HasManyThrough, BelongsToMany, MorphOne, MorphMany, or MorphToMany.', + $relationship, + get_class($snapshotable) + ) + ); }); } } @@ -131,11 +153,14 @@ public static function bootHasSnapshot() */ public function initializeHasSnapshot() { + $this->makeHidden(array_merge($this->hidden, ['snapshotSource', 'snapshot'])); $this->mergeFillable($this->getFillableForSnapshot()); $this->withSnapshot = array_values(array_intersect($this->with, $this->getSourceRelationshipsToSnapshot())); - $this->with = array_merge(array_values(array_diff($this->with, $this->withSnapshot)), ['snapshot']); + $baseWith = array_merge(array_values(array_diff($this->with, $this->withSnapshot)), ['snapshot', 'snapshotSource']); + $syncedRelations = static::getSourceRelationshipsToSync(); + $this->with = array_values(array_unique(array_merge($baseWith, $syncedRelations))); } /** @@ -372,7 +397,6 @@ public function attributesToArray(): array $snapshot = $this->snapshot; - if($source){ $reservedAttributes = $this->getReservedAttributesAgainstSnapshot(); $snapshotableSourceAttributes = $this->getSnapshotableSourceAttributes(); @@ -392,6 +416,7 @@ public function attributesToArray(): array $snapshottedKeys = $this->getSourceAttributesToSnapshot(); $snapshottedAttributes = []; + if($snapshot){ $snapshottedAttributes = array_intersect_key($snapshot->data, array_flip($snapshottedKeys)); if($snapshottedWiths){ @@ -417,13 +442,12 @@ public function __get($key) $sourceClass = new ($this->getSnapshotSourceClass()); $foreignKey = $this->getSnapshotSourceForeignKey(); - if($this->exists && !in_array($key, $reservedAttributes) && !in_array($key, ['snapshot', 'source', 'snapshotSource']) ){ - if($this->snapshot()->exists() && $foreignKey ){ + if($this->snapshot && $foreignKey ){ $snapshot = $this->snapshot; @@ -431,13 +455,9 @@ public function __get($key) return $snapshot->source_id; } - $source = $this->relationLoaded('snapshotSource') - ? $this->getRelation('snapshotSource') - : $this->snapshotSource; - if($this->fieldIsSnapshotSynced($key)){ - if($source){ - return $source->{$key}; + if($this->snapshotSource){ + return $this->snapshotSource->{$key}; } } @@ -503,7 +523,6 @@ public function __get($key) */ public function __call($method, $parameters) { - if($this->exists && !$this->hasColumn($method) ){ $snapshotSourceClass = $this->getSnapshotSourceClass(); @@ -541,8 +560,6 @@ public function __call($method, $parameters) } } - - return parent::__call($method, $parameters); } } diff --git a/src/Models/Snapshot.php b/src/Models/Snapshot.php index 98ced6b..18d43d0 100644 --- a/src/Models/Snapshot.php +++ b/src/Models/Snapshot.php @@ -18,6 +18,10 @@ class Snapshot extends Model 'data' => 'array', ]; + protected $hidden = [ + 'data', + ]; + public function snapshotable(): \Illuminate\Database\Eloquent\Relations\MorphTo { return $this->morphTo(); diff --git a/src/Relations/Concerns/ResolvesSnapshotSource.php b/src/Relations/Concerns/ResolvesSnapshotSource.php new file mode 100644 index 0000000..a730971 --- /dev/null +++ b/src/Relations/Concerns/ResolvesSnapshotSource.php @@ -0,0 +1,119 @@ + $models Snapshot models (e.g. PressReleasePackage) + * @return array Source model IDs + */ + protected function getSourceIdsFromSnapshotModels(array $models): array + { + $sourceIds = []; + + foreach ($models as $model) { + $sourceId = $this->getSourceIdFromSnapshotModel($model); + if ($sourceId !== null) { + $sourceIds[] = $sourceId; + } + } + + return array_values(array_unique($sourceIds)); + } + + /** + * Get source ID from a single snapshot model. + * + * @return int|string|null + */ + protected function getSourceIdFromSnapshotModel(Model $model) + { + $snapshot = $model->relationLoaded('snapshot') + ? $model->getRelation('snapshot') + : $model->snapshot; + + if ($snapshot instanceof Snapshot) { + return $snapshot->source_id; + } + + $class = get_class($model); + if (method_exists($class, 'getSnapshotSourceForeignKey')) { + $foreignKey = $class::getSnapshotSourceForeignKey(); + + return $model->getAttribute($foreignKey); + } + + return null; + } + + /** + * Build a map of snapshot model key => source_id for matching. + * + * @param array $models + * @return array + */ + protected function getSnapshotKeyToSourceIdMap(array $models): array + { + $map = []; + + foreach ($models as $model) { + $sourceId = $this->getSourceIdFromSnapshotModel($model); + if ($sourceId !== null) { + $map[$model->getKey()] = $sourceId; + } + } + + return $map; + } + + /** + * Ensure snapshot relation is loaded on models that don't have it. + * + * @param array $models + */ + protected function ensureSnapshotLoaded(array $models): void + { + if (empty($models)) { + return; + } + + $needsLoad = []; + foreach ($models as $model) { + if (! $model->relationLoaded('snapshot')) { + $needsLoad[] = $model; + } + } + + if (empty($needsLoad)) { + return; + } + + $modelClass = get_class($models[0]); + $ids = array_map(fn ($m) => $m->getKey(), $needsLoad); + + $snapshots = Snapshot::query() + ->where('snapshotable_type', $modelClass) + ->whereIn('snapshotable_id', $ids) + ->get() + ->keyBy('snapshotable_id'); + + foreach ($needsLoad as $model) { + $snapshot = $snapshots->get($model->getKey()); + if ($snapshot) { + $model->setRelation('snapshot', $snapshot); + } + } + } +} diff --git a/src/Relations/SnapshotSyncedBelongsTo.php b/src/Relations/SnapshotSyncedBelongsTo.php new file mode 100644 index 0000000..245f4c6 --- /dev/null +++ b/src/Relations/SnapshotSyncedBelongsTo.php @@ -0,0 +1,171 @@ +packageType). + */ +class SnapshotSyncedBelongsTo extends BelongsTo +{ + use ResolvesSnapshotSource; + + protected string $sourceClass; + + protected string $sourceTable; + + public function __construct( + Builder $query, + Model $parent, + string $foreignKey, + string $ownerKey, + string $relationName, + string $sourceClass + ) { + $this->sourceClass = $sourceClass; + $this->sourceTable = (new $sourceClass)->getTable(); + + parent::__construct($query, $parent, $foreignKey, $ownerKey, $relationName); + } + + /** + * Set the base constraints on the relation query. + */ + public function addConstraints(): void + { + if (static::$constraints) { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId !== null) { + $source = $this->sourceClass::find($sourceId); + $fkValue = $source?->getAttribute($this->foreignKey); + if ($fkValue !== null) { + $this->query->where( + $this->related->qualifyColumn($this->ownerKey), + '=', + $fkValue + ); + } else { + $this->query->whereRaw('1 = 0'); + } + } else { + $this->query->whereRaw('1 = 0'); + } + } + } + + /** + * Set the constraints for an eager load of the relation. + */ + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $sourceInstance = new $this->sourceClass; + $sourceFkQualified = $this->sourceTable . '.' . $this->foreignKey; + + $relatedIds = $sourceInstance::query() + ->whereIn($sourceInstance->getKeyName(), $sourceIds) + ->whereNotNull($this->foreignKey) + ->pluck($this->foreignKey) + ->unique() + ->values() + ->all(); + + if (empty($relatedIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $whereIn = $this->whereInMethod($this->related, $this->ownerKey); + $this->whereInEager( + $whereIn, + $this->related->qualifyColumn($this->ownerKey), + $relatedIds + ); + } + + /** + * Match the eagerly loaded results to their parents. + */ + public function match(array $models, Collection $results, $relation): array + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + return $models; + } + + $sourceInstance = new $this->sourceClass; + $sourceToFk = $sourceInstance::query() + ->whereIn($sourceInstance->getKeyName(), $sourceIds) + ->pluck($this->foreignKey, $sourceInstance->getKeyName()) + ->all(); + + $dictionary = []; + foreach ($results as $result) { + $ownerKey = $this->getDictionaryKey($result->getAttribute($this->ownerKey)); + $dictionary[$ownerKey] = $result; + } + + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId === null) { + continue; + } + + $fkValue = $sourceToFk[$sourceId] ?? null; + if ($fkValue !== null) { + $key = $this->getDictionaryKey($fkValue); + if (isset($dictionary[$key])) { + $model->setRelation($relation, $dictionary[$key]); + } + } + } + + return $models; + } + + /** + * Get the results of the relationship. + */ + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId === null) { + return $this->getDefaultFor($this->parent); + } + + $source = $this->sourceClass::find($sourceId); + if ($source === null) { + return $this->getDefaultFor($this->parent); + } + + $fkValue = $source->getAttribute($this->foreignKey); + if ($fkValue === null) { + return $this->getDefaultFor($this->parent); + } + + return $this->query->where($this->related->qualifyColumn($this->ownerKey), $fkValue)->first() + ?: $this->getDefaultFor($this->parent); + } +} diff --git a/src/Relations/SnapshotSyncedBelongsToMany.php b/src/Relations/SnapshotSyncedBelongsToMany.php new file mode 100644 index 0000000..446b09b --- /dev/null +++ b/src/Relations/SnapshotSyncedBelongsToMany.php @@ -0,0 +1,99 @@ +sourceClass = $sourceClass; + + parent::__construct($query, $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName); + } + + protected function addWhereConstraints(): self + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId !== null) { + $this->query->where($this->getQualifiedForeignPivotKeyName(), '=', $sourceId); + } else { + $this->query->whereRaw('1 = 0'); + } + + return $this; + } + + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $this->performJoin(); + $whereIn = $this->whereInMethod($this->parent, $this->parentKey); + $this->whereInEager($whereIn, $this->getQualifiedForeignPivotKeyName(), $sourceIds); + } + + public function match(array $models, Collection $results, $relation): array + { + $this->ensureSnapshotLoaded($models); + + $dictionary = $this->buildDictionary($results); + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId !== null) { + $key = $this->getDictionaryKey($sourceId); + if (isset($dictionary[$key])) { + $model->setRelation($relation, $this->related->newCollection($dictionary[$key])); + } + } + } + + return $models; + } + + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId === null) { + return $this->related->newCollection(); + } + + $this->performJoin(); + $this->query->where($this->getQualifiedForeignPivotKeyName(), '=', $sourceId); + + return $this->query->get(); + } +} diff --git a/src/Relations/SnapshotSyncedHasMany.php b/src/Relations/SnapshotSyncedHasMany.php new file mode 100644 index 0000000..93d40ed --- /dev/null +++ b/src/Relations/SnapshotSyncedHasMany.php @@ -0,0 +1,108 @@ +sourceClass = $sourceClass; + $this->sourceTable = (new $sourceClass)->getTable(); + + parent::__construct($query, $parent, $foreignKey, $localKey); + } + + public function addConstraints(): void + { + if (static::$constraints) { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId !== null) { + $this->query->where($this->foreignKey, '=', $sourceId)->whereNotNull($this->foreignKey); + } else { + $this->query->whereRaw('1 = 0'); + } + } + } + + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $whereIn = $this->whereInMethod($this->parent, $this->localKey); + $this->whereInEager($whereIn, $this->foreignKey, $sourceIds, $this->getRelationQuery()); + } + + public function match(array $models, Collection $results, $relation): array + { + $this->ensureSnapshotLoaded($models); + + $dictionary = $this->buildDictionary($results); + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId !== null) { + $key = $this->getDictionaryKey($sourceId); + if (isset($dictionary[$key])) { + $model->setRelation($relation, $this->related->newCollection($dictionary[$key])); + } + } + } + + return $models; + } + + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId === null) { + return $this->related->newCollection(); + } + + return $this->query->where($this->foreignKey, $sourceId)->get(); + } + + public function getParentKey(): mixed + { + return $this->getSourceIdFromSnapshotModel($this->parent); + } + + protected function buildDictionary(Collection $results): array + { + $foreign = $this->getForeignKeyName(); + + return $results->mapToDictionary(function ($result) use ($foreign) { + return [$this->getDictionaryKey($result->{$foreign}) => $result]; + })->all(); + } +} diff --git a/src/Relations/SnapshotSyncedHasManyThrough.php b/src/Relations/SnapshotSyncedHasManyThrough.php new file mode 100644 index 0000000..9a42c46 --- /dev/null +++ b/src/Relations/SnapshotSyncedHasManyThrough.php @@ -0,0 +1,105 @@ +sourceClass = $sourceClass; + $this->snapshotParent = $snapshotParent; + + parent::__construct($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + } + + public function addConstraints(): void + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->snapshotParent); + if ($sourceId === null) { + $this->query->whereRaw('1 = 0'); + + return; + } + + $this->performJoin(); + if (static::$constraints) { + $this->query->where($this->getQualifiedFirstKeyName(), '=', $sourceId); + } + } + + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $this->performJoin(); + $whereIn = $this->whereInMethod($this->farParent, $this->localKey); + $this->whereInEager($whereIn, $this->getQualifiedFirstKeyName(), $sourceIds); + } + + public function match(array $models, Collection $results, $relation): array + { + $this->ensureSnapshotLoaded($models); + + $dictionary = $this->buildDictionary($results); + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId !== null) { + $key = $this->getDictionaryKey($sourceId); + if (isset($dictionary[$key])) { + $model->setRelation($relation, $this->related->newCollection($dictionary[$key])); + } + } + } + + return $models; + } + + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->snapshotParent); + if ($sourceId === null) { + return $this->related->newCollection(); + } + + $this->performJoin(); + $this->query->where($this->getQualifiedFirstKeyName(), '=', $sourceId); + + return $this->query->get(); + } +} diff --git a/src/Relations/SnapshotSyncedHasOne.php b/src/Relations/SnapshotSyncedHasOne.php new file mode 100644 index 0000000..10d6321 --- /dev/null +++ b/src/Relations/SnapshotSyncedHasOne.php @@ -0,0 +1,109 @@ +sourceClass = $sourceClass; + $this->sourceTable = (new $sourceClass)->getTable(); + + parent::__construct($query, $parent, $foreignKey, $localKey); + } + + public function addConstraints(): void + { + if (static::$constraints) { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId !== null) { + $this->query->where($this->foreignKey, '=', $sourceId)->whereNotNull($this->foreignKey); + } else { + $this->query->whereRaw('1 = 0'); + } + } + } + + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $whereIn = $this->whereInMethod($this->parent, $this->localKey); + $this->whereInEager($whereIn, $this->foreignKey, $sourceIds, $this->getRelationQuery()); + } + + public function match(array $models, Collection $results, $relation): array + { + $this->ensureSnapshotLoaded($models); + + $dictionary = $this->buildDictionary($results); + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId !== null) { + $key = $this->getDictionaryKey($sourceId); + if (isset($dictionary[$key])) { + $model->setRelation($relation, reset($dictionary[$key])); + } + } + } + + return $models; + } + + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId === null) { + return $this->getDefaultFor($this->parent); + } + + return $this->query->where($this->foreignKey, $sourceId)->first() + ?: $this->getDefaultFor($this->parent); + } + + public function getParentKey(): mixed + { + return $this->getSourceIdFromSnapshotModel($this->parent); + } + + protected function buildDictionary(Collection $results): array + { + $foreign = $this->getForeignKeyName(); + + return $results->mapToDictionary(function ($result) use ($foreign) { + return [$this->getDictionaryKey($result->{$foreign}) => $result]; + })->all(); + } +} diff --git a/src/Relations/SnapshotSyncedHasOneThrough.php b/src/Relations/SnapshotSyncedHasOneThrough.php new file mode 100644 index 0000000..6168694 --- /dev/null +++ b/src/Relations/SnapshotSyncedHasOneThrough.php @@ -0,0 +1,115 @@ +sourceClass = $sourceClass; + $this->snapshotParent = $snapshotParent; + + parent::__construct($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + } + + public function addConstraints(): void + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->snapshotParent); + if ($sourceId === null) { + $this->query->whereRaw('1 = 0'); + + return; + } + + $this->performJoin(); + if (static::$constraints) { + $this->query->where($this->getQualifiedFirstKeyName(), '=', $sourceId); + } + } + + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $this->performJoin(); + $whereIn = $this->whereInMethod($this->farParent, $this->localKey); + $this->whereInEager($whereIn, $this->getQualifiedFirstKeyName(), $sourceIds); + } + + public function match(array $models, Collection $results, $relation): array + { + $this->ensureSnapshotLoaded($models); + + $dictionary = $this->buildDictionary($results); + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId !== null) { + $key = $this->getDictionaryKey($sourceId); + if (isset($dictionary[$key])) { + $model->setRelation($relation, reset($dictionary[$key])); + } + } + } + + return $models; + } + + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->snapshotParent); + if ($sourceId === null) { + return $this->getDefaultFor($this->snapshotParent); + } + + $this->performJoin(); + $this->query->where($this->getQualifiedFirstKeyName(), '=', $sourceId); + + return $this->query->first() ?: $this->getDefaultFor($this->snapshotParent); + } + + protected function buildDictionary(Collection $results): array + { + $dictionary = []; + foreach ($results as $result) { + $dictionary[$result->laravel_through_key][] = $result; + } + + return $dictionary; + } +} diff --git a/src/Relations/SnapshotSyncedMorphMany.php b/src/Relations/SnapshotSyncedMorphMany.php new file mode 100644 index 0000000..62c11f0 --- /dev/null +++ b/src/Relations/SnapshotSyncedMorphMany.php @@ -0,0 +1,111 @@ +sourceClass = $sourceClass; + + parent::__construct($query, $parent, $type, $id, $localKey); + } + + public function addConstraints(): void + { + if (static::$constraints) { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId !== null) { + $this->query->where($this->morphType, $this->morphClass) + ->where($this->foreignKey, '=', $sourceId) + ->whereNotNull($this->foreignKey); + } else { + $this->query->whereRaw('1 = 0'); + } + } + } + + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $this->query->where($this->morphType, $this->morphClass); + $whereIn = $this->whereInMethod($this->parent, $this->localKey); + $this->whereInEager($whereIn, $this->foreignKey, $sourceIds, $this->getRelationQuery()); + } + + public function match(array $models, Collection $results, $relation): array + { + $this->ensureSnapshotLoaded($models); + + $dictionary = $this->buildDictionary($results); + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId !== null) { + $key = $this->getDictionaryKey($sourceId); + if (isset($dictionary[$key])) { + $model->setRelation($relation, $this->related->newCollection($dictionary[$key])); + } + } + } + + return $models; + } + + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId === null) { + return $this->related->newCollection(); + } + + return $this->query->where($this->morphType, $this->morphClass) + ->where($this->foreignKey, $sourceId) + ->get(); + } + + public function getParentKey(): mixed + { + return $this->getSourceIdFromSnapshotModel($this->parent); + } + + protected function buildDictionary(Collection $results): array + { + $foreign = $this->getForeignKeyName(); + + return $results->mapToDictionary(function ($result) use ($foreign) { + return [$this->getDictionaryKey($result->{$foreign}) => $result]; + })->all(); + } +} diff --git a/src/Relations/SnapshotSyncedMorphOne.php b/src/Relations/SnapshotSyncedMorphOne.php new file mode 100644 index 0000000..8b4ed0a --- /dev/null +++ b/src/Relations/SnapshotSyncedMorphOne.php @@ -0,0 +1,111 @@ +sourceClass = $sourceClass; + + parent::__construct($query, $parent, $type, $id, $localKey); + } + + public function addConstraints(): void + { + if (static::$constraints) { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId !== null) { + $this->query->where($this->morphType, $this->morphClass) + ->where($this->foreignKey, '=', $sourceId) + ->whereNotNull($this->foreignKey); + } else { + $this->query->whereRaw('1 = 0'); + } + } + } + + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $this->query->where($this->morphType, $this->morphClass); + $whereIn = $this->whereInMethod($this->parent, $this->localKey); + $this->whereInEager($whereIn, $this->foreignKey, $sourceIds, $this->getRelationQuery()); + } + + public function match(array $models, Collection $results, $relation): array + { + $this->ensureSnapshotLoaded($models); + + $dictionary = $this->buildDictionary($results); + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId !== null) { + $key = $this->getDictionaryKey($sourceId); + if (isset($dictionary[$key])) { + $model->setRelation($relation, reset($dictionary[$key])); + } + } + } + + return $models; + } + + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId === null) { + return $this->getDefaultFor($this->parent); + } + + return $this->query->where($this->morphType, $this->morphClass) + ->where($this->foreignKey, $sourceId) + ->first() ?: $this->getDefaultFor($this->parent); + } + + public function getParentKey(): mixed + { + return $this->getSourceIdFromSnapshotModel($this->parent); + } + + protected function buildDictionary(Collection $results): array + { + $foreign = $this->getForeignKeyName(); + + return $results->mapToDictionary(function ($result) use ($foreign) { + return [$this->getDictionaryKey($result->{$foreign}) => $result]; + })->all(); + } +} diff --git a/src/Relations/SnapshotSyncedMorphTo.php b/src/Relations/SnapshotSyncedMorphTo.php new file mode 100644 index 0000000..cf11ecf --- /dev/null +++ b/src/Relations/SnapshotSyncedMorphTo.php @@ -0,0 +1,140 @@ +packageable). + * Batch loads by grouping source records by morph type. + * + * Filters eager loads to only include relations that exist on each morph type, + * since morph targets (e.g. PackageCountry, PackageRegion) may not share the same relations. + */ +class SnapshotSyncedMorphTo extends MorphTo +{ + use ResolvesSnapshotSource; + + protected string $sourceClass; + + protected string $sourceTable; + + public function __construct( + Builder $query, + Model $parent, + string $foreignKey, + string $ownerKey, + string $type, + string $relation, + string $sourceClass + ) { + $this->sourceClass = $sourceClass; + $this->sourceTable = (new $sourceClass)->getTable(); + + parent::__construct($query, $parent, $foreignKey, $ownerKey, $type, $relation); + } + + /** + * Get all of the relation results for a type. + * Does NOT merge parent eager loads - morph targets have different schemas. + * Only morphableEagerLoads (type-specific) are used. Keeps first-level relation only. + */ + protected function getResultsByType($type) + { + $instance = $this->createModelByType($type); + + $ownerKey = $this->ownerKey ?? $instance->getKeyName(); + + $eagerLoads = (array) ($this->morphableEagerLoads[get_class($instance)] ?? []); + + $query = $this->replayMacros($instance->newQuery()) + ->mergeConstraintsFrom($this->getQuery()) + ->with($eagerLoads) + ->withCount( + (array) ($this->morphableEagerLoadCounts[get_class($instance)] ?? []) + ); + + if ($callback = ($this->morphableConstraints[get_class($instance)] ?? null)) { + $callback($query); + } + + $whereIn = $this->whereInMethod($instance, $ownerKey); + + return $query->{$whereIn}( + $instance->getTable().'.'.$ownerKey, + $this->gatherKeysByType($type, $instance->getKeyType()) + )->get(); + } + + /** + * Set the constraints for an eager load of the relation. + */ + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + $this->models = Collection::make($models); + $this->dictionary = []; + + return; + } + + $sourceRecords = $this->sourceClass::query() + ->whereIn((new $this->sourceClass)->getKeyName(), $sourceIds) + ->whereNotNull($this->foreignKey) + ->whereNotNull($this->morphType) + ->get([(new $this->sourceClass)->getKeyName(), $this->foreignKey, $this->morphType]); + + $this->models = Collection::make($models); + $this->dictionary = []; + + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId === null) { + continue; + } + + $sourceRecord = $sourceRecords->firstWhere((new $this->sourceClass)->getKeyName(), $sourceId); + if ($sourceRecord) { + $morphTypeKey = $this->getDictionaryKey($sourceRecord->{$this->morphType}); + $foreignKeyKey = $this->getDictionaryKey($sourceRecord->{$this->foreignKey}); + $this->dictionary[$morphTypeKey][$foreignKeyKey][] = $model; + } + } + } + + /** + * Get the results of the relationship (lazy load). + */ + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId === null) { + return null; + } + + $source = $this->sourceClass::find($sourceId); + if ($source === null || $source->{$this->morphType} === null || $source->{$this->foreignKey} === null) { + return null; + } + + $type = $source->{$this->morphType}; + $id = $source->{$this->foreignKey}; + + $instance = $this->createModelByType($type); + + return $instance->newQuery()->find($id); + } +} diff --git a/src/Relations/SnapshotSyncedMorphToMany.php b/src/Relations/SnapshotSyncedMorphToMany.php new file mode 100644 index 0000000..40b46ac --- /dev/null +++ b/src/Relations/SnapshotSyncedMorphToMany.php @@ -0,0 +1,115 @@ +sourceClass = $sourceClass; + + parent::__construct( + $query, + $parent, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName, + $inverse + ); + } + + protected function addWhereConstraints(): self + { + $this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass); + + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId !== null) { + $this->query->where($this->qualifyPivotColumn($this->foreignPivotKey), '=', $sourceId); + } else { + $this->query->whereRaw('1 = 0'); + } + + return $this; + } + + public function addEagerConstraints(array $models): void + { + $this->ensureSnapshotLoaded($models); + + $sourceIds = $this->getSourceIdsFromSnapshotModels($models); + if (empty($sourceIds)) { + $this->eagerKeysWereEmpty = true; + + return; + } + + $this->performJoin(); + $this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass); + $whereIn = $this->whereInMethod($this->parent, $this->parentKey); + $this->whereInEager($whereIn, $this->qualifyPivotColumn($this->foreignPivotKey), $sourceIds); + } + + public function match(array $models, Collection $results, $relation): array + { + $this->ensureSnapshotLoaded($models); + + $dictionary = $this->buildDictionary($results); + $snapshotKeyToSourceId = $this->getSnapshotKeyToSourceIdMap($models); + + foreach ($models as $model) { + $sourceId = $snapshotKeyToSourceId[$model->getKey()] ?? null; + if ($sourceId !== null) { + $key = $this->getDictionaryKey($sourceId); + if (isset($dictionary[$key])) { + $model->setRelation($relation, $this->related->newCollection($dictionary[$key])); + } + } + } + + return $models; + } + + public function getResults(): mixed + { + $sourceId = $this->getSourceIdFromSnapshotModel($this->parent); + if ($sourceId === null) { + return $this->related->newCollection(); + } + + $this->performJoin(); + $this->addWhereConstraints(); + + return $this->query->get(); + } +} diff --git a/src/Relations/SnapshotSyncedRelationFactory.php b/src/Relations/SnapshotSyncedRelationFactory.php new file mode 100644 index 0000000..ad53e1f --- /dev/null +++ b/src/Relations/SnapshotSyncedRelationFactory.php @@ -0,0 +1,154 @@ +getRelated(); + $query = $related->newQuery(); + + return match (get_class($sourceRelation)) { + BelongsTo::class => new SnapshotSyncedBelongsTo( + $query, + $snapshotParent, + $sourceRelation->getForeignKeyName(), + $sourceRelation->getOwnerKeyName(), + $relationName, + $sourceClass + ), + MorphTo::class => new SnapshotSyncedMorphTo( + $query, + $snapshotParent, + $sourceRelation->getForeignKeyName(), + $sourceRelation->getOwnerKeyName() ?? $related->getKeyName(), + $sourceRelation->getMorphType(), + $relationName, + $sourceClass + ), + HasOne::class => new SnapshotSyncedHasOne( + $query, + $snapshotParent, + $sourceRelation->getForeignKeyName(), + $sourceRelation->getLocalKeyName(), + $sourceClass + ), + HasMany::class => new SnapshotSyncedHasMany( + $query, + $snapshotParent, + $sourceRelation->getForeignKeyName(), + $sourceRelation->getLocalKeyName(), + $sourceClass + ), + HasOneThrough::class => new SnapshotSyncedHasOneThrough( + $query, + $snapshotParent, + self::getRelationProperty($sourceRelation, 'farParent'), + self::getRelationProperty($sourceRelation, 'throughParent'), + $sourceRelation->getFirstKeyName(), + self::getRelationProperty($sourceRelation, 'secondKey'), + $sourceRelation->getLocalKeyName(), + $sourceRelation->getSecondLocalKeyName(), + $sourceClass + ), + HasManyThrough::class => new SnapshotSyncedHasManyThrough( + $query, + $snapshotParent, + self::getRelationProperty($sourceRelation, 'farParent'), + self::getRelationProperty($sourceRelation, 'throughParent'), + $sourceRelation->getFirstKeyName(), + self::getRelationProperty($sourceRelation, 'secondKey'), + $sourceRelation->getLocalKeyName(), + $sourceRelation->getSecondLocalKeyName(), + $sourceClass + ), + BelongsToMany::class => new SnapshotSyncedBelongsToMany( + $query, + $snapshotParent, + $sourceRelation->getTable(), + $sourceRelation->getForeignPivotKeyName(), + $sourceRelation->getRelatedPivotKeyName(), + $sourceRelation->getParentKeyName(), + $sourceRelation->getRelatedKeyName(), + $relationName, + $sourceClass + ), + MorphOne::class => new SnapshotSyncedMorphOne( + $query, + $snapshotParent, + $sourceRelation->getMorphType(), + $sourceRelation->getForeignKeyName(), + $sourceRelation->getLocalKeyName(), + $sourceClass + ), + MorphMany::class => new SnapshotSyncedMorphMany( + $query, + $snapshotParent, + $sourceRelation->getMorphType(), + $sourceRelation->getForeignKeyName(), + $sourceRelation->getLocalKeyName(), + $sourceClass + ), + MorphToMany::class => new SnapshotSyncedMorphToMany( + $query, + $snapshotParent, + preg_replace('/_type$/', '', $sourceRelation->getMorphType()), + $sourceRelation->getTable(), + $sourceRelation->getForeignPivotKeyName(), + $sourceRelation->getRelatedPivotKeyName(), + $sourceRelation->getParentKeyName(), + $sourceRelation->getRelatedKeyName(), + $relationName, + $sourceRelation->getInverse(), + $sourceClass + ), + default => throw new \InvalidArgumentException( + 'Unsupported relation type for snapshot sync: ' . get_class($sourceRelation) + ), + }; + } + + /** + * Check if the source relation type is supported. + */ + public static function supports(Relation $sourceRelation): bool + { + return isset(SnapshotSyncedRelations::MAP[get_class($sourceRelation)]); + } + + /** + * Get a protected/private property from a relation via reflection. + */ + private static function getRelationProperty(Relation $relation, string $property): mixed + { + $ref = new \ReflectionClass($relation); + $prop = $ref->getProperty($property); + $prop->setAccessible(true); + + return $prop->getValue($relation); + } +} diff --git a/src/Relations/SnapshotSyncedRelations.php b/src/Relations/SnapshotSyncedRelations.php new file mode 100644 index 0000000..1abcb99 --- /dev/null +++ b/src/Relations/SnapshotSyncedRelations.php @@ -0,0 +1,24 @@ + SnapshotSyncedBelongsTo::class, + \Illuminate\Database\Eloquent\Relations\HasOne::class => SnapshotSyncedHasOne::class, + \Illuminate\Database\Eloquent\Relations\HasMany::class => SnapshotSyncedHasMany::class, + \Illuminate\Database\Eloquent\Relations\HasOneThrough::class => SnapshotSyncedHasOneThrough::class, + \Illuminate\Database\Eloquent\Relations\HasManyThrough::class => SnapshotSyncedHasManyThrough::class, + \Illuminate\Database\Eloquent\Relations\BelongsToMany::class => SnapshotSyncedBelongsToMany::class, + \Illuminate\Database\Eloquent\Relations\MorphTo::class => SnapshotSyncedMorphTo::class, + \Illuminate\Database\Eloquent\Relations\MorphOne::class => SnapshotSyncedMorphOne::class, + \Illuminate\Database\Eloquent\Relations\MorphMany::class => SnapshotSyncedMorphMany::class, + \Illuminate\Database\Eloquent\Relations\MorphToMany::class => SnapshotSyncedMorphToMany::class, + ]; +}