Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace Dystore\Api\Domain\Products\JsonApi\Filters;

use Illuminate\Database\Eloquent\Builder;
use LaravelJsonApi\Eloquent\Contracts\Filter;
use LaravelJsonApi\Eloquent\Filters\Concerns\DeserializesValue;

/**
* Filter products by collections within specific collection groups.
*
* Accepts a nested array keyed by collection group handle, each containing
* an `id` key with comma-separated collection IDs. Produces AND logic
* between groups, OR logic within a group's collection IDs.
*
* Example: ?filter[collection_groups][group-1][id]=1,2,3&filter[collection_groups][group-2][id]=4,5,6
*
* @phpstan-consistent-constructor
*/
class CollectionGroupFilter implements Filter
{
use DeserializesValue;

private string $name;

public function __construct(string $name)
{
$this->name = $name;
}

public static function make(string $name): self
{
return new static($name);
}

public function key(): string
{
return $this->name;
}

public function isSingular(): bool
{
return false;
}

/**
* Apply the filter to the query.
*
* @param Builder $query
* @param mixed $value
* @return Builder
*/
public function apply($query, $value)
{
if (! is_array($value)) {
return $query;
}

foreach ($value as $groupHandle => $filters) {
if (! is_array($filters) || empty($filters['id'])) {
continue;
}

$ids = is_string($filters['id'])
? array_filter(explode(',', $filters['id']), fn ($id) => $id !== '')
: (array) $filters['id'];

if (empty($ids)) {
continue;
}

$query->whereHas('collections', function ($q) use ($groupHandle, $ids) {
$q->whereHas('group', function ($q) use ($groupHandle) {
$q->where(
$q->getModel()->qualifyColumn('handle'),
$groupHandle,
);
});

$q->whereIn(
$q->getModel()->qualifyColumn('id'),
$ids,
);
});
}

return $query;
}
}
3 changes: 3 additions & 0 deletions packages/api/src/Domain/Products/JsonApi/V1/ProductSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Dystore\Api\Domain\JsonApi\Eloquent\Sorts\InDefaultOrder;
use Dystore\Api\Domain\JsonApi\Eloquent\Sorts\InRandomOrder;
use Dystore\Api\Domain\Products\Builders\ProductBuilder;
use Dystore\Api\Domain\Products\JsonApi\Filters\CollectionGroupFilter;
use Dystore\Api\Domain\Products\JsonApi\Filters\InStockFilter;
use Dystore\Api\Domain\Products\JsonApi\Filters\ProductFilterCollection;
use Dystore\Api\Support\Models\Actions\SchemaType;
Expand Down Expand Up @@ -317,6 +318,8 @@ public function filters(): array

WhereHas::make($this, 'collections'),

CollectionGroupFilter::make('collection_groups'),

WhereHas::make($this, 'tags'),

...(new ProductFilterCollection)->toArray(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
<?php

use Dystore\Api\Domain\Prices\Models\Price;
use Dystore\Api\Domain\Products\Models\Product;
use Dystore\Api\Domain\ProductVariants\Models\ProductVariant;
use Dystore\Tests\Api\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;

uses(TestCase::class, RefreshDatabase::class)
->group('products', 'filters');

it('can filter products by a single collection group', function () {
/** @var TestCase $this */
$group = CollectionGroup::factory()->create(['handle' => 'category']);

$collectionA = Collection::factory()->create(['collection_group_id' => $group->id]);
$collectionB = Collection::factory()->create(['collection_group_id' => $group->id]);

$matchingProduct = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$matchingProduct->collections()->attach([$collectionA->id]);

$nonMatchingProduct = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();

$response = $this
->jsonApi()
->expects('products')
->get(serverUrl("/products?filter[collection_groups][category][id]={$collectionA->id}"));

$response
->assertSuccessful()
->assertFetchedMany([$matchingProduct])
->assertDoesntHaveIncluded();
});

it('can filter products by multiple collection ids within a group using OR logic', function () {
/** @var TestCase $this */
$group = CollectionGroup::factory()->create(['handle' => 'category']);

$collectionA = Collection::factory()->create(['collection_group_id' => $group->id]);
$collectionB = Collection::factory()->create(['collection_group_id' => $group->id]);
$collectionC = Collection::factory()->create(['collection_group_id' => $group->id]);

$productA = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$productA->collections()->attach([$collectionA->id]);

$productB = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$productB->collections()->attach([$collectionB->id]);

$productC = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$productC->collections()->attach([$collectionC->id]);

$ids = implode(',', [$collectionA->id, $collectionB->id]);

$response = $this
->jsonApi()
->expects('products')
->get(serverUrl("/products?filter[collection_groups][category][id]={$ids}"));

$response
->assertSuccessful()
->assertFetchedMany([$productA, $productB])
->assertDoesntHaveIncluded();
});

it('can filter products across multiple collection groups with AND logic', function () {
/** @var TestCase $this */
$groupCategory = CollectionGroup::factory()->create(['handle' => 'category']);
$groupSeason = CollectionGroup::factory()->create(['handle' => 'season']);

$categoryA = Collection::factory()->create(['collection_group_id' => $groupCategory->id]);
$seasonWinter = Collection::factory()->create(['collection_group_id' => $groupSeason->id]);
$seasonSummer = Collection::factory()->create(['collection_group_id' => $groupSeason->id]);

// Product in categoryA AND seasonWinter — should match
$matchingProduct = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$matchingProduct->collections()->attach([$categoryA->id, $seasonWinter->id]);

// Product in categoryA but NOT in seasonWinter — should NOT match
$categoryOnlyProduct = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$categoryOnlyProduct->collections()->attach([$categoryA->id, $seasonSummer->id]);

// Product in seasonWinter but NOT in categoryA — should NOT match
$seasonOnlyProduct = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$seasonOnlyProduct->collections()->attach([$seasonWinter->id]);

$response = $this
->jsonApi()
->expects('products')
->get(serverUrl("/products?filter[collection_groups][category][id]={$categoryA->id}&filter[collection_groups][season][id]={$seasonWinter->id}"));

$response
->assertSuccessful()
->assertFetchedMany([$matchingProduct])
->assertDoesntHaveIncluded();
});

it('returns all products when collection_groups filter value is empty', function () {
/** @var TestCase $this */
$products = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->count(2)
->create();

$response = $this
->jsonApi()
->expects('products')
->get(serverUrl('/products'));

$response
->assertSuccessful()
->assertFetchedMany($products);
});

it('returns no products when filtering by a nonexistent group handle', function () {
/** @var TestCase $this */
$group = CollectionGroup::factory()->create(['handle' => 'category']);
$collection = Collection::factory()->create(['collection_group_id' => $group->id]);

$product = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$product->collections()->attach([$collection->id]);

$response = $this
->jsonApi()
->expects('products')
->get(serverUrl("/products?filter[collection_groups][nonexistent-handle][id]={$collection->id}"));

$response
->assertSuccessful()
->assertFetchedNone();
});

it('ignores groups with empty collection IDs and returns all products', function () {
/** @var TestCase $this */
$group = CollectionGroup::factory()->create(['handle' => 'category']);
$collection = Collection::factory()->create(['collection_group_id' => $group->id]);

$products = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->count(2)
->create();
$products->first()->collections()->attach([$collection->id]);

$response = $this
->jsonApi()
->expects('products')
->get(serverUrl('/products?filter[collection_groups][category][id]='));

$response
->assertSuccessful()
->assertFetchedMany($products);
});

it('returns no products when filtering by nonexistent collection IDs', function () {
/** @var TestCase $this */
$group = CollectionGroup::factory()->create(['handle' => 'category']);
$collection = Collection::factory()->create(['collection_group_id' => $group->id]);

$product = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$product->collections()->attach([$collection->id]);

$response = $this
->jsonApi()
->expects('products')
->get(serverUrl('/products?filter[collection_groups][category][id]=99999'));

$response
->assertSuccessful()
->assertFetchedNone();
});

it('returns only matching products when mixing valid and nonexistent collection IDs', function () {
/** @var TestCase $this */
$group = CollectionGroup::factory()->create(['handle' => 'category']);
$collectionA = Collection::factory()->create(['collection_group_id' => $group->id]);

$matchingProduct = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$matchingProduct->collections()->attach([$collectionA->id]);

$nonMatchingProduct = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();

$ids = implode(',', [$collectionA->id, 99999]);

$response = $this
->jsonApi()
->expects('products')
->get(serverUrl("/products?filter[collection_groups][category][id]={$ids}"));

$response
->assertSuccessful()
->assertFetchedMany([$matchingProduct])
->assertDoesntHaveIncluded();
});

it('does not duplicate products belonging to multiple collections within the same group', function () {
/** @var TestCase $this */
$group = CollectionGroup::factory()->create(['handle' => 'category']);
$collectionA = Collection::factory()->create(['collection_group_id' => $group->id]);
$collectionB = Collection::factory()->create(['collection_group_id' => $group->id]);

$product = Product::factory()
->has(ProductVariant::factory()->has(Price::factory()), 'variants')
->create();
$product->collections()->attach([$collectionA->id, $collectionB->id]);

$ids = implode(',', [$collectionA->id, $collectionB->id]);

$response = $this
->jsonApi()
->expects('products')
->get(serverUrl("/products?filter[collection_groups][category][id]={$ids}"));

$response
->assertSuccessful()
->assertFetchedMany([$product])
->assertDoesntHaveIncluded();
});
Loading