diff --git a/Changelog.md b/Changelog.md index 3cc27348..8d7a1fbf 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,7 @@ - Added the `priceGreaterThan`, `priceLessThan` and `priceBetween` methods to the ProductSearch class by [Matima](https://github.com/mahdirezaei-dev) in [#176](https://github.com/vanilophp/framework/pull/176) - Added the `Macroable` trait to the `ProductSearch` class +- Added the unidirectional links feature - Added the possibility to retrieve the link items directly using `linkItems()` method as `Get::the($type)->linkItems()->of($model)` - Added the `link_items` helper (shortcut to Get::the()->linkItems() - Changed the offline payment gateway's icon from a circle to a plug+x diff --git a/src/Links/Changelog.md b/src/Links/Changelog.md index b7a8b72a..b44f53d9 100644 --- a/src/Links/Changelog.md +++ b/src/Links/Changelog.md @@ -5,6 +5,7 @@ ## Unreleased ##### 2024-XX-YY +- Added the unidirectional links feature - Added the possibility to retrieve the link items directly using `linkItems()` method as `Get::the($type)->linkItems()->of($model)` - Added the `link_items` helper (shortcut to Get::the()->linkItems() diff --git a/src/Links/Models/LinkGroup.php b/src/Links/Models/LinkGroup.php index 2fb0dfa1..321aeb2c 100644 --- a/src/Links/Models/LinkGroup.php +++ b/src/Links/Models/LinkGroup.php @@ -26,11 +26,13 @@ * @property int $id * @property int $link_type_id * @property int|null $property_id + * @property int|null $root_item_id * @property Carbon $created_at * @property Carbon|null $updated_at * * @property-read LinkType $type * @property-read Collection $items + * @property-read LinkGroupItem $rootItem * * @method static LinkGroup create(array $attributes) */ @@ -51,4 +53,9 @@ public function items(): HasMany { return $this->hasMany(LinkGroupItemProxy::modelClass(), 'link_group_id', 'id'); } + + public function rootItem(): BelongsTo + { + return $this->belongsTo(LinkGroupItemProxy::modelClass(), 'root_item_id', 'id'); + } } diff --git a/src/Links/Query/Establish.php b/src/Links/Query/Establish.php index f0496812..d2989d18 100644 --- a/src/Links/Query/Establish.php +++ b/src/Links/Query/Establish.php @@ -27,6 +27,8 @@ final class Establish use HasBaseModel; use CachesMorphTypes; + private bool $unidirectional = false; + private string $wants = 'link'; public static function a(LinkType|string $type): self @@ -39,6 +41,13 @@ public static function an(LinkType|string $type): self return self::a($type); } + public function unidirectional(): self + { + $this->unidirectional = true; + + return $this; + } + public function link(): self { $this->wants = 'link'; @@ -59,11 +68,15 @@ public function and(Model ...$models): void $destinationGroup = $groups->first(); if (null === $destinationGroup) { $destinationGroup = $this->createNewLinkGroup(); - LinkGroupItemProxy::create([ + $rootItem = LinkGroupItemProxy::create([ 'link_group_id' => $destinationGroup->id, 'linkable_id' => $this->baseModel->id, 'linkable_type' => $this->morphTypeOf($this->baseModel::class), ]); + if ($this->unidirectional) { + $destinationGroup->root_item_id = $rootItem->id; + $destinationGroup->save(); + } } foreach ($models as $model) { diff --git a/src/Links/Query/Get.php b/src/Links/Query/Get.php index 1613bd3b..fd5018c8 100644 --- a/src/Links/Query/Get.php +++ b/src/Links/Query/Get.php @@ -45,24 +45,28 @@ public function of(Model $model): Collection $result = collect(); if ('linkItems' === $this->wants) { $groups->each(function ($group) use ($result, $model) { - $result->push( - ...$group - ->items - ->reject(fn ($item) => $item->linkable_id === $model->id) - ); + if (is_null($group->root_item_id) || $group->rootItem->linkable_id === $model->id) { + $result->push( + ...$group + ->items + ->reject(fn($item) => $item->linkable_id === $model->id) + ); + } }); return $result; } $groups->each(function ($group) use ($result, $model) { - $result->push( - ...$group - ->items - ->map - ->linkable - ->reject(fn ($item) => $item->id === $model->id) - ); + if (is_null($group->root_item_id) || $group->rootItem->linkable_id === $model->id) { + $result->push( + ...$group + ->items + ->map + ->linkable + ->reject(fn($item) => $item->id === $model->id) + ); + } }); return $result; diff --git a/src/Links/Tests/Feature/QueryEstablishTest.php b/src/Links/Tests/Feature/QueryEstablishTest.php index 22312c93..7e1478d0 100644 --- a/src/Links/Tests/Feature/QueryEstablishTest.php +++ b/src/Links/Tests/Feature/QueryEstablishTest.php @@ -119,6 +119,40 @@ public function morphed_models_can_be_linked_together() $this->assertCount(1, Get::the('variant')->links()->of($prod2)); } + /** @test */ + public function unidirectional_links_can_be_established() + { + $phone = TestLinkableProduct::create(['name' => 'iPhone 15'])->fresh(); + $case1 = TestLinkableProduct::create(['name' => 'iPhone 15 Plastic Case 1'])->fresh(); + LinkType::create(['name' => 'Accessory']); + + Establish::a('accessory')->unidirectional()->link()->between($phone)->and($case1); + + $this->assertCount(1, $phone->links('accessory')); + $this->assertEquals($case1->id, $phone->links('accessory')->first()->id); + + $this->assertCount(0, $case1->links('accessory')); + } + + /** @test */ + public function unidirectional_links_can_be_established_between_multiple_entries() + { + $phone = TestLinkableProduct::create(['name' => 'iPhone 16'])->fresh(); + $case1 = TestLinkableProduct::create(['name' => 'iPhone 16 Plastic Case 1'])->fresh(); + $case2 = TestLinkableProduct::create(['name' => 'iPhone 16 Plastic Case 2'])->fresh(); + LinkType::create(['name' => 'Protection']); + + Establish::a('protection')->unidirectional()->link()->between($phone)->and($case1); + Establish::a('protection')->link()->between($phone)->and($case2); + + $this->assertCount(2, $phone->links('protection')); + $this->assertEquals($case1->id, $phone->links('protection')->first()->id); + $this->assertEquals($case2->id, $phone->links('protection')->last()->id); + + $this->assertCount(0, $case1->links('protection')); + $this->assertCount(0, $case2->links('protection')); + } + protected function setUpDatabase($app) { $this->loadMigrationsFrom(dirname(__DIR__) . '/migrations_of_property_module'); diff --git a/src/Links/Tests/Feature/QueryGetTest.php b/src/Links/Tests/Feature/QueryGetTest.php index bac90d41..9fbe72d6 100644 --- a/src/Links/Tests/Feature/QueryGetTest.php +++ b/src/Links/Tests/Feature/QueryGetTest.php @@ -19,6 +19,7 @@ use Vanilo\Links\Models\LinkGroup; use Vanilo\Links\Models\LinkGroupItem; use Vanilo\Links\Models\LinkType; +use Vanilo\Links\Query\Establish; use Vanilo\Links\Query\Get; use Vanilo\Links\Tests\Dummies\Property; use Vanilo\Links\Tests\Dummies\TestLinkableProduct; @@ -279,6 +280,34 @@ public function the_link_groups_helper_returns_link_groups_in_which_the_model_is $this->assertInstanceOf(LinkGroup::class, $variantGroups->first()); } + /** @test */ + public function unidirectional_links_can_be_properly_queried() + { + $phone = TestLinkableProduct::create(['name' => 'iPhone 17'])->fresh(); + $caseX = TestLinkableProduct::create(['name' => 'iPhone 17 Plastic Case X'])->fresh(); + $caseY = TestLinkableProduct::create(['name' => 'iPhone 17 Plastic Case Y'])->fresh(); + LinkType::create(['name' => 'Sleeves']); + + Establish::a('sleeves')->unidirectional()->link()->between($phone)->and($caseX); + Establish::a('sleeves')->unidirectional()->link()->between($phone)->and($caseY); + + $sleeves = Get::the('sleeves')->links()->of($phone); + $this->assertCount(2, $sleeves); + $this->assertEquals($caseX->id, $sleeves->first()->id); + $this->assertEquals($caseY->id, $sleeves->last()->id); + + $this->assertCount(0, Get::the('sleeves')->links()->of($caseX)); + $this->assertCount(0, Get::the('sleeves')->links()->of($caseY)); + + $sleeveItems = Get::the('sleeves')->linkItems()->of($phone); + $this->assertCount(2, $sleeveItems); + $this->assertEquals($caseX->id, $sleeveItems->first()->linkable_id); + $this->assertEquals($caseY->id, $sleeveItems->last()->linkable_id); + + $this->assertCount(0, Get::the('sleeves')->linkItems()->of($caseX)); + $this->assertCount(0, Get::the('sleeves')->linkItems()->of($caseY)); + } + protected function setUpDatabase($app) { $this->loadMigrationsFrom(dirname(__DIR__) . '/migrations_of_property_module'); diff --git a/src/Links/Traits/Linkable.php b/src/Links/Traits/Linkable.php index 30a0cb3d..9d9fe6a2 100644 --- a/src/Links/Traits/Linkable.php +++ b/src/Links/Traits/Linkable.php @@ -37,14 +37,18 @@ public function links(LinkType|string $type, $propertyId = null): Collection // @todo Optimize this to a single query $result = Collection::make(); - foreach ($this->linkGroups()->filter(fn ($group) => $group->type->id === $type->id) as $group) { - $result->push( - ...$group - ->items - ->map - ->linkable - ->reject(fn ($item) => $item->id === $this->id) - ); + + $groups = $this->linkGroups()->filter(fn ($group) => $group->type->id === $type->id); + foreach ($groups as $group) { + if (is_null($group->root_item_id) || $group->rootItem->linkable_id === $this->id) { + $result->push( + ...$group + ->items + ->map + ->linkable + ->reject(fn ($item) => $item->id === $this->id) + ); + } } return $result; diff --git a/src/Links/resources/database/migrations/2024_05_31_074502_add_root_item_to_link_group_items_table.php b/src/Links/resources/database/migrations/2024_05_31_074502_add_root_item_to_link_group_items_table.php new file mode 100644 index 00000000..f6281985 --- /dev/null +++ b/src/Links/resources/database/migrations/2024_05_31_074502_add_root_item_to_link_group_items_table.php @@ -0,0 +1,25 @@ +unsignedBigInteger('root_item_id')->nullable(); + + $table->foreign('root_item_id')->references('id')->on('link_group_items'); + }); + } + + public function down(): void + { + Schema::table('link_groups', function (Blueprint $table) { + $table->dropColumn('root_item_id'); + }); + } +};