From 01341ec2d8044290f9ada354f7a100c404f73d68 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Fri, 22 May 2026 13:59:37 +0600 Subject: [PATCH] fix(migrations): resolve ms3_grid_fields missing on install (#270) Stop initial_schema from silently continuing when table creation fails, use datetime columns for msGridField on MySQL 5.7, and add a scoped repair migration for installs with empty grid configs. --- .../20251020000000_initial_schema.php | 47 ++++- ...22120000_repair_grid_fields_if_missing.php | 182 ++++++++++++++++++ .../schema/minishop3.mysql.schema.xml | 4 +- .../minishop3/src/Model/mysql/msGridField.php | 9 +- 4 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 core/components/minishop3/migrations/20260522120000_repair_grid_fields_if_missing.php diff --git a/core/components/minishop3/migrations/20251020000000_initial_schema.php b/core/components/minishop3/migrations/20251020000000_initial_schema.php index 8182bc2c..98c6c42e 100644 --- a/core/components/minishop3/migrations/20251020000000_initial_schema.php +++ b/core/components/minishop3/migrations/20251020000000_initial_schema.php @@ -50,14 +50,12 @@ public function up() // Get MODX instance $modxConfigPath = dirname(__FILE__, 5) . '/config.core.php'; if (!file_exists($modxConfigPath)) { - $this->output->writeln('MODX config.core.php not found'); - return; + throw new \RuntimeException('MODX config.core.php not found'); } require_once $modxConfigPath; if (!defined('MODX_CORE_PATH')) { - $this->output->writeln('MODX_CORE_PATH not defined'); - return; + throw new \RuntimeException('MODX_CORE_PATH not defined'); } require_once MODX_CORE_PATH . 'vendor/autoload.php'; @@ -71,6 +69,7 @@ public function up() $modx->addPackage('MiniShop3\\Model', $modelPath, null, 'MiniShop3\\'); $manager = $modx->getManager(); + $failedTables = []; $this->output->writeln('Creating MiniShop3 tables...'); $this->output->writeln("Model path: {$modelPath}"); @@ -82,6 +81,7 @@ public function up() $mysqlClass = 'MiniShop3\\Model\\mysql\\' . $className; if (!class_exists($mysqlClass)) { $this->output->writeln(" ✗ Model class not found: {$mysqlClass}"); + $failedTables[] = $this->resolveLogicalTableName($className) ?? $className; continue; } @@ -102,18 +102,32 @@ public function up() if ($created) { $this->output->writeln(" ✓ Created table: {$tableName}"); } else { + $logicalTable = $this->resolveLogicalTableName($className) ?? trim($tableName, '`'); $this->output->writeln(" ✗ Failed to create table: {$tableName}"); + $failedTables[] = $logicalTable; - // Log xPDO errors - $errors = $modx->errorHandler->errors; - if (!empty($errors)) { - foreach ($errors as $error) { - $this->output->writeln(" " . print_r($error, true) . ""); - } + foreach ($modx->errorHandler->errors as $error) { + $message = is_array($error) ? ($error['message'] ?? json_encode($error)) : (string) $error; + $this->output->writeln(" {$message}"); } + continue; + } + + $logicalTable = $this->resolveLogicalTableName($className); + if ($logicalTable !== null && !$this->hasTable($logicalTable)) { + $this->output->writeln( + " ✗ Failed to create table: {$tableName} (createObjectContainer succeeded but table is missing)" + ); + $failedTables[] = $logicalTable; } } + if ($failedTables !== []) { + throw new \RuntimeException( + 'MiniShop3 initial schema failed for tables: ' . implode(', ', $failedTables) + ); + } + $this->output->writeln('MiniShop3 schema creation completed!'); // Add foreign keys after all tables are created @@ -181,6 +195,19 @@ public function down() $this->output->writeln('MiniShop3 schema removal completed!'); } + /** + * Unprefixed Phinx table name for a model class (e.g. msGridField -> ms3_grid_fields). + */ + protected function resolveLogicalTableName(string $className): ?string + { + $mysqlClass = 'MiniShop3\\Model\\mysql\\' . $className; + if (!class_exists($mysqlClass)) { + return null; + } + + return $mysqlClass::$metaMap['table'] ?? null; + } + /** * Convert CamelCase to snake_case * Example: msOrderProduct -> order_products diff --git a/core/components/minishop3/migrations/20260522120000_repair_grid_fields_if_missing.php b/core/components/minishop3/migrations/20260522120000_repair_grid_fields_if_missing.php new file mode 100644 index 00000000..f8083df3 --- /dev/null +++ b/core/components/minishop3/migrations/20260522120000_repair_grid_fields_if_missing.php @@ -0,0 +1,182 @@ + seed migration + optional follow-up patches for that grid only. + * + * @var array}> + */ + private const GRID_REPAIR_PLAN = [ + 'customers' => [ + 'seed' => ['class' => 'SeedCustomersGridConfig', 'file' => '20251127000002_seed_customers_grid_config.php'], + ], + 'orders' => [ + 'seed' => ['class' => 'SeedOrdersGridConfig', 'file' => '20251204140000_seed_orders_grid_config.php'], + 'patches' => [ + ['class' => 'FixOrdersGridFilterable', 'file' => '20260107120000_fix_orders_grid_filterable.php'], + ['class' => 'UpdateOrdersGridStatusFields', 'file' => '20260119220000_update_orders_grid_status_fields.php'], + ], + ], + 'order_products' => [ + 'seed' => ['class' => 'SeedOrderProductsGridConfig', 'file' => '20251215120000_seed_order_products_grid_config.php'], + ], + 'deliveries' => [ + 'seed' => ['class' => 'SeedDeliveriesGridConfig', 'file' => '20251222120000_seed_deliveries_grid_config.php'], + ], + 'vendors' => [ + 'seed' => ['class' => 'SeedVendorsGridConfig', 'file' => '20251223130000_seed_vendors_grid_config.php'], + ], + 'category-products' => [ + 'seed' => ['class' => 'SeedCategoryProductsGridConfig', 'file' => '20251231140000_seed_category_products_grid_config.php'], + 'patches' => [ + ['class' => 'AddDuplicatePublishToCategoryProductsActions', 'file' => '20260317120000_add_duplicate_publish_to_category_products_actions.php'], + ], + ], + ]; + + private string $prefix = ''; + + public function up(): void + { + $this->prefix = (string) ($this->getAdapter()->getOption('table_prefix') ?? ''); + + $emptyGridKeys = $this->findEmptyGridKeys(); + if ($emptyGridKeys === []) { + return; + } + + if (!$this->hasTable('ms3_grid_fields')) { + $this->ensureGridFieldsTable(); + } + + if (!$this->hasTable('ms3_grid_fields')) { + throw new \RuntimeException('ms3_grid_fields is still missing after repair attempt'); + } + + $this->loadMigrationFilesForGridKeys($emptyGridKeys); + + foreach ($emptyGridKeys as $gridKey) { + $plan = self::GRID_REPAIR_PLAN[$gridKey]; + $this->runChildMigration($plan['seed']['class']); + + foreach ($plan['patches'] ?? [] as $patch) { + $this->runChildMigration($patch['class']); + } + } + } + + public function down(): void + { + // Repair is data correction; rolling back would remove restored defaults. + } + + /** + * @return list + */ + private function findEmptyGridKeys(): array + { + if (!$this->hasTable('ms3_grid_fields')) { + return array_keys(self::GRID_REPAIR_PLAN); + } + + $emptyGridKeys = []; + foreach (array_keys(self::GRID_REPAIR_PLAN) as $gridKey) { + if ($this->countGridRows($gridKey) === 0) { + $emptyGridKeys[] = $gridKey; + } + } + + return $emptyGridKeys; + } + + private function ensureGridFieldsTable(): void + { + $modx = $this->bootstrapModx(); + $tableFqn = $modx->getTableName(\MiniShop3\Model\msGridField::class); + $created = $modx->getManager()->createObjectContainer(\MiniShop3\Model\msGridField::class); + if (!$created || !$this->hasTable('ms3_grid_fields')) { + throw new \RuntimeException('Failed to create table ' . trim($tableFqn, '`')); + } + + $this->output->writeln('Created missing table ' . trim($tableFqn, '`') . ''); + } + + private function countGridRows(string $gridKey): int + { + $quotedGridKey = $this->getAdapter()->getConnection()->quote($gridKey); + $row = $this->fetchRow( + "SELECT COUNT(*) AS cnt FROM {$this->prefix}ms3_grid_fields WHERE grid_key = {$quotedGridKey}" + ); + + return (int) ($row['cnt'] ?? 0); + } + + /** + * @param list $gridKeys + */ + private function loadMigrationFilesForGridKeys(array $gridKeys): void + { + $migrationsDir = __DIR__ . DIRECTORY_SEPARATOR; + $filesToLoad = []; + + foreach ($gridKeys as $gridKey) { + $plan = self::GRID_REPAIR_PLAN[$gridKey]; + $filesToLoad[$plan['seed']['file']] = true; + + foreach ($plan['patches'] ?? [] as $patch) { + $filesToLoad[$patch['file']] = true; + } + } + + foreach (array_keys($filesToLoad) as $fileName) { + require_once $migrationsDir . $fileName; + } + } + + private function runChildMigration(string $migrationClass): void + { + /** @var AbstractMigration $migration */ + $migration = new $migrationClass(); + $migration->setAdapter($this->getAdapter()); + $migration->setOutput($this->output); + $migration->up(); + } + + private function bootstrapModx(): \MODX\Revolution\modX + { + $modxConfigPath = dirname(__FILE__, 5) . '/config.core.php'; + if (!file_exists($modxConfigPath)) { + throw new \RuntimeException('MODX config.core.php not found'); + } + + require_once $modxConfigPath; + if (!defined('MODX_CORE_PATH')) { + throw new \RuntimeException('MODX_CORE_PATH not defined'); + } + + require_once MODX_CORE_PATH . 'vendor/autoload.php'; + require_once MODX_CORE_PATH . 'model/modx/modx.class.php'; + + $modx = new \MODX\Revolution\modX(); + $modx->initialize('mgr'); + + $modelPath = MODX_CORE_PATH . 'components/minishop3/src/Model/'; + $modx->addPackage('MiniShop3\\Model', $modelPath, null, 'MiniShop3\\'); + + return $modx; + } +} diff --git a/core/components/minishop3/schema/minishop3.mysql.schema.xml b/core/components/minishop3/schema/minishop3.mysql.schema.xml index ec4c333d..187eb075 100644 --- a/core/components/minishop3/schema/minishop3.mysql.schema.xml +++ b/core/components/minishop3/schema/minishop3.mysql.schema.xml @@ -622,8 +622,8 @@ - - + + diff --git a/core/components/minishop3/src/Model/mysql/msGridField.php b/core/components/minishop3/src/Model/mysql/msGridField.php index f46810ff..5717c99b 100644 --- a/core/components/minishop3/src/Model/mysql/msGridField.php +++ b/core/components/minishop3/src/Model/mysql/msGridField.php @@ -123,16 +123,15 @@ class msGridField extends \MiniShop3\Model\msGridField 'default' => 1, ], 'created_at' => [ - 'dbtype' => 'timestamp', - 'phptype' => 'timestamp', + 'dbtype' => 'datetime', + 'phptype' => 'datetime', 'null' => false, 'default' => 'CURRENT_TIMESTAMP', ], 'updated_at' => [ - 'dbtype' => 'timestamp', - 'phptype' => 'timestamp', + 'dbtype' => 'datetime', + 'phptype' => 'datetime', 'null' => true, - 'extra' => 'on update CURRENT_TIMESTAMP', ], ], 'indexes' => [