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
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Grid;

use MiniShop3\Interfaces\ComputedFieldInterface;
use MODX\Revolution\modX;

/**
* Validates grid column JSON config by column type (template/relation/…).
*
* @phpstan-type ValidationResult array{success: bool, message?: string, config?: array<string, mixed>}
*/
final class GridColumnTypeValidator
{
/** @var list<string> */
private const ALLOWED_AGGREGATIONS = ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'];

/** @var list<string> */
private const ALLOWED_ACTION_HANDLERS = [
'edit', 'delete', 'view', 'refresh', 'addresses', 'publish', 'duplicate',
];

/** @var list<string> */
private const ALLOWED_SEVERITIES = ['secondary', 'success', 'info', 'warn', 'danger'];

/** @var array<string, string> */
private const RELATION_REQUIRED = [
'table' => 'relation.table is required',
'foreignKey' => 'relation.foreignKey is required',
'displayField' => 'relation.displayField is required',
];

public function __construct(private modX $modx)
{
}

/**
* @param array<string, mixed> $config
* @return ValidationResult
*/
public function validateForType(string $type, array $config, string $fieldName = ''): array
{
return match ($type) {
'template' => $this->validateTemplateConfig($config),
'relation' => $this->validateRelationConfig($config),
'computed' => $this->validateComputedConfig($config),
'actions' => $this->validateActionsConfig($config),
'option' => $this->validateOptionConfig($config, $fieldName),
default => ['success' => true],
};
}

/**
* @param array<string, mixed> $config
* @return ValidationResult
*/
public function validateTemplateConfig(array $config): array
{
if (empty($config['template'])) {
return ['success' => false, 'message' => 'template is required for template field'];
}

if (!is_string($config['template'])) {
return ['success' => false, 'message' => 'template must be a string'];
}

return ['success' => true];
}

/**
* @param array<string, mixed> $config
* @return ValidationResult
*/
public function validateRelationConfig(array $config): array
{
$relation = $config['relation'] ?? [];

foreach (self::RELATION_REQUIRED as $key => $message) {
if (empty($relation[$key])) {
return ['success' => false, 'message' => $message];
}
}

$aggregation = $relation['aggregation'] ?? null;
if ($aggregation !== null && !in_array($aggregation, self::ALLOWED_AGGREGATIONS, true)) {
return [
'success' => false,
'message' => 'Invalid aggregation type. Allowed: ' . implode(', ', self::ALLOWED_AGGREGATIONS),
];
}

$tableOrModel = $relation['table'];
$isModel = str_contains($tableOrModel, '\\') || str_contains($tableOrModel, '::');

if ($isModel) {
try {
$tableName = $this->modx->getTableName($tableOrModel);

if (empty($tableName)) {
return ['success' => false, 'message' => "Unable to get table name for model: {$tableOrModel}"];
}

$config['relation']['resolvedTableName'] = $tableName;
} catch (\Exception $e) {
return [
'success' => false,
'message' => "Invalid model class: {$tableOrModel}. " . $e->getMessage(),
];
}
} else {
$tablePrefix = $this->modx->config['table_prefix'] ?? '';
if ($tablePrefix !== '' && !str_starts_with($tableOrModel, $tablePrefix)) {
$tableOrModel = $tablePrefix . $tableOrModel;
}
$config['relation']['resolvedTableName'] = $tableOrModel;
}

return ['success' => true, 'config' => $config];
}

/**
* @param array<string, mixed> $config
* @return ValidationResult
*/
public function validateComputedConfig(array $config): array
{
$computed = $config['computed'] ?? [];

if (empty($computed['className'])) {
return ['success' => false, 'message' => 'computed.className is required'];
}

if (!class_exists($computed['className'])) {
return ['success' => false, 'message' => "Class {$computed['className']} not found"];
}

$interfaces = class_implements($computed['className']) ?: [];
if (!isset($interfaces[ComputedFieldInterface::class])) {
return ['success' => false, 'message' => 'Class must implement ComputedFieldInterface'];
}

return ['success' => true];
}

/**
* @param array<string, mixed> $config
* @return ValidationResult
*/
public function validateActionsConfig(array $config): array
{
$actions = $config['actions'] ?? [];

if (!is_array($actions)) {
return ['success' => false, 'message' => 'actions must be an array'];
}

$actionNames = [];

foreach ($actions as $index => $action) {
if (empty($action['name'])) {
return ['success' => false, 'message' => "actions[{$index}].name is required"];
}

if (in_array($action['name'], $actionNames, true)) {
return ['success' => false, 'message' => "Duplicate action name: {$action['name']}"];
}
$actionNames[] = $action['name'];

if (empty($action['handler'])) {
return ['success' => false, 'message' => "actions[{$index}].handler is required"];
}

$handler = $action['handler'];
if (
!in_array($handler, self::ALLOWED_ACTION_HANDLERS, true)
&& !str_starts_with($handler, 'custom:')
) {
$this->modx->log(
modX::LOG_LEVEL_INFO,
"[GridColumnTypeValidator] Custom handler used: {$handler} in action {$action['name']}"
);
}

if (!empty($action['severity']) && !in_array($action['severity'], self::ALLOWED_SEVERITIES, true)) {
return [
'success' => false,
'message' => "Invalid severity for action {$action['name']}. Allowed: "
. implode(', ', self::ALLOWED_SEVERITIES),
];
}
}

return ['success' => true];
}

/**
* @param array<string, mixed> $config
* @return ValidationResult
*/
public function validateOptionConfig(array $config, string $fieldName = ''): array
{
$option = $config['option'] ?? [];

if (empty($option['key'])) {
return ['success' => false, 'message' => 'option.key is required for option field'];
}

$key = (string) $option['key'];
if (!OptionColumnSpec::isValidOptionKey($key)) {
return ['success' => false, 'message' => 'option.key must contain only letters, numbers and underscores'];
}

if ($fieldName !== '' && !OptionColumnSpec::isValidFieldName($fieldName)) {
return [
'success' => false,
'message' => "Field name '{$fieldName}' is not allowed for option columns: "
. 'it collides with a builtin product column or contains invalid characters. '
. "Use a name like 'option_{$key}' instead.",
];
}

return ['success' => true];
}
}
149 changes: 149 additions & 0 deletions core/components/minishop3/src/Services/Grid/GridConfigRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Grid;

use MiniShop3\Model\msGridField;
use MODX\Revolution\modX;

/**
* Persistence for msGridField rows (grid column config).
*/
final class GridConfigRepository
{
public function __construct(private modX $modx)
{
}

/**
* @return list<msGridField>
*/
public function findByGridKey(string $gridKey, bool $includeHidden = false): array
{
$where = ['grid_key' => $gridKey];
if (!$includeHidden) {
$where['visible'] = true;
}

return $this->collectSorted($where);
}

/**
* @return list<msGridField>
*/
public function findFilterableByGridKey(string $gridKey): array
{
return $this->collectSorted([
'grid_key' => $gridKey,
'filterable' => true,
]);
}

public function findOne(string $gridKey, string $fieldName): ?msGridField
{
/** @var msGridField|null $field */
$field = $this->modx->getObject(msGridField::class, [
'grid_key' => $gridKey,
'field_name' => $fieldName,
]);

return $field instanceof msGridField ? $field : null;
}

public function nextSortOrder(string $gridKey): int
{
$query = $this->modx->newQuery(msGridField::class);
$query->where(['grid_key' => $gridKey]);
$query->sortby('sort_order', 'DESC');
$query->limit(1);
$lastField = $this->modx->getObject(msGridField::class, $query);

return $lastField ? (int) $lastField->get('sort_order') + 1 : 1;
}

/**
* @param array<string, mixed> $attributes
*/
public function create(array $attributes): msGridField
{
/** @var msGridField $field */
$field = $this->modx->newObject(msGridField::class);
$field->fromArray($attributes);

return $field;
}

public function save(msGridField $field): bool
{
return (bool) $field->save();
}

public function remove(msGridField $field): bool
{
return (bool) $field->remove();
}

/**
* Non-system fields whose names are not in $fieldNamesToKeep (candidates for delete on save).
*
* Empty keep list returns [] — never wipe all non-system rows. Callers that omit every
* field name must not treat that as "delete everything" (xPDO `NOT IN ()` is also invalid).
*
* @param list<string> $fieldNamesToKeep
* @return list<msGridField>
*/
public function findNonSystemNotIn(string $gridKey, array $fieldNamesToKeep): array
{
if ($fieldNamesToKeep === []) {
return [];
}

/** @var list<msGridField> $fields */
$fields = array_values($this->modx->getCollection(msGridField::class, [
'grid_key' => $gridKey,
'is_system' => false,
'field_name:NOT IN' => $fieldNamesToKeep,
]) ?: []);

return $fields;
}

/**
* @return array<string, mixed>
*/
public function decodeConfig(mixed $config): array
{
if (is_string($config)) {
$decoded = json_decode($config, true);

return is_array($decoded) ? $decoded : [];
}

return is_array($config) ? $config : [];
}

/**
* @param array<string, mixed> $config
*/
public function encodeConfig(array $config): string
{
return (string) json_encode($config, JSON_UNESCAPED_UNICODE);
}

/**
* @param array<string, mixed> $where
* @return list<msGridField>
*/
private function collectSorted(array $where): array
{
$query = $this->modx->newQuery(msGridField::class);
$query->where($where);
$query->sortby('sort_order', 'ASC');

/** @var list<msGridField> $fields */
$fields = array_values($this->modx->getCollection(msGridField::class, $query) ?: []);

return $fields;
}
}
Loading