From a479073b16ff294ab5b91e284e8cc89f3266d1ca Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Fri, 27 Sep 2019 10:16:22 +0100 Subject: [PATCH 1/3] Add has-one-through relationship --- src/Eloquent/AbstractAdapter.php | 9 ++ src/Eloquent/HasOneThrough.php | 50 +++++++++ tests/dummy/app/History.php | 23 ++++ tests/dummy/app/JsonApi/Histories/Adapter.php | 43 +++++++ tests/dummy/app/JsonApi/Histories/Schema.php | 55 +++++++++ .../app/JsonApi/Histories/Validators.php | 56 +++++++++ tests/dummy/app/JsonApi/Suppliers/Adapter.php | 52 +++++++++ tests/dummy/app/JsonApi/Suppliers/Schema.php | 70 ++++++++++++ .../app/JsonApi/Suppliers/Validators.php | 56 +++++++++ tests/dummy/app/Supplier.php | 23 ++++ tests/dummy/app/User.php | 8 ++ tests/dummy/config/json-api-v1.php | 2 + .../dummy/database/factories/ModelFactory.php | 17 +++ .../2018_02_11_1648_create_tables.php | 16 +++ tests/dummy/routes/api.php | 6 + .../Eloquent/HasManyThroughTest.php | 4 +- .../Eloquent/HasOneThroughTest.php | 106 ++++++++++++++++++ 17 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 src/Eloquent/HasOneThrough.php create mode 100644 tests/dummy/app/History.php create mode 100644 tests/dummy/app/JsonApi/Histories/Adapter.php create mode 100644 tests/dummy/app/JsonApi/Histories/Schema.php create mode 100644 tests/dummy/app/JsonApi/Histories/Validators.php create mode 100644 tests/dummy/app/JsonApi/Suppliers/Adapter.php create mode 100644 tests/dummy/app/JsonApi/Suppliers/Schema.php create mode 100644 tests/dummy/app/JsonApi/Suppliers/Validators.php create mode 100644 tests/dummy/app/Supplier.php create mode 100644 tests/lib/Integration/Eloquent/HasOneThroughTest.php diff --git a/src/Eloquent/AbstractAdapter.php b/src/Eloquent/AbstractAdapter.php index d24d1c47..c825124b 100644 --- a/src/Eloquent/AbstractAdapter.php +++ b/src/Eloquent/AbstractAdapter.php @@ -555,6 +555,15 @@ protected function hasOne($modelKey = null) return new HasOne($modelKey ?: $this->guessRelation()); } + /** + * @param string|null $modelKey + * @return HasOneThrough + */ + protected function hasOneThrough($modelKey = null) + { + return new HasOneThrough($modelKey ?: $this->guessRelation()); + } + /** * @param string|null $modelKey * @return HasMany diff --git a/src/Eloquent/HasOneThrough.php b/src/Eloquent/HasOneThrough.php new file mode 100644 index 00000000..335b0e3e --- /dev/null +++ b/src/Eloquent/HasOneThrough.php @@ -0,0 +1,50 @@ +belongsTo(User::class); + } +} diff --git a/tests/dummy/app/JsonApi/Histories/Adapter.php b/tests/dummy/app/JsonApi/Histories/Adapter.php new file mode 100644 index 00000000..6eab2e62 --- /dev/null +++ b/tests/dummy/app/JsonApi/Histories/Adapter.php @@ -0,0 +1,43 @@ +getRouteKey(); + } + + /** + * @param History $resource + * @return array + */ + public function getAttributes($resource) + { + return ['detail' => $resource->detail]; + } + + /** + * @param History $resource + * @param bool $isPrimary + * @param array $includeRelationships + * @return array + */ + public function getRelationships($resource, $isPrimary, array $includeRelationships) + { + return [ + 'user' => [ + self::SHOW_SELF => false, + self::SHOW_RELATED => false, + self::SHOW_DATA => isset($includeRelationships['user']), + self::DATA => static function () use ($resource) { + return $resource->user; + }, + ], + ]; + } + + +} diff --git a/tests/dummy/app/JsonApi/Histories/Validators.php b/tests/dummy/app/JsonApi/Histories/Validators.php new file mode 100644 index 00000000..338d358c --- /dev/null +++ b/tests/dummy/app/JsonApi/Histories/Validators.php @@ -0,0 +1,56 @@ + ['required', 'string'], + ]; + } + + /** + * @inheritDoc + */ + protected function queryRules(): array + { + return []; + } + +} diff --git a/tests/dummy/app/JsonApi/Suppliers/Adapter.php b/tests/dummy/app/JsonApi/Suppliers/Adapter.php new file mode 100644 index 00000000..45ae38e7 --- /dev/null +++ b/tests/dummy/app/JsonApi/Suppliers/Adapter.php @@ -0,0 +1,52 @@ +hasOneThrough(); + } + + /** + * @inheritDoc + */ + protected function filter($query, Collection $filters) + { + // TODO: Implement filter() method. + } + +} diff --git a/tests/dummy/app/JsonApi/Suppliers/Schema.php b/tests/dummy/app/JsonApi/Suppliers/Schema.php new file mode 100644 index 00000000..550137bf --- /dev/null +++ b/tests/dummy/app/JsonApi/Suppliers/Schema.php @@ -0,0 +1,70 @@ +getRouteKey(); + } + + /** + * @param Supplier $resource + * @return array + */ + public function getAttributes($resource) + { + return ['name' => $resource->name]; + } + + /** + * @param Supplier $resource + * @param bool $isPrimary + * @param array $includeRelationships + * @return array + */ + public function getRelationships($resource, $isPrimary, array $includeRelationships) + { + return [ + 'user-history' => [ + self::SHOW_SELF => true, + self::SHOW_RELATED => true, + self::SHOW_DATA => isset($includeRelationships['user-history']), + self::DATA => static function () use ($resource) { + return $resource->userHistory; + }, + ], + ]; + } + + +} diff --git a/tests/dummy/app/JsonApi/Suppliers/Validators.php b/tests/dummy/app/JsonApi/Suppliers/Validators.php new file mode 100644 index 00000000..566b368c --- /dev/null +++ b/tests/dummy/app/JsonApi/Suppliers/Validators.php @@ -0,0 +1,56 @@ + ['required', 'string'], + ]; + } + + /** + * @inheritDoc + */ + protected function queryRules(): array + { + return []; + } + +} diff --git a/tests/dummy/app/Supplier.php b/tests/dummy/app/Supplier.php new file mode 100644 index 00000000..747cb1d7 --- /dev/null +++ b/tests/dummy/app/Supplier.php @@ -0,0 +1,23 @@ +hasOneThrough(History::class, User::class); + } +} diff --git a/tests/dummy/app/User.php b/tests/dummy/app/User.php index 9b3856ab..c8256d08 100644 --- a/tests/dummy/app/User.php +++ b/tests/dummy/app/User.php @@ -80,4 +80,12 @@ public function country() { return $this->belongsTo(Country::class); } + + /** + * @return BelongsTo + */ + public function supplier() + { + return $this->belongsTo(Supplier::class); + } } diff --git a/tests/dummy/config/json-api-v1.php b/tests/dummy/config/json-api-v1.php index b1ed4147..f1b1e5e7 100644 --- a/tests/dummy/config/json-api-v1.php +++ b/tests/dummy/config/json-api-v1.php @@ -57,9 +57,11 @@ 'comments' => \DummyApp\Comment::class, 'countries' => \DummyApp\Country::class, 'downloads' => \DummyApp\Download::class, + 'histories' => \DummyApp\History::class, 'phones' => \DummyApp\Phone::class, 'posts' => \DummyApp\Post::class, 'sites' => \DummyApp\Entities\Site::class, + 'suppliers' => \DummyApp\Supplier::class, 'tags' => \DummyApp\Tag::class, 'users' => \DummyApp\User::class, 'videos' => \DummyApp\Video::class, diff --git a/tests/dummy/database/factories/ModelFactory.php b/tests/dummy/database/factories/ModelFactory.php index 872c5e20..47beb032 100644 --- a/tests/dummy/database/factories/ModelFactory.php +++ b/tests/dummy/database/factories/ModelFactory.php @@ -145,3 +145,20 @@ }, ]; }); + +/** Supplier */ +$factory->define(DummyApp\Supplier::class, function (Faker $faker) { + return [ + 'name' => $faker->company, + ]; +}); + +/** History */ +$factory->define(DummyApp\History::class, function (Faker $faker) { + return [ + 'detail' => $faker->paragraph, + 'user_id' => function () { + return factory(DummyApp\User::class)->create()->getKey(); + }, + ]; +}); diff --git a/tests/dummy/database/migrations/2018_02_11_1648_create_tables.php b/tests/dummy/database/migrations/2018_02_11_1648_create_tables.php index 553bbade..23267f37 100644 --- a/tests/dummy/database/migrations/2018_02_11_1648_create_tables.php +++ b/tests/dummy/database/migrations/2018_02_11_1648_create_tables.php @@ -37,6 +37,7 @@ public function up() $table->rememberToken(); $table->timestamps(); $table->unsignedInteger('country_id')->nullable(); + $table->unsignedInteger('supplier_id')->nullable(); }); Schema::create('avatars', function (Blueprint $table) { @@ -107,6 +108,19 @@ public function up() $table->timestamps(); $table->string('category'); }); + + Schema::create('suppliers', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + $table->string('name'); + }); + + Schema::create('histories', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + $table->text('detail'); + $table->unsignedInteger('user_id'); + }); } /** @@ -122,5 +136,7 @@ public function down() Schema::dropIfExists('phones'); Schema::dropIfExists('countries'); Schema::dropIfExists('downloads'); + Schema::dropIfExists('suppliers'); + Schema::dropIfExists('histories'); } } diff --git a/tests/dummy/routes/api.php b/tests/dummy/routes/api.php index 41d12bc2..0f0ab7e5 100644 --- a/tests/dummy/routes/api.php +++ b/tests/dummy/routes/api.php @@ -62,4 +62,10 @@ $api->resource('sites', [ 'controller' => true, ]); + + $api->resource('suppliers', [ + 'has-one' => [ + 'user-history' => ['only' => ['read', 'related']], + ], + ]); }); diff --git a/tests/lib/Integration/Eloquent/HasManyThroughTest.php b/tests/lib/Integration/Eloquent/HasManyThroughTest.php index d19904b7..fd2c6950 100644 --- a/tests/lib/Integration/Eloquent/HasManyThroughTest.php +++ b/tests/lib/Integration/Eloquent/HasManyThroughTest.php @@ -23,9 +23,9 @@ use DummyApp\User; /** - * Class HasManyTest + * Class HasManyThroughTest * - * Test a JSON API has-many relationship that relates to an Eloquent + * Test a JSON API has-many-through relationship that relates to an Eloquent * has-many-through relationship. * * In our dummy app, this is the posts relationship on a country model. diff --git a/tests/lib/Integration/Eloquent/HasOneThroughTest.php b/tests/lib/Integration/Eloquent/HasOneThroughTest.php new file mode 100644 index 00000000..b6b0e4ba --- /dev/null +++ b/tests/lib/Integration/Eloquent/HasOneThroughTest.php @@ -0,0 +1,106 @@ +create(); + $user = factory(User::class)->create(['supplier_id' => $supplier->getKey()]); + $history = factory(History::class)->create(['user_id' => $user->getKey()]); + + $data = [ + 'type' => 'histories', + 'id' => (string) $history->getRouteKey(), + 'attributes' => [ + 'detail' => $history->detail, + ], + 'relationships' => [ + 'user' => [ + 'data' => [ + 'type' => 'users', + 'id' => (string) $user->getRouteKey(), + ], + ], + ], + ]; + + $this->withoutExceptionHandling() + ->doReadRelated($supplier, 'user-history', ['include' => 'user']) + ->assertFetchedOne($data); + } + + public function testReadRelatedEmpty(): void + { + $supplier = factory(Supplier::class)->create(); + + $this->withoutExceptionHandling() + ->doReadRelated($supplier, 'user-history') + ->assertFetchedNull(); + } + + public function testReadRelationship(): void + { + $supplier = factory(Supplier::class)->create(); + $user = factory(User::class)->create(['supplier_id' => $supplier->getKey()]); + $history = factory(History::class)->create(['user_id' => $user->getKey()]); + + $this->withoutExceptionHandling() + ->willSeeResourceType('histories') + ->doReadRelationship($supplier, 'user-history') + ->assertFetchedToOne($history); + } + + public function testReadEmptyRelationship(): void + { + $supplier = factory(Supplier::class)->create(); + + $this->withoutExceptionHandling() + ->doReadRelationship($supplier, 'user-history') + ->assertFetchedNull(); + } +} From 9581748070298bca3ec5464dec2ad6f000ea7348 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Fri, 27 Sep 2019 10:31:40 +0100 Subject: [PATCH 2/3] Skip tests for older versions of Laravel --- .../Eloquent/HasOneThroughTest.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/lib/Integration/Eloquent/HasOneThroughTest.php b/tests/lib/Integration/Eloquent/HasOneThroughTest.php index b6b0e4ba..a90e15ff 100644 --- a/tests/lib/Integration/Eloquent/HasOneThroughTest.php +++ b/tests/lib/Integration/Eloquent/HasOneThroughTest.php @@ -21,6 +21,7 @@ use DummyApp\History; use DummyApp\Supplier; use DummyApp\User; +use Illuminate\Database\Eloquent\Relations\HasOneThrough; /** * Class HasOneThroughTest @@ -49,6 +50,8 @@ class HasOneThroughTest extends TestCase */ public function testReadRelated(): void { + $this->checkSupported(); + $supplier = factory(Supplier::class)->create(); $user = factory(User::class)->create(['supplier_id' => $supplier->getKey()]); $history = factory(History::class)->create(['user_id' => $user->getKey()]); @@ -76,6 +79,8 @@ public function testReadRelated(): void public function testReadRelatedEmpty(): void { + $this->checkSupported(); + $supplier = factory(Supplier::class)->create(); $this->withoutExceptionHandling() @@ -85,6 +90,8 @@ public function testReadRelatedEmpty(): void public function testReadRelationship(): void { + $this->checkSupported(); + $supplier = factory(Supplier::class)->create(); $user = factory(User::class)->create(['supplier_id' => $supplier->getKey()]); $history = factory(History::class)->create(['user_id' => $user->getKey()]); @@ -97,10 +104,23 @@ public function testReadRelationship(): void public function testReadEmptyRelationship(): void { + $this->checkSupported(); + $supplier = factory(Supplier::class)->create(); $this->withoutExceptionHandling() ->doReadRelationship($supplier, 'user-history') ->assertFetchedNull(); } + + /** + * @return void + * @todo remove when minimum Laravel version is 5.8. + */ + private function checkSupported(): void + { + if (!class_exists(HasOneThrough::class)) { + $this->markTestSkipped('Eloquent has-one-through not supported.'); + } + } } From 4c032df18e4bd2ea4f298200912d0a1519b609fa Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Fri, 27 Sep 2019 10:35:48 +0100 Subject: [PATCH 3/3] Update docs --- docs/basics/adapters.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/basics/adapters.md b/docs/basics/adapters.md index 34fe6998..69489bd1 100644 --- a/docs/basics/adapters.md +++ b/docs/basics/adapters.md @@ -224,6 +224,7 @@ for Eloquent models. The relationship types available are `belongsTo`, `hasOne`, | Eloquent | JSON API | | :-- | :-- | | `hasOne` | `hasOne` | +| `hasOneThrough` | `hasOneThrough` | | `belongsTo` | `belongsTo` | | `hasMany` | `hasMany` | | `belongsToMany` | `hasMany` | @@ -338,15 +339,16 @@ class Adapter extends AbstractAdapter } ``` -#### Has-Many-Through +#### Has-One-Through and Has-Many-Through -The JSON API `hasManyThrough` relation can be used for an Eloquent `hasManyThrough` relation. The important thing -to note about this relationship is it is **read-only**. This is because the relationship can be modified in your API -by modifying the intermediary model. For example, a `countries` resource might have many `posts` resources through -an intermediate `users` resource. The relationship is effectively modified by creating and deleting posts and/or a -user changing which country they are associated to. +The JSON API `hasOneThrough` and `hasManyThrough` relations can be used for an Eloquent `hasOneThrough` +and `hasManyThrough` relation. The important thing to note about these relationships is that both are **read-only**. +This is because the relationship can be modified in your API by modifying the intermediary model. +For example, a `countries` resource might have many `posts` resources through an intermediate `users` resource. +The relationship is effectively modified by creating and deleting posts and/or a user changing which country they +are associated to. -Define a has-many-through relationship on an adapter as follows: +Use the `hasOneThrough()` or `hasManyThrough()` methods on your adapter as follows: ```php class Adapter extends AbstractAdapter