Skip to content
Open
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
4 changes: 4 additions & 0 deletions core/components/minishop3/lexicon/en/vue.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@
$_lang['field_type_model'] = 'Model Field';
$_lang['field_type_template'] = 'Template Field';
$_lang['field_type_relation'] = 'Relation Field';
$_lang['field_type_option'] = 'Product Option';
$_lang['field_type_computed'] = 'Computed Field';
$_lang['field_template'] = 'Template';
$_lang['field_template_placeholder'] = 'Example: {first_name} {last_name}';
Expand All @@ -363,6 +364,9 @@
$_lang['relation_aggregation_min'] = 'MIN (minimum)';
$_lang['relation_aggregation_max'] = 'MAX (maximum)';
$_lang['relation_hint'] = 'Specify table name or xPDO model class. JOIN query is executed once for all rows';
$_lang['option_key'] = 'Option Key';
$_lang['option_key_placeholder'] = 'Example: length, width, material';
$_lang['option_key_hint'] = 'Key of the product option from ms3_product_options. Use option_{key} as field name (e.g. option_length)';
$_lang['computed_class_name'] = 'Class';
$_lang['computed_class_name_placeholder'] = 'Example: MiniShop3\\Computed\\DiscountPercent';
$_lang['computed_class_hint'] = 'Class must implement ComputedFieldInterface';
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/lexicon/ru/vue.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@
$_lang['field_type_model'] = 'Модельное поле';
$_lang['field_type_template'] = 'Шаблонное поле';
$_lang['field_type_relation'] = 'Связанное поле';
$_lang['field_type_option'] = 'Опция товара';
$_lang['field_type_computed'] = 'Вычисляемое поле';
$_lang['field_template'] = 'Шаблон';
$_lang['field_template_placeholder'] = 'Например: {first_name} {last_name}';
Expand All @@ -363,6 +364,9 @@
$_lang['relation_aggregation_min'] = 'MIN (минимум)';
$_lang['relation_aggregation_max'] = 'MAX (максимум)';
$_lang['relation_hint'] = 'Укажите имя таблицы или класс модели xPDO. JOIN запрос выполняется один раз для всех строк';
$_lang['option_key'] = 'Ключ опции';
$_lang['option_key_placeholder'] = 'Например: length, width, material';
$_lang['option_key_hint'] = 'Ключ опции товара из ms3_product_options. Имя поля: option_{key} (например: option_length)';
$_lang['computed_class_name'] = 'Класс';
$_lang['computed_class_name_placeholder'] = 'Например: MiniShop3\\Computed\\DiscountPercent';
$_lang['computed_class_hint'] = 'Класс должен реализовывать ComputedFieldInterface';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use MiniShop3\Model\msProduct;
use MiniShop3\Model\msProductData;
use MiniShop3\Model\msProductOption;
use MiniShop3\Model\msCategory;
use MiniShop3\Router\Response;
use MiniShop3\Services\FilterConfigManager;
Expand Down Expand Up @@ -57,85 +58,23 @@ public function getList(array $params = []): array
$sortDir = 'ASC';
}

// Build query
$c = $this->modx->newQuery(msProduct::class);
$c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id');

// class_key filter (getIterator doesn't call addDerivativeCriteria)
$c->where(['msProduct.class_key' => msProduct::class]);

// Parent filter
if ($nested) {
// Get all child category IDs
$categoryIds = $this->getChildCategories($categoryId);
$categoryIds[] = $categoryId;
$c->where(['msProduct.parent:IN' => $categoryIds]);
} else {
$c->where(['msProduct.parent' => $categoryId]);
}

// Search filter
if (!empty($query)) {
$c->where([
'msProduct.pagetitle:LIKE' => "%{$query}%",
'OR:Data.article:LIKE' => "%{$query}%",
]);
}

// Boolean filters for msProduct fields
$productBooleanFields = ['published', 'deleted', 'hidemenu', 'isfolder'];
foreach ($productBooleanFields as $field) {
if (isset($params[$field]) && $params[$field] !== '') {
$c->where(["msProduct.{$field}" => (int)$params[$field]]);
}
}

// Boolean filters for msProductData fields
$dataBooleanFields = ['new', 'popular', 'favorite'];
foreach ($dataBooleanFields as $field) {
if (isset($params[$field]) && $params[$field] !== '') {
$c->where(["Data.{$field}" => (int)$params[$field]]);
}
}

// Text filters for msProduct fields (LIKE search)
$productTextFields = ['pagetitle', 'longtitle', 'alias', 'description', 'introtext', 'content'];
foreach ($productTextFields as $field) {
if (!empty($params[$field])) {
$c->where(["msProduct.{$field}:LIKE" => "%{$params[$field]}%"]);
}
}
$gridConfig = $this->modx->services->get('ms3_grid_config');
$gridFields = $gridConfig ? $gridConfig->getGridConfig('category-products', true) : [];
$optionFields = $gridConfig ? $gridConfig->extractOptionFields($gridFields) : [];

// Text filters for msProductData fields (LIKE search)
$dataTextFields = ['article', 'made_in'];
foreach ($dataTextFields as $field) {
if (!empty($params[$field])) {
$c->where(["Data.{$field}:LIKE" => "%{$params[$field]}%"]);
}
}
$c = $this->buildProductListQuery($categoryId, $params, $nested, $optionFields);

// Numeric filters for msProductData fields (exact match)
$dataNumericFields = ['price', 'old_price', 'weight', 'vendor_id'];
foreach ($dataNumericFields as $field) {
if (isset($params[$field]) && $params[$field] !== '') {
$c->where(["Data.{$field}" => $params[$field]]);
}
}

// Default: hide deleted if not explicitly filtered
if (!isset($params['deleted']) || $params['deleted'] === '') {
$c->where(['msProduct.deleted' => 0]);
}
$countQuery = $this->buildProductListQuery($categoryId, $params, $nested, $optionFields);
$countQuery->select('COUNT(DISTINCT msProduct.id)');
$countQuery->prepare();
$countQuery->stmt->execute();
$total = (int)$countQuery->stmt->fetchColumn();

// Get total count
$total = $this->modx->getCount(msProduct::class, $c);

// Apply sorting and pagination
$c->sortby($sortBy, $sortDir);
$sortField = $this->mapSortField($sortBy, $optionFields);
$c->sortby($sortField, $sortDir);
$c->limit($limit, $start);

// Select fields
$c->select([
$selectParts = [
'msProduct.*',
'Data.article',
'Data.price',
Expand All @@ -148,13 +87,22 @@ public function getList(array $params = []): array
'Data.new',
'Data.popular',
'Data.favorite',
]);
];
foreach ($optionFields as $opt) {
$selectParts[] = "GROUP_CONCAT(DISTINCT `{$opt['alias']}`.value) AS `{$opt['fieldName']}`";
}
$c->select($selectParts);
if (!empty($optionFields)) {
$c->groupby('msProduct.id');
}

$products = $this->modx->getIterator(msProduct::class, $c);
$c->prepare();
$rows = $c->stmt->execute() ? $c->stmt->fetchAll(\PDO::FETCH_ASSOC) : [];

$optionFieldNames = array_column($optionFields, 'fieldName');
$results = [];
foreach ($products as $product) {
$results[] = $this->formatProduct($product, $nested);
foreach ($rows as $row) {
$results[] = $this->formatProduct($row, $nested, $optionFieldNames);
}

return Response::success([
Expand Down Expand Up @@ -394,45 +342,78 @@ public function publish(array $params = []): array
}

/**
* Format product for API response
* Map sort field to SQL expression (supports option fields)
*
* For option fields uses GROUP_CONCAT to comply with MySQL ONLY_FULL_GROUP_BY.
*
* @param string $sortBy
* @param array $optionFields
* @return string
*/
protected function mapSortField(string $sortBy, array $optionFields): string
{
foreach ($optionFields as $opt) {
if ($opt['fieldName'] === $sortBy) {
return "GROUP_CONCAT(DISTINCT `{$opt['alias']}`.value)";
}
}
$productFields = ['id', 'pagetitle', 'menuindex', 'published', 'createdon', 'editedon'];
if (in_array($sortBy, $productFields)) {
return "msProduct.{$sortBy}";
}
$dataFields = ['article', 'price', 'old_price', 'weight', 'vendor_id', 'made_in'];
if (in_array($sortBy, $dataFields)) {
return "Data.{$sortBy}";
}
return "msProduct.{$sortBy}";
}

/**
* Format product row for API response
*
* @param msProduct $product
* @param array $row Raw row from query (includes joined option values)
* @param bool $nested
* @param array $optionFieldNames Allowed option field names (prevents leaking internal xPDO/MySQL columns)
* @return array
*/
protected function formatProduct(msProduct $product, bool $nested = false): array
protected function formatProduct(array $row, bool $nested = false, array $optionFieldNames = []): array
{
$id = (int)$row['id'];
$data = [
'id' => $product->get('id'),
'pagetitle' => $product->get('pagetitle'),
'longtitle' => $product->get('longtitle'),
'alias' => $product->get('alias'),
'parent' => $product->get('parent'),
'menuindex' => $product->get('menuindex'),
'published' => (bool)$product->get('published'),
'deleted' => (bool)$product->get('deleted'),
'hidemenu' => (bool)$product->get('hidemenu'),
'createdon' => $product->get('createdon'),
'editedon' => $product->get('editedon'),
// Product data
'article' => $product->get('article'),
'price' => (float)$product->get('price'),
'old_price' => (float)$product->get('old_price'),
'weight' => (float)$product->get('weight'),
'image' => $product->get('image'),
'thumb' => $product->get('thumb'),
'vendor_id' => (int)$product->get('vendor_id'),
'made_in' => $product->get('made_in'),
'new' => (bool)$product->get('new'),
'popular' => (bool)$product->get('popular'),
'favorite' => (bool)$product->get('favorite'),
// Preview URL
'preview_url' => $this->modx->makeUrl($product->get('id'), '', '', 'full'),
'id' => $id,
'pagetitle' => $row['pagetitle'] ?? '',
'longtitle' => $row['longtitle'] ?? '',
'alias' => $row['alias'] ?? '',
'parent' => (int)($row['parent'] ?? 0),
'menuindex' => (int)($row['menuindex'] ?? 0),
'published' => (bool)($row['published'] ?? false),
'deleted' => (bool)($row['deleted'] ?? false),
'hidemenu' => (bool)($row['hidemenu'] ?? false),
'createdon' => $row['createdon'] ?? null,
'editedon' => $row['editedon'] ?? null,
'article' => $row['article'] ?? '',
'price' => (float)($row['price'] ?? 0),
'old_price' => (float)($row['old_price'] ?? 0),
'weight' => (float)($row['weight'] ?? 0),
'image' => $row['image'] ?? '',
'thumb' => $row['thumb'] ?? '',
'vendor_id' => (int)($row['vendor_id'] ?? 0),
'made_in' => $row['made_in'] ?? '',
'new' => (bool)($row['new'] ?? false),
'popular' => (bool)($row['popular'] ?? false),
'favorite' => (bool)($row['favorite'] ?? false),
'preview_url' => $this->modx->makeUrl($id, '', '', 'full'),
];

// Add category name for nested products
if ($nested && $product->get('parent') != 0) {
$parent = $this->modx->getObject(msCategory::class, $product->get('parent'));
$allowedOptionFields = array_flip($optionFieldNames);
foreach ($row as $key => $value) {
if (!array_key_exists($key, $data) && isset($allowedOptionFields[$key])) {
$data[$key] = $value;
}
}

if ($nested && ($row['parent'] ?? 0) != 0) {
$parent = $this->modx->getObject(msCategory::class, (int)$row['parent']);
if ($parent) {
$data['category_name'] = $parent->get('pagetitle');
}
Expand All @@ -441,6 +422,97 @@ protected function formatProduct(msProduct $product, bool $nested = false): arra
return $data;
}

/**
* Build base product list query with JOINs and filters (no select/sort/limit)
*
* @param int $categoryId
* @param array $params
* @param bool $nested
* @param array $optionFields
* @return \xPDO\Om\xPDOQuery
*/
protected function buildProductListQuery(int $categoryId, array $params, bool $nested, array $optionFields): \xPDO\Om\xPDOQuery
{
$query = trim($params['query'] ?? '');
$c = $this->modx->newQuery(msProduct::class);
$c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id');

foreach ($optionFields as $opt) {
$alias = $opt['alias'];
$key = $opt['key'];
$c->leftJoin(
msProductOption::class,
$alias,
"`{$alias}`.product_id = msProduct.id AND `{$alias}`.key = '{$key}'"
);
}

$c->where(['msProduct.class_key' => msProduct::class]);

if ($nested) {
$categoryIds = $this->getChildCategories($categoryId);
$categoryIds[] = $categoryId;
$c->where(['msProduct.parent:IN' => $categoryIds]);
} else {
$c->where(['msProduct.parent' => $categoryId]);
}

if (!empty($query)) {
$c->where([
'msProduct.pagetitle:LIKE' => "%{$query}%",
'OR:Data.article:LIKE' => "%{$query}%",
]);
}

$productBooleanFields = ['published', 'deleted', 'hidemenu', 'isfolder'];
foreach ($productBooleanFields as $field) {
if (isset($params[$field]) && $params[$field] !== '') {
$c->where(["msProduct.{$field}" => (int)$params[$field]]);
}
}

$dataBooleanFields = ['new', 'popular', 'favorite'];
foreach ($dataBooleanFields as $field) {
if (isset($params[$field]) && $params[$field] !== '') {
$c->where(["Data.{$field}" => (int)$params[$field]]);
}
}

$productTextFields = ['pagetitle', 'longtitle', 'alias', 'description', 'introtext', 'content'];
foreach ($productTextFields as $field) {
if (!empty($params[$field])) {
$c->where(["msProduct.{$field}:LIKE" => "%{$params[$field]}%"]);
}
}

$dataTextFields = ['article', 'made_in'];
foreach ($dataTextFields as $field) {
if (!empty($params[$field])) {
$c->where(["Data.{$field}:LIKE" => "%{$params[$field]}%"]);
}
}

$dataNumericFields = ['price', 'old_price', 'weight', 'vendor_id'];
foreach ($dataNumericFields as $field) {
if (isset($params[$field]) && $params[$field] !== '') {
$c->where(["Data.{$field}" => $params[$field]]);
}
}

foreach ($optionFields as $opt) {
$paramKey = 'filter_' . $opt['fieldName'];
if (isset($params[$paramKey]) && $params[$paramKey] !== '') {
$c->where(["`{$opt['alias']}`.value:LIKE" => "%{$params[$paramKey]}%"]);
}
}

if (!isset($params['deleted']) || $params['deleted'] === '') {
$c->where(['msProduct.deleted' => 0]);
}

return $c;
}

/**
* Get all child category IDs recursively
*
Expand Down
Loading