@@ -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 *
0 commit comments