Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[10.x] Add JoinMany and JoinOne methods for joining Models to Builder #46603

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/Illuminate/Database/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +30,7 @@
*/
class Builder implements BuilderContract
{
use BuildsQueries, ForwardsCalls, QueriesRelationships {
use BuildsQueries, ForwardsCalls, QueriesRelationships, JoinsModels {
BuildsQueries::sole as baseSole;
}

Expand Down Expand Up @@ -1290,6 +1291,13 @@ public function applyScopes()
return $builder;
}

/**
* @return array<Scope>
*/
public function getScopes(): array {
return $this->scopes;
}

/**
* Apply the given scope on the current builder instance.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public static function getGlobalScope($scope)
/**
* Get the global scopes for this class instance.
*
* @return array
* @return array<Scope>
*/
public function getGlobalScopes()
{
Expand Down
102 changes: 102 additions & 0 deletions src/Illuminate/Database/Eloquent/Concerns/JoinsModels.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Illuminate\Database\Eloquent\Concerns;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Str;

trait JoinsModels
{
/**
* @param class-string<Model>|Model|Builder<Model> $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(),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relations actually don't work very well yet (see PR description), i could remove this for now so it would just throw an exception instead?

};

return $this->joinManyOn($this->getModel(), $builder, $joinType,null, $overrideJoinColumnName);
}

/**
* @param class-string|Model|Builder<Model> $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;
}
}
2 changes: 1 addition & 1 deletion src/Illuminate/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt
/**
* The array of global scopes on the model.
*
* @var array
* @var array<Scope>
*/
protected static $globalScopes = [];

Expand Down
5 changes: 3 additions & 2 deletions src/Illuminate/Database/Query/Grammars/Grammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).')';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was actually a bug, nested inner where's would be cut off on 'whe' and still leave 're ' in the query

}

/**
Expand Down
117 changes: 117 additions & 0 deletions tests/Database/DatabaseEloquentJoinsModelsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

namespace Illuminate\Tests\Database;

use Illuminate\Database\Capsule\Manager as DB;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use PHPUnit\Framework\TestCase;

class DatabaseEloquentJoinsModelsTest extends TestCase
{
private DB $db;

protected function setUp(): void
{
$db = new DB;

$db->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';
}