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
12 changes: 12 additions & 0 deletions core/components/minishop3/lexicon/en/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,18 @@
$_lang['ms3_err_product_id_required'] = 'Product ID is required';
$_lang['ms3_err_product_nf'] = 'Product not found';
$_lang['ms3_err_product_update_failed'] = 'Failed to update product';
$_lang['ms3_err_catalog_parents_invalid'] = 'Invalid parents filter';
$_lang['ms3_err_catalog_parents_limit'] = 'Too many parent category IDs';
$_lang['ms3_err_catalog_price_invalid'] = 'Invalid price filter';
$_lang['ms3_err_catalog_price_range'] = 'price_max must be greater than or equal to price_min';
$_lang['ms3_err_catalog_stock_invalid'] = 'Invalid stock_min filter';
$_lang['ms3_err_catalog_vendor_invalid'] = 'Invalid vendor_id filter';
$_lang['ms3_err_catalog_vendor_limit'] = 'Too many vendor IDs';
$_lang['ms3_err_catalog_options_json'] = 'options must be a JSON object or map';
$_lang['ms3_err_catalog_options_limit'] = 'Too many option filters or values';
$_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_category_products_reordered'] = 'Products reordered successfully';
$_lang['ms3_category_product_published'] = 'Product published';
$_lang['ms3_category_product_unpublished'] = 'Product unpublished';
Expand Down
12 changes: 12 additions & 0 deletions core/components/minishop3/lexicon/ru/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,18 @@
$_lang['ms3_err_product_id_required'] = 'Не указан ID товара';
$_lang['ms3_err_product_nf'] = 'Товар не найден';
$_lang['ms3_err_product_update_failed'] = 'Не удалось обновить товар';
$_lang['ms3_err_catalog_parents_invalid'] = 'Некорректный фильтр parents';
$_lang['ms3_err_catalog_parents_limit'] = 'Слишком много ID категорий в parents';
$_lang['ms3_err_catalog_price_invalid'] = 'Некорректный фильтр цены';
$_lang['ms3_err_catalog_price_range'] = 'price_max должен быть не меньше price_min';
$_lang['ms3_err_catalog_stock_invalid'] = 'Некорректный фильтр stock_min';
$_lang['ms3_err_catalog_vendor_invalid'] = 'Некорректный фильтр vendor_id';
$_lang['ms3_err_catalog_vendor_limit'] = 'Слишком много ID вендоров';
$_lang['ms3_err_catalog_options_json'] = 'options должен быть JSON-объектом или картой';
$_lang['ms3_err_catalog_options_limit'] = 'Слишком много ключей или значений опций';
$_lang['ms3_err_catalog_option_key_invalid'] = 'Некорректный ключ опции';
$_lang['ms3_err_catalog_option_value_invalid'] = 'Некорректное значение опции';
$_lang['ms3_err_catalog_option_unknown'] = 'Неизвестный ключ опции';
$_lang['ms3_category_products_reordered'] = 'Порядок товаров успешно изменён';
$_lang['ms3_category_product_published'] = 'Товар опубликован';
$_lang['ms3_category_product_unpublished'] = 'Товар снят с публикации';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MiniShop3\Services\Product\ProductCatalogFilterException;
use MiniShop3\Services\Product\ProductCatalogService;
use MODX\Revolution\modX;

Expand Down Expand Up @@ -55,14 +56,22 @@ public function get(array $params = []): Response
/**
* GET /api/v1/product/list
*
* Query: parent|category, limit, offset|page, sort, dir, query,
* context, include_options, include_content
* Query: parent|category, parents, nested, price_min, price_max, in_stock, stock_min,
* vendor_id, new, popular, favorite, options (JSON),
* limit, offset|page, sort, dir, query, context, include_options, include_content
*
* @param array<string, mixed> $params Route + query params (Router merges $_GET)
*/
public function getList(array $params = []): Response
{
$result = $this->catalog()->getList($params);
try {
$result = $this->catalog()->getList($params);
} catch (ProductCatalogFilterException $e) {
return Response::error(
$this->modx->lexicon($e->getLexiconKey()),
HttpStatus::BAD_REQUEST
);
}

return Response::success($result);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Product;

use MiniShop3\Model\msOption;
use MiniShop3\Model\msProductOption;
use MiniShop3\Services\Category\CategoryProductScopeService;
use MODX\Revolution\modX;
use xPDO\Om\xPDOQuery;

/**
* Apply validated ProductCatalogFilterSpec onto an xPDO product list query.
*/
final class ProductCatalogFilterApplier
{
/** Depth when nested=1 (matches ms3_products default depth window). */
private const NESTED_DEPTH = 10;

public function __construct(
private modX $modx,
private CategoryProductScopeService $scope,
) {
}

/**
* @param bool $dedupeRows When true, GROUP BY product id after option JOINs (list pages).
* Count queries must pass false and use COUNT(DISTINCT) instead.
*/
public function apply(xPDOQuery $query, ProductCatalogFilterSpec $filters, bool $dedupeRows = true): void
{
$this->applyCategoryScope($query, $filters);
$this->applyDataFilters($query, $filters);
$this->applyOptionFilters($query, $filters, $dedupeRows);
}

private function applyCategoryScope(xPDOQuery $query, ProductCatalogFilterSpec $filters): void
{
if (!$filters->hasParents()) {
return;
}

$depth = $filters->nested ? self::NESTED_DEPTH : 0;
$parentsCsv = implode(',', $filters->parentIds);
$categoryIds = $this->scope->resolveCategoryIdsFromParents($parentsCsv, $depth);

if ($categoryIds === []) {
// Force empty result set without raw SQL.
$query->where(['msProduct.id' => 0]);

return;
}

$this->scope->applyProductCategoryScope($query, $categoryIds);
}

private function applyDataFilters(xPDOQuery $query, ProductCatalogFilterSpec $filters): void
{
if ($filters->priceMin !== null) {
$query->where(['Data.price:>=' => $filters->priceMin]);
}
if ($filters->priceMax !== null) {
$query->where(['Data.price:<=' => $filters->priceMax]);
}

if ($filters->inStock) {
$query->where(['Data.stock:>' => 0]);
}
if ($filters->stockMin !== null) {
$query->where(['Data.stock:>=' => $filters->stockMin]);
}

if ($filters->vendorIds !== []) {
$query->where(['Data.vendor_id:IN' => $filters->vendorIds]);
}

if ($filters->flagNew) {
$query->where(['Data.new' => 1]);
}
if ($filters->flagPopular) {
$query->where(['Data.popular' => 1]);
}
if ($filters->flagFavorite) {
$query->where(['Data.favorite' => 1]);
}
}

private function applyOptionFilters(
xPDOQuery $query,
ProductCatalogFilterSpec $filters,
bool $dedupeRows,
): void {
if ($filters->options === []) {
return;
}

$this->assertOptionKeysExist(array_keys($filters->options));

$index = 0;
foreach ($filters->options as $key => $values) {
$alias = 'OptFilter' . $index++;

// Key is validated as [a-zA-Z0-9_]+ and exists in msOption.
$query->innerJoin(
msProductOption::class,
$alias,
"`{$alias}`.product_id = Data.id AND `{$alias}`.`key` = " . $this->modx->quote($key)
);
$query->where(["{$alias}.value:IN" => $values]);
}

// Multi-value option rows duplicate product rows on list pages.
// Count uses COUNT(DISTINCT) without GROUP BY (GROUP BY would break fetchColumn total).
if ($dedupeRows) {
$query->groupby('msProduct.id');
}
}

/**
* @param list<string> $keys
*/
private function assertOptionKeysExist(array $keys): void
{
$c = $this->modx->newQuery(msOption::class);
$c->where(['key:IN' => $keys]);
$c->select('key');

if (!$c->prepare() || !$c->stmt->execute()) {
throw new \RuntimeException('Failed to validate catalog option keys');
}

$found = array_map('strval', $c->stmt->fetchAll(\PDO::FETCH_COLUMN) ?: []);
if (array_diff($keys, $found) !== []) {
throw new ProductCatalogFilterException('ms3_err_catalog_option_unknown');
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Product;

/**
* Invalid public product/list filter params (maps to HTTP 400 + lexicon).
*/
final class ProductCatalogFilterException extends \InvalidArgumentException
{
public function __construct(
private readonly string $lexiconKey,
) {
parent::__construct($lexiconKey);
}

public function getLexiconKey(): string
{
return $this->lexiconKey;
}
}
Loading