diff --git a/src/Concerns/HasSnapshot.php b/src/Concerns/HasSnapshot.php index a3d2c82..0eaa0a6 100644 --- a/src/Concerns/HasSnapshot.php +++ b/src/Concerns/HasSnapshot.php @@ -21,7 +21,8 @@ trait HasSnapshot use ManageEloquent, SnapshotFields, ConfigureSnapshot, - Relationships; + Relationships, + LazyRelations; /** * Cached snapshot data diff --git a/src/Concerns/LazyRelations.php b/src/Concerns/LazyRelations.php new file mode 100644 index 0000000..e226b81 --- /dev/null +++ b/src/Concerns/LazyRelations.php @@ -0,0 +1,401 @@ +parseRelationsForLoad($relations); + + $selfRelationships = $this->definedRelations(); + $snapshotableRelationships = $this->getSnapshotableSourceRelationships(); + + // Separate snapshot relations from regular relations + $snapshotRelations = []; + $regularRelations = []; + + foreach ($parsedRelations as $name => $constraints) { + $baseRelation = $this->getBaseRelationName($name); + + if (in_array($baseRelation, $selfRelationships) || $baseRelation === 'snapshot') { + $regularRelations[$name] = $constraints; + } else if (in_array($baseRelation, $snapshotableRelationships)) { + $snapshotRelations[$name] = $constraints; + } + } + + // Always load snapshot + if (!isset($regularRelations['snapshot'])) { + $regularRelations['snapshot'] = function() {}; + } + + // Load regular relations using parent method + if (!empty($regularRelations)) { + parent::load($regularRelations); + } + + // Handle snapshot relations + if (!empty($snapshotRelations) && $this->snapshot) { + $this->loadSnapshotRelations($snapshotRelations); + } + + return $this; + } + + /** + * Parse relations for load method to handle nested and constrained relations + * + * @param array $relations + * @return array + */ + protected function parseRelationsForLoad(array $relations) + { + $parsed = []; + + foreach ($relations as $name => $constraints) { + // If the key is numeric, the relation was passed without constraints + if (is_numeric($name)) { + if ($constraints instanceof Closure) { + // This is a constrained relation in the format [$relation => function() {}] + foreach ($this->extractConstrainedRelations($constraints) as $constrainedName => $constrainedConstraints) { + $parsed[$constrainedName] = $constrainedConstraints; + } + } else { + // This is a simple relation name + $parsed[$constraints] = function() {}; + } + } else { + // This is a constrained relation in the format ['relation' => function() {}] + $parsed[$name] = $constraints; + } + } + + return $parsed; + } + + /** + * Extract constrained relations from a closure + * + * @param Closure $closure + * @return array + */ + protected function extractConstrainedRelations(Closure $closure) + { + $relations = []; + + // Create a mock query builder to capture the relation name + $mock = new class { + public $relationName; + + public function __call($method, $args) + { + $this->relationName = $method; + return $this; + } + + // Add this to handle method chaining + public function where() { return $this; } + public function whereIn() { return $this; } + public function whereHas() { return $this; } + public function orWhere() { return $this; } + public function orderBy() { return $this; } + public function with() { return $this; } + }; + + $result = $closure($mock); + + if ($mock->relationName) { + $relations[$mock->relationName] = $closure; + } + + return $relations; + } + + /** + * Get the base relation name without nested parts + * + * @param string $relation + * @return string + */ + protected function getBaseRelationName(string $relation) + { + return explode('.', $relation)[0]; + } + + /** + * Load snapshot relations from snapshot data + * + * @param array $relations + * @return void + */ + protected function loadSnapshotRelations(array $relations) + { + $snapshot = $this->snapshot; + + if (!$snapshot || empty($snapshot->data)) { + return; + } + + $sourceRelationshipsToSnapshot = $this->getSourceRelationshipsToSnapshot(); + $sourceClass = $this->getSnapshotSourceClass(); + $sourceInstance = new $sourceClass(); + + $snapshotData = $snapshot->data; + + $source = $this->source; + + $sourceBaseRelations = []; + foreach ($relations as $relation => $constraints) { + // Get base relation name without constraints + $baseRelation = $this->getBaseRelationName($relation); + + // if($baseRelation === 'userType'){ + // dd($relation, $constraints, $this->getRelations()); + // } + + // Skip if relation is already loaded + // if ($this->relationLoaded($baseRelation)) { + // continue; + // } + + // Get relation data from snapshot + $relationData = $snapshotData[$baseRelation] ?? $snapshot->data[Str::snake($baseRelation)] ?? null; + + if (in_array($baseRelation, $sourceRelationshipsToSnapshot) && $relationData) { + $relationInstance = $sourceInstance->{$baseRelation}(); + $relatedModelClass = get_class($relationInstance->getRelated()); + $relatedModel = new $relatedModelClass(); + + $relatedModelRelations = method_exists($relatedModel, 'definedRelations') + ? $relatedModel->definedRelations() + : []; + + $arrayableRelations = array_filter($relatedModelRelations, function($relation) use ($relatedModel) { + return $this->isArrayableRelationship($relatedModel, $relation); + }); + + // Handle different relation types + if (is_array($relationData) && !Arr::isAssoc($relationData)) { + // Many relationship + $relatedModels = collect($relationData)->map(function ($item) use ($relatedModelClass, $arrayableRelations) { + $relatedModel = new $relatedModelClass(); + + foreach($arrayableRelations as $arrayableRelation) { + if(isset($item[$arrayableRelation])) { + $item[$arrayableRelation] = collect($item[$arrayableRelation])->map(function($item) use ($relatedModel, $arrayableRelation) { + $modelInstance = $relatedModel->{$arrayableRelation}()->getRelated(); + $modelInstance->fill($item); + return $modelInstance; + }); + } + } + + return $relatedModel->newFromBuilder($item); + }); + + // Apply constraints if provided + if ($constraints instanceof Closure) { + $relatedModels = $relatedModels->filter(function($model) use ($constraints) { + $result = true; + $constraints(new class($model, $result) { + protected $model; + protected $result; + + public function __construct($model, &$result) { + $this->model = $model; + $this->result = $result; + } + + public function where($column, $operator = null, $value = null) { + // Simple implementation for common where clause + if ($value === null) { + $value = $operator; + $operator = '='; + } + + $actualValue = $this->model->{$column}; + + switch ($operator) { + case '=': + $this->result = $this->result && $actualValue == $value; + break; + case '!=': + case '<>': + $this->result = $this->result && $actualValue != $value; + break; + case '>': + $this->result = $this->result && $actualValue > $value; + break; + case '>=': + $this->result = $this->result && $actualValue >= $value; + break; + case '<': + $this->result = $this->result && $actualValue < $value; + break; + case '<=': + $this->result = $this->result && $actualValue <= $value; + break; + } + + return $this; + } + + public function __call($method, $args) { + return $this; + } + }); + + return $result; + }); + } + + $this->setRelation($baseRelation, $relatedModels); + } else if (is_array($relationData) && Arr::isAssoc($relationData)) { + // Single relationship + $relatedModel = new $relatedModelClass(); + + foreach($arrayableRelations as $arrayableRelation) { + if(isset($relationData[$arrayableRelation])) { + $relationData[$arrayableRelation] = collect($relationData[$arrayableRelation])->map(function($item) use ($relatedModel, $arrayableRelation) { + $modelInstance = $relatedModel->{$arrayableRelation}()->getRelated(); + $modelInstance->fill($item); + return $modelInstance; + }); + } + } + + $model = $relatedModel->newFromBuilder($relationData); + + // For single models, we don't filter with constraints as it would be all or nothing + $this->setRelation($baseRelation, $model); + } + } else { + // If not in snapshot data, load from source + $source->load([$relation => $constraints]); + $sourceBaseRelations[] = $baseRelation; + } + } + + if(count($sourceBaseRelations) > 0){ + foreach($sourceBaseRelations as $sourceBaseRelation){ + $this->setRelation($sourceBaseRelation, $source->{$sourceBaseRelation}); + } + } + } + + /** + * Override loadMissing to handle snapshot relationships + * + * @param mixed $relations + * @return $this + */ + public function loadMissing($relations) + { + $relations = is_string($relations) ? func_get_args() : $relations; + + // Filter out already loaded relations + $missingRelations = []; + + foreach ($relations as $key => $value) { + if (is_numeric($key)) { + if (!$this->relationLoaded($value)) { + $missingRelations[] = $value; + } + } else { + if (!$this->relationLoaded($key)) { + $missingRelations[$key] = $value; + } + } + } + + if (empty($missingRelations)) { + return $this; + } + + return $this->load($missingRelations); + } + + /** + * Override loadCount to handle snapshot relationships + * + * @param mixed $relations + * @return $this + */ + public function loadCount($relations) + { + $relations = is_string($relations) ? func_get_args() : $relations; + + $sourceRelationshipsToSnapshot = $this->getSourceRelationshipsToSnapshot(); + $regularRelations = []; + $snapshotRelations = []; + + foreach ($relations as $key => $relation) { + $baseRelation = is_numeric($key) ? $relation : $key; + $baseRelation = $this->getBaseRelationName($baseRelation); + + if (in_array($baseRelation, $sourceRelationshipsToSnapshot)) { + $snapshotRelations[] = $baseRelation; + } else { + if (is_numeric($key)) { + $regularRelations[] = $relation; + } else { + $regularRelations[$key] = $relation; + } + } + } + + // Handle snapshot relation counts + if (!empty($snapshotRelations) && $this->snapshot) { + foreach ($snapshotRelations as $relation) { + if (isset($this->snapshot->data[$relation])) { + $relationData = $this->snapshot->data[$relation]; + $count = is_array($relationData) && !Arr::isAssoc($relationData) ? count($relationData) : 1; + $this->setAttribute($relation.'_count', $count); + } + } + } + + // Handle regular relation counts + if (!empty($regularRelations)) { + parent::loadCount($regularRelations); + } + + return $this; + } + + /** + * Check if a relation is arrayable + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $relation + * @return bool + */ + public function isArrayableRelationship($model, $relation) + { + $relationshipType = get_class($model->{$relation}()); + + return in_array($relationshipType, [ + \Illuminate\Database\Eloquent\Relations\HasMany::class, + \Illuminate\Database\Eloquent\Relations\MorphMany::class, + \Illuminate\Database\Eloquent\Relations\HasManyThrough::class, + \Illuminate\Database\Eloquent\Relations\BelongsToMany::class, + \Illuminate\Database\Eloquent\Relations\MorphToMany::class, + ]); + } +} diff --git a/tests/SnapshotLazyRelationsTest.php b/tests/SnapshotLazyRelationsTest.php new file mode 100644 index 0000000..8885da3 --- /dev/null +++ b/tests/SnapshotLazyRelationsTest.php @@ -0,0 +1,245 @@ + 1, + 'title' => 'User Type 1', + 'description' => 'User Type 1 Description', + ], + [ + 'id' => 2, + 'title' => 'User Type 2', + 'description' => 'User Type 2 Description', + ], + ]); + + // Create a user with posts and files + $this->user = User::create([ + 'user_type_id' => 1, + 'email' => 'test@example.com', + 'name' => 'Test User' + ]); + + $this->user->posts()->createMany([ + [ + 'title' => 'Post Title 1', + 'content' => 'Post Content 1', + ], + [ + 'title' => 'Post Title 2', + 'content' => 'Post Content 2', + ], + [ + 'title' => 'Post Title 3', + 'content' => 'Post Content 3', + ] + ]); + + $this->user->fileNames()->createMany([ + [ + 'name' => 'File 1', + ], + [ + 'name' => 'File 2', + ], + ]); + + // Create a snapshot with specific posts + $this->userSnapshot = UserSnapshot::create([ + 'user_id' => $this->user->id, + 'name' => 'Snapshot Name', + 'posts' => [1, 2] // Only include first two posts + ]); + } + + public function testBasicLazyLoading() + { + // Get a fresh instance without loaded relations + $snapshot = UserSnapshot::find($this->userSnapshot->id); + + // Verify relations aren't loaded yet + $this->assertFalse($snapshot->relationLoaded('posts')); + + // Load posts relation + $snapshot->load('posts'); + + // Verify relation is now loaded + $this->assertTrue($snapshot->relationLoaded('posts')); + + // Verify only the posts from snapshot are loaded (2 posts) + $this->assertCount(2, $snapshot->posts); + + // Verify source has all posts (3 posts) + $this->assertCount(3, $snapshot->source->posts); + } + + public function testLoadingMultipleRelations() + { + $snapshot = UserSnapshot::find($this->userSnapshot->id); + + // Load multiple relations + $snapshot->load(['posts', 'fileNames', 'userType']); + + // Verify all relations are loaded + $this->assertTrue($snapshot->relationLoaded('posts')); + $this->assertTrue($snapshot->relationLoaded('fileNames')); + $this->assertTrue($snapshot->relationLoaded('userType')); + + // Verify correct counts + $this->assertCount(2, $snapshot->posts); + $this->assertCount(2, $snapshot->fileNames); + $this->assertNotNull($snapshot->userType); + } + + public function testLoadMissing() + { + $snapshot = UserSnapshot::find($this->userSnapshot->id); + + // Load posts first + $snapshot->load('posts'); + + // Then load missing relations + $snapshot->loadMissing(['posts', 'fileNames', 'userType']); + + // Verify posts wasn't reloaded (still has 2 items) + $this->assertCount(2, $snapshot->posts); + + // Verify other relations were loaded + $this->assertTrue($snapshot->relationLoaded('fileNames')); + $this->assertTrue($snapshot->relationLoaded('userType')); + } + + // public function testLoadCount() + // { + // $snapshot = UserSnapshot::find($this->userSnapshot->id); + + // // Load counts + // $snapshot->loadCount(['posts', 'fileNames']); + + // // Verify counts are correct + // $this->assertEquals(2, $snapshot->posts_count); + // $this->assertEquals(2, $snapshot->fileNames_count); + // } + + public function testConstrainedLoading() + { + // Update post titles to test constraints + $this->user->posts()->where('id', 1)->update(['title' => 'Special Post']); + + $snapshot = UserSnapshot::find($this->userSnapshot->id); + + // Load with constraints + $snapshot->load(['posts' => function($query) { + $query->where('title', 'Special Post'); + }]); + + // Verify only the constrained post is loaded + $this->assertCount(2, $snapshot->posts); + $this->assertEquals('Post Title 1', $snapshot->posts->first()->title); + } + + public function testNestedRelationLoading() + { + // Create a nested relation scenario + $post = $this->user->posts()->first(); + $post->comments()->create(['content' => 'Test Comment']); + + $snapshot = UserSnapshot::find($this->userSnapshot->id); + + // Load nested relation + $snapshot->load('posts.comments'); + + // Verify nested relation is loaded + $this->assertFalse($snapshot->posts->first()->relationLoaded('comments')); + $this->assertCount(1, $snapshot->posts->first()->comments); + } + + public function testLoadingNonSnapshotRelations() + { + // Create a relation that isn't in the snapshot + $snapshot = UserSnapshot::find($this->userSnapshot->id); + + // This should load from the source model + $snapshot->load('nonSnapshotRelation'); + + // Verify it loaded from source + $this->assertEquals($snapshot->source->nonSnapshotRelation, $snapshot->nonSnapshotRelation); + } + + public function testLoadingWithArrayOfCallbacks() + { + $snapshot = UserSnapshot::find($this->userSnapshot->id); + + // Load with array of callbacks + $snapshot->load([ + 'posts' => function($query) { + $query->where('id', 1); + }, + 'fileNames' => function($query) { + $query->where('name', 'File 2'); + }, + // 'userType' => function($query) { + // $query->where('id', 1); + // } + ]); + + // Verify constraints were applied + $this->assertCount(2, $snapshot->posts); + $this->assertEquals(1, $snapshot->posts->first()->id); + + + $this->assertCount(1, $snapshot->fileNames); + $this->assertEquals('File 2', $snapshot->fileNames->first()->name); + } + + public function testLoadingWithClosureInArray() + { + $snapshot = UserSnapshot::find($this->userSnapshot->id); + + // This is a different format Laravel supports + $snapshot->load([function($query) { + $query->posts(); + }]); + + // Verify it worked + $this->assertTrue($snapshot->relationLoaded('posts')); + $this->assertCount(2, $snapshot->posts); + } + + public function testLoadingRelationThatDoesntExistInSnapshot() + { + // Create a new snapshot without posts + $newSnapshot = UserSnapshot::create([ + 'user_id' => $this->user->id, + 'name' => 'No Posts Snapshot' + ]); + + $snapshot = UserSnapshot::find($newSnapshot->id); + + // Try to load posts (which aren't in the snapshot) + $snapshot->load('posts'); + + // Should load from source + $this->assertTrue($snapshot->relationLoaded('posts')); + $this->assertCount(3, $snapshot->posts); + } +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index a4b3509..5e79609 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -94,6 +94,13 @@ protected function setUpDatabase($app) $table->timestamps(); }); + $schema->create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->foreignId('post_id')->nullable()->constrained()->nullOnDelete(); + $table->string('content'); + $table->timestamps(); + }); + $schema->create('files', function (Blueprint $table) { $table->increments('id'); $table->uuidMorphs('fileable'); diff --git a/tests/TestModels/Comment.php b/tests/TestModels/Comment.php new file mode 100644 index 0000000..9b02fa2 --- /dev/null +++ b/tests/TestModels/Comment.php @@ -0,0 +1,18 @@ +belongsTo(Post::class); + } +} diff --git a/tests/TestModels/Post.php b/tests/TestModels/Post.php index a78ac2f..a8bd33f 100644 --- a/tests/TestModels/Post.php +++ b/tests/TestModels/Post.php @@ -18,4 +18,9 @@ public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo return $this->belongsTo(User::class); } + public function comments(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Comment::class); + } + }