diff --git a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php index f9f21536d1fc..11a8701fb106 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php @@ -224,6 +224,111 @@ public function orWhereDoesntHave($relation, ?Closure $callback = null) return $this->doesntHave($relation, 'or', $callback); } + /** + * Add a "whereHas" condition with OR between columns inside relationships. + * + * @param array $tuples + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function whereHasAny(array $tuples, $operator = null, $value = null) + { + return $this->hasNestedColumnsGroup($tuples, $operator, $value, 'or', 'and'); + } + + /** + * Add an "orWhereHas" condition with OR between columns inside relationships. + * + * @param array $tuples + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereHasAny(array $tuples, $operator = null, $value = null) + { + return $this->hasNestedColumnsGroup($tuples, $operator, $value, 'or', 'or'); + } + + /** + * Add a "whereHas" condition with AND between columns inside relationships. + * + * @param array $tuples + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function whereHasAll(array $tuples, $operator = null, $value = null) + { + return $this->hasNestedColumnsGroup($tuples, $operator, $value, 'and', 'and'); + } + + /** + * Add an "orWhereHas" condition with AND between columns inside relationships. + * + * @param array $tuples + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereHasAll(array $tuples, $operator = null, $value = null) + { + return $this->hasNestedColumnsGroup($tuples, $operator, $value, 'and', 'or'); + } + + /** + * Method to build "has" conditions for relationships, + * combining multiple columns with given boolean logic. + * + * @param array $tuples + * @param mixed $operator + * @param mixed $value + * @param string $innerBoolean + * @param string $outerBoolean + * @return $this + */ + protected function hasNestedColumnsGroup($tuples, $operator, $value, $innerBoolean, $outerBoolean) + { + $callback = function (Builder $query) use ($tuples, $operator, $value, $innerBoolean) { + foreach ($tuples as $relation => $columns) { + $this->hasNestedColumns( + $query, $relation, $columns, $operator, $value, $innerBoolean, $innerBoolean + ); + } + }; + + return $this->where($callback, boolean: $outerBoolean); + } + + /** + * Add nested "where" clauses on relationships for multiple columns with boolean conditions. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $relation + * @param (\Illuminate\Contracts\Database\Query\Expression|string|\Closure)[]|\Closure $columns + * @param mixed $operator + * @param mixed $value + * @param string $innerBoolean + * @param string $outerBoolean + * @return $this + */ + protected function hasNestedColumns($query, $relation, $columns, $operator, $value, $innerBoolean, $outerBoolean) + { + $callback = fn (Builder $builder) => $builder->query->addNestedWhereColumns( + $columns, $operator, $value, $innerBoolean, 'and' + ); + + if ($columns instanceof Closure) { + $callback = $columns; + } + + return $query->has( + $relation, + boolean: $outerBoolean, + callback: $callback, + ); + } + /** * Add a polymorphic relationship count / exists condition to the query. * diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index c1fa22a9d7f8..83640a7197fd 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -904,6 +904,25 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' return $this; } + /** + * Add an "and/or where" clause to the query for multiple columns with "and/or" conditions between them. + * + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns + * @param mixed $operator + * @param mixed $value + * @param string $innerBoolean + * @param string $outerBoolean + * @return $this + */ + public function addNestedWhereColumns($columns, $operator, $value, $innerBoolean, $outerBoolean) + { + return $this->whereNested(function ($query) use ($columns, $operator, $value, $innerBoolean) { + foreach ($columns as $column) { + $query->where($column, $operator, $value, $innerBoolean); + } + }, $outerBoolean); + } + /** * Add an array of where clauses to the query. * @@ -2275,13 +2294,7 @@ public function whereAll($columns, $operator = null, $value = null, $boolean = ' $value, $operator, func_num_args() === 2 ); - $this->whereNested(function ($query) use ($columns, $operator, $value) { - foreach ($columns as $column) { - $query->where($column, $operator, $value, 'and'); - } - }, $boolean); - - return $this; + return $this->addNestedWhereColumns($columns, $operator, $value, 'and', $boolean); } /** @@ -2312,13 +2325,7 @@ public function whereAny($columns, $operator = null, $value = null, $boolean = ' $value, $operator, func_num_args() === 2 ); - $this->whereNested(function ($query) use ($columns, $operator, $value) { - foreach ($columns as $column) { - $query->where($column, $operator, $value, 'or'); - } - }, $boolean); - - return $this; + return $this->addNestedWhereColumns($columns, $operator, $value, 'or', $boolean); } /** diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 96a6beed7e20..1d3619376747 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -1301,6 +1301,50 @@ public function testWhereAttachedToCollection() $this->assertSame('select * from "eloquent_builder_test_model_far_related_stubs" where exists (select * from "eloquent_builder_test_model_parent_stubs" inner join "user_role" on "eloquent_builder_test_model_parent_stubs"."id" = "user_role"."self_id" where "eloquent_builder_test_model_far_related_stubs"."id" = "user_role"."related_id" and "eloquent_builder_test_model_parent_stubs"."id" in (3, 4))', $builder->toSql()); } + public function testWhereAny() + { + $builder = EloquentBuilderTestModelParentStub::whereHasAny([ + 'address' => ['zipcode', 'street'], + 'foo' => ['bar'], + ], '90210'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where (exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("zipcode" = ? or "street" = ?)) or exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("bar" = ?)))', $builder->toSql()); + $this->assertEquals(['90210', '90210', '90210'], $builder->getBindings()); + } + + public function testOrWhereAny() + { + $builder = EloquentBuilderTestModelParentStub::where('name', 'larry')->orWhereHasAny([ + 'address' => ['zipcode', 'street'], + 'foo' => ['bar'], + ], '90210'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "name" = ? or (exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("zipcode" = ? or "street" = ?)) or exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("bar" = ?)))', $builder->toSql()); + $this->assertEquals(['larry', '90210', '90210', '90210'], $builder->getBindings()); + } + + public function testWhereAll() + { + $builder = EloquentBuilderTestModelParentStub::whereHasAll([ + 'address' => ['zipcode', 'street'], + 'foo' => ['bar'], + ], '90210'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where (exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("zipcode" = ? and "street" = ?)) and exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("bar" = ?)))', $builder->toSql()); + $this->assertEquals(['90210', '90210', '90210'], $builder->getBindings()); + } + + public function testOrWhereAll() + { + $builder = EloquentBuilderTestModelParentStub::where('name', 'larry')->orWhereHasAll([ + 'address' => ['zipcode', 'street'], + 'foo' => ['bar'], + ], '90210'); + + $this->assertSame('select * from "eloquent_builder_test_model_parent_stubs" where "name" = ? or (exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("zipcode" = ? and "street" = ?)) and exists (select * from "eloquent_builder_test_model_close_related_stubs" where "eloquent_builder_test_model_parent_stubs"."foo_id" = "eloquent_builder_test_model_close_related_stubs"."id" and ("bar" = ?)))', $builder->toSql()); + $this->assertEquals(['larry', '90210', '90210', '90210'], $builder->getBindings()); + } + public function testDeleteOverride() { $builder = $this->getBuilder(); diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 1d176afa00f1..86cf72ff3cc1 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -121,6 +121,7 @@ protected function createSchema() $table->integer('user_id'); $table->integer('parent_id')->nullable(); $table->string('name'); + $table->string('content')->nullable(); $table->timestamps(); }); @@ -1520,6 +1521,148 @@ public function testWhereAttachedTo() $this->assertTrue($achievedByUser1or2->contains($achievement3)); } + public function testWhereHasAnyFiltersBasedOnAnyColumnAcrossRelationships() + { + $user = EloquentTestUser::create(['email' => 'foo@example.com']); + $user->posts()->createMany([ + ['name' => 'Match Title', 'content' => 'Match Title', 'user_id' => $user->id], + ['name' => 'Other', 'content' => 'Other', 'user_id' => $user->id], + ]); + + $user->photos()->createMany([ + ['name' => 'Match Photo'], + ['name' => 'Ignore Photo'], + ]); + + $results = EloquentTestUser::query() + ->whereHasAny([ + 'posts.user' => ['email'], + 'posts' => ['name', 'content'], + 'photos' => ['name'], + ], 'like', '%Match%') + ->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->first()->is($user)); + } + + public function testOrWhereHasAnyCombinesWithPreviousConditions() + { + $u1 = EloquentTestUser::create(['email' => 'a@example.com']); + $u2 = EloquentTestUser::create(['email' => 'b@example.com']); + + $u1->posts()->create(['name' => 'Foo', 'content' => 'Foo', 'user_id' => $u1->id]); + $u2->posts()->create(['name' => 'Bar', 'content' => 'Bar', 'user_id' => $u2->id]); + + $u1->photos()->create(['name' => 'PhotoFoo']); + $u2->photos()->create(['name' => 'PhotoBar']); + + $results = EloquentTestUser::query() + ->whereHasAny([ + 'posts' => ['name', 'content'], + 'photos' => ['name'], + ], 'like', '%Foo%') + ->orWhereHasAny([ + 'posts' => ['name', 'content'], + 'photos' => ['name'], + ], 'like', '%Bar%') + ->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($u1)); + $this->assertTrue($results->contains($u2)); + } + + public function testWhereHasAllRequiresAllColumnsToMatch() + { + $user = EloquentTestUser::create(['email' => 'c@example.com']); + + $user->posts()->createMany([ + ['name' => 'Same', 'content' => 'Same', 'user_id' => $user->id], + ['name' => 'Same', 'content' => 'Diff', 'user_id' => $user->id], + ]); + + $user->photos()->create(['name' => 'Same', 'imageable_id' => $user->id, 'imageable_type' => EloquentTestUser::class]); + $user->photos()->create(['name' => 'Other', 'imageable_id' => $user->id, 'imageable_type' => EloquentTestUser::class]); + + $results = EloquentTestUser::query() + ->whereHasAll([ + 'posts' => ['name', 'content'], + 'photos' => ['name'], + ], 'like', 'Same') + ->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->first()->is($user)); + } + + public function testOrWhereHasAllCombinesAndOrLogic() + { + $u1 = EloquentTestUser::create(['email' => 'd1@example.com']); + $u2 = EloquentTestUser::create(['email' => 'd2@example.com']); + + $u1->posts()->create(['name' => 'X', 'content' => 'X', 'user_id' => $u1->id]); + $u1->photos()->create(['name' => 'PicX', 'imageable_id' => $u1->id, 'imageable_type' => EloquentTestUser::class]); + + $u2->posts()->create(['name' => 'X', 'content' => 'No', 'user_id' => $u2->id]); + $u2->photos()->create(['name' => 'PicNo', 'imageable_id' => $u2->id, 'imageable_type' => EloquentTestUser::class]); + + $results = EloquentTestUser::query() + ->whereHasAll( + ['posts' => ['name', 'content'], 'photos' => ['name']], + 'like', '%X%' + ) + ->orWhereHasAll( + ['photos' => ['name']], + 'like', '%No%' + ) + ->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($u1)); + $this->assertTrue($results->contains($u2)); + } + + public function testWhereHasAnyAcceptsClosureOnRelationship() + { + $user = EloquentTestUser::create(['email' => 'closure@example.com']); + + $user->posts()->create(['name' => 'MatchMe', 'user_id' => $user->id]); + $user->photos()->create(['name' => 'PhotoMe']); + + $results = EloquentTestUser::query() + ->whereHasAny([ + 'posts' => fn ($q) => $q->where('name', 'MatchMe'), + 'photos' => fn ($q) => $q->where('name', 'PhotoMe'), + ]) + ->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->first()->is($user)); + } + + public function testWhereHasAllAcceptsClosureOnRelationship() + { + $userWith = EloquentTestUser::create(['email' => 'closure_all@example.com']); + $userWithout = EloquentTestUser::create(['email' => 'no_closure@example.com']); + + $userWith->posts()->create(['name' => 'OnlyMatch', 'user_id' => $userWith->id]); + $userWith->photos()->create(['name' => 'OnlyPic']); + + $userWithout->posts()->create(['name' => 'Other', 'user_id' => $userWithout->id]); + $userWithout->photos()->create(['name' => 'OtherPic']); + + $results = EloquentTestUser::query() + ->whereHasAll([ + 'posts' => fn ($q) => $q->where('name', 'OnlyMatch'), + 'photos' => fn ($q) => $q->where('name', 'OnlyPic'), + ]) + ->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->first()->is($userWith)); + } + public function testBasicHasManyEagerLoading() { $user = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']);