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 @@ -50,14 +50,12 @@ public function up()
// Get MODX instance
$modxConfigPath = dirname(__FILE__, 5) . '/config.core.php';
if (!file_exists($modxConfigPath)) {
$this->output->writeln('<error>MODX config.core.php not found</error>');
return;
throw new \RuntimeException('MODX config.core.php not found');
}

require_once $modxConfigPath;
if (!defined('MODX_CORE_PATH')) {
$this->output->writeln('<error>MODX_CORE_PATH not defined</error>');
return;
throw new \RuntimeException('MODX_CORE_PATH not defined');
}

require_once MODX_CORE_PATH . 'vendor/autoload.php';
Expand All @@ -71,6 +69,7 @@ public function up()
$modx->addPackage('MiniShop3\\Model', $modelPath, null, 'MiniShop3\\');

$manager = $modx->getManager();
$failedTables = [];

$this->output->writeln('<info>Creating MiniShop3 tables...</info>');
$this->output->writeln("<comment>Model path: {$modelPath}</comment>");
Expand All @@ -82,6 +81,7 @@ public function up()
$mysqlClass = 'MiniShop3\\Model\\mysql\\' . $className;
if (!class_exists($mysqlClass)) {
$this->output->writeln("<error> ✗ Model class not found: {$mysqlClass}</error>");
$failedTables[] = $this->resolveLogicalTableName($className) ?? $className;
continue;
}

Expand All @@ -102,18 +102,32 @@ public function up()
if ($created) {
$this->output->writeln("<info> ✓ Created table: {$tableName}</info>");
} else {
$logicalTable = $this->resolveLogicalTableName($className) ?? trim($tableName, '`');
$this->output->writeln("<error> ✗ Failed to create table: {$tableName}</error>");
$failedTables[] = $logicalTable;

// Log xPDO errors
$errors = $modx->errorHandler->errors;
if (!empty($errors)) {
foreach ($errors as $error) {
$this->output->writeln("<error> " . print_r($error, true) . "</error>");
}
foreach ($modx->errorHandler->errors as $error) {
$message = is_array($error) ? ($error['message'] ?? json_encode($error)) : (string) $error;
$this->output->writeln("<error> {$message}</error>");
}
continue;
}

$logicalTable = $this->resolveLogicalTableName($className);
if ($logicalTable !== null && !$this->hasTable($logicalTable)) {
$this->output->writeln(
"<error> ✗ Failed to create table: {$tableName} (createObjectContainer succeeded but table is missing)</error>"
);
$failedTables[] = $logicalTable;
}
}

if ($failedTables !== []) {
throw new \RuntimeException(
'MiniShop3 initial schema failed for tables: ' . implode(', ', $failedTables)
);
}

$this->output->writeln('<info>MiniShop3 schema creation completed!</info>');

// Add foreign keys after all tables are created
Expand Down Expand Up @@ -181,6 +195,19 @@ public function down()
$this->output->writeln('<info>MiniShop3 schema removal completed!</info>');
}

/**
* 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?php

declare(strict_types=1);

use Phinx\Migration\AbstractMigration;

/**
* Self-heal ms3_grid_fields when initial_schema or seed migrations did not populate defaults.
*
* Covers:
* - table missing after a silent/partial initial_schema run (#270)
* - empty grid configs after seed migrations no-op'd (double-prefix bug in #271, fixed in #276)
*
* Only re-seeds grid keys that are empty; patch migrations run only for the affected grid.
*/
final class RepairGridFieldsIfMissing extends AbstractMigration
{
/**
* grid_key => seed migration + optional follow-up patches for that grid only.
*
* @var array<string, array{seed: array{class: string, file: string}, patches?: list<array{class: string, file: string}>}>
*/
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<string>
*/
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('<info>Created missing table ' . trim($tableFqn, '`') . '</info>');
}

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<string> $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;
}
}
4 changes: 2 additions & 2 deletions core/components/minishop3/schema/minishop3.mysql.schema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -622,8 +622,8 @@
<field key="config" dbtype="text" phptype="json" null="true"/>
<field key="is_system" dbtype="tinyint" precision="1" phptype="boolean" null="false" default="0"/>
<field key="is_default" dbtype="tinyint" precision="1" phptype="boolean" null="false" default="1"/>
<field key="created_at" dbtype="timestamp" phptype="timestamp" null="false" default="CURRENT_TIMESTAMP"/>
<field key="updated_at" dbtype="timestamp" phptype="timestamp" null="true" extra="on update CURRENT_TIMESTAMP"/>
<field key="created_at" dbtype="datetime" phptype="datetime" null="false" default="CURRENT_TIMESTAMP"/>
<field key="updated_at" dbtype="datetime" phptype="datetime" null="true"/>

<index alias="idx_grid_field" name="idx_grid_field" primary="false" unique="true" type="BTREE">
<column key="grid_key" length="" collation="A" null="false"/>
Expand Down
9 changes: 4 additions & 5 deletions core/components/minishop3/src/Model/mysql/msGridField.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down