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
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
use MiniShop3\Model\msProductData;
use MiniShop3\Model\msProductOption;
use MiniShop3\Services\Grid\GridOptionColumnResolver;
use MiniShop3\Services\Grid\GridRelationColumnResolver;
use MiniShop3\Services\Grid\OptionColumnSpec;
use MiniShop3\Services\Grid\RelationColumnSpec;
use MODX\Revolution\modX;
use xPDO\Om\xPDOQuery;

/**
* Category products grid: SQL list query with optional option columns (JOIN + GROUP BY + GROUP_CONCAT).
* Category products grid: SQL list query with optional option columns (JOIN + GROUP BY + GROUP_CONCAT)
* and relation columns (JOIN + SELECT from related tables).
*
* Multi-value options appear as one string (MySQL GROUP_CONCAT). For very large sets, server
* `group_concat_max_len` may truncate the result.
Expand Down Expand Up @@ -43,16 +46,17 @@ public function getPage(
string $sortDir,
): array {
$optionSpecs = GridOptionColumnResolver::resolve($gridFields);
$relationSpecs = GridRelationColumnResolver::resolve($this->modx, $gridFields);

$c = $this->buildProductListQuery($categoryId, $params, $nested, $optionSpecs);
$c = $this->buildProductListQuery($categoryId, $params, $nested, $optionSpecs, $relationSpecs);

$countQuery = $this->buildProductListQuery($categoryId, $params, $nested, $optionSpecs);
$countQuery = $this->buildProductListQuery($categoryId, $params, $nested, $optionSpecs, $relationSpecs);
$countQuery->select('COUNT(DISTINCT msProduct.id)');
$countQuery->prepare();
$countQuery->stmt->execute();
$total = (int) $countQuery->stmt->fetchColumn();

$sortField = $this->mapSortField($sortBy, $optionSpecs);
$sortField = $this->mapSortField($sortBy, $optionSpecs, $relationSpecs);
$c->sortby($sortField, $sortDir);
$c->limit($limit, $start);

Expand All @@ -73,6 +77,9 @@ public function getPage(
foreach ($optionSpecs as $spec) {
$selectParts[] = $this->aggregateOptionValueSql($spec->alias) . " AS `{$spec->fieldName}`";
}
foreach ($relationSpecs as $spec) {
$selectParts[] = $spec->selectExpression();
}
// xPDOQuery::select() declares string, accepts both at runtime but PHPStan is strict.
$c->select(implode(', ', $selectParts));
if ($optionSpecs !== []) {
Expand All @@ -83,9 +90,10 @@ public function getPage(
$rows = $c->stmt->execute() ? $c->stmt->fetchAll(\PDO::FETCH_ASSOC) : [];

$optionFieldNames = array_map(static fn (OptionColumnSpec $s) => $s->fieldName, $optionSpecs);
$relationFieldNames = array_map(static fn (RelationColumnSpec $s) => $s->fieldName, $relationSpecs);
$results = [];
foreach ($rows as $row) {
$results[] = $this->formatProductRow($row, $nested, $optionFieldNames);
$results[] = $this->formatProductRow($row, $nested, $optionFieldNames, $relationFieldNames);
}

return [
Expand All @@ -103,15 +111,21 @@ private function aggregateOptionValueSql(string $alias): string
}

/**
* @param list<OptionColumnSpec> $optionSpecs
* @param list<OptionColumnSpec> $optionSpecs
* @param list<RelationColumnSpec> $relationSpecs
*/
private function mapSortField(string $sortBy, array $optionSpecs): string
private function mapSortField(string $sortBy, array $optionSpecs, array $relationSpecs): string
{
foreach ($optionSpecs as $spec) {
if ($spec->fieldName === $sortBy) {
return $this->aggregateOptionValueSql($spec->alias);
}
}
foreach ($relationSpecs as $spec) {
if ($spec->fieldName === $sortBy) {
return $spec->sortExpression();
}
}
$productFields = ['id', 'pagetitle', 'menuindex', 'published', 'createdon', 'editedon'];
if (in_array($sortBy, $productFields, true)) {
return "msProduct.{$sortBy}";
Expand All @@ -125,10 +139,16 @@ private function mapSortField(string $sortBy, array $optionSpecs): string
}

/**
* @param list<OptionColumnSpec> $optionSpecs
* @param list<OptionColumnSpec> $optionSpecs
* @param list<RelationColumnSpec> $relationSpecs
*/
private function buildProductListQuery(int $categoryId, array $params, bool $nested, array $optionSpecs): xPDOQuery
{
private function buildProductListQuery(
int $categoryId,
array $params,
bool $nested,
array $optionSpecs,
array $relationSpecs,
): xPDOQuery {
$query = trim((string) ($params['query'] ?? ''));
$c = $this->modx->newQuery(msProduct::class);
$c->innerJoin(msProductData::class, 'Data', 'msProduct.id = Data.id');
Expand All @@ -143,6 +163,10 @@ private function buildProductListQuery(int $categoryId, array $params, bool $nes
);
}

foreach (GridRelationColumnResolver::uniqueJoins($relationSpecs) as $spec) {
$c->leftJoin($spec->modelClass, $spec->alias, $spec->joinCondition());
}

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

if ($nested) {
Expand Down Expand Up @@ -240,12 +264,17 @@ private function getChildCategories(int $parentId): array
}

/**
* @param list<string> $optionFieldNames Allowed option field names (whitelist)
* @param list<string> $optionFieldNames Allowed option field names (whitelist)
* @param list<string> $relationFieldNames Allowed relation field names (whitelist)
*
* @return array<string, mixed>
*/
private function formatProductRow(array $row, bool $nested, array $optionFieldNames): array
{
private function formatProductRow(
array $row,
bool $nested,
array $optionFieldNames,
array $relationFieldNames,
): array {
$id = (int) $row['id'];
$data = [
'id' => $id,
Expand Down Expand Up @@ -273,9 +302,9 @@ private function formatProductRow(array $row, bool $nested, array $optionFieldNa
'preview_url' => $this->modx->makeUrl($id, '', '', 'full'),
];

$allowedOptionFields = array_flip($optionFieldNames);
$allowedExtraFields = array_flip(array_merge($optionFieldNames, $relationFieldNames));
foreach ($row as $key => $value) {
if (!array_key_exists($key, $data) && isset($allowedOptionFields[$key])) {
if (!array_key_exists($key, $data) && isset($allowedExtraFields[$key])) {
$data[$key] = $value;
}
}
Expand Down
42 changes: 42 additions & 0 deletions core/components/minishop3/src/Services/Grid/GridColumnRules.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Grid;

/**
* Shared validation for grid column names and SQL identifiers.
*/
final class GridColumnRules
{
public const SQL_IDENTIFIER_PATTERN = '/^[a-z0-9_]+$/i';

/**
* Builtin product / product-data column names emitted by CategoryProductsListService.
* Extra columns (option, relation) must not collide with these.
*/
public const RESERVED_CATEGORY_PRODUCT_FIELD_NAMES = [
'id', 'pagetitle', 'longtitle', 'alias', 'parent', 'menuindex',
'published', 'deleted', 'hidemenu', 'createdon', 'editedon',
'article', 'price', 'old_price', 'weight', 'image', 'thumb',
'vendor_id', 'made_in', 'new', 'popular', 'favorite',
'preview_url', 'category_name',
];

public static function isValidSqlIdentifier(string $name): bool
{
return (bool) preg_match(self::SQL_IDENTIFIER_PATTERN, $name);
}

/**
* fieldName for category-products extra columns: safe SQL alias + no builtin collision.
*/
public static function isValidCategoryProductExtraFieldName(string $name): bool
{
if (!self::isValidSqlIdentifier($name)) {
return false;
}

return !in_array(strtolower($name), self::RESERVED_CATEGORY_PRODUCT_FIELD_NAMES, true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Grid;

use MiniShop3\Services\GridConfigService;
use MODX\Revolution\modX;

final class GridRelationColumnResolver
{
/**
* @param array<int, array<string, mixed>> $gridFields
*
* @return list<RelationColumnSpec>
*/
public static function resolve(modX $modx, array $gridFields): array
{
/** @var GridConfigService|null $gridConfig */
$gridConfig = $modx->services->get('ms3_grid_config');
if (!$gridConfig) {
return [];
}

$relationGroups = $gridConfig->extractRelationFields($gridFields);
$out = [];

foreach ($relationGroups as $group) {
foreach ($group['fields'] as $fieldDef) {
$spec = RelationColumnSpec::fromGroupField($group, $fieldDef);
if ($spec !== null) {
$out[] = $spec;
}
}
}

return $out;
}

/**
* Unique JOIN specs by alias (one JOIN per table+foreignKey group).
*
* @param list<RelationColumnSpec> $specs
*
* @return list<RelationColumnSpec>
*/
public static function uniqueJoins(array $specs): array
{
$seen = [];
$joins = [];

foreach ($specs as $spec) {
if (isset($seen[$spec->alias])) {
continue;
}
$seen[$spec->alias] = true;
$joins[] = $spec;
}

return $joins;
}
}
28 changes: 4 additions & 24 deletions core/components/minishop3/src/Services/Grid/OptionColumnSpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,11 @@
*/
final readonly class OptionColumnSpec
{
/** Same rule as {@see \MiniShop3\Services\GridConfigService::validateOptionConfig()} */
private const OPTION_KEY_PATTERN = '/^[a-z0-9_]+$/i';
/** Same rule as {@see GridColumnRules::SQL_IDENTIFIER_PATTERN} */
private const OPTION_KEY_PATTERN = GridColumnRules::SQL_IDENTIFIER_PATTERN;

/** Same rule as OPTION_KEY_PATTERN; fieldName also lands in `SELECT … AS \`{name}\``. */
private const FIELD_NAME_PATTERN = '/^[a-z0-9_]+$/i';

/**
* Builtin product / product-data column names that already appear in SELECT.
* If user picks one as option fieldName, PDO FETCH_ASSOC overwrites the builtin
* with GROUP_CONCAT string → cast in formatProductRow returns 0 / garbage.
* Disallow at spec creation to prevent silent data corruption.
*/
private const RESERVED_NAMES = [
// modResource
'id', 'pagetitle', 'longtitle', 'alias', 'parent', 'menuindex',
'published', 'deleted', 'hidemenu', 'createdon', 'editedon',
// msProductData
'article', 'price', 'old_price', 'weight', 'image', 'thumb',
'vendor_id', 'made_in', 'new', 'popular', 'favorite',
// formatProductRow synthetics
'preview_url', 'category_name',
];
private const FIELD_NAME_PATTERN = GridColumnRules::SQL_IDENTIFIER_PATTERN;

public function __construct(
public string $fieldName,
Expand Down Expand Up @@ -81,10 +64,7 @@ public static function isValidOptionKey(string $key): bool
*/
public static function isValidFieldName(string $name): bool
{
if (!preg_match(self::FIELD_NAME_PATTERN, $name)) {
return false;
}
return !in_array(strtolower($name), self::RESERVED_NAMES, true);
return GridColumnRules::isValidCategoryProductExtraFieldName($name);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Grid;

/**
* Foreign keys on msProductData that reference other tables by id (local column → remote.id).
*
* Mirrors {@see \MiniShop3\Model\mysql\msProductData} aggregates where foreign = id.
* Update when new msProductData aggregate FK is added.
*/
final class ProductDataForeignKeys
{
/** @var list<string> */
private const KEYS = [
'vendor_id',
];

/**
* @return list<string>
*/
public static function all(): array
{
return self::KEYS;
}

public static function contains(string $foreignKey): bool
{
return in_array($foreignKey, self::KEYS, true);
}
}
Loading