Skip to content

Commit

Permalink
+ Added array-syntax for handling multiple join constraints
Browse files Browse the repository at this point in the history
  • Loading branch information
tylernathanreed committed Jul 25, 2021
1 parent efb1b59 commit e1ca07d
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 16 deletions.
53 changes: 45 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ This package adds the ability to join on a relationship by name.
- [4. Adding pivot constraints](#joining-constraints-pivot)
- [Query Scopes](#joining-constraints-pivot-scopes)
- [Soft Deletes](#joining-constraints-pivot-soft-deletes)
- [5. Joining through relationships](#joining-through)
- [5. Adding multiple constraints](#multiple-constraints)
- [Array-Syntax](#multiple-constraints-array)
- [Through-Syntax](#multiple-constraints-through)
- [6. Joining on circular relationships](#joining-circular)
- [7. Aliasing joins](#joining-aliasing)
- [Aliasing Pivot Tables](#joining-aliasing-pivot)
Expand Down Expand Up @@ -179,25 +181,60 @@ Country::query()->joinRelation('posts', function ($join, $through) {

When using a "Belongs/Morph to Many" relationship, a pivot model must be specified for soft deletes to be considered.

<a name="joining-through"></a>
### 5. Joining through relationships
<a name="multiple-constraints"></a>
### 5. Adding multiple constraints

There are times where you want to tack on clauses for intermediate joins. This can get a bit tricky in some other packages (by trying to automatically deduce whether or not to apply a join, or by not handling this situation at all).
There are times where you want to tack on clauses for intermediate joins. This can get a bit tricky in some other packages (by trying to automatically deduce whether or not to apply a join, or by not handling this situation at all). This package introduces two solutions, where both have value in different situations.

This package introduces something I'm calling a "through" join. Essentially, a "through" join indicates "I want to apply only the final relation in the 'dot' notation to my query".
<a name="multiple-constraints-array"></a>
#### Array-Syntax

Here's an example:
The first approach to handling multiple constraints is using an array syntax. This approach allows you to define all of your nested joins and constraints together:

```php
User::query()->joinRelation('posts.comments', [
function ($join) { $join->where('is_active', '=', 1); },
function ($join) { $join->where('comments.title', 'like', '%looking for something%'); }
});
```

The array syntax supports both sequential and associative variants:

```php
// Sequential
User::query()->joinRelation('posts.comments', [
null,
function ($join) { $join->where('comments.title', 'like', '%looking for something%'); }
});

// Associative
User::query()->joinRelation('posts.comments', [
'comments' => function ($join) { $join->where('comments.title', 'like', '%looking for something%'); }
});
```

If you're using aliases, the associate array syntax refers to the fully qualified relation:
```php
User::query()->joinRelation('posts as articles.comments as threads', [
'posts as articles' => function ($join) { $join->where('is_active', '=', 1); },
'comments as threads' => function ($join) { $join->where('threads.title', 'like', '%looking for something%'); }
});
```

<a name="multiple-constraints-through"></a>
#### Through-Syntax

The second approach to handling multiple constraints is using a through syntax. This approach allows us to define your joins and constraints individually:

```php
// Using a query scope on the "Post" model
User::query()->joinRelation('posts', function ($join) {
$join->where('is_active', '=', 1);
})->joinThroughRelation('posts.comments', function ($join) {
$join->where('comments.title', 'like', '%looking for something%');
});
```

The second part, `joinThroughRelation`, will only apply the `comments` relation join, but it will do so as if it came from the `Post` model.
The "through" concept here allows you to define a nested join using "dot" syntax, where only the final relation is actually constrained, and the prior relations are assumed to already be handled. So in this case, the `joinThroughRelation` method will only apply the `comments` relation join, but it will do so as if it came from the `Post` model.

<a name="joining-circular"></a>
### 6. Joining on circular relationships
Expand Down
8 changes: 8 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,12 @@
<exclude-pattern>*/tests/*</exclude-pattern>
</rule>

<rule ref="Squiz.Functions.MultiLineFunctionDeclaration.ContentAfterBrace">
<exclude-pattern>*/tests/*</exclude-pattern>
</rule>

<rule ref="Squiz.WhiteSpace.ScopeClosingBrace.ContentBefore">
<exclude-pattern>*/tests/*</exclude-pattern>
</rule>

</ruleset>
31 changes: 23 additions & 8 deletions src/Mixins/JoinsRelationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Reedware\LaravelRelationJoins\Mixins;

use Closure;
use Illuminate\Support\Arr;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
Expand All @@ -25,17 +26,17 @@ public function joinRelation()
* Add a relationship join condition to the query.
*
* @param mixed $relation
* @param \Closure|null $callback
* @param \Closure|array|null $callback
* @param string $type
* @param bool $through
* @param \Illuminate\Database\Eloquent\Builder $relatedQuery
* @param mixed $morphTypes
*
* @return \Illuminate\Database\Eloquent\Builder|static
*/
return function ($relation, Closure $callback = null, $type = 'inner', $through = false, Builder $relatedQuery = null, $morphTypes = ['*']) {
return function ($relation, $callback = null, $type = 'inner', $through = false, Builder $relatedQuery = null, $morphTypes = ['*']) {

if (!$morphTypes instanceof MorphTypes) {
if (! $morphTypes instanceof MorphTypes) {
$morphTypes = new MorphTypes($morphTypes);
}

Expand Down Expand Up @@ -79,6 +80,11 @@ public function joinRelation()
// Next we will call any given callback as an "anonymous" scope so they can get the
// proper logical grouping of the where clauses if needed by this Eloquent query
// builder. Then, we will be ready to finalize and return this query instance.

if (is_array($callback)) {
$callback = reset($callback);
}

if ($callback) {
$this->callJoinScope($joinQuery, $callback);
} else {
Expand All @@ -105,24 +111,33 @@ protected function joinNestedRelation()
* Add nested relationship join conditions to the query.
*
* @param string $relations
* @param \Closure|null $callback
* @param \Closure|array|null $callbacks
* @param string $type
* @param bool $through
* @param \Reedware\LaravelRelationJoins\MorphTypes $morphTypes
*
* @return \Illuminate\Database\Eloquent\Builder|static
*/
return function ($relations, ?Closure $callback, $type, $through, MorphTypes $morphTypes) {
return function ($relations, $callbacks, $type, $through, MorphTypes $morphTypes) {

$relations = explode('.', $relations);

$relatedQuery = $this;

$callbacks = is_array($callbacks)
? (
Arr::isAssoc($callbacks)
? $callbacks
: array_combine($relations, $callbacks)
)
: [end($relations) => $callbacks];

while (count($relations) > 0) {
$closure = count($relations) > 1 ? null : $callback;
$useThrough = count($relations) > 1 && $through;
$relation = array_shift($relations);
$callback = $callbacks[$relation] ?? null;
$useThrough = count($relations) > 0 && $through;

$relatedQuery = $this->joinRelation(array_shift($relations), $closure, $type, $useThrough, $relatedQuery, $morphTypes);
$relatedQuery = $this->joinRelation($relation, $callback, $type, $useThrough, $relatedQuery, $morphTypes);
}

return $this;
Expand Down
150 changes: 150 additions & 0 deletions tests/Unit/JoinsRelationshipsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,154 @@ public function anonymousRelation_alias(Closure $query, string $builderClass)
$this->assertEquals('select * from "users" inner join "countries" as "kingdoms" on "kingdoms"."name" = "users"."kingdom_name"', $builder->toSql());
$this->assertEquals($builderClass, get_class($builder));
}

/**
* @test
* @dataProvider queryDataProvider
*/
public function singleconstraint_array(Closure $query, string $builderClass)
{
$builder = $query(new EloquentUserModelStub)
->joinRelation('posts', [
function ($join) { $join->where('posts.active', '=', true); }
]);

$this->assertEquals('select * from "users" inner join "posts" on "posts"."user_id" = "users"."id" and "posts"."active" = ?', $builder->toSql());
$this->assertEquals([true], $builder->getBindings());
$this->assertEquals($builderClass, get_class($builder));
}

/**
* @test
* @dataProvider queryDataProvider
*/
public function multiconstraint_sequential(Closure $query, string $builderClass)
{
$builder = $query(new EloquentUserModelStub)
->joinRelation('posts.comments', [
function ($join) { $join->where('posts.active', '=', true); },
function ($join) { $join->where('comments.likes', '>=', 10); }
]);

$this->assertEquals('select * from "users" inner join "posts" on "posts"."user_id" = "users"."id" and "posts"."active" = ? inner join "comments" on "comments"."post_id" = "posts"."id" and "comments"."likes" >= ?', $builder->toSql());
$this->assertEquals([true, 10], $builder->getBindings());
$this->assertEquals($builderClass, get_class($builder));
}

/**
* @test
* @dataProvider queryDataProvider
*/
public function multiconstraint_associative(Closure $query, string $builderClass)
{
$builder = $query(new EloquentUserModelStub)
->joinRelation('posts.comments', [
'comments' => function ($join) { $join->where('comments.likes', '>=', 10); },
'posts' => function ($join) { $join->where('posts.active', '=', true); }
]);

$this->assertEquals('select * from "users" inner join "posts" on "posts"."user_id" = "users"."id" and "posts"."active" = ? inner join "comments" on "comments"."post_id" = "posts"."id" and "comments"."likes" >= ?', $builder->toSql());
$this->assertEquals([true, 10], $builder->getBindings());
$this->assertEquals($builderClass, get_class($builder));
}

/**
* @test
* @dataProvider queryDataProvider
*/
public function multiconstraint_alias(Closure $query, string $builderClass)
{
$builder = $query(new EloquentUserModelStub)
->joinRelation('posts as articles.comments as threads', [
'comments as threads' => function ($join) { $join->where('threads.likes', '>=', 10); },
'posts as articles' => function ($join) { $join->where('articles.active', '=', true); }
]);

$this->assertEquals('select * from "users" inner join "posts" as "articles" on "articles"."user_id" = "users"."id" and "articles"."active" = ? inner join "comments" as "threads" on "threads"."post_id" = "articles"."id" and "threads"."likes" >= ?', $builder->toSql());
$this->assertEquals([true, 10], $builder->getBindings());
$this->assertEquals($builderClass, get_class($builder));
}

/**
* @test
* @dataProvider queryDataProvider
*/
public function multiconstraint_single_first(Closure $query, string $builderClass)
{
$builder = $query(new EloquentUserModelStub)
->joinRelation('posts.comments', [
'posts' => function ($join) { $join->where('posts.active', '=', true); }
]);

$this->assertEquals('select * from "users" inner join "posts" on "posts"."user_id" = "users"."id" and "posts"."active" = ? inner join "comments" on "comments"."post_id" = "posts"."id"', $builder->toSql());
$this->assertEquals([true], $builder->getBindings());
$this->assertEquals($builderClass, get_class($builder));
}

/**
* @test
* @dataProvider queryDataProvider
*/
public function multiconstraint_single_last(Closure $query, string $builderClass)
{
$builder = $query(new EloquentUserModelStub)
->joinRelation('posts.comments', [
'comments' => function ($join) { $join->where('comments.likes', '>=', 10); }
]);

$this->assertEquals('select * from "users" inner join "posts" on "posts"."user_id" = "users"."id" inner join "comments" on "comments"."post_id" = "posts"."id" and "comments"."likes" >= ?', $builder->toSql());
$this->assertEquals([10], $builder->getBindings());
$this->assertEquals($builderClass, get_class($builder));
}

/**
* @test
* @dataProvider queryDataProvider
*/
public function multiconstraint_single_middle(Closure $query, string $builderClass)
{
$builder = $query(new EloquentUserModelStub)
->joinRelation('posts.comments.likes', [
'comments' => function ($join) { $join->where('comments.likes', '>=', 10); }
]);

$this->assertEquals('select * from "users" inner join "posts" on "posts"."user_id" = "users"."id" inner join "comments" on "comments"."post_id" = "posts"."id" and "comments"."likes" >= ? inner join "likes" on "likes"."comment_id" = "comments"."id"', $builder->toSql());
$this->assertEquals([10], $builder->getBindings());
$this->assertEquals($builderClass, get_class($builder));
}

/**
* @test
* @dataProvider queryDataProvider
*/
public function multiconstraint_skip_middle_sequential(Closure $query, string $builderClass)
{
$builder = $query(new EloquentUserModelStub)
->joinRelation('posts.comments.likes', [
function ($join) { $join->where('posts.active', '=', true); },
null,
function ($join) { $join->where('likes.emoji', '=', 'thumbs-up'); }
]);

$this->assertEquals('select * from "users" inner join "posts" on "posts"."user_id" = "users"."id" and "posts"."active" = ? inner join "comments" on "comments"."post_id" = "posts"."id" inner join "likes" on "likes"."comment_id" = "comments"."id" and "likes"."emoji" = ?', $builder->toSql());
$this->assertEquals([true, 'thumbs-up'], $builder->getBindings());
$this->assertEquals($builderClass, get_class($builder));
}

/**
* @test
* @dataProvider queryDataProvider
*/
public function multiconstraint_skip_middle_associative(Closure $query, string $builderClass)
{
$builder = $query(new EloquentUserModelStub)
->joinRelation('posts.comments.likes', [
'posts' => function ($join) { $join->where('posts.active', '=', true); },
'likes' => function ($join) { $join->where('likes.emoji', '=', 'thumbs-up'); }
]);

$this->assertEquals('select * from "users" inner join "posts" on "posts"."user_id" = "users"."id" and "posts"."active" = ? inner join "comments" on "comments"."post_id" = "posts"."id" inner join "likes" on "likes"."comment_id" = "comments"."id" and "likes"."emoji" = ?', $builder->toSql());
$this->assertEquals([true, 'thumbs-up'], $builder->getBindings());
$this->assertEquals($builderClass, get_class($builder));
}
}

0 comments on commit e1ca07d

Please sign in to comment.