diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 55223ed9a874..cdecc8b296ac 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Database\Eloquent\Builder as BuilderContract; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Concerns\BuildsQueries; +use Illuminate\Database\Eloquent\Concerns\JoinsModels; use Illuminate\Database\Eloquent\Concerns\QueriesRelationships; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Relation; @@ -29,7 +30,7 @@ */ class Builder implements BuilderContract { - use BuildsQueries, ForwardsCalls, QueriesRelationships { + use BuildsQueries, ForwardsCalls, QueriesRelationships, JoinsModels { BuildsQueries::sole as baseSole; } @@ -1290,6 +1291,13 @@ public function applyScopes() return $builder; } + /** + * @return array + */ + public function getScopes(): array { + return $this->scopes; + } + /** * Apply the given scope on the current builder instance. * diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasGlobalScopes.php b/src/Illuminate/Database/Eloquent/Concerns/HasGlobalScopes.php index 72afb178897b..f26265e68c41 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasGlobalScopes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasGlobalScopes.php @@ -62,7 +62,7 @@ public static function getGlobalScope($scope) /** * Get the global scopes for this class instance. * - * @return array + * @return array */ public function getGlobalScopes() { diff --git a/src/Illuminate/Database/Eloquent/Concerns/JoinsModels.php b/src/Illuminate/Database/Eloquent/Concerns/JoinsModels.php new file mode 100644 index 000000000000..f0f167efba19 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Concerns/JoinsModels.php @@ -0,0 +1,102 @@ +|Model|Builder $model + * @param string $joinType + * @param string|null $overrideJoinColumnName + * @return static + */ + public function joinMany($model, string $joinType = 'inner', ?string $overrideJoinColumnName = null): static { + /** @var Builder $builder */ + $builder = match(true) { + is_string($model) => (new $model())->newQuery(), + $model instanceof Builder => $model, + $model instanceof Model => $model->newQuery(), + $model instanceof Relation => $model->getQuery(), + }; + + return $this->joinManyOn($this->getModel(), $builder, $joinType,null, $overrideJoinColumnName); + } + + /** + * @param class-string|Model|Builder $model + * @param string $joinType + * @param string|null $overrideBaseColumn + * @return static + */ + public function joinOne($model, string $joinType = 'inner', ?string $overrideBaseColumn = null): static { + $builder = match(true) { + is_string($model) => (new $model())->newQuery(), + $model instanceof Builder => $model, + $model instanceof Model => $model->newQuery(), + $model instanceof Relation => $model->getQuery(), + }; + + $this->joinOneOn($this->getModel(), $builder, $joinType, $overrideBaseColumn); + + return $this; + } + + + private function joinManyOn(Model $baseModel, Builder $builderToJoin, ?string $joinType = 'inner', ?string $overrideBaseColumnName = null, ?string $overrideJoinColumnName = null): static + { + $modelToJoin = $builderToJoin->getModel(); + $manyJoinColumnName = $overrideJoinColumnName ?? (Str::singular($baseModel->getTable()). '_' . $baseModel->getKeyName()); + $baseColumnName = $overrideBaseColumnName ?? $baseModel->getKeyName(); + $this->join( + $modelToJoin->getTable(), fn(JoinClause $join) => + $join->on( + $modelToJoin->qualifyColumn($manyJoinColumnName), + '=', + $baseModel->qualifyColumn($baseColumnName), + )->addNestedWhereQuery($builderToJoin->applyScopes()->getQuery()), + type: $joinType + ); + + return $this; + } + + private function joinOneOn(Model $baseModel, Builder $builderToJoin, string $joinType = 'inner', string $overrideBaseColumnName = null, string $overrideJoinColumnName = null): static + { + $modelToJoin = $builderToJoin->getModel(); + $joinColumnName = $overrideBaseColumnName ?? $modelToJoin->getKeyName(); + $baseColumnName = $overrideJoinColumnName ?? (Str::singular($modelToJoin->getTable()). '_' . $modelToJoin->getKeyName()); + $this->join( + $modelToJoin->getTable(), fn(JoinClause $join) => + $join->on( + $modelToJoin->qualifyColumn($joinColumnName), + '=', + $baseModel->qualifyColumn($baseColumnName), + )->addNestedWhereQuery($builderToJoin->getQuery()), + type: $joinType + ); + $this->applyScopesWith($builderToJoin->getScopes(), $modelToJoin); + return $this; + } + + /** + * @param Scope[] $scopes + * @param Model $model + * @return static + */ + private function applyScopesWith(array $scopes, Model $model): static + { + foreach($scopes as $scope){ + $scope->apply($this, $model); + } + return $this; + } +} diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 3a5173117573..5bad9661c0bf 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -149,7 +149,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The array of global scopes on the model. * - * @var array + * @var array */ protected static $globalScopes = []; diff --git a/src/Illuminate/Database/Query/Grammars/Grammar.php b/src/Illuminate/Database/Query/Grammars/Grammar.php index e15c55644070..67fdd15d339e 100755 --- a/src/Illuminate/Database/Query/Grammars/Grammar.php +++ b/src/Illuminate/Database/Query/Grammars/Grammar.php @@ -504,9 +504,10 @@ protected function whereNested(Builder $query, $where) // Here we will calculate what portion of the string we need to remove. If this // is a join clause query, we need to remove the "on" portion of the SQL and // if it is a normal query we need to take the leading "where" of queries. - $offset = $query instanceof JoinClause ? 3 : 6; + $whereSql = $this->compileWheres($where['query']); + $offset = $query instanceof JoinClause && str_starts_with($whereSql, 'on ') ? 3 : 6; - return '('.substr($this->compileWheres($where['query']), $offset).')'; + return '('.substr($whereSql, $offset).')'; } /** diff --git a/tests/Database/DatabaseEloquentJoinsModelsTest.php b/tests/Database/DatabaseEloquentJoinsModelsTest.php new file mode 100644 index 000000000000..23ffbfa63b4a --- /dev/null +++ b/tests/Database/DatabaseEloquentJoinsModelsTest.php @@ -0,0 +1,117 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + $this->db = $db; + } + protected function tearDown(): void + { + \Mockery::close(); + } + public function testJoinMany() + { + $mock = \Mockery::mock(Builder::class, [$this->db->getDatabaseManager()->query()])->makePartial(); + $mock->shouldReceive('join')->withSomeOfArgs('comments')->andReturn($mock)->once(); + $query =$mock->setModel(new Blog()); + $query->joinMany(Comment::class); + \Mockery::close(); + } + + public function testJoinOne() + { + $mock = \Mockery::mock(Builder::class, [$this->db->getDatabaseManager()->query()])->makePartial(); + $mock->shouldReceive('join')->withSomeOfArgs('blogs')->andReturn($mock)->once(); + $query = $mock->setModel(new Comment()); + $query->joinOne(Blog::class); + \Mockery::close(); + } + + public function testSimpleHasMany() + { + $blog = new Blog(); + $query = $blog->newQuery()->joinMany(Comment::class)->toSql(); + $this->assertSame('select * from "blogs" inner join "comments" on "comments"."blog_id" = "blogs"."id"', $query); + } + + public function testSimpleHasOne() + { + $query = (new Comment())->newQuery()->joinOne(Blog::class)->toSql(); + $this->assertSame('select * from "comments" inner join "blogs" on "blogs"."id" = "comments"."blog_id"', $query); + } + + public function testSimpleHasManyAlternativePrimaryKeyName() + { + $blog = new Alternative(); + $query = $blog->newQuery()->joinMany(Blog::class)->toSql(); + $this->assertSame('select * from "alternatives" inner join "blogs" on "blogs"."alternative_key" = "alternatives"."key"', $query); + } + + public function testIncludeScopesInJoin() + { + $blog = new Blog(); + $query = $blog->newQuery()->joinMany(DeletableComment::class)->toSql(); + $this->assertSame('select * from "blogs" inner join "deletable_comments" on "deletable_comments"."blog_id" = "blogs"."id" and ("deletable_comments"."deleted_at" is null)', $query); + } + + public function testCanJoinBuilder() + { + $blog = new Blog(); + $query = $blog->newQuery()->joinMany(DeletableComment::withTrashed())->toSql(); + $this->assertSame('select * from "blogs" inner join "deletable_comments" on "deletable_comments"."blog_id" = "blogs"."id"', $query); + } + + public function testAddWhereStatements() + { + $blog = new Blog(); + $query = $blog->newQuery()->joinMany(Comment::query()->whereNull('comments.deleted_at'))->toSql(); + $this->assertSame('select * from "blogs" inner join "comments" on "comments"."blog_id" = "blogs"."id" and ("comments"."deleted_at" is null)', $query); + } + + public function testAddingOnRelation() + { + $blog = new Blog(); + $query = $blog->comments()->joinOne(User::class)->toSql(); + $this->assertSame('select * from "comments" inner join "users" on "users"."id" = "comments"."user_id" where "comments"."blog_id" is null and "comments"."blog_id" is not null', $query); + } +} + + +class Blog extends Model { + public function comments(){ + return $this->hasMany(Comment::class); + } +} +class Comment extends Model {} +class User extends Model {} + +class DeletableComment extends Model { + use SoftDeletes; +} + +class Alternative extends Model +{ + protected $primaryKey = 'key'; +}