diff --git a/core/components/minishop3/config/routes/web.php b/core/components/minishop3/config/routes/web.php index 8a3c80e66..39dfd9ed1 100644 --- a/core/components/minishop3/config/routes/web.php +++ b/core/components/minishop3/config/routes/web.php @@ -279,6 +279,11 @@ $controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx); return $controller->getList($params); }); + + $router->get('/filters', function ($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx); + return $controller->filters($params); + }); }); // Public category catalog — no TokenMiddleware (headless nav / PLP) diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 756f38378..163378ff0 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -237,6 +237,8 @@ $_lang['ms3_err_catalog_option_key_invalid'] = 'Invalid option key'; $_lang['ms3_err_catalog_option_value_invalid'] = 'Invalid option value'; $_lang['ms3_err_catalog_option_unknown'] = 'Unknown option key'; +$_lang['ms3_err_catalog_facet_keys_invalid'] = 'Invalid facet keys parameter'; +$_lang['ms3_err_catalog_facet_keys_limit'] = 'Too many facet option keys'; $_lang['ms3_category_products_reordered'] = 'Products reordered successfully'; $_lang['ms3_category_product_published'] = 'Product published'; $_lang['ms3_category_product_unpublished'] = 'Product unpublished'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 26ab56839..266ae656c 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -237,6 +237,8 @@ $_lang['ms3_err_catalog_option_key_invalid'] = 'Некорректный ключ опции'; $_lang['ms3_err_catalog_option_value_invalid'] = 'Некорректное значение опции'; $_lang['ms3_err_catalog_option_unknown'] = 'Неизвестный ключ опции'; +$_lang['ms3_err_catalog_facet_keys_invalid'] = 'Некорректный параметр keys для facets'; +$_lang['ms3_err_catalog_facet_keys_limit'] = 'Слишком много ключей опций для facets'; $_lang['ms3_category_products_reordered'] = 'Порядок товаров успешно изменён'; $_lang['ms3_category_product_published'] = 'Товар опубликован'; $_lang['ms3_category_product_unpublished'] = 'Товар снят с публикации'; diff --git a/core/components/minishop3/migrations/20260817130105_add_product_options_composite_indexes.php b/core/components/minishop3/migrations/20260817130105_add_product_options_composite_indexes.php new file mode 100644 index 000000000..5d44dc8f6 --- /dev/null +++ b/core/components/minishop3/migrations/20260817130105_add_product_options_composite_indexes.php @@ -0,0 +1,76 @@ +table(self::TABLE); + + if (!$table->hasIndexByName(self::INDEX_KEY_PRODUCT)) { + $table->addIndex(['key', 'product_id'], [ + 'name' => self::INDEX_KEY_PRODUCT, + ]); + } + + if (!$table->hasIndexByName(self::INDEX_PRODUCT_KEY)) { + $table->addIndex(['product_id', 'key'], [ + 'name' => self::INDEX_PRODUCT_KEY, + ]); + } + + $table->update(); + + $table = $this->table(self::TABLE); + + // Drop redundant single-column indexes after composites exist. + if ($table->hasIndexByName('key') && $table->hasIndexByName(self::INDEX_KEY_PRODUCT)) { + $table->removeIndexByName('key'); + } + if ($table->hasIndexByName('product_id') && $table->hasIndexByName(self::INDEX_PRODUCT_KEY)) { + $table->removeIndexByName('product_id'); + } + + $table->update(); + } + + public function down(): void + { + $table = $this->table(self::TABLE); + + if (!$table->hasIndexByName('product_id')) { + $table->addIndex(['product_id'], ['name' => 'product_id']); + } + if (!$table->hasIndexByName('key')) { + $table->addIndex(['key'], ['name' => 'key']); + } + + $table->update(); + + $table = $this->table(self::TABLE); + + if ($table->hasIndexByName(self::INDEX_KEY_PRODUCT)) { + $table->removeIndexByName(self::INDEX_KEY_PRODUCT); + } + if ($table->hasIndexByName(self::INDEX_PRODUCT_KEY)) { + $table->removeIndexByName(self::INDEX_PRODUCT_KEY); + } + + $table->update(); + } +} diff --git a/core/components/minishop3/src/Controllers/Api/Web/ProductController.php b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php index f67db7982..35a2164c0 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/ProductController.php +++ b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php @@ -8,6 +8,7 @@ use MiniShop3\Router\Response; use MiniShop3\Services\Product\ProductCatalogFilterException; use MiniShop3\Services\Product\ProductCatalogService; +use MiniShop3\Services\Product\ProductFacetService; use MODX\Revolution\modX; /** @@ -76,6 +77,28 @@ public function getList(array $params = []): Response return Response::success($result); } + /** + * GET /api/v1/product/filters + * + * Soft facets for headless PLP (#565). Same filter query params as product/list, + * plus keys, include_price (default 1), include_vendors (default 0). + * + * @param array $params + */ + public function filters(array $params = []): Response + { + try { + $result = $this->facets()->getFilters($params); + } catch (ProductCatalogFilterException $e) { + return Response::error( + $this->modx->lexicon($e->getLexiconKey()), + HttpStatus::BAD_REQUEST + ); + } + + return Response::success($result); + } + private function catalog(): ProductCatalogService { /** @var ProductCatalogService $service */ @@ -83,4 +106,12 @@ private function catalog(): ProductCatalogService return $service; } + + private function facets(): ProductFacetService + { + /** @var ProductFacetService $service */ + $service = $this->modx->services->get('ms3_product_facets'); + + return $service; + } } diff --git a/core/components/minishop3/src/Middleware/TokenMiddleware.php b/core/components/minishop3/src/Middleware/TokenMiddleware.php index 807d3ef9a..5c035a923 100644 --- a/core/components/minishop3/src/Middleware/TokenMiddleware.php +++ b/core/components/minishop3/src/Middleware/TokenMiddleware.php @@ -35,6 +35,7 @@ class TokenMiddleware implements MiddlewareInterface private array $publicRoutes = [ '/api/v1/product/get/', '/api/v1/product/list', + '/api/v1/product/filters', '/api/v1/category/get/', '/api/v1/category/list', '/api/v1/category/tree', diff --git a/core/components/minishop3/src/Model/mysql/msProductOption.php b/core/components/minishop3/src/Model/mysql/msProductOption.php index dd64722d7..4a7908eba 100644 --- a/core/components/minishop3/src/Model/mysql/msProductOption.php +++ b/core/components/minishop3/src/Model/mysql/msProductOption.php @@ -46,14 +46,20 @@ class msProductOption extends \MiniShop3\Model\msProductOption ], 'indexes' => [ - 'product_id' => + 'key_product_id' => [ - 'alias' => 'product_id', + 'alias' => 'key_product_id', 'primary' => false, 'unique' => false, 'type' => 'BTREE', 'columns' => [ + 'key' => + [ + 'length' => '', + 'collation' => 'A', + 'null' => false, + ], 'product_id' => [ 'length' => '', @@ -62,14 +68,20 @@ class msProductOption extends \MiniShop3\Model\msProductOption ], ], ], - 'key' => + 'product_id_key' => [ - 'alias' => 'key', + 'alias' => 'product_id_key', 'primary' => false, 'unique' => false, 'type' => 'BTREE', 'columns' => [ + 'product_id' => + [ + 'length' => '', + 'collation' => 'A', + 'null' => false, + ], 'key' => [ 'length' => '', diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 296706fd8..2e1e25643 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -158,6 +158,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Product\ProductCatalogService::class, 'interface' => null, ], + 'ms3_product_facets' => [ + 'class' => \MiniShop3\Services\Product\ProductFacetService::class, + 'interface' => null, + ], 'ms3_category_catalog' => [ 'class' => \MiniShop3\Services\Category\CategoryCatalogService::class, 'interface' => null, diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php index 035853642..522b9ac88 100644 --- a/core/components/minishop3/src/ServiceRegistryFactories.php +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -39,6 +39,7 @@ public static function map(): array 'ms3_product_category_tree' => $modxOnly(), 'ms3_product_link_service' => $modxOnly(), 'ms3_product_catalog' => $modxOnly(), + 'ms3_product_facets' => $modxOnly(), 'ms3_category_catalog' => $modxOnly(), 'ms3_delivery_catalog' => $modxOnly(), 'ms3_payment_catalog' => $modxOnly(), diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogFilterApplier.php b/core/components/minishop3/src/Services/Product/ProductCatalogFilterApplier.php index 70569169a..ce4d7e7e5 100644 --- a/core/components/minishop3/src/Services/Product/ProductCatalogFilterApplier.php +++ b/core/components/minishop3/src/Services/Product/ProductCatalogFilterApplier.php @@ -16,7 +16,7 @@ final class ProductCatalogFilterApplier { /** Depth when nested=1 (matches ms3_products default depth window). */ - private const NESTED_DEPTH = 10; + public const NESTED_DEPTH = 10; public function __construct( private modX $modx, @@ -95,7 +95,7 @@ private function applyOptionFilters( return; } - $this->assertOptionKeysExist(array_keys($filters->options)); + $this->assertKnownOptionKeys(array_keys($filters->options)); $index = 0; foreach ($filters->options as $key => $values) { @@ -120,8 +120,12 @@ private function applyOptionFilters( /** * @param list $keys */ - private function assertOptionKeysExist(array $keys): void + public function assertKnownOptionKeys(array $keys): void { + if ($keys === []) { + return; + } + $c = $this->modx->newQuery(msOption::class); $c->where(['key:IN' => $keys]); $c->select('key'); diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogFilterSpec.php b/core/components/minishop3/src/Services/Product/ProductCatalogFilterSpec.php index 45f3e92a3..d62b9a36d 100644 --- a/core/components/minishop3/src/Services/Product/ProductCatalogFilterSpec.php +++ b/core/components/minishop3/src/Services/Product/ProductCatalogFilterSpec.php @@ -46,4 +46,68 @@ public function hasDataFilters(): bool || $this->flagFavorite || $this->options !== []; } + + /** + * Soft price facet: bounds ignore selected price_min/price_max so the slider does not collapse. + */ + public function withoutPriceBounds(): self + { + if ($this->priceMin === null && $this->priceMax === null) { + return $this; + } + + return $this->with(['priceMin' => null, 'priceMax' => null]); + } + + /** + * Soft option facet: buckets for $key ignore selected values of that key only. + */ + public function withoutOptionKey(string $key): self + { + if (!array_key_exists($key, $this->options)) { + return $this; + } + + $options = $this->options; + unset($options[$key]); + + return $this->with(['options' => $options]); + } + + /** + * Soft vendor facet: buckets ignore selected vendor_id filter. + */ + public function withoutVendors(): self + { + if ($this->vendorIds === []) { + return $this; + } + + return $this->with(['vendorIds' => []]); + } + + /** + * @param array{ + * priceMin?: float|null, + * priceMax?: float|null, + * vendorIds?: list, + * options?: array> + * } $overrides + */ + private function with(array $overrides): self + { + return new self( + parentIds: $this->parentIds, + nested: $this->nested, + priceMin: array_key_exists('priceMin', $overrides) ? $overrides['priceMin'] : $this->priceMin, + priceMax: array_key_exists('priceMax', $overrides) ? $overrides['priceMax'] : $this->priceMax, + inStock: $this->inStock, + stockMin: $this->stockMin, + vendorIds: array_key_exists('vendorIds', $overrides) ? $overrides['vendorIds'] : $this->vendorIds, + flagNew: $this->flagNew, + flagPopular: $this->flagPopular, + flagFavorite: $this->flagFavorite, + options: array_key_exists('options', $overrides) ? $overrides['options'] : $this->options, + ); + } } diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogService.php b/core/components/minishop3/src/Services/Product/ProductCatalogService.php index e97cce915..2eb6e11b8 100644 --- a/core/components/minishop3/src/Services/Product/ProductCatalogService.php +++ b/core/components/minishop3/src/Services/Product/ProductCatalogService.php @@ -289,6 +289,21 @@ private function countList(array $params, ProductCatalogFilterSpec $filters): in return (int) $countQuery->stmt->fetchColumn(); } + /** + * Storefront-scoped product query (visibility + context + list filters). + * Shared with product/filters facets (#565). + * + * @param array $params + * @param bool $dedupeRows GROUP BY for list pages; false for COUNT(DISTINCT) / aggregates + */ + public function buildScopedListQuery( + array $params, + ProductCatalogFilterSpec $filters, + bool $dedupeRows = true, + ): xPDOQuery { + return $this->buildListQuery($params, $filters, $dedupeRows); + } + /** * @param array $params * @param bool $dedupeRows GROUP BY for list pages; false for COUNT(DISTINCT) queries diff --git a/core/components/minishop3/src/Services/Product/ProductFacetService.php b/core/components/minishop3/src/Services/Product/ProductFacetService.php new file mode 100644 index 000000000..05ea07a58 --- /dev/null +++ b/core/components/minishop3/src/Services/Product/ProductFacetService.php @@ -0,0 +1,471 @@ + $params + * @return array{ + * price?: array{min: float|null, max: float|null}, + * options: array>, + * vendors?: list + * } + * + * @throws ProductCatalogFilterException + */ + public function getFilters(array $params): array + { + $filters = ProductCatalogFilterParser::parse($params); + $includePrice = ProductCatalogService::toBool($params['include_price'] ?? true); + $includeVendors = ProductCatalogService::toBool($params['include_vendors'] ?? false); + [$keys, $keysOk] = $this->resolveFacetKeys($params, $filters); + + $cacheKey = $this->buildCacheKey($params, $filters, $keys, $includePrice, $includeVendors); + $cached = $this->cacheGet($cacheKey); + if (is_array($cached)) { + return $cached; + } + + $result = [ + 'options' => [], + ]; + $cacheable = $keysOk; + + if ($includePrice) { + [$result['price'], $priceOk] = $this->aggregatePriceBounds($params, $filters); + $cacheable = $cacheable && $priceOk; + } + + foreach ($keys as $key) { + [$result['options'][$key], $optionOk] = $this->aggregateOptionBuckets($params, $filters, $key); + $cacheable = $cacheable && $optionOk; + } + + if ($includeVendors) { + [$result['vendors'], $vendorOk] = $this->aggregateVendorBuckets($params, $filters); + $cacheable = $cacheable && $vendorOk; + } + + // Do not cache empty/partial payloads produced by SQL failures. + if ($cacheable) { + $this->cacheSet($cacheKey, $result); + } + + return $result; + } + + /** + * @param array $params + * @return array{0: list, 1: bool} keys, SQL ok + */ + private function resolveFacetKeys(array $params, ProductCatalogFilterSpec $filters): array + { + // Explicit keys= (including empty) must not silently fall back to auto-discovery. + if (array_key_exists('keys', $params) && $params['keys'] !== null) { + $keys = $this->parseRequestedKeys($params['keys']); + $this->filterApplier()->assertKnownOptionKeys($keys); + + return [$keys, true]; + } + + [$fromCategory, $categoryOk] = $this->keysFromCategoryOptions($params, $filters); + if ($fromCategory !== []) { + return [$fromCategory, $categoryOk]; + } + + return $this->keysFromAllOptions(); + } + + /** + * @return list + */ + private function parseRequestedKeys(mixed $raw): array + { + $parts = is_array($raw) ? $raw : explode(',', (string) $raw); + $keys = []; + foreach ($parts as $part) { + if (is_array($part)) { + throw new ProductCatalogFilterException('ms3_err_catalog_facet_keys_invalid'); + } + $key = trim((string) $part); + if ($key === '') { + continue; + } + if (!preg_match('/^[a-zA-Z0-9_]+$/', $key)) { + throw new ProductCatalogFilterException('ms3_err_catalog_facet_keys_invalid'); + } + $keys[] = $key; + } + + $keys = array_values(array_unique($keys)); + if ($keys === []) { + throw new ProductCatalogFilterException('ms3_err_catalog_facet_keys_invalid'); + } + if (count($keys) > self::MAX_FACET_KEYS) { + throw new ProductCatalogFilterException('ms3_err_catalog_facet_keys_limit'); + } + + return $keys; + } + + /** + * @param array $params + * @return array{0: list, 1: bool} + */ + private function keysFromCategoryOptions(array $params, ProductCatalogFilterSpec $filters): array + { + $categoryIds = $this->resolveScopeCategoryIds($params, $filters); + if ($categoryIds === []) { + return [[], true]; + } + + $c = $this->modx->newQuery(msCategoryOption::class); + $c->innerJoin(msOption::class, 'Option', 'Option.id = msCategoryOption.option_id'); + $c->where([ + 'msCategoryOption.category_id:IN' => $categoryIds, + 'msCategoryOption.active' => 1, + ]); + $c->select('Option.key'); + $c->groupby('Option.key'); + $c->sortby('MIN(msCategoryOption.position)', 'ASC'); + $c->limit(self::MAX_FACET_KEYS); + + return $this->fetchQueryKeys($c); + } + + /** + * @return array{0: list, 1: bool} + */ + private function keysFromAllOptions(): array + { + $c = $this->modx->newQuery(msOption::class); + $c->select('key'); + $c->sortby('id', 'ASC'); + $c->limit(self::MAX_FACET_KEYS); + + return $this->fetchQueryKeys($c); + } + + /** + * @return array{0: list, 1: bool} + */ + private function fetchQueryKeys(xPDOQuery $query): array + { + if (!$query->prepare() || !$query->stmt->execute()) { + return [[], false]; + } + + $keys = array_values(array_filter( + array_map('strval', $query->stmt->fetchAll(\PDO::FETCH_COLUMN) ?: []), + static fn (string $key): bool => $key !== '' + )); + + return [$keys, true]; + } + + /** + * Category IDs for auto facet keys. nested expands only when parents= is set + * (parity with product/list: parent|category alone is leaf-only). + * + * @param array $params + * @return list + */ + private function resolveScopeCategoryIds(array $params, ProductCatalogFilterSpec $filters): array + { + $parentIds = $filters->parentIds; + if ($parentIds === []) { + $parent = (int) ($params['parent'] ?? $params['category'] ?? 0); + if ($parent > 0) { + $parentIds = [$parent]; + } + } + + if ($parentIds === []) { + return []; + } + + $depth = ($filters->hasParents() && $filters->nested) + ? ProductCatalogFilterApplier::NESTED_DEPTH + : 0; + + return $this->scope()->resolveCategoryIdsFromParents(implode(',', $parentIds), $depth); + } + + /** + * @param array $params + * @return array{0: array{min: float|null, max: float|null}, 1: bool} + */ + private function aggregatePriceBounds(array $params, ProductCatalogFilterSpec $filters): array + { + $query = $this->catalog()->buildScopedListQuery( + $params, + $filters->withoutPriceBounds(), + false, + ); + $query->select('MIN(Data.price) AS price_min, MAX(Data.price) AS price_max'); + + if (!$query->prepare() || !$query->stmt->execute()) { + return [['min' => null, 'max' => null], false]; + } + + $row = $query->stmt->fetch(\PDO::FETCH_ASSOC) ?: []; + + return [ + [ + 'min' => $this->nullableFloat($row['price_min'] ?? null), + 'max' => $this->nullableFloat($row['price_max'] ?? null), + ], + true, + ]; + } + + /** + * Soft option facet via scoped product query + JOIN (no PHP ID materialization). + * + * @param array $params + * @return array{0: list, 1: bool} + */ + private function aggregateOptionBuckets( + array $params, + ProductCatalogFilterSpec $filters, + string $key, + ): array { + $query = $this->catalog()->buildScopedListQuery( + $params, + $filters->withoutOptionKey($key), + false, + ); + $query->innerJoin( + msProductOption::class, + 'FacetOpt', + 'FacetOpt.product_id = Data.id AND FacetOpt.`key` = ' . $this->modx->quote($key) + ); + $query->select('FacetOpt.value AS value, COUNT(DISTINCT msProduct.id) AS cnt'); + $query->groupby('FacetOpt.value'); + $query->sortby('cnt', 'DESC'); + $query->limit(self::MAX_VALUES_PER_KEY); + + if (!$query->prepare() || !$query->stmt->execute()) { + return [[], false]; + } + + $buckets = []; + foreach ($query->stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) { + $value = trim((string) ($row['value'] ?? '')); + if ($value === '') { + continue; + } + $buckets[] = [ + 'value' => $value, + 'count' => (int) ($row['cnt'] ?? 0), + ]; + } + + return [$buckets, true]; + } + + /** + * @param array $params + * @return array{0: list, 1: bool} + */ + private function aggregateVendorBuckets(array $params, ProductCatalogFilterSpec $filters): array + { + $query = $this->catalog()->buildScopedListQuery( + $params, + $filters->withoutVendors(), + false, + ); + $query->where(['Data.vendor_id:>' => 0]); + $query->select('Data.vendor_id AS vendor_id, COUNT(DISTINCT msProduct.id) AS cnt'); + $query->groupby('Data.vendor_id'); + $query->sortby('cnt', 'DESC'); + $query->limit(self::MAX_VENDOR_BUCKETS); + + if (!$query->prepare() || !$query->stmt->execute()) { + return [[], false]; + } + + $rows = $query->stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + if ($rows === []) { + return [[], true]; + } + + $ids = []; + $counts = []; + foreach ($rows as $row) { + $id = (int) ($row['vendor_id'] ?? 0); + if ($id <= 0) { + continue; + } + $ids[] = $id; + $counts[$id] = (int) ($row['cnt'] ?? 0); + } + + if ($ids === []) { + return [[], true]; + } + + [$names, $namesOk] = $this->loadVendorNames($ids); + $buckets = []; + foreach ($ids as $id) { + $buckets[] = [ + 'id' => $id, + 'name' => $names[$id] ?? '', + 'count' => $counts[$id] ?? 0, + ]; + } + + return [$buckets, $namesOk]; + } + + /** + * @param list $ids + * @return array{0: array, 1: bool} + */ + private function loadVendorNames(array $ids): array + { + $c = $this->modx->newQuery(msVendor::class); + $c->where(['id:IN' => $ids]); + $c->select('id, name'); + + if (!$c->prepare() || !$c->stmt->execute()) { + return [[], false]; + } + + $names = []; + foreach ($c->stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) { + $names[(int) $row['id']] = (string) ($row['name'] ?? ''); + } + + return [$names, true]; + } + + private function nullableFloat(mixed $value): ?float + { + return $value === null || $value === '' ? null : (float) $value; + } + + /** + * @param array $params + * @param list $keys + */ + private function buildCacheKey( + array $params, + ProductCatalogFilterSpec $filters, + array $keys, + bool $includePrice, + bool $includeVendors, + ): string { + $payload = [ + 'context' => (string) ($params['context'] ?? ''), + 'parent' => (int) ($params['parent'] ?? $params['category'] ?? 0), + 'parents' => $filters->parentIds, + 'nested' => $filters->nested, + 'price_min' => $filters->priceMin, + 'price_max' => $filters->priceMax, + 'in_stock' => $filters->inStock, + 'stock_min' => $filters->stockMin, + 'vendor_id' => $filters->vendorIds, + 'new' => $filters->flagNew, + 'popular' => $filters->flagPopular, + 'favorite' => $filters->flagFavorite, + 'options' => $filters->options, + 'query' => trim((string) ($params['query'] ?? '')), + 'keys' => $keys, + 'include_price' => $includePrice, + 'include_vendors' => $includeVendors, + ]; + + return 'facets_' . hash('sha256', (string) json_encode($payload, JSON_UNESCAPED_UNICODE)); + } + + /** + * @return array|null + */ + private function cacheGet(string $key): ?array + { + $cacheManager = $this->modx->cacheManager; + if (!is_object($cacheManager)) { + return null; + } + + $value = $cacheManager->get($key, $this->cacheOptions()); + + return is_array($value) ? $value : null; + } + + /** + * @param array $value + */ + private function cacheSet(string $key, array $value): void + { + $cacheManager = $this->modx->cacheManager; + if (!is_object($cacheManager)) { + return; + } + + $cacheManager->set($key, $value, self::CACHE_TTL_SECONDS, $this->cacheOptions()); + } + + /** + * @return array + */ + private function cacheOptions(): array + { + return [xPDO::OPT_CACHE_KEY => 'ms3/facets']; + } + + private function catalog(): ProductCatalogService + { + /** @var ProductCatalogService $service */ + $service = $this->modx->services->get('ms3_product_catalog'); + + return $service; + } + + private function scope(): CategoryProductScopeService + { + /** @var CategoryProductScopeService $service */ + $service = $this->modx->services->get('ms3_category_product_scope'); + + return $service; + } + + private function filterApplier(): ProductCatalogFilterApplier + { + return new ProductCatalogFilterApplier($this->modx, $this->scope()); + } +} diff --git a/core/components/minishop3/tests/ProductCatalogFiltersRoutesTest.php b/core/components/minishop3/tests/ProductCatalogFiltersRoutesTest.php index 32a102971..6ab4bf083 100644 --- a/core/components/minishop3/tests/ProductCatalogFiltersRoutesTest.php +++ b/core/components/minishop3/tests/ProductCatalogFiltersRoutesTest.php @@ -48,7 +48,7 @@ [ 'applyProductCategoryScope', 'msProductOption', - 'assertOptionKeysExist', + 'assertKnownOptionKeys', 'Data.price', 'Data.stock', "groupby('msProduct.id')", diff --git a/core/components/minishop3/tests/ProductFacetRoutesTest.php b/core/components/minishop3/tests/ProductFacetRoutesTest.php new file mode 100644 index 000000000..bb7805f2c --- /dev/null +++ b/core/components/minishop3/tests/ProductFacetRoutesTest.php @@ -0,0 +1,130 @@ + $routes, + 'controller' => $controller, + 'facet' => $facet, + 'catalog' => $catalog, + 'middleware' => $middleware, + 'registry' => $registry, + 'factories' => $factories, + 'en' => $en, + 'ru' => $ru, + ] as $label => $src +) { + if ($src === false || $src === '') { + $fail("unable to read {$label}"); + } +} + +if (!str_contains($routes, "get('/filters'")) { + $fail('web.php missing GET /product/filters'); +} +if (!str_contains($routes, '->filters($params)')) { + $fail('web.php must call ProductController::filters'); +} +if (!str_contains($routes, "get('/list'")) { + $fail('product/list route must remain'); +} + +if (!str_contains($controller, 'function filters(')) { + $fail('ProductController missing filters()'); +} +if (!str_contains($controller, 'ms3_product_facets')) { + $fail('ProductController must resolve ms3_product_facets'); +} +if (!str_contains($controller, 'function getList(')) { + $fail('ProductController::getList must remain'); +} + +foreach ( + [ + 'withoutPriceBounds()', + 'withoutOptionKey(', + 'withoutVendors()', + 'buildScopedListQuery(', + 'assertKnownOptionKeys(', + 'FacetOpt', + '$cacheable', + 'CACHE_TTL_SECONDS', + 'MAX_FACET_KEYS', + 'MAX_VALUES_PER_KEY', + ] as $needle +) { + if (!str_contains($facet, $needle)) { + $fail("ProductFacetService missing: {$needle}"); + } +} + +if (str_contains($facet, 'collectProductIds') || str_contains($facet, 'queryFailed')) { + $fail('ProductFacetService must not materialize product IDs or use queryFailed flag'); +} + +$migration = file_get_contents( + __DIR__ . '/../migrations/20260817130105_add_product_options_composite_indexes.php' +); +$metaMap = file_get_contents(__DIR__ . '/../src/Model/mysql/msProductOption.php'); +if ($migration === false || $metaMap === false) { + $fail('unable to read product_options index migration/metaMap'); +} +foreach (['key_product_id', 'product_id_key'] as $indexName) { + if (!str_contains($migration, $indexName) || !str_contains($metaMap, "'{$indexName}'")) { + $fail("composite index {$indexName} missing in migration or msProductOption metaMap"); + } +} + +if (!str_contains($catalog, 'function buildScopedListQuery(')) { + $fail('ProductCatalogService must expose buildScopedListQuery'); +} + +if (!preg_match('/private array \$publicRoutes\s*=\s*\[(.*?)\];/s', $middleware, $match)) { + $fail('publicRoutes array not found'); +} +if (!str_contains($match[1], "'/api/v1/product/filters'")) { + $fail('TokenMiddleware publicRoutes missing /api/v1/product/filters'); +} + +if (!str_contains($registry, "'ms3_product_facets'")) { + $fail('ServiceRegistry missing ms3_product_facets'); +} +if (!str_contains($factories, "'ms3_product_facets'")) { + $fail('ServiceRegistryFactories missing ms3_product_facets'); +} + +foreach ( + [ + 'ms3_err_catalog_facet_keys_invalid', + 'ms3_err_catalog_facet_keys_limit', + ] as $key +) { + if (!str_contains($en, $key) || !str_contains($ru, $key)) { + $fail("lexicon missing {$key} in en/ru"); + } +} + +fwrite(STDOUT, "OK ProductFacetRoutesTest\n"); +exit(0); diff --git a/core/components/minishop3/tests/ProductFacetSoftSemanticsTest.php b/core/components/minishop3/tests/ProductFacetSoftSemanticsTest.php new file mode 100644 index 000000000..fe50fbf92 --- /dev/null +++ b/core/components/minishop3/tests/ProductFacetSoftSemanticsTest.php @@ -0,0 +1,120 @@ +getLexiconKey() !== $lexiconKey) { + $fail($case . ': expected lexicon ' . $lexiconKey . ', got ' . $e->getLexiconKey()); + } + } +}; + +$spec = ProductCatalogFilterParser::parse([ + 'price_min' => '100', + 'price_max' => '5000', + 'vendor_id' => '3,7', + 'options' => '{"color":["black"],"size":["M"]}', +]); + +$priceSoft = $spec->withoutPriceBounds(); +$assertSame(null, $priceSoft->priceMin, 'price soft clears min'); +$assertSame(null, $priceSoft->priceMax, 'price soft clears max'); +$assertSame(['color' => ['black'], 'size' => ['M']], $priceSoft->options, 'price soft keeps options'); +$assertSame([3, 7], $priceSoft->vendorIds, 'price soft keeps vendors'); + +$colorSoft = $spec->withoutOptionKey('color'); +$assertSame(['size' => ['M']], $colorSoft->options, 'option soft drops only color'); +$assertSame(100.0, $colorSoft->priceMin, 'option soft keeps price_min'); +$assertSame(['black'], $spec->options['color'], 'original options unchanged'); + +$sizeSoft = $spec->withoutOptionKey('size'); +$assertSame(['color' => ['black']], $sizeSoft->options, 'option soft drops only size'); + +$missing = $spec->withoutOptionKey('memory'); +$assertSame($spec->options, $missing->options, 'unknown option key is no-op'); + +$vendorSoft = $spec->withoutVendors(); +$assertSame([], $vendorSoft->vendorIds, 'vendor soft clears vendor_id'); +$assertSame(['color' => ['black'], 'size' => ['M']], $vendorSoft->options, 'vendor soft keeps options'); + +$assertSame(20, ProductFacetService::MAX_FACET_KEYS, 'max facet keys'); +$assertSame(50, ProductFacetService::MAX_VALUES_PER_KEY, 'max values per key'); +$assertSame(120, ProductFacetService::CACHE_TTL_SECONDS, 'cache TTL'); + +$reflection = new ReflectionClass(ProductFacetService::class); +$parseKeys = $reflection->getMethod('parseRequestedKeys'); +$parseKeys->setAccessible(true); +$service = $reflection->newInstanceWithoutConstructor(); + +$assertSame(['color', 'memory'], $parseKeys->invoke($service, 'color,memory,color'), 'keys CSV unique'); +$assertThrows( + 'ms3_err_catalog_facet_keys_invalid', + static fn () => $parseKeys->invoke($service, 'bad-key!'), + 'invalid key char' +); +$assertThrows( + 'ms3_err_catalog_facet_keys_invalid', + static fn () => $parseKeys->invoke($service, ''), + 'empty keys' +); +$assertThrows( + 'ms3_err_catalog_facet_keys_invalid', + static fn () => $parseKeys->invoke($service, ' '), + 'whitespace keys' +); + +// nested expands only with parents= (parity with product/list parent|category) +$facetSrc = file_get_contents(__DIR__ . '/../src/Services/Product/ProductFacetService.php'); +if ($facetSrc === false || !str_contains($facetSrc, 'hasParents() && $filters->nested')) { + $fail('resolveScopeCategoryIds must gate nested on hasParents()'); +} +if (!str_contains($facetSrc, 'innerJoin(') || !str_contains($facetSrc, 'FacetOpt')) { + $fail('option facets must JOIN FacetOpt (no PHP ID materialization)'); +} +if (str_contains($facetSrc, 'collectProductIds')) { + $fail('collectProductIds must be removed'); +} +if (!str_contains($facetSrc, '$cacheable')) { + $fail('must track cacheable flag for SQL failures'); +} +$assertThrows( + 'ms3_err_catalog_facet_keys_limit', + static function () use ($parseKeys, $service): void { + $keys = []; + for ($i = 0; $i < ProductFacetService::MAX_FACET_KEYS + 1; $i++) { + $keys[] = 'k' . $i; + } + $parseKeys->invoke($service, implode(',', $keys)); + }, + 'keys cap' +); + +fwrite(STDOUT, "OK ProductFacetSoftSemanticsTest\n"); +exit(0); diff --git a/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php b/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php index 0fca981bb..cd04e6389 100644 --- a/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php +++ b/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php @@ -39,6 +39,7 @@ [ '/api/v1/product/get/', '/api/v1/product/list', + '/api/v1/product/filters', '/api/v1/category/get/', '/api/v1/category/list', '/api/v1/category/tree',