diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index d32a17b207ef..0199361721f0 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -138,19 +138,26 @@ class Builder implements BuilderContract ]; /** - * Applied global scopes. + * The applied global scopes. * * @var array */ protected $scopes = []; /** - * Removed global scopes. + * The removed global scopes. * * @var array */ protected $removedScopes = []; + /** + * The inherited global scopes. + * + * @var array + */ + protected $inheritedScopes = []; + /** * The callbacks that should be invoked after retrieving data from the database. * @@ -204,6 +211,21 @@ public function withGlobalScope($identifier, $scope) return $this; } + /** + * Register many new global scopes. + * + * @param array $scopes + * @return $this + */ + public function withGlobalScopes(array $scopes) + { + foreach ($scopes as $identifier => $scope) { + $this->withGlobalScope($identifier, $scope); + } + + return $this; + } + /** * Remove a registered global scope. * @@ -257,6 +279,25 @@ public function withoutGlobalScopesExcept(array $scopes = []) return $this; } + /** + * Register scopes that related model eager loading queries should inherit. + * + * @param array $scopes + * @return $this + */ + public function inheritScopes(array $keep = [], array $remove = []) + { + foreach ($keep as $identifier => $scope) { + $this->inheritedScopes['keep'][$identifier] = $scope; + } + + foreach ($remove as $identifier => $scope) { + $this->inheritedScopes['remove'][$identifier] = $scope; + } + + return $this; + } + /** * Get an array of global scopes that were removed from the query. * @@ -940,6 +981,13 @@ protected function eagerLoadRelation(array $models, $name, Closure $constraints) $relation->addEagerConstraints($models); + if (! empty($this->inheritedScopes)) { + $relation + ->withGlobalScopes($this->inheritedScopes['keep'] ?? []) + ->withoutGlobalScopes($this->inheritedScopes['remove'] ?? []) + ->inheritScopes(...$this->inheritedScopes); + } + $constraints($relation); // Once we have the results, we just match those back up to their parent models diff --git a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php index d1ef0d22b9b9..3d5877484f66 100644 --- a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php +++ b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php @@ -9,7 +9,7 @@ class SoftDeletingScope implements Scope * * @var string[] */ - protected $extensions = ['Restore', 'RestoreOrCreate', 'CreateOrRestore', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; + protected $extensions = ['Restore', 'RestoreOrCreate', 'CreateOrRestore', 'WithTrashed', 'WithTrashedRelations', 'WithoutTrashed', 'OnlyTrashed']; /** * Apply the scope to a given Eloquent query builder. @@ -127,6 +127,19 @@ protected function addWithTrashed(Builder $builder) }); } + /** + * Add the with-trashed relationships extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder<*> $builder + * @return void + */ + protected function addWithTrashedRelations(Builder $builder) + { + $builder->macro('withTrashedRelations', function (Builder $builder) { + return $builder->withTrashed()->inheritScopes(remove: [$this]); + }); + } + /** * Add the without-trashed extension to the builder. * diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index c031a6c80099..5938b1c2ca34 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -858,6 +858,37 @@ public function testRelationshipEagerLoadProcess() unset($_SERVER['__eloquent.constrain']); } + public function testScopedRelationshipEagerLoadProcess() + { + $builder = m::mock(Builder::class.'[getRelation]', [$this->getMockQueryBuilder()]); + $builder->setEagerLoads(['orders' => function ($query) { + $_SERVER['__eloquent.constrain'] = $query; + }]); + $scope = function ($query) { + $query->where('active', 1); + }; + $builder->withGlobalScope('test_scope', $scope); + $builder->inheritScopes(['test_scope' => $scope]); + + $relation = m::mock(stdClass::class); + $relation->shouldReceive('addEagerConstraints')->once()->with(['models']); + $relation->shouldReceive('withGlobalScopes')->once()->with(m::on(function ($scopes) { + return isset($scopes['test_scope']); + }))->andReturnSelf(); + $relation->shouldReceive('withoutGlobalScopes')->once()->with(m::type('array'))->andReturnSelf(); + $relation->shouldReceive('inheritScopes')->once()->andReturnSelf(); + $relation->shouldReceive('initRelation')->once()->with(['models'], 'orders')->andReturn(['models']); + $relation->shouldReceive('getEager')->once()->andReturn(['results']); + $relation->shouldReceive('match')->once()->with(['models'], ['results'], 'orders')->andReturn(['models.matched']); + $builder->shouldReceive('getRelation')->once()->with('orders')->andReturn($relation); + + $results = $builder->eagerLoadRelations(['models']); + + $this->assertEquals(['models.matched'], $results); + $this->assertEquals($relation, $_SERVER['__eloquent.constrain']); + unset($_SERVER['__eloquent.constrain']); + } + public function testRelationshipEagerLoadProcessForImplicitlyEmpty() { $queryBuilder = $this->getMockQueryBuilder(); diff --git a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php index 195e2dfd7e17..eebf707c9f3a 100644 --- a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php +++ b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -967,6 +967,26 @@ public function testSelfReferencingRelationshipWithSoftDeletes() $this->assertEquals(1, SoftDeletesTestUser::whereHas('self_referencing')->count()); } + public function testAllRelationshipsLoadWithSoftDeletes() + { + [$taylor, $abigail] = $this->createUsers(); + + $post1 = tap($taylor->posts()->create(['title' => 'First Title']))->delete(); + + $post2 = $abigail->posts()->create(['title' => 'First Title']); + $post3 = tap($abigail->posts()->create(['title' => 'Second Title']))->delete(); + + tap($post1->comments()->create(['body' => 'Comment 1']))->delete(); + $post2->comments()->create(['body' => 'Comment 2']); + tap($post2->comments()->create(['body' => 'Comment 3']))->delete(); + $post3->comments()->create(['body' => 'Comment 4']); + + $users = SoftDeletesTestUser::with('posts.comments')->withTrashedRelations()->get(); + $this->assertCount(2, $users); + $this->assertCount(3, $users->pluck('posts')->collapse()); + $this->assertCount(4, $users->pluck('posts')->collapse()->pluck('comments')->collapse()); + } + /** * Helpers... * diff --git a/tests/Database/DatabaseSoftDeletingScopeTest.php b/tests/Database/DatabaseSoftDeletingScopeTest.php index adf03ac6dc3c..f4871c938931 100644 --- a/tests/Database/DatabaseSoftDeletingScopeTest.php +++ b/tests/Database/DatabaseSoftDeletingScopeTest.php @@ -112,6 +112,24 @@ public function testWithTrashedExtension() $this->assertEquals($givenBuilder, $result); } + public function testWithTrashedRelationsExtension() + { + $builder = new EloquentBuilder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + $scope = m::mock(SoftDeletingScope::class.'[remove]'); + $scope->extend($builder); + $callback = $builder->getMacro('withTrashedRelations'); + $givenBuilder = m::mock(EloquentBuilder::class); + $givenBuilder->shouldReceive('withTrashed')->once()->andReturn($givenBuilder); + $givenBuilder->shouldReceive('inheritScopes')->once()->with([], [$scope])->andReturn($givenBuilder); + $result = $callback($givenBuilder); + + $this->assertEquals($givenBuilder, $result); + } + public function testOnlyTrashedExtension() { $builder = new EloquentBuilder(new BaseBuilder(