Skip to content

Отклонять зарезервированные слова MySQL 8 в SQL-идентификаторах - #663

Merged
biz87 merged 2 commits into
betafrom
fix/issue-655-mysql-reserved-identifiers
Sep 9, 2026
Merged

biz87 merged 2 commits into
betafrom
fix/issue-655-mysql-reserved-identifiers

Conversation

@Ibochkarev

Copy link
Copy Markdown
Member

Описание

GridColumnRules::isValidSqlIdentifier() принимал имена вроде order, rank, groups. Теперь проверка включает полный список reserved-слов MySQL 8 (R).

При создании extra field зарезервированный ключ отклоняется с лексиконом ms3_err_extra_field_key_reserved и подсказкой альтернативы. Существующие ключи не мигрируются: update extra field ключ не переименовывает и не ревалидирует. Для relation JOIN/SELECT идентификаторы квотятся через RelationSqlFragments (включая заказы), чтобы grandfathered reserved displayField не ломали SQL.

Тип изменений

  • Исправление бага (non-breaking change)
  • Новая функциональность (non-breaking change)
  • Breaking change (изменение, ломающее обратную совместимость)
  • Рефакторинг (без изменения функциональности)
  • Документация
  • Другое (опишите):

Связанные Issues

Closes #655

Как это было протестировано?

cd core/components/minishop3
php -l src/Services/Grid/GridColumnRules.php
php -l src/Services/Grid/Mysql8ReservedKeywords.php
php -l src/Services/Grid/RelationSqlFragments.php
php -l src/Services/ExtraFieldsService.php
php -l src/Services/Order/ManagerOrderListService.php
php tests/RelationColumnSpecTest.php
./vendor/bin/phpunit tests/Unit/Services/ExtraFieldsValidationTest.php tests/Unit/Services/Grid/GridRelationFieldExtractorTest.php

Результаты: php -l OK, RelationColumnSpecTest OK, PHPUnit OK (10 tests, 610 assertions), exit 0.

  • Ручное тестирование
  • Автоматические тесты (composer ci:php / composer test, npm run lint:ci, composer stan / GitHub Actions CI)
  • Тестирование на разных версиях PHP/MODX

Конфигурация тестирования:

  • MiniShop3: ветка fix/issue-655-mysql-reserved-identifiers
  • MODX: n/a (unit/smoke)
  • PHP: локальный CLI 8.x

Скриншоты (если применимо)

До После

Чеклист

  • Код соответствует стилю проекта
  • Добавлены/обновлены комментарии в сложных местах
  • Изменения не ломают существующую функциональность
  • Лексиконы добавлены на двух языках (ru/en)
  • PHPStan проходит без новых ошибок (composer stan / CI job PHPStan)
  • ESLint проходит без ошибок (npm run lint:ci для Vue)
  • Обновлён CHANGELOG.md (для значимых изменений)

Дополнительные заметки

Политика grandfathering: новые ключи режутся на create (extra fields) и в GridColumnTypeValidator для relation. Уже сохранённые relation FK/displayField читаются charset-only + quoted SQL. Bulk saveGridConfig по-прежнему не гоняет reserved-check. Клиентский Vue regex для ключа не трогали. CustomersController raw SQL для relation aggregation вне этого PR.

@Ibochkarev Ibochkarev added priority: low Низкий приоритет, когда будет время bug Something isn't working labels Sep 8, 2026
@Ibochkarev
Ibochkarev requested a review from biz87 September 8, 2026 02:46
@Ibochkarev Ibochkarev self-assigned this Sep 8, 2026
@biz87

biz87 commented Sep 8, 2026

Copy link
Copy Markdown
Member

Список зарезервированных слов собран тщательно — 262 позиции, все версионные добавления MySQL 8 на месте: LATERAL (8.0.14), EMPTY/JSON_TABLE (8.0.4), оконные функции (8.0.2), CUBE/FUNCTION/GROUPING/OF/RECURSIVE (8.0.1), STORED/SYSTEM/VIRTUAL (8.0.3), EXCEPT/INTERSECT (8.0.31). Ложных срабатываний нет: status, type, name, value, title, active, date, time, path, level, member корректно отсутствуют — это ключевые слова, но не зарезервированные, и запрещать их было бы ошибкой.

Grandfathering для extra fields сделан правильно: key не входит в $allowedFields в updateField(), поэтому существующие поля не ревалидируются. Это покрыто твоим же тестом.

Квотирование идентификаторов в RelationSqlFragments для пути чтения работает — grandfathered значения не ломают SQL.

Локально: мерж с beta чистый, smoke 98/98, PHPUnit 307 тестов / 1344 assertions, PHPStan из vendor/bin — 0 ошибок. Конфликтов с #661 и #665 по лексиконам нет, проверено пробным слиянием.

Но есть регрессия в механизме, который PR тоже трогает.

Grandfathering не распространён на relation-колонки грид-конфигов

GridColumnTypeValidator::validateRelationConfig() проверяет relation.foreignKey и relation.displayField через новую строгую isValidSqlIdentifier() без исключений для уже сохранённых значений:

if (!GridColumnRules::isValidSqlIdentifier((string) $relation['foreignKey'])) {
    return ['success' => false, 'message' => 'relation.foreignKey ' . self::NON_RESERVED_SQL_IDENTIFIER_MSG];
}

А prepareTypedConfig(), откуда она вызывается, дёргают обе точки — и addField() (GridConfigService.php:340), и updateField() (:410), причём на полном присланном конфиге, а не на разнице.

Ключевое: GridFieldsConfig.vue в saveEdit() отправляет весь блок config.relation целиком, включая поля, которых пользователь не касался. То есть достаточно открыть карточку существующей relation-колонки и сменить подпись — и displayField уедет на сервер и будет отклонён.

Проверено не чтением, а прогоном: вызвал GridColumnTypeValidator::validateForType() со стабом из tests/stubs/GridValidatorModxStub.php, подставив displayField = 'rank' — то самое значение, которое ты сам используешь как пример grandfathered в RelationColumnSpecTest.php. Результат:

'success' => false,
'message' => 'relation.displayField must be a non-reserved SQL identifier (letters, numbers, underscores)'

Одинаково и для создания, и для обновления. Значит магазин с уже настроенной relation-колонкой, где displayField или foreignKey совпадает с зарезервированным словом, после обновления не сможет отредактировать эту колонку вообще. Причём увидит нелокализованное техническое сообщение без подсказки, что делать.

Показательно, что для пути чтения ты rank как grandfathered-значение предусмотрел, а для пути записи — нет.

Второй баг того же корня, тише и потому неприятнее

RelationColumnSpec::fromGroupField() проверяет fieldName — алиас колонки в выводе — через isValidCategoryProductExtraFieldName(), которая внутри теперь тоже строгая. До PR проверка была только по набору символов, поэтому fieldName = 'rank' для relation-колонки в гриде category-products была легальна.

После PR такая колонка при рендере молча пропадает: fromGroupField() возвращает null, а GridRelationColumnResolver::resolve() и CategoryProductsListService просто пропускают null — без ошибки, без записи в лог. Владелец магазина увидит, что колонка исчезла, и не поймёт почему.

Что нужно решить

Либо распространить ту же схему, что сделана для extra fields: проверять зарезервированные слова только при создании новой relation-колонки, а при обновлении сверяться с сохранённым в БД значением, а не со слепо присланным. Либо явно объявить это ломающим изменением — с внятным сообщением об ошибке и описанным путём миграции.

Сам не резолвлю: это решение о поведении, а не механический конфликт.

Мелочи

Похоже, в списке отсутствует array — зарезервировано с MySQL 8.0.17 в контексте CAST(... AS ... ARRAY) и multi-valued индексов. Имя колонки маловероятное, но раз уж список собирается полным, стоит свериться с официальной страницей dev.mysql.com/doc/refman/8.0/en/keywords.html.

Сообщения GridColumnTypeValidator не лексиконизированы — это существующая конвенция файла, не твоя недоработка. Но раз теперь этот путь реально может блокировать сохранение у живых магазинов, английский технический текст без объяснения станет заметнее.

Отдельно, вне этого PR

Ты честно отметил, что CustomersController остаётся вне scope. Подтверждаю: там relation-агрегация строится сырым SQL без квотирования — тот же класс бага, который ты чинишь в ManagerOrderListService. Завёл отдельной задачей, чтобы не потерялось.

@Ibochkarev

Copy link
Copy Markdown
Member Author

Решение: grandfathering как у extra fields (не breaking).

  1. Relation update — updateField передаёт сохранённый config в validateRelationConfig. Неизменённые foreignKey/displayField проходят charset-only; новое значение по-прежнему должно быть non-reserved. Create остаётся строгим.
  2. fieldName = rank — на чтении RelationColumnSpec / update category-products используют isReadableCategoryProductExtraFieldName (charset + no builtin collision). Create по-прежнему режет reserved через isValidCategoryProductExtraFieldName.
  3. array добавлен в Mysql8ReservedKeywords::LIST.

Тесты: GridColumnTypeValidatorTest, RelationColumnSpecTest, PHPUnit ExtraFields reserved — зелёные.

CustomersController (#673) — отдельно в #679.

Ibochkarev added a commit that referenced this pull request Sep 9, 2026
…e-673-customers-relation-sql-quote

Pick up relation grandfathering review fixes from #663.
Block reserved column keys at ExtraFields create time with a clear
lexicon suggestion, while grandfathering existing keys and quoting
relation SQL so reserved display fields stay safe at runtime.
Reject reserved FK/displayField/fieldName only on create; keep stored
values editable. Read path allows reserved aliases when quoted. Add
missing MySQL 8 keyword array.
@Ibochkarev
Ibochkarev force-pushed the fix/issue-655-mysql-reserved-identifiers branch from 32de0e9 to 2d3f56d Compare September 9, 2026 02:45
@biz87
biz87 merged commit 70bcb82 into beta Sep 9, 2026
3 checks passed
@biz87
biz87 deleted the fix/issue-655-mysql-reserved-identifiers branch September 9, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working priority: low Низкий приоритет, когда будет время

Projects

None yet

Development

Successfully merging this pull request may close these issues.

isValidSqlIdentifier не отсеивает зарезервированные слова MySQL 8

2 participants