Skip to content

Commit

Permalink
Support HasManyThrough relationships on PostgreSQL
Browse files Browse the repository at this point in the history
  • Loading branch information
staudenmeir committed Dec 18, 2018
1 parent 2d9dd76 commit fef0069
Show file tree
Hide file tree
Showing 11 changed files with 242 additions and 38 deletions.
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,7 @@ class Locale extends Model
}
```

Remember to use the `HasJsonRelationships` trait in both the parent and the related model.

**Limitations:** On PostgreSQL, `HasManyThrough` relationships don't work with integer keys.
Remember to use the `HasJsonRelationships` trait in both the parent and the related model.

### Many-To-Many Relationships

Expand Down Expand Up @@ -165,7 +163,7 @@ $user->roles()->toggle([2 => ['active' => true], 3])->save();

### Referential Integrity

On one-to-many relationships, you can still ensure referential integrity.
On one-to-many relationships, you can still ensure referential integrity.

[MySQL](https://dev.mysql.com/doc/refman/en/create-table-foreign-keys.html) and [SQL Server](https://docs.microsoft.com/en-us/sql/relational-databases/tables/specify-computed-columns-in-a-table) support foreign keys on JSON columns with generated/computed columns.

Expand Down
39 changes: 22 additions & 17 deletions src/HasJsonRelationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Staudenmeir\EloquentJsonRelations\Relations\Postgres\BelongsTo as BelongsToPostgres;
use Staudenmeir\EloquentJsonRelations\Relations\Postgres\HasMany as HasManyPostgres;
use Staudenmeir\EloquentJsonRelations\Relations\Postgres\HasOne as HasOnePostgres;
use Staudenmeir\EloquentJsonRelations\Relations\Postgres\HasManyThrough as HasManyThroughPostgres;
use Staudenmeir\EloquentJsonRelations\Relations\Postgres\MorphMany as MorphManyPostgres;
use Staudenmeir\EloquentJsonRelations\Relations\Postgres\MorphOne as MorphOnePostgres;

Expand Down Expand Up @@ -136,6 +137,27 @@ protected function newHasMany(Builder $query, Model $parent, $foreignKey, $local
return new HasMany($query, $parent, $foreignKey, $localKey);
}

/**
* Instantiate a new HasManyThrough relationship.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Illuminate\Database\Eloquent\Model $farParent
* @param \Illuminate\Database\Eloquent\Model $throughParent
* @param string $firstKey
* @param string $secondKey
* @param string $localKey
* @param string $secondLocalKey
* @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/
protected function newHasManyThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey)
{
if ($query->getConnection()->getDriverName() === 'pgsql') {
return new HasManyThroughPostgres($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey);
}

return new HasManyThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey);
}

/**
* Instantiate a new MorphMany relationship.
*
Expand Down Expand Up @@ -226,21 +248,4 @@ protected function newHasManyJson(Builder $query, Model $parent, $foreignKey, $l
{
return new HasManyJson($query, $parent, $foreignKey, $localKey);
}

/**
* Instantiate a new HasManyThrough relationship.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Illuminate\Database\Eloquent\Model $farParent
* @param \Illuminate\Database\Eloquent\Model $throughParent
* @param string $firstKey
* @param string $secondKey
* @param string $localKey
* @param string $secondLocalKey
* @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/
protected function newHasManyThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey)
{
return new HasManyThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey);
}
}
109 changes: 109 additions & 0 deletions src/Relations/Postgres/HasManyThrough.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

namespace Staudenmeir\EloquentJsonRelations\Relations\Postgres;

use Illuminate\Database\Eloquent\Builder;
use Staudenmeir\EloquentJsonRelations\HasManyThrough as Base;

class HasManyThrough extends Base
{
use IsPostgresRelation;

/**
* Set the join clause on the query.
*
* @param \Illuminate\Database\Eloquent\Builder|null $query
* @return void
*/
protected function performJoin(Builder $query = null)
{
$query = $query ?: $this->query;

$farKey = $this->jsonColumn($query, $this->throughParent, $this->getQualifiedFarKeyName(), $this->secondLocalKey);

$query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey);

if ($this->throughParentSoftDeletes()) {
$query->whereNull($this->throughParent->getQualifiedDeletedAtColumn());
}
}

/**
* Add the constraints for a relationship query.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Illuminate\Database\Eloquent\Builder $parentQuery
* @param array|mixed $columns
* @return \Illuminate\Database\Eloquent\Builder
*/
public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*'])
{
if ($parentQuery->getQuery()->from === $query->getQuery()->from) {
return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns);
}

if ($parentQuery->getQuery()->from === $this->throughParent->getTable()) {
return $this->getRelationExistenceQueryForThroughSelfRelation($query, $parentQuery, $columns);
}

$this->performJoin($query);

$firstKey = $this->jsonColumn($query, $this->farParent, $this->getQualifiedFirstKeyName(), $this->localKey);

return $query->select($columns)->whereColumn(
$this->getQualifiedLocalKeyName(), '=', $firstKey
);
}

/**
* Add the constraints for a relationship query on the same table.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Illuminate\Database\Eloquent\Builder $parentQuery
* @param array|mixed $columns
* @return \Illuminate\Database\Eloquent\Builder
*/
public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*'])
{
$query->from($query->getModel()->getTable().' as '.$hash = $this->getRelationCountHash());

$farKey = $this->jsonColumn($query, $this->throughParent, $hash.'.'.$this->secondKey, $this->secondLocalKey);

$query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey);

$query->getModel()->setTable($hash);

$firstKey = $this->jsonColumn($query, $this->farParent, $this->getQualifiedFirstKeyName(), $this->localKey);

return $query->select($columns)->whereColumn(
$parentQuery->getQuery()->from.'.'.$this->localKey, '=', $firstKey
);
}

/**
* Add the constraints for a relationship query on the same table as the through parent.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Illuminate\Database\Eloquent\Builder $parentQuery
* @param array|mixed $columns
* @return \Illuminate\Database\Eloquent\Builder
*/
public function getRelationExistenceQueryForThroughSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*'])
{
$table = $this->throughParent->getTable().' as '.$hash = $this->getRelationCountHash();

$farKey = $this->jsonColumn($query, $this->throughParent, $this->getQualifiedFarKeyName(), $this->secondLocalKey);

$query->join($table, $hash.'.'.$this->secondLocalKey, '=', $farKey);

if ($this->throughParentSoftDeletes()) {
$query->whereNull($hash.'.'.$this->throughParent->getDeletedAtColumn());
}

$firstKey = $this->jsonColumn($query, $this->farParent, $hash.'.'.$this->firstKey, $this->localKey);

return $query->select($columns)->whereColumn(
$parentQuery->getQuery()->from.'.'.$this->localKey, '=', $firstKey
);
}
}
13 changes: 0 additions & 13 deletions src/Relations/Postgres/HasOneOrMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace Staudenmeir\EloquentJsonRelations\Relations\Postgres;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

trait HasOneOrMany
{
Expand Down Expand Up @@ -50,16 +49,4 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder
$this->getQualifiedParentKeyName(), '=', $second
);
}

/**
* Get the name of the "where in" method for eager loading.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @return string
*/
protected function whereInMethod(Model $model, $key)
{
return 'whereIn';
}
}
12 changes: 12 additions & 0 deletions src/Relations/Postgres/IsPostgresRelation.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,16 @@ protected function jsonColumn(Builder $query, Model $model, $column, $key)

return new Expression($sql);
}

/**
* Get the name of the "where in" method for eager loading.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @return string
*/
protected function whereInMethod(Model $model, $key)
{
return 'whereIn';
}
}
21 changes: 21 additions & 0 deletions tests/HasManyThroughTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

namespace Tests;

use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Tests\Models\Category;
use Tests\Models\Locale;
use Tests\Models\User;

class HasManyThroughTest extends TestCase
{
Expand Down Expand Up @@ -33,4 +36,22 @@ public function testExistenceQuery()

$this->assertEquals([1], $locales->pluck('id')->all());
}

public function testExistenceQueryForSelfRelation()
{
$users = User::has('teamMates')->get();

$this->assertEquals([1], $users->pluck('id')->all());
}

public function testExistenceQueryForThroughSelfRelation()
{
if (! method_exists(HasManyThrough::class, 'getRelationExistenceQueryForThroughSelfRelation')) {
$this->markTestSkipped();
}

$categories = Category::has('subProducts')->get();

$this->assertEquals([1], $categories->pluck('id')->all());
}
}
19 changes: 19 additions & 0 deletions tests/Models/Category.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Tests\Models;

use Illuminate\Database\Eloquent\SoftDeletes;

class Category extends Model
{
use SoftDeletes;

protected $casts = [
'options' => 'json'
];

public function subProducts()
{
return $this->hasManyThrough(Product::class, self::class, 'options->parent_id', 'options->category_id');
}
}
10 changes: 10 additions & 0 deletions tests/Models/Product.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Tests\Models;

class Product extends Model
{
protected $casts = [
'options' => 'json'
];
}
10 changes: 10 additions & 0 deletions tests/Models/Team.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Tests\Models;

class Team extends Model
{
protected $casts = [
'options' => 'json'
];
}
5 changes: 5 additions & 0 deletions tests/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ public function roles3()
{
return $this->belongsToJson(Role::class, 'options[]->role_id');
}

public function teamMates()
{
return $this->hasManyThrough(self::class, Team::class, 'options->owner_id', 'options->team_id');
}
}
36 changes: 32 additions & 4 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use PHPUnit\Framework\TestCase as Base;
use Tests\Models\Category;
use Tests\Models\Comment;
use Tests\Models\Locale;
use Tests\Models\Post;
use Tests\Models\Product;
use Tests\Models\Role;
use Tests\Models\Team;
use Tests\Models\User;

abstract class TestCase extends Base
Expand All @@ -31,13 +34,13 @@ protected function setUp()
$table->increments('id');
});

DB::schema()->create('users', function (Blueprint $table) {
DB::schema()->create('locales', function (Blueprint $table) {
$table->increments('id');
$table->json('options');
});

DB::schema()->create('locales', function (Blueprint $table) {
DB::schema()->create('users', function (Blueprint $table) {
$table->increments('id');
$table->json('options');
});

DB::schema()->create('posts', function (Blueprint $table) {
Expand All @@ -50,6 +53,22 @@ protected function setUp()
$table->json('options');
});

DB::schema()->create('teams', function (Blueprint $table) {
$table->increments('id');
$table->json('options');
});

DB::schema()->create('categories', function (Blueprint $table) {
$table->increments('id');
$table->json('options');
$table->softDeletes();
});

DB::schema()->create('products', function (Blueprint $table) {
$table->increments('id');
$table->json('options');
});

Model::unguarded(function () {
Role::create();
Role::create();
Expand All @@ -69,7 +88,7 @@ protected function setUp()
],
],
]);
User::create(['options' => []]);
User::create(['options' => ['team_id' => 1]]);
User::create([
'options' => [
'role_ids' => [2, 3],
Expand All @@ -94,6 +113,15 @@ protected function setUp()
Comment::create(['options' => ['commentable_type' => Post::class, 'commentable_id' => 1]]);
Comment::create(['options' => ['parent_id' => 1]]);
Comment::create(['options' => ['commentable_type' => User::class, 'commentable_id' => 2]]);

Team::create(['options' => ['owner_id' => 1]]);
Team::create(['options' => []]);

Category::create(['options' => []]);
Category::create(['options' => ['parent_id' => 1]]);

Product::create(['options' => ['category_id' => 2]]);
Product::create(['options' => []]);
});
}
}

0 comments on commit fef0069

Please sign in to comment.