diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 77c0bd27d836..4437650016ba 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -742,9 +742,13 @@ public function get($columns = ['*']) $models = $builder->eagerLoadRelations($models); } - return $this->applyAfterQueryCallbacks( - $builder->getModel()->newCollection($models) - ); + $collection = $builder->getModel()->newCollection($models); + + if (Model::isAutoloadingRelationsGlobally()) { + $collection->withRelationAutoload(); + } + + return $this->applyAfterQueryCallbacks($collection); } /** diff --git a/src/Illuminate/Database/Eloquent/Collection.php b/src/Illuminate/Database/Eloquent/Collection.php index 1ae950cb9e73..5cfedc6c212d 100755 --- a/src/Illuminate/Database/Eloquent/Collection.php +++ b/src/Illuminate/Database/Eloquent/Collection.php @@ -248,6 +248,31 @@ public function loadMissing($relations) return $this; } + /** + * Load a relationship path with types if it is not already eager loaded. + * + * @return void + */ + public function loadMissingRelationWithTypes(array $path) + { + [$name, $class] = array_shift($path); + + $this->filter(fn ($model) => ! is_null($model) && ! $model->relationLoaded($name) && $model::class === $class) + ->load($name); + + if (empty($path)) { + return; + } + + $models = $this->pluck($name)->whereNotNull(); + + if ($models->first() instanceof BaseCollection) { + $models = $models->collapse(); + } + + (new static($models))->loadMissingRelationWithTypes($path); + } + /** * Load a relationship path if it is not already eager loaded. * @@ -314,6 +339,21 @@ public function loadMorphCount($relation, $relations) return $this; } + /** + * Enable relation autoload for the collection. + * + * @return $this + */ + public function withRelationAutoload() + { + $callback = fn ($path) => $this->loadMissingRelationWithTypes($path); + + $this->each(fn ($model) => $model->hasRelationAutoloadCallback() + || $model->usingRelationAutoloadCallback($this, $callback)); + + return $this; + } + /** * Determine if a key exists in the collection. * diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index c1ddc54c8f4b..5deddd89d9b9 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -549,6 +549,10 @@ public function getRelationValue($key) return; } + if ($this->handleRelationAutoload($key)) { + return $this->relations[$key]; + } + if ($this->preventsLazyLoading) { $this->handleLazyLoadingViolation($key); } diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php index c62132a26185..e950fe43d466 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php @@ -39,6 +39,20 @@ trait HasRelationships */ protected $touches = []; + /** + * The relationship autoload callback. + * + * @var ?Closure + */ + protected $relationAutoloadCallback = null; + + /** + * The relationship autoload context. + * + * @var ?Collection + */ + protected $relationAutoloadContext = null; + /** * The many to many relationship methods. * @@ -90,6 +104,132 @@ public static function resolveRelationUsing($name, Closure $callback) ); } + /** + * Set relation autoload callback for model and its relations. + * + * @param mixed $context + * @param Closure $callback + * @return $this + */ + public function usingRelationAutoloadCallback($context, Closure $callback) + { + $this->relationAutoloadContext = $context; + $this->relationAutoloadCallback = $callback; + + foreach ($this->relations as $key => $value) { + $this->applyRelationAutoloadCallbackToValue($key, $value); + } + + return $this; + } + + /** + * Get relation autoload context. + * + * @return mixed + */ + public function getRelationAutoloadContext() + { + return $this->relationAutoloadContext; + } + + /** + * Enable relation autoload for model and its relations if not already enabled. + * + * @return $this + */ + public function withRelationAutoload() + { + if ($this->hasRelationAutoloadCallback()) { + return $this; + } + + $collection = new Collection([$this]); + + $this->usingRelationAutoloadCallback( + $collection, + fn ($path) => $collection->loadMissingRelationWithTypes($path) + ); + + return $this; + } + + /** + * Check if relation autoload callback is set. + * + * @return bool + */ + public function hasRelationAutoloadCallback() + { + return ! is_null($this->relationAutoloadCallback); + } + + /** + * Trigger relation autoload callback and check if relation is loaded. + * + * @param string $key + * @return bool + */ + protected function handleRelationAutoload($key) + { + if (! $this->hasRelationAutoloadCallback()) { + return false; + } + + $this->triggerRelationAutoloadCallback($key, []); + + return $this->relationLoaded($key); + } + + /** + * Trigger relation autoload callback. + * + * @param string $key + * @param array $keys + * @return void + */ + protected function triggerRelationAutoloadCallback($key, $keys) + { + call_user_func( + $this->relationAutoloadCallback, + array_merge([[$key, get_class($this)]], $keys) + ); + } + + /** + * Apply relation autoload callback to value. + * + * @param string $key + * @param mixed $values + * @return void + */ + protected function applyRelationAutoloadCallbackToValue($key, $values) + { + if (! $this->hasRelationAutoloadCallback() || ! $values) { + return; + } + + if ($values instanceof Model) { + $values = [$values]; + } + + if (! is_iterable($values)) { + return; + } + + $callback = fn (array $keys) => $this->triggerRelationAutoloadCallback($key, $keys); + + foreach ($values as $item) { + $context = $item->getRelationAutoloadContext(); + + // check if relation autoload contexts are different + // to avoid circular relation autoload + if (is_null($context) || $context !== $this->relationAutoloadContext) { + $item->usingRelationAutoloadCallback($this->relationAutoloadContext, $callback); + } + } + } + /** * Define a one-to-one relationship. * @@ -917,6 +1057,8 @@ public function setRelation($relation, $value) { $this->relations[$relation] = $value; + $this->applyRelationAutoloadCallbackToValue($relation, $value); + return $this; } diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 7afa59933416..312dc755ef1a 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -173,6 +173,13 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt */ protected static $modelsShouldPreventLazyLoading = false; + /** + * Indicates whether relations should be automatically loaded on all models. + * + * @var bool + */ + protected static $modelsShouldGlobalAutoloadRelations = false; + /** * The callback that is responsible for handling lazy loading violations. * @@ -442,6 +449,17 @@ public static function preventLazyLoading($value = true) static::$modelsShouldPreventLazyLoading = $value; } + /** + * Determine if model relationships should be automatically loaded. + * + * @param bool $value + * @return void + */ + public static function globalAutoloadRelations($value = true) + { + static::$modelsShouldGlobalAutoloadRelations = $value; + } + /** * Register a callback that is responsible for handling lazy loading violations. * @@ -2208,6 +2226,16 @@ public static function preventsLazyLoading() return static::$modelsShouldPreventLazyLoading; } + /** + * Determine if relations autoload is enabled. + * + * @return bool + */ + public static function isAutoloadingRelationsGlobally() + { + return static::$modelsShouldGlobalAutoloadRelations; + } + /** * Determine if discarding guarded attribute fills is disabled. * diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php index c7cab6453dfb..08f1bd45a56e 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php @@ -27,6 +27,7 @@ public function testModelsAreProperlyMatchedToParents() $model1->shouldReceive('getAttribute')->with('foo')->passthru(); $model1->shouldReceive('hasGetMutator')->andReturn(false); $model1->shouldReceive('hasAttributeMutator')->andReturn(false); + $model1->shouldReceive('hasRelationAutoloadCallback')->andReturn(false); $model1->shouldReceive('getCasts')->andReturn([]); $model1->shouldReceive('getRelationValue', 'relationLoaded', 'relationResolver', 'setRelation', 'isRelation')->passthru(); @@ -36,6 +37,7 @@ public function testModelsAreProperlyMatchedToParents() $model2->shouldReceive('getAttribute')->with('foo')->passthru(); $model2->shouldReceive('hasGetMutator')->andReturn(false); $model2->shouldReceive('hasAttributeMutator')->andReturn(false); + $model2->shouldReceive('hasRelationAutoloadCallback')->andReturn(false); $model2->shouldReceive('getCasts')->andReturn([]); $model2->shouldReceive('getRelationValue', 'relationLoaded', 'relationResolver', 'setRelation', 'isRelation')->passthru(); diff --git a/tests/Integration/Database/EloquentModelRelationAutoloadTest.php b/tests/Integration/Database/EloquentModelRelationAutoloadTest.php new file mode 100644 index 000000000000..7891eb30201d --- /dev/null +++ b/tests/Integration/Database/EloquentModelRelationAutoloadTest.php @@ -0,0 +1,183 @@ +increments('id'); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('parent_id')->nullable(); + $table->morphs('commentable'); + }); + + Schema::create('likes', function (Blueprint $table) { + $table->increments('id'); + $table->morphs('likeable'); + }); + } + + public function testRelationAutoload() + { + $post1 = Post::create(); + $comment1 = $post1->comments()->create(['parent_id' => null]); + $comment2 = $post1->comments()->create(['parent_id' => $comment1->id]); + $comment2->likes()->create(); + $comment2->likes()->create(); + + $post2 = Post::create(); + $comment3 = $post2->comments()->create(['parent_id' => null]); + $comment3->likes()->create(); + + $posts = Post::get(); + + DB::enableQueryLog(); + + $likes = []; + + $posts->withRelationAutoload(); + + foreach ($posts as $post) { + foreach ($post->comments as $comment) { + $likes = array_merge($likes, $comment->likes->all()); + } + } + + $this->assertCount(2, DB::getQueryLog()); + $this->assertCount(3, $likes); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('likes')); + } + + public function testRelationAutoloadVariousNestedMorphRelations() + { + tap(Post::create(), function ($post) { + $post->likes()->create(); + $post->comments()->create(); + tap($post->comments()->create(), function ($comment) { + $comment->likes()->create(); + $comment->likes()->create(); + }); + }); + + tap(Post::create(), function ($post) { + $post->likes()->create(); + tap($post->comments()->create(), function ($comment) { + $comment->likes()->create(); + }); + }); + + tap(Video::create(), function ($video) { + tap($video->comments()->create(), function ($comment) { + $comment->likes()->create(); + }); + }); + + tap(Video::create(), function ($video) { + tap($video->comments()->create(), function ($comment) { + $comment->likes()->create(); + }); + }); + + $likes = Like::get(); + + DB::enableQueryLog(); + + $videos = []; + $videoLike = null; + + $likes->withRelationAutoload(); + + foreach ($likes as $like) { + $likeable = $like->likeable; + + if (($likeable instanceof Comment) && ($likeable->commentable instanceof Video)) { + $videos[] = $likeable->commentable; + $videoLike = $like; + } + } + + $this->assertCount(4, DB::getQueryLog()); + $this->assertCount(2, $videos); + $this->assertTrue($videoLike->relationLoaded('likeable')); + $this->assertTrue($videoLike->likeable->relationLoaded('commentable')); + } +} + +class Comment extends Model +{ + public $timestamps = false; + + protected $guarded = []; + + public function parent() + { + return $this->belongsTo(self::class); + } + + public function likes() + { + return $this->morphMany(Like::class, 'likeable'); + } + + public function commentable() + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public $timestamps = false; + + public function comments() + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function likes() + { + return $this->morphMany(Like::class, 'likeable'); + } +} + +class Video extends Model +{ + public $timestamps = false; + + public function comments() + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function likes() + { + return $this->morphMany(Like::class, 'likeable'); + } +} + +class Like extends Model +{ + public $timestamps = false; + + protected $guarded = []; + + public function likeable() + { + return $this->morphTo(); + } +}