Skip to content

fix(options): clear orphan msOption.modcategory_id on modCategory removal#228

Merged
biz87 merged 1 commit intobetafrom
fix/orphan-modcategory-id
Apr 26, 2026
Merged

fix(options): clear orphan msOption.modcategory_id on modCategory removal#228
biz87 merged 1 commit intobetafrom
fix/orphan-modcategory-id

Conversation

@biz87
Copy link
Copy Markdown
Member

@biz87 biz87 commented Apr 26, 2026

Проблема

В карточке товара на вкладке «Опции товара» появляются два таба с одинаковой подписью «Без группы» (плюс реальный таб именованной группы). Скриншот:

  • «Без группы» — 5 опций
  • «MiniShop3» — 2 опции
  • «Без группы» — 10 опций

Причина — у опций, относящихся к этим двум табам, в БД лежат разные modcategory_id, ссылающиеся на уже удалённые modCategory (типичный сценарий — деинсталляция стороннего компонента, который при установке создавал свою категорию для опций MS3).

В OptionLoaderService::getFieldsForProduct() группировка делается через LEFT JOIN modCategory. Для удалённых категорий category_name приходит NULL, но modcategory_id сохраняется. Vue-компонент ProductOptionsTab.vue группирует строго по сырому modcategory_id:

const groupId = Number(option.modcategory_id) || 0

→ две группы с разными мёртвыми modcategory_id → два отдельных таба с одинаковым фолбэк-заголовком «Без группы».

Что сделано

Подписал плагин MiniShop3 на событие OnCategoryRemove и в обработчике обнуляю msOption.modcategory_id для всех записей, ссылавшихся на удалённую категорию.

Это зеркалит штатный MODX-паттерн — внутри modCategory::remove() уже происходит сброс поля category к 0 для modChunk, modPlugin, modSnippet, modTemplate, modTemplateVar. Просто расширяем его на msOption.

case 'OnCategoryRemove':
    /** @var modCategory $category */
    if (!isset($category) || !$category instanceof modCategory) {
        break;
    }

    $categoryId = (int)$category->get('id');
    if ($categoryId <= 0) {
        break;
    }

    $count = $modx->updateCollection(
        msOption::class,
        ['modcategory_id' => 0],
        ['modcategory_id' => $categoryId]
    );

    if (is_int(\$count) && \$count > 0) {
        $modx->log(
            modX::LOG_LEVEL_INFO,
            \"[MiniShop3] Cleared modcategory_id for {\$count} option(s) after removing modCategory #{\$categoryId}\"
        );
    }
    break;

Плюс зарегистрировал OnCategoryRemove в _build/elements/plugins.php — чтобы привязка плагин↔событие применилась при сборке/апгрейде пакета.

Что НЕ делаем

Миграцию для одноразовой чистки уже грязных данных не добавляю — встретить такой кейс в текущем темпе распространения MS3 редко, и тащить отдельную миграцию ради единичных инсталляций избыточно. Операторы, наткнувшиеся на проблему, могут вычистить руками одной из команд:

SQL (стандартный префикс modx_):

UPDATE modx_ms3_options o
LEFT JOIN modx_categories c ON c.id = o.modcategory_id
SET o.modcategory_id = 0
WHERE o.modcategory_id > 0 AND c.id IS NULL;

xPDO (если префикс нестандартный):

\$c = \$modx->newQuery(MiniShop3\Model\msOption::class);
\$c->leftJoin(MODX\Revolution\modCategory::class, 'Category', 'Category.id = msOption.modcategory_id');
\$c->where(['msOption.modcategory_id:>' => 0, 'Category.id:IS' => null]);

foreach (\$modx->getIterator(MiniShop3\Model\msOption::class, \$c) as \$option) {
    \$option->set('modcategory_id', 0);
    \$option->save();
}

После применения PR новые висячие связи появляться не будут.

Что не закрывает этот PR

Архитектурный вопрос — нужно ли вообще привязывать msOption к modCategory (стандартной таблице категорий элементов MODX), или собрать собственную таблицу ms3_option_groups — вынесен в отдельный issue #227 для обсуждения с комьюнити.

Test plan

  • Установить апдейт пакета.
  • Создать modCategory через Элементы → Категории → Новая.
  • В Настройки → Опции создать новую опцию и привязать к этой категории (поле «Категория»).
  • Удалить созданную modCategory.
  • Открыть товар, у которого есть эта опция → вкладка Опции товара показывает её в группе «Без группы», а не отдельным табом-фантомом.
  • В логах MODX — info-сообщение [MiniShop3] Cleared modcategory_id for N option(s) after removing modCategory #ID.

Связанные

…oval

Subscribes the MiniShop3 plugin to OnCategoryRemove and resets
msOption.modcategory_id to 0 for any options that referenced the
removed modCategory.

Without this, deleting a modCategory (e.g. on uninstall of a third-
party component) leaves dangling FK references on msOption.
modcategory_id. The product options UI groups options by the raw id,
so two different orphan ids both render as separate "Без группы"
tabs even though category_name is empty for both.

Mirrors the built-in MODX behaviour: modCategory::remove() already
resets the `category` field to 0 on modChunk / modPlugin /
modSnippet / modTemplate / modTemplateVar — we extend the same
pattern to msOption.

Existing dirty rows (orphan modcategory_id from past removals) are
not touched here. Operators can clean them up manually with one of:

  UPDATE modx_ms3_options o
  LEFT JOIN modx_categories c ON c.id = o.modcategory_id
  SET o.modcategory_id = 0
  WHERE o.modcategory_id > 0 AND c.id IS NULL;

or the equivalent xPDO loop.

Architectural follow-up (whether msOption should be tied to
modCategory at all) is tracked separately in #227.
@biz87 biz87 merged commit 6a12580 into beta Apr 26, 2026
@biz87 biz87 deleted the fix/orphan-modcategory-id branch April 26, 2026 11:28
@biz87 biz87 mentioned this pull request Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants