Skip to content

Commit af276ff

Browse files
committed
refactor(category): streamline product list query and enhance option field handling
- Consolidated product list query logic into a new method, buildProductListQuery, for better readability and maintainability. - Updated option field handling to use GROUP_CONCAT for compliance with MySQL ONLY_FULL_GROUP_BY. - Enhanced formatProduct method to prevent leaking internal columns by validating option field names. - Added validation for option keys in GridConfigService to ensure data integrity.
1 parent bb19d23 commit af276ff

2 files changed

Lines changed: 112 additions & 91 deletions

File tree

core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php

Lines changed: 106 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -62,93 +62,9 @@ public function getList(array $params = []): array
6262
$gridFields = $gridConfig ? $gridConfig->getGridConfig('category-products', true) : [];
6363
$optionFields = $gridConfig ? $gridConfig->extractOptionFields($gridFields) : [];
6464

65-
$c = $this->modx->newQuery(msProduct::class);
66-
$c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id');
65+
$c = $this->buildProductListQuery($categoryId, $params, $nested, $optionFields);
6766

68-
foreach ($optionFields as $opt) {
69-
$alias = $opt['alias'];
70-
$key = $opt['key'];
71-
$c->leftJoin(
72-
msProductOption::class,
73-
$alias,
74-
"`{$alias}`.product_id = msProduct.id AND `{$alias}`.key = '{$key}'"
75-
);
76-
}
77-
78-
$c->where(['msProduct.class_key' => msProduct::class]);
79-
80-
// Parent filter
81-
if ($nested) {
82-
// Get all child category IDs
83-
$categoryIds = $this->getChildCategories($categoryId);
84-
$categoryIds[] = $categoryId;
85-
$c->where(['msProduct.parent:IN' => $categoryIds]);
86-
} else {
87-
$c->where(['msProduct.parent' => $categoryId]);
88-
}
89-
90-
// Search filter
91-
if (!empty($query)) {
92-
$c->where([
93-
'msProduct.pagetitle:LIKE' => "%{$query}%",
94-
'OR:Data.article:LIKE' => "%{$query}%",
95-
]);
96-
}
97-
98-
// Boolean filters for msProduct fields
99-
$productBooleanFields = ['published', 'deleted', 'hidemenu', 'isfolder'];
100-
foreach ($productBooleanFields as $field) {
101-
if (isset($params[$field]) && $params[$field] !== '') {
102-
$c->where(["msProduct.{$field}" => (int)$params[$field]]);
103-
}
104-
}
105-
106-
// Boolean filters for msProductData fields
107-
$dataBooleanFields = ['new', 'popular', 'favorite'];
108-
foreach ($dataBooleanFields as $field) {
109-
if (isset($params[$field]) && $params[$field] !== '') {
110-
$c->where(["Data.{$field}" => (int)$params[$field]]);
111-
}
112-
}
113-
114-
// Text filters for msProduct fields (LIKE search)
115-
$productTextFields = ['pagetitle', 'longtitle', 'alias', 'description', 'introtext', 'content'];
116-
foreach ($productTextFields as $field) {
117-
if (!empty($params[$field])) {
118-
$c->where(["msProduct.{$field}:LIKE" => "%{$params[$field]}%"]);
119-
}
120-
}
121-
122-
// Text filters for msProductData fields (LIKE search)
123-
$dataTextFields = ['article', 'made_in'];
124-
foreach ($dataTextFields as $field) {
125-
if (!empty($params[$field])) {
126-
$c->where(["Data.{$field}:LIKE" => "%{$params[$field]}%"]);
127-
}
128-
}
129-
130-
// Numeric filters for msProductData fields (exact match)
131-
$dataNumericFields = ['price', 'old_price', 'weight', 'vendor_id'];
132-
foreach ($dataNumericFields as $field) {
133-
if (isset($params[$field]) && $params[$field] !== '') {
134-
$c->where(["Data.{$field}" => $params[$field]]);
135-
}
136-
}
137-
138-
foreach ($optionFields as $opt) {
139-
$paramKey = 'filter_' . $opt['fieldName'];
140-
if (isset($params[$paramKey]) && $params[$paramKey] !== '') {
141-
$filterValue = $this->modx->escape($params[$paramKey]);
142-
$c->where(["`{$opt['alias']}`.value:LIKE" => "%{$filterValue}%"]);
143-
}
144-
}
145-
146-
// Default: hide deleted if not explicitly filtered
147-
if (!isset($params['deleted']) || $params['deleted'] === '') {
148-
$c->where(['msProduct.deleted' => 0]);
149-
}
150-
151-
$countQuery = clone $c;
67+
$countQuery = $this->buildProductListQuery($categoryId, $params, $nested, $optionFields);
15268
$countQuery->select('COUNT(DISTINCT msProduct.id)');
15369
$countQuery->prepare();
15470
$countQuery->stmt->execute();
@@ -173,16 +89,20 @@ public function getList(array $params = []): array
17389
'Data.favorite',
17490
];
17591
foreach ($optionFields as $opt) {
176-
$selectParts[] = "`{$opt['alias']}`.value AS `{$opt['fieldName']}`";
92+
$selectParts[] = "GROUP_CONCAT(DISTINCT `{$opt['alias']}`.value) AS `{$opt['fieldName']}`";
17793
}
17894
$c->select($selectParts);
95+
if (!empty($optionFields)) {
96+
$c->groupby('msProduct.id');
97+
}
17998

18099
$c->prepare();
181100
$rows = $c->stmt->execute() ? $c->stmt->fetchAll(\PDO::FETCH_ASSOC) : [];
182101

102+
$optionFieldNames = array_column($optionFields, 'fieldName');
183103
$results = [];
184104
foreach ($rows as $row) {
185-
$results[] = $this->formatProduct($row, $nested);
105+
$results[] = $this->formatProduct($row, $nested, $optionFieldNames);
186106
}
187107

188108
return Response::success([
@@ -424,6 +344,8 @@ public function publish(array $params = []): array
424344
/**
425345
* Map sort field to SQL expression (supports option fields)
426346
*
347+
* For option fields uses GROUP_CONCAT to comply with MySQL ONLY_FULL_GROUP_BY.
348+
*
427349
* @param string $sortBy
428350
* @param array $optionFields
429351
* @return string
@@ -432,7 +354,7 @@ protected function mapSortField(string $sortBy, array $optionFields): string
432354
{
433355
foreach ($optionFields as $opt) {
434356
if ($opt['fieldName'] === $sortBy) {
435-
return "`{$opt['alias']}`.value";
357+
return "GROUP_CONCAT(DISTINCT `{$opt['alias']}`.value)";
436358
}
437359
}
438360
$productFields = ['id', 'pagetitle', 'menuindex', 'published', 'createdon', 'editedon'];
@@ -451,9 +373,10 @@ protected function mapSortField(string $sortBy, array $optionFields): string
451373
*
452374
* @param array $row Raw row from query (includes joined option values)
453375
* @param bool $nested
376+
* @param array $optionFieldNames Allowed option field names (prevents leaking internal xPDO/MySQL columns)
454377
* @return array
455378
*/
456-
protected function formatProduct(array $row, bool $nested = false): array
379+
protected function formatProduct(array $row, bool $nested = false, array $optionFieldNames = []): array
457380
{
458381
$id = (int)$row['id'];
459382
$data = [
@@ -482,8 +405,9 @@ protected function formatProduct(array $row, bool $nested = false): array
482405
'preview_url' => $this->modx->makeUrl($id, '', '', 'full'),
483406
];
484407

408+
$allowedOptionFields = array_flip($optionFieldNames);
485409
foreach ($row as $key => $value) {
486-
if (!array_key_exists($key, $data)) {
410+
if (!array_key_exists($key, $data) && isset($allowedOptionFields[$key])) {
487411
$data[$key] = $value;
488412
}
489413
}
@@ -498,6 +422,97 @@ protected function formatProduct(array $row, bool $nested = false): array
498422
return $data;
499423
}
500424

425+
/**
426+
* Build base product list query with JOINs and filters (no select/sort/limit)
427+
*
428+
* @param int $categoryId
429+
* @param array $params
430+
* @param bool $nested
431+
* @param array $optionFields
432+
* @return \xPDO\Om\xPDOQuery
433+
*/
434+
protected function buildProductListQuery(int $categoryId, array $params, bool $nested, array $optionFields): \xPDO\Om\xPDOQuery
435+
{
436+
$query = trim($params['query'] ?? '');
437+
$c = $this->modx->newQuery(msProduct::class);
438+
$c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id');
439+
440+
foreach ($optionFields as $opt) {
441+
$alias = $opt['alias'];
442+
$key = $opt['key'];
443+
$c->leftJoin(
444+
msProductOption::class,
445+
$alias,
446+
"`{$alias}`.product_id = msProduct.id AND `{$alias}`.key = '{$key}'"
447+
);
448+
}
449+
450+
$c->where(['msProduct.class_key' => msProduct::class]);
451+
452+
if ($nested) {
453+
$categoryIds = $this->getChildCategories($categoryId);
454+
$categoryIds[] = $categoryId;
455+
$c->where(['msProduct.parent:IN' => $categoryIds]);
456+
} else {
457+
$c->where(['msProduct.parent' => $categoryId]);
458+
}
459+
460+
if (!empty($query)) {
461+
$c->where([
462+
'msProduct.pagetitle:LIKE' => "%{$query}%",
463+
'OR:Data.article:LIKE' => "%{$query}%",
464+
]);
465+
}
466+
467+
$productBooleanFields = ['published', 'deleted', 'hidemenu', 'isfolder'];
468+
foreach ($productBooleanFields as $field) {
469+
if (isset($params[$field]) && $params[$field] !== '') {
470+
$c->where(["msProduct.{$field}" => (int)$params[$field]]);
471+
}
472+
}
473+
474+
$dataBooleanFields = ['new', 'popular', 'favorite'];
475+
foreach ($dataBooleanFields as $field) {
476+
if (isset($params[$field]) && $params[$field] !== '') {
477+
$c->where(["Data.{$field}" => (int)$params[$field]]);
478+
}
479+
}
480+
481+
$productTextFields = ['pagetitle', 'longtitle', 'alias', 'description', 'introtext', 'content'];
482+
foreach ($productTextFields as $field) {
483+
if (!empty($params[$field])) {
484+
$c->where(["msProduct.{$field}:LIKE" => "%{$params[$field]}%"]);
485+
}
486+
}
487+
488+
$dataTextFields = ['article', 'made_in'];
489+
foreach ($dataTextFields as $field) {
490+
if (!empty($params[$field])) {
491+
$c->where(["Data.{$field}:LIKE" => "%{$params[$field]}%"]);
492+
}
493+
}
494+
495+
$dataNumericFields = ['price', 'old_price', 'weight', 'vendor_id'];
496+
foreach ($dataNumericFields as $field) {
497+
if (isset($params[$field]) && $params[$field] !== '') {
498+
$c->where(["Data.{$field}" => $params[$field]]);
499+
}
500+
}
501+
502+
foreach ($optionFields as $opt) {
503+
$paramKey = 'filter_' . $opt['fieldName'];
504+
if (isset($params[$paramKey]) && $params[$paramKey] !== '') {
505+
$c->where(["`{$opt['alias']}`.value:LIKE" => "%{$params[$paramKey]}%"]);
506+
}
507+
}
508+
509+
if (!isset($params['deleted']) || $params['deleted'] === '') {
510+
$c->where(['msProduct.deleted' => 0]);
511+
}
512+
513+
return $c;
514+
}
515+
501516
/**
502517
* Get all child category IDs recursively
503518
*

core/components/minishop3/src/Services/GridConfigService.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,8 @@ protected function validateOptionConfig(array $config): array
762762
/**
763763
* Extract option fields from grid config for JOIN building
764764
*
765+
* Re-validates option.key on read (defense in depth) — config can be modified directly in DB.
766+
*
765767
* @param array $gridFields Array of grid field configs
766768
* @return array List of option field definitions: [['fieldName' => 'option_length', 'key' => 'length', 'alias' => 'opt_length'], ...]
767769
*/
@@ -780,6 +782,10 @@ public function extractOptionFields(array $gridFields): array
780782
}
781783

782784
$key = $option['key'];
785+
if (!preg_match('/^[a-z0-9_]+$/i', $key)) {
786+
continue;
787+
}
788+
783789
$fieldName = $field['name'];
784790
$alias = 'opt_' . $key;
785791

0 commit comments

Comments
 (0)