diff --git a/composer.json b/composer.json index 4edd5a5a8..3f95b4eab 100644 --- a/composer.json +++ b/composer.json @@ -114,8 +114,7 @@ "lunarphp/lunar": "1.1.0", "lunarphp/stripe": "^1.1.0", "spatie/laravel-newsletter": "^5.1", - "spatie/laravel-stripe-webhooks": "^3.6", - "staudenmeir/eloquent-has-many-deep": "^1.20" + "spatie/laravel-stripe-webhooks": "^3.6" }, "type": "library", "authors": [ diff --git a/packages/api/src/Domain/Collections/JsonApi/V1/CollectionSchema.php b/packages/api/src/Domain/Collections/JsonApi/V1/CollectionSchema.php index 16452620b..a1b016372 100644 --- a/packages/api/src/Domain/Collections/JsonApi/V1/CollectionSchema.php +++ b/packages/api/src/Domain/Collections/JsonApi/V1/CollectionSchema.php @@ -81,6 +81,9 @@ public function fields(): array AttributeData::make('attribute_data') ->groupAttributes(), + Number::make('product_position') + ->extractUsing(fn (Collection $model) => $model->pivot?->position), + Number::make('parent_id', 'parent_id') ->hidden(), diff --git a/packages/api/src/Domain/Products/Concerns/HasRelationships.php b/packages/api/src/Domain/Products/Concerns/HasRelationships.php index 0ea7b23e2..21ce41b97 100644 --- a/packages/api/src/Domain/Products/Concerns/HasRelationships.php +++ b/packages/api/src/Domain/Products/Concerns/HasRelationships.php @@ -5,7 +5,9 @@ use Dystore\Api\Domain\Prices\Builders\PriceBuilder; use Dystore\Api\Domain\Prices\Models\Price; use Dystore\Api\Domain\Products\Models\Product; +use Dystore\Api\Domain\Products\Relations\BelongsToManyThrough; use Dystore\Api\Domain\ProductTypes\Models\ProductType; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOneThrough; @@ -16,13 +18,9 @@ use Lunar\Models\Attribute; use Lunar\Models\ProductOptionValue; use Lunar\Models\ProductVariant; -use Staudenmeir\EloquentHasManyDeep\HasManyDeep; -use Staudenmeir\EloquentHasManyDeep\HasRelationships as DeepRelationships; trait HasRelationships { - use DeepRelationships; - /** * Get the mapped attributes relation. */ @@ -199,17 +197,28 @@ public function mostExpensiveVariant(): HasOne } /** - * @return HasManyDeep + * Get distinct product option values from all variants. */ - public function variantValues(): HasManyDeep + public function variantValues(): BelongsToMany { /** @var Product $this */ - $variantsTable = $this->variants()->getModel()->getTable(); $prefix = Config::get('lunar.database.table_prefix'); + $pivotTable = "{$prefix}product_option_value_product_variant"; + $variantsTable = (new (ProductVariant::modelClass()))->getTable(); + + $instance = $this->newRelatedInstance(ProductOptionValue::modelClass()); - return $this->hasManyDeepFromRelations( - $this->variants(), - (new ProductVariant)->values(), + return new BelongsToManyThrough( + $instance->newQuery(), + $this, + $pivotTable, + 'variant_id', + 'value_id', + 'id', + 'id', + $variantsTable, + 'product_id', + 'variantValues' ); } } diff --git a/packages/api/src/Domain/Products/Models/Product.php b/packages/api/src/Domain/Products/Models/Product.php index 7b1e46e10..a7ca58dd2 100644 --- a/packages/api/src/Domain/Products/Models/Product.php +++ b/packages/api/src/Domain/Products/Models/Product.php @@ -5,13 +5,12 @@ use Dystore\Api\Domain\Products\Builders\ProductBuilder; use Dystore\Api\Domain\Products\Concerns\InteractsWithDystoreApi; use Dystore\Api\Domain\Products\Contracts\Product as ProductContract; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\Relations\MorphToMany; use Lunar\Models\Product as LunarProduct; -use Lunar\Models\ProductOptionValue; -use Staudenmeir\EloquentHasManyDeep\HasManyDeep; /** * @method static ProductBuilder query() @@ -23,7 +22,7 @@ * @method HasOneThrough highestPrice() Get the highest price relation through variants. * @method HasOne cheapestVariant() Get the cheapest variant relation. * @method HasOne mostExpensiveVariant() Get the most expensive variant relation. - * @method HasManyDeep variantValues() Get product option values relation through variants. + * @method BelongsToMany variantValues() Get distinct product option values from all variants. */ class Product extends LunarProduct implements ProductContract { diff --git a/packages/api/src/Domain/Products/Relations/BelongsToManyThrough.php b/packages/api/src/Domain/Products/Relations/BelongsToManyThrough.php new file mode 100644 index 000000000..e6597ff76 --- /dev/null +++ b/packages/api/src/Domain/Products/Relations/BelongsToManyThrough.php @@ -0,0 +1,422 @@ +throughTable = $throughTable; + $this->throughForeignKey = $throughForeignKey; + + parent::__construct( + $query, + $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName + ); + } + + /** + * Get the pivot columns for the relation. + * Return empty array to prevent pivot columns from being selected. + */ + protected function aliasedPivotColumns(): array + { + // Don't select any pivot columns for a "through" relationship + // This prevents duplicates caused by different pivot combinations + return []; + } + + /** + * Set the select clause for the relation query. + * Override to ensure we only select from the related table. + */ + protected function shouldSelect(array $columns = ['*']): array + { + if ($columns == ['*']) { + $columns = [$this->related->getTable().'.*']; + } + + return array_merge($columns, $this->aliasedPivotColumns()); + } + + /** + * Set the join clause for the relation query. + */ + protected function performJoin($query = null): static + { + $query = $query ?: $this->query; + + // Explicitly select only related table columns to make GROUP BY work properly + if (empty($query->getQuery()->columns)) { + $query->select($this->related->getTable().'.*'); + } + + // Join to the pivot table + $query->join($this->table, $this->getQualifiedRelatedKeyName(), '=', $this->getQualifiedRelatedPivotKeyName()); + + // Join to the intermediate (through) table + $query->join( + $this->throughTable, + $this->table.'.'.$this->foreignPivotKey, + '=', + $this->throughTable.'.id' + ); + + return $this; + } + + /** + * Set the where clause for the relation query. + */ + protected function addWhereConstraints(): static + { + $this->query->where( + $this->throughTable.'.'.$this->throughForeignKey, + '=', + $this->parent->{$this->parentKey} + ); + + return $this; + } + + /** + * Set the constraints for an eager load of the relation. + */ + public function addConstraints(): void + { + parent::addConstraints(); + + // Apply GROUP BY to ensure distinct results + // This must be done in addConstraints so it's active before pagination + $this->applyDistinctConstraints(); + + // Ensure we only select related table columns for GROUP BY to work + $this->query->getQuery()->columns = null; + $this->query->select($this->related->getTable().'.*'); + } + + /** + * Set the constraints for eager loading of the relation. + */ + public function addEagerConstraints(array $models): void + { + parent::addEagerConstraints($models); + + // Apply GROUP BY to ensure distinct results during eager loading + $this->applyDistinctConstraints(); + + // Ensure we only select related table columns for GROUP BY to work + $this->query->getQuery()->columns = null; + $this->query->select($this->related->getTable().'.*'); + } + + /** + * Match the eagerly loaded results to their parents. + * Override to ensure distinct results are properly matched. + */ + public function match(array $models, Collection $results, $relation): array + { + // First deduplicate the results by primary key + $results = $results->unique(function ($model) { + return $model->getKey(); + })->values(); + + // Then use parent's matching logic + return parent::match($models, $results, $relation); + } + + /** + * Build model dictionary keyed by the relation's foreign key. + * Override to work without pivot data by using the through table. + */ + protected function buildDictionary(Collection $results): array + { + // For a "through" relationship, we need to query which parents + // each result belongs to since we don't have pivot data + $dictionary = []; + + // Get all the result IDs + $resultIds = $results->pluck($this->relatedKey)->all(); + + if (empty($resultIds)) { + return $dictionary; + } + + // Query the relationship to get parent-child mappings + // Use a fresh query builder to avoid duplicate joins + $mappings = $this->related->getConnection() + ->table($this->table) + ->join($this->throughTable, $this->table.'.'.$this->foreignPivotKey, '=', $this->throughTable.'.id') + ->whereIn($this->table.'.'.$this->relatedPivotKey, $resultIds) + ->select([ + $this->throughTable.'.'.$this->throughForeignKey.' as parent_key', + $this->table.'.'.$this->relatedPivotKey.' as related_key', + ]) + ->distinct() + ->get(); + + // Build dictionary from mappings + foreach ($mappings as $mapping) { + $parentKey = $this->getDictionaryKey($mapping->parent_key); + $relatedKey = $mapping->related_key; + + // Find the result with this related key + $result = $results->firstWhere($this->relatedKey, $relatedKey); + + if ($result && ! isset($dictionary[$parentKey])) { + $dictionary[$parentKey] = []; + } + + if ($result) { + // Only add if not already in the dictionary (ensure distinct per parent) + $alreadyAdded = false; + foreach ($dictionary[$parentKey] as $existing) { + if ($existing->getKey() === $result->getKey()) { + $alreadyAdded = true; + break; + } + } + + if (! $alreadyAdded) { + $dictionary[$parentKey][] = $result; + } + } + } + + return $dictionary; + } + + /** + * Get the underlying query builder instance. + * Override to ensure any direct query builder access also gets our constraints. + */ + public function getQuery() + { + $query = parent::getQuery(); + + // Apply DISTINCT only once to avoid issues with multiple getQuery() calls + if (! $this->distinctApplied) { + $this->distinctApplied = true; + + // When query is accessed directly (bypassing our get() method), + // ensure deduplication by using DISTINCT + // Remove GROUP BY and ensure we only select from related table + $query->getQuery()->groups = null; + $query->getQuery()->columns = null; + $query->select($this->related->getTable().'.*'); + $query->distinct(); + + } + + return $query; + } + + /** + * Get the base query builder driving the Eloquent builder. + */ + public function toBase() + { + + return parent::toBase(); + } + + /** + * Apply distinct constraints to the query. + */ + protected function applyDistinctConstraints(): void + { + $relatedTable = $this->related->getTable(); + + // Get all columns for GROUP BY to satisfy MySQL ONLY_FULL_GROUP_BY + $groupByColumns = $this->related->getConnection() + ->getSchemaBuilder() + ->getColumnListing($relatedTable); + + // Apply groupBy with all columns from the related table + foreach ($groupByColumns as $column) { + $this->query->groupBy("{$relatedTable}.{$column}"); + } + } + + /** + * Add the constraints for a relationship query. + */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']): Builder + { + if ($parentQuery->getQuery()->from === $query->getQuery()->from) { + return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); + } + + $this->performJoin($query); + + // Check if this is a count query (columns will be a COUNT expression) + $isCountQuery = is_object($columns) && + method_exists($columns, 'getValue') && + str_contains(strtolower($columns->getValue($query->getGrammar())), 'count'); + + if ($isCountQuery) { + // For count queries, we need to use COUNT(DISTINCT ...) + // Extract the column being counted (usually '*') + $countExpression = $columns->getValue($query->getGrammar()); + + // Replace count(*) with count(distinct related_table.id) + $distinctCount = str_replace( + 'count(*)', + 'count(distinct '.$this->getQualifiedRelatedKeyName().')', + $countExpression + ); + + $columns = new \Illuminate\Database\Query\Expression($distinctCount); + } + + return $query->select($columns)->whereColumn( + $this->throughTable.'.'.$this->throughForeignKey, + '=', + $this->getQualifiedParentKeyName() + ); + } + + /** + * Add the constraints for a relationship count query. + */ + public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery): Builder + { + // Don't call getRelationExistenceQuery because it adds a select() + // which interferes with our selectRaw() + if ($parentQuery->getQuery()->from === $query->getQuery()->from) { + return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, ['*']); + } + + $this->performJoin($query); + + // Clear any GROUP BY from the query as it interferes with COUNT + $query->getQuery()->groups = null; + + // Use COUNT(DISTINCT) to count unique related records + $result = $query + ->selectRaw('COUNT(DISTINCT '.$this->getQualifiedRelatedKeyName().') as aggregate') + ->whereColumn( + $this->throughTable.'.'.$this->throughForeignKey, + '=', + $this->getQualifiedParentKeyName() + ); + + return $result; + } + + /** + * Execute the query as a "select" statement. + */ + public function get($columns = ['*']): Collection + { + // Ensure we're only selecting related table columns + if ($columns == ['*']) { + $columns = [$this->related->getTable().'.*']; + } + + $results = parent::get($columns); + + // The GROUP BY should have already ensured distinct results, but deduplicate as a safety measure + $unique = $results->unique(function ($model) { + return $model->getKey(); + })->values(); + + return $unique; + } + + /** + * Get the count of the relationship. + */ + public function count(): int + { + // Clone the query to avoid modifying the original + $query = clone $this->query; + + // Clear any existing selects and group by to avoid interference + $query->getQuery()->orders = null; + $query->getQuery()->groups = null; + $query->getQuery()->columns = null; + + // Use COUNT(DISTINCT) to count unique related records + $result = $query + ->selectRaw('COUNT(DISTINCT '.$this->getQualifiedRelatedKeyName().') as aggregate') + ->first(); + + return (int) ($result->aggregate ?? 0); + } + + /** + * Get the results of the relationship. + * This is called when accessing the relationship as a dynamic property. + */ + public function getResults(): Collection + { + return $this->get(); + } + + /** + * Paginate the relationship. + * Override to ensure items are properly deduplicated. + */ + public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null) + { + // Let the parent handle the pagination logic + $paginator = parent::paginate($perPage, $columns, $pageName, $page, $total); + + // Get the items and ensure they're deduplicated + $items = $paginator->items(); + $collection = $this->related->newCollection($items); + $uniqueItems = $collection->unique(function ($model) { + return $model->getKey(); + })->values()->all(); + + // Replace the paginator's items with deduplicated ones + // We need to create a new paginator with the correct count + return new \Illuminate\Pagination\LengthAwarePaginator( + $uniqueItems, + $paginator->total(), // Keep the correct total + $paginator->perPage(), + $paginator->currentPage(), + $paginator->getOptions() + ); + } +} diff --git a/tests/api/Feature/Domain/Products/JsonApi/V1/ProductProductOptionValuesRelationshipTest.php b/tests/api/Feature/Domain/Products/JsonApi/V1/ProductProductOptionValuesRelationshipTest.php index 98d041c5e..f29a243d4 100644 --- a/tests/api/Feature/Domain/Products/JsonApi/V1/ProductProductOptionValuesRelationshipTest.php +++ b/tests/api/Feature/Domain/Products/JsonApi/V1/ProductProductOptionValuesRelationshipTest.php @@ -11,20 +11,21 @@ it('can list product options values through relationship', function () { /** @var TestCase $this */ + $productOption = ProductOption::factory()->create(); + $product = Product::factory() - ->hasAttached( - ProductOption::factory() - ->count(2) - ->has(ProductOptionValue::factory()->count(3), 'values'), - ['position' => 1], - ) + ->hasAttached($productOption, ['position' => 1], 'productOptions') ->create(); - $values = $product->productOptions->map(fn (ProductOption $option) => $option->values->first()); + $values = ProductOptionValue::factory() + ->for($productOption, 'option') + ->count(4) + ->create(); $variant = ProductVariant::factory() ->for($product, 'product') ->hasAttached($values, [], 'values') + ->count(4) ->create(); $response = $this @@ -32,6 +33,11 @@ ->expects('product_option_values') ->get(serverUrl("/products/{$product->getRouteKey()}/product_option_values")); + $this->assertEquals( + $product->variantValues->pluck('id')->sort()->values(), + $values->pluck('id')->sort()->values() + ); + $response ->assertSuccessful() ->assertFetchedMany($product->variantValues) @@ -67,3 +73,335 @@ expect($response->json('data.relationships.product_option_values.meta.count'))->toBe(2); })->group('products', 'counts'); + +it('returns distinct product option values even when same value is used across multiple variants', function () { + /** @var TestCase $this */ + + // Create a product with product options and values + $product = Product::factory() + ->hasAttached( + ProductOption::factory() + ->count(2) + ->has(ProductOptionValue::factory()->count(3), 'values'), + ['position' => 1], + ) + ->create(); + + // Get the first value from the first option - this will be shared across variants + $sharedValue = $product->productOptions->first()->values->first(); + + // Get unique values from second option for each variant + $uniqueValue1 = $product->productOptions->last()->values->get(0); + $uniqueValue2 = $product->productOptions->last()->values->get(1); + $uniqueValue3 = $product->productOptions->last()->values->get(2); + + // Create 3 variants, all using the same shared value plus a unique value + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$sharedValue, $uniqueValue1], [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$sharedValue, $uniqueValue2], [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$sharedValue, $uniqueValue3], [], 'values') + ->create(); + + // Get variant values + $variantValues = $product->variantValues; + + // Should return exactly 4 distinct values (1 shared + 3 unique) + expect($variantValues)->toHaveCount(4); + + // Check that each value appears only once + $valueIds = $variantValues->pluck('id'); + expect($valueIds->unique())->toHaveCount(4); + + // Verify the shared value appears only once (not 3 times) + expect($valueIds->filter(fn ($id) => $id === $sharedValue->id))->toHaveCount(1); + + // Verify all expected values are present + expect($valueIds->sort()->values()->toArray())->toBe([ + $sharedValue->id, + $uniqueValue1->id, + $uniqueValue2->id, + $uniqueValue3->id, + ]); +})->group('products', 'distinct'); + +it('returns distinct values when querying through relationship', function () { + /** @var TestCase $this */ + $product = Product::factory() + ->hasAttached( + ProductOption::factory() + ->has(ProductOptionValue::factory()->count(5), 'values'), + ['position' => 1], + ) + ->create(); + + $values = $product->productOptions->first()->values; + + // Create multiple variants with overlapping values + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$values->get(0), $values->get(1), $values->get(2)], [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$values->get(1), $values->get(2), $values->get(3)], [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$values->get(2), $values->get(3), $values->get(4)], [], 'values') + ->create(); + + // Should return exactly 5 distinct values (0,1,2,3,4) + $variantValues = $product->variantValues; + + expect($variantValues)->toHaveCount(5); + expect($variantValues->pluck('id')->sort()->values()->toArray())->toBe([ + $values->get(0)->id, + $values->get(1)->id, + $values->get(2)->id, + $values->get(3)->id, + $values->get(4)->id, + ]); +})->group('products', 'distinct'); + +it('returns empty collection when product has no variants', function () { + /** @var TestCase $this */ + $product = Product::factory()->create(); + + $variantValues = $product->variantValues; + + expect($variantValues)->toBeEmpty(); +})->group('products', 'distinct'); + +it('returns distinct values with eager loading', function () { + /** @var TestCase $this */ + $product = Product::factory() + ->hasAttached( + ProductOption::factory() + ->has(ProductOptionValue::factory()->count(3), 'values'), + ['position' => 1], + ) + ->create(); + + $sharedValue = $product->productOptions->first()->values->first(); + $uniqueValue = $product->productOptions->first()->values->last(); + + // Create 2 variants sharing a value + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$sharedValue, $uniqueValue], [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$sharedValue], [], 'values') + ->create(); + + // Eager load the relationship + $productWithValues = Product::with('variantValues')->find($product->id); + + expect($productWithValues->variantValues)->toHaveCount(2); + expect($productWithValues->variantValues->pluck('id')->sort()->values()->toArray())->toBe([ + $sharedValue->id, + $uniqueValue->id, + ]); +})->group('products', 'distinct', 'eager-loading'); + +it('returns distinct values with pagination', function () { + /** @var TestCase $this */ + + // Create a product with multiple options and values + $product = Product::factory() + ->hasAttached( + ProductOption::factory() + ->count(2) + ->has(ProductOptionValue::factory()->count(5), 'values'), + ['position' => 1], + ) + ->create(); + + $allValues = $product->productOptions->flatMap(fn (ProductOption $option) => $option->values); + + // Create multiple variants using various combinations of values + // This will create duplicates if not handled properly + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$allValues->get(0), $allValues->get(1)], [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$allValues->get(0), $allValues->get(2)], [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached([$allValues->get(1), $allValues->get(3)], [], 'values') + ->create(); + + // Request first page with 2 items per page + $response = $this + ->jsonApi() + ->expects('product_option_values') + ->page(['number' => 1, 'size' => 2]) + ->get(serverUrl("/products/{$product->getRouteKey()}/product_option_values")); + + $response->assertSuccessful(); + + // Should have exactly 2 items on first page + expect($response->json('data'))->toHaveCount(2); + + // Verify no duplicate IDs on first page + $firstPageIds = collect($response->json('data'))->pluck('id')->all(); + expect($firstPageIds)->toHaveCount(2); + expect(array_unique($firstPageIds))->toHaveCount(2); + + // Request second page + $response2 = $this + ->jsonApi() + ->expects('product_option_values') + ->page(['number' => 2, 'size' => 2]) + ->get(serverUrl("/products/{$product->getRouteKey()}/product_option_values")); + + $response2->assertSuccessful(); + + // Second page should also have distinct IDs + $secondPageIds = collect($response2->json('data'))->pluck('id')->all(); + expect(array_unique($secondPageIds))->toHaveCount(count($secondPageIds)); + + // Verify no overlap between pages + $overlap = array_intersect($firstPageIds, $secondPageIds); + expect($overlap)->toBeEmpty(); +})->group('products', 'distinct', 'pagination'); + +it('returns correct total count with pagination', function () { + /** @var TestCase $this */ + $product = Product::factory() + ->hasAttached( + ProductOption::factory() + ->has(ProductOptionValue::factory()->count(10), 'values'), + ['position' => 1], + ) + ->create(); + + $values = $product->productOptions->first()->values; + + // Create 3 variants, each using 5 values (with some overlap) + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached($values->take(5), [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached($values->slice(3, 5), [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached($values->slice(5, 5), [], 'values') + ->create(); + + // Should have exactly 10 distinct values (all values are used) + $response = $this + ->jsonApi() + ->expects('product_option_values') + ->page(['number' => 1, 'size' => 3]) + ->get(serverUrl("/products/{$product->getRouteKey()}/product_option_values")); + + $response->assertSuccessful(); + + // Total should be 10, not 15 (which would be the case without distinct) + expect($response->json('meta.page.total'))->toBe(10); + expect($response->json('meta.page.lastPage'))->toBe(4); // 10 items / 3 per page = 4 pages +})->group('products', 'distinct', 'pagination'); + +it('returns correct count when calling count() method directly', function () { + /** @var TestCase $this */ + $product = Product::factory() + ->hasAttached( + ProductOption::factory() + ->has(ProductOptionValue::factory()->count(5), 'values'), + ['position' => 1], + ) + ->create(); + + $values = $product->productOptions->first()->values; + + // Create 3 variants using same values - would be 15 rows without distinct + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached($values, [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached($values, [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached($values, [], 'values') + ->create(); + + // Direct count on relationship should return distinct count + expect($product->variantValues()->count())->toBe(5); + + // Collection count should also be 5 + expect($product->variantValues->count())->toBe(5); +})->group('products', 'distinct', 'count'); + +it('returns correct count in JSON API meta matching total', function () { + /** @var TestCase $this */ + $product = Product::factory() + ->hasAttached( + ProductOption::factory() + ->has(ProductOptionValue::factory()->count(8), 'values'), + ['position' => 1], + ) + ->create(); + + $values = $product->productOptions->first()->values; + + // Create multiple variants with overlapping values + // This would create many duplicate rows if not handled properly + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached($values->take(4), [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached($values->slice(2, 4), [], 'values') + ->create(); + + ProductVariant::factory() + ->for($product, 'product') + ->hasAttached($values->slice(4, 4), [], 'values') + ->create(); + + // All 8 values are used across the 3 variants + $response = $this + ->jsonApi() + ->expects('product_option_values') + ->get(serverUrl("/products/{$product->getRouteKey()}/product_option_values")); + + $response->assertSuccessful(); + + $meta = $response->json('meta'); + + // The count should match the total (distinct count) + expect($meta['count'])->toBe(8, 'Meta count should be distinct count'); + expect($meta['page']['total'])->toBe(8, 'Page total should be distinct count'); + expect($meta['count'])->toBe($meta['page']['total'], 'Count should equal total'); +})->group('products', 'distinct', 'count', 'json-api');