From 74a30e95a2d8f1609785b526e0f92f2a8a6e9595 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Tue, 24 Feb 2026 15:20:50 -0500 Subject: [PATCH 1/8] Add additional check for empty array --- src/Db/Adapter/AbstractAdapter.php | 2 +- .../TestCase/Db/Adapter/MysqlAdapterTest.php | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 3bb4a81b..556a6078 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -745,7 +745,7 @@ protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?ar return ''; } - if ($conflictColumns !== null) { + if ($conflictColumns !== null && $conflictColumns !== []) { trigger_error( 'The $conflictColumns parameter is ignored by MySQL. ' . 'MySQL\'s ON DUPLICATE KEY UPDATE applies to all unique constraints on the table.', diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index aeee6316..9b0e0510 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3080,6 +3080,38 @@ public function testInsertOrUpdateModeResetsAfterSave() ])->save(); } + public function testInsertOrUpdateWithEmptyConflictColumnsDoesNotWarn() + { + $table = new Table('currencies', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 3]) + ->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4]) + ->addIndex('code', ['unique' => true]) + ->create(); + + $warning = null; + set_error_handler(function (int $errno, string $errstr) use (&$warning) { + $warning = $errstr; + + return true; + }, E_USER_WARNING); + + try { + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0000], + ['code' => 'EUR', 'rate' => 0.9000], + ], ['rate'], [])->save(); + } finally { + restore_error_handler(); + } + + $this->assertNull($warning, 'Empty conflictColumns should not trigger a warning for MySQL'); + + $rows = $this->adapter->fetchAll('SELECT * FROM currencies ORDER BY code'); + $this->assertCount(2, $rows); + $this->assertEquals('0.9000', $rows[0]['rate']); + $this->assertEquals('1.0000', $rows[1]['rate']); + } + public function testCreateTableWithRangeColumnsPartitioning() { // MySQL requires RANGE COLUMNS for DATE columns From 69dde0eaa0b704df612fd32c691478e294982db0 Mon Sep 17 00:00:00 2001 From: Matthias Wirtz Date: Tue, 3 Mar 2026 05:06:21 +0100 Subject: [PATCH 2/8] add using when changing column type to json (#1031) --- src/Db/Adapter/PostgresAdapter.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index 5251ce4a..878f81ee 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -476,6 +476,12 @@ protected function getChangeColumnInstructions( $quotedColumnName, ); } + if (in_array($newColumn->getType(), ['json'])) { + $sql .= sprintf( + ' USING (%s::jsonb)', + $quotedColumnName, + ); + } // NULL and DEFAULT cannot be set while changing column type $sql = preg_replace('/ NOT NULL/', '', $sql); $sql = preg_replace('/ DEFAULT NULL/', '', $sql); From 944c4e20808dc846d4bd95690e727f767adb1403 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Thu, 5 Mar 2026 01:10:09 -0500 Subject: [PATCH 3/8] Fix CI failures on MySQL/MariaDB (#1034) 1. Handle uninitialized Column::$fixed property in BakeMigrationDiffCommand When TableSchema::getColumn() is called on cached/serialized schema objects, the Column::$fixed property may not be initialized, causing an Error. Added safeGetColumn() helper that catches this error and uses reflection to initialize uninitialized properties before retry. 2. Fix CURRENT_TIMESTAMP test assertion for MySQL/MariaDB Different versions of MySQL and MariaDB return CURRENT_TIMESTAMP in different formats (CURRENT_TIMESTAMP, current_timestamp(), CURRENT_TIMESTAMP()). Changed the test to use a regex that accepts all valid formats case-insensitively. Fixes #1033 --- src/Command/BakeMigrationDiffCommand.php | 73 ++++++++++++++++++++++-- tests/TestCase/MigrationsTest.php | 16 ++++-- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/Command/BakeMigrationDiffCommand.php b/src/Command/BakeMigrationDiffCommand.php index 68bcd71b..d0490daf 100644 --- a/src/Command/BakeMigrationDiffCommand.php +++ b/src/Command/BakeMigrationDiffCommand.php @@ -27,13 +27,17 @@ use Cake\Database\Schema\ForeignKey; use Cake\Database\Schema\Index; use Cake\Database\Schema\TableSchema; +use Cake\Database\Schema\TableSchemaInterface; use Cake\Database\Schema\UniqueKey; use Cake\Datasource\ConnectionManager; use Cake\Event\Event; use Cake\Event\EventManager; +use Error; use Migrations\Migration\ManagerFactory; use Migrations\Util\TableFinder; use Migrations\Util\UtilTrait; +use ReflectionException; +use ReflectionProperty; /** * Task class for generating migration diff files. @@ -259,7 +263,7 @@ protected function getColumns(): void // brand new columns $addedColumns = array_diff($currentColumns, $oldColumns); foreach ($addedColumns as $columnName) { - $column = $currentSchema->getColumn($columnName); + $column = $this->safeGetColumn($currentSchema, $columnName); /** @var int $key */ $key = array_search($columnName, $currentColumns); if ($key > 0) { @@ -274,8 +278,8 @@ protected function getColumns(): void // changes in columns meta-data foreach ($currentColumns as $columnName) { - $column = $currentSchema->getColumn($columnName); - $oldColumn = $this->dumpSchema[$table]->getColumn($columnName); + $column = $this->safeGetColumn($currentSchema, $columnName); + $oldColumn = $this->safeGetColumn($this->dumpSchema[$table], $columnName); unset( $column['collate'], $column['fixed'], @@ -351,7 +355,7 @@ protected function getColumns(): void $removedColumns = array_diff($oldColumns, $currentColumns); if ($removedColumns) { foreach ($removedColumns as $columnName) { - $column = $this->dumpSchema[$table]->getColumn($columnName); + $column = $this->safeGetColumn($this->dumpSchema[$table], $columnName); /** @var int $key */ $key = array_search($columnName, $oldColumns); if ($key > 0) { @@ -621,6 +625,67 @@ public function template(): string return 'Migrations.config/diff'; } + /** + * Safely get column information from a TableSchema. + * + * This method handles the case where Column::$fixed property may not be + * initialized (e.g., when loaded from a cached/serialized schema). + * + * @param \Cake\Database\Schema\TableSchemaInterface $schema The table schema + * @param string $columnName The column name + * @return array|null Column data array or null if column doesn't exist + */ + protected function safeGetColumn(TableSchemaInterface $schema, string $columnName): ?array + { + try { + return $schema->getColumn($columnName); + } catch (Error $e) { + // Handle uninitialized typed property errors (e.g., Column::$fixed) + // This can happen with cached/serialized schema objects + if (str_contains($e->getMessage(), 'must not be accessed before initialization')) { + // Initialize uninitialized properties using reflection and retry + $this->initializeColumnProperties($schema, $columnName); + + return $schema->getColumn($columnName); + } + throw $e; + } + } + + /** + * Initialize potentially uninitialized Column properties using reflection. + * + * @param \Cake\Database\Schema\TableSchemaInterface $schema The table schema + * @param string $columnName The column name + * @return void + */ + protected function initializeColumnProperties(TableSchemaInterface $schema, string $columnName): void + { + // Access the internal columns array via reflection + $reflection = new ReflectionProperty($schema, '_columns'); + $columns = $reflection->getValue($schema); + + if (!isset($columns[$columnName]) || !($columns[$columnName] instanceof Column)) { + return; + } + + $column = $columns[$columnName]; + + // List of nullable properties that might not be initialized + $nullableProperties = ['fixed', 'collate', 'unsigned', 'generated', 'srid', 'onUpdate']; + + foreach ($nullableProperties as $propertyName) { + try { + $propReflection = new ReflectionProperty(Column::class, $propertyName); + if (!$propReflection->isInitialized($column)) { + $propReflection->setValue($column, null); + } + } catch (Error | ReflectionException) { + // Property doesn't exist or can't be accessed, skip it + } + } + } + /** * Gets the option parser instance and configures it. * diff --git a/tests/TestCase/MigrationsTest.php b/tests/TestCase/MigrationsTest.php index 5601a4ff..2830b88c 100644 --- a/tests/TestCase/MigrationsTest.php +++ b/tests/TestCase/MigrationsTest.php @@ -15,7 +15,6 @@ use Cake\Core\Configure; use Cake\Core\Plugin; -use Cake\Database\Driver\Mysql; use Cake\Database\Driver\Sqlserver; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\TestCase; @@ -218,14 +217,19 @@ public function testMigrateAndRollback() $expected = ['id', 'name', 'created', 'updated']; $this->assertEquals($expected, $columns); $createdColumn = $storesTable->getSchema()->getColumn('created'); - $expected = 'CURRENT_TIMESTAMP'; $driver = $this->Connection->getDriver(); - if ($driver instanceof Mysql && $driver->isMariadb()) { - $expected = 'current_timestamp()'; - } elseif ($driver instanceof Sqlserver) { + if ($driver instanceof Sqlserver) { $expected = 'getdate()'; + $this->assertEquals($expected, $createdColumn['default']); + } else { + // MySQL and MariaDB may return CURRENT_TIMESTAMP in different formats + // depending on version: CURRENT_TIMESTAMP, current_timestamp(), CURRENT_TIMESTAMP() + $this->assertMatchesRegularExpression( + '/^current_timestamp(\(\))?$/i', + $createdColumn['default'], + 'Default value should be CURRENT_TIMESTAMP in some form', + ); } - $this->assertEquals($expected, $createdColumn['default']); // Rollback last $rollback = $this->migrations->rollback(); From d6720e14e1772f07483f3152e66dc1198c8763fb Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Thu, 5 Mar 2026 01:13:22 -0500 Subject: [PATCH 4/8] Fix TEXT column variants not round-tripping correctly (#1032) When using migration_diff, TEXT column variants (TINYTEXT, MEDIUMTEXT, LONGTEXT) were not being properly mapped back from the database. The BLOB type handling already used rawType to distinguish BLOB variants, but TEXT variants were missing equivalent handling. This adds similar rawType-based mapping for TEXT columns in mapColumnType() and includes round-trip tests. Fixes #1029 --- src/Db/Adapter/MysqlAdapter.php | 14 +++++++++ .../TestCase/Db/Adapter/MysqlAdapterTest.php | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index b1d8a182..c07cfe6f 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -594,6 +594,20 @@ protected function mapColumnType(array $columnData): array } } // else: keep as binary or varbinary (actual BINARY/VARBINARY column) + } elseif ($type === TableSchema::TYPE_TEXT) { + // CakePHP returns TEXT columns as 'text' with specific lengths + // Check the raw MySQL type to distinguish TEXT variants + $rawType = $columnData['rawType'] ?? ''; + if (str_contains($rawType, 'tinytext')) { + $length = static::TEXT_TINY; + } elseif (str_contains($rawType, 'mediumtext')) { + $length = static::TEXT_MEDIUM; + } elseif (str_contains($rawType, 'longtext')) { + $length = static::TEXT_LONG; + } else { + // Regular TEXT - use null to indicate default TEXT type + $length = null; + } } return [$type, $length]; diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 9b0e0510..85aded71 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -1369,6 +1369,37 @@ public function testBlobRoundTrip(string $type, ?int $limit, string $expectedTyp $this->adapter->dropTable('blob_round_trip_test'); } + public static function textRoundTripData() + { + return [ + // type, limit, expected type after round-trip, expected limit after round-trip + ['text', null, 'text', null], + ['text', MysqlAdapter::TEXT_TINY, 'text', MysqlAdapter::TEXT_TINY], + ['text', MysqlAdapter::TEXT_MEDIUM, 'text', MysqlAdapter::TEXT_MEDIUM], + ['text', MysqlAdapter::TEXT_LONG, 'text', MysqlAdapter::TEXT_LONG], + ]; + } + + #[DataProvider('textRoundTripData')] + public function testTextRoundTrip(string $type, ?int $limit, string $expectedType, ?int $expectedLimit) + { + // Create a table with a TEXT column + $table = new Table('text_round_trip_test', [], $this->adapter); + $table->addColumn('text_col', $type, ['limit' => $limit]) + ->save(); + + // Read the column back from the database + $columns = $this->adapter->getColumns('text_round_trip_test'); + + $textColumn = $columns[1]; + $this->assertNotNull($textColumn, 'TEXT column not found'); + $this->assertSame($expectedType, $textColumn->getType(), 'Type mismatch after round-trip'); + $this->assertSame($expectedLimit, $textColumn->getLimit(), 'Limit mismatch after round-trip'); + + // Clean up + $this->adapter->dropTable('text_round_trip_test'); + } + public function testTimestampInvalidLimit() { $this->adapter->connect(); From 9d74ef7f224b963177afed9eeeaf0f17e9a3a261 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 7 Mar 2026 05:00:26 +0100 Subject: [PATCH 5/8] Add fixed option for binary column type (#1014) Add support for the `fixed` attribute on binary columns to distinguish between fixed-length BINARY and variable-length VARBINARY types. This mirrors cakephp/cakephp#19207. * Add test cases for fixed option on binary columns Tests cover: - Column class getter/setter for fixed option - Column::toArray() including fixed in output - Column::setOptions() accepting fixed option - MigrationHelper::getColumnOption() including/excluding fixed - MysqlAdapter creating BINARY vs VARBINARY based on fixed option * Add column type assertions to binary fixed test --- src/Db/Adapter/MysqlAdapter.php | 3 ++ src/Db/Table/Column.php | 32 ++++++++++++ src/View/Helper/MigrationHelper.php | 3 +- .../TestCase/Db/Adapter/MysqlAdapterTest.php | 49 +++++++++++++++++ tests/TestCase/Db/Table/ColumnTest.php | 52 +++++++++++++++++++ .../View/Helper/MigrationHelperTest.php | 34 ++++++++++++ 6 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index c07cfe6f..a8aa1e8c 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -651,6 +651,9 @@ public function getColumns(string $tableName): array if ($record['onUpdate'] ?? false) { $column->setUpdate($record['onUpdate']); } + if ($record['fixed'] ?? false) { + $column->setFixed(true); + } $columns[] = $column; } diff --git a/src/Db/Table/Column.php b/src/Db/Table/Column.php index 98f28b31..e608560e 100644 --- a/src/Db/Table/Column.php +++ b/src/Db/Table/Column.php @@ -117,6 +117,11 @@ class Column extends DatabaseColumn */ protected ?string $lock = null; + /** + * @var bool|null + */ + protected ?bool $fixed = null; + /** * Column constructor * @@ -772,6 +777,31 @@ public function getLock(): ?string return $this->lock; } + /** + * Sets whether field should use fixed-length storage (for binary columns). + * + * When true, binary columns will use BINARY(n) instead of VARBINARY(n). + * + * @param bool $fixed Fixed + * @return $this + */ + public function setFixed(bool $fixed) + { + $this->fixed = $fixed; + + return $this; + } + + /** + * Gets whether field should use fixed-length storage. + * + * @return bool|null + */ + public function getFixed(): ?bool + { + return $this->fixed; + } + /** * Gets all allowed options. Each option must have a corresponding `setFoo` method. * @@ -802,6 +832,7 @@ protected function getValidOptions(): array 'generated', 'algorithm', 'lock', + 'fixed', ]; } @@ -894,6 +925,7 @@ public function toArray(): array 'default' => $default, 'generated' => $this->getGenerated(), 'unsigned' => $this->getUnsigned(), + 'fixed' => $this->getFixed(), 'onUpdate' => $this->getUpdate(), 'collate' => $this->getCollation(), 'precision' => $precision, diff --git a/src/View/Helper/MigrationHelper.php b/src/View/Helper/MigrationHelper.php index 3302e7e5..75e04cbb 100644 --- a/src/View/Helper/MigrationHelper.php +++ b/src/View/Helper/MigrationHelper.php @@ -389,6 +389,7 @@ public function getColumnOption(array $options): array 'scale', 'after', 'collate', + 'fixed', ]); $columnOptions = array_intersect_key($options, $wantedOptions); if (empty($columnOptions['comment'])) { @@ -495,7 +496,7 @@ public function attributes(TableSchemaInterface|string $table, string $column): 'comment', 'unsigned', 'signed', 'properties', 'autoIncrement', 'unique', - 'collate', + 'collate', 'fixed', ]; $attributes = []; diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 85aded71..1ab36b5c 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -3541,4 +3541,53 @@ public function testCombinedPartitionAndColumnOperations(): void $this->assertCount(1, $rows); $this->assertEquals('A description', $rows[0]['description']); } + + public function testBinaryColumnWithFixedOption(): void + { + $table = new Table('binary_fixed_test', [], $this->adapter); + $table->addColumn('hash', 'binary', ['limit' => 20, 'fixed' => true]) + ->addColumn('data', 'binary', ['limit' => 20]) + ->save(); + + $this->assertTrue($this->adapter->hasColumn('binary_fixed_test', 'hash')); + $this->assertTrue($this->adapter->hasColumn('binary_fixed_test', 'data')); + + // Check that the fixed column is created as BINARY and the non-fixed as VARBINARY + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM binary_fixed_test'); + $hashColumn = null; + $dataColumn = null; + foreach ($rows as $row) { + if ($row['Field'] === 'hash') { + $hashColumn = $row; + } + if ($row['Field'] === 'data') { + $dataColumn = $row; + } + } + + $this->assertNotNull($hashColumn); + $this->assertNotNull($dataColumn); + $this->assertSame('binary(20)', $hashColumn['Type']); + $this->assertSame('varbinary(20)', $dataColumn['Type']); + + // Verify the fixed attribute is reflected back + $columns = $this->adapter->getColumns('binary_fixed_test'); + $hashCol = null; + $dataCol = null; + foreach ($columns as $col) { + if ($col->getName() === 'hash') { + $hashCol = $col; + } + if ($col->getName() === 'data') { + $dataCol = $col; + } + } + + $this->assertNotNull($hashCol); + $this->assertNotNull($dataCol); + $this->assertSame('binary', $hashCol->getType()); + $this->assertSame('binary', $dataCol->getType()); + $this->assertTrue($hashCol->getFixed()); + $this->assertNull($dataCol->getFixed()); + } } diff --git a/tests/TestCase/Db/Table/ColumnTest.php b/tests/TestCase/Db/Table/ColumnTest.php index 0652149f..2d5ccd1a 100644 --- a/tests/TestCase/Db/Table/ColumnTest.php +++ b/tests/TestCase/Db/Table/ColumnTest.php @@ -275,4 +275,56 @@ public function testUnsignedConfigurationDoesNotAffectNonIntegerTypes(): void $decimalColumn->setName('price')->setType('decimal'); $this->assertFalse($decimalColumn->isUnsigned()); } + + public function testFixedOptionDefaultsToNull(): void + { + $column = new Column(); + $column->setName('data')->setType('binary'); + + $this->assertNull($column->getFixed()); + } + + public function testSetFixedTrue(): void + { + $column = new Column(); + $column->setName('hash')->setType('binary')->setFixed(true); + + $this->assertTrue($column->getFixed()); + } + + public function testSetFixedFalse(): void + { + $column = new Column(); + $column->setName('data')->setType('binary')->setFixed(false); + + $this->assertFalse($column->getFixed()); + } + + public function testSetOptionsWithFixed(): void + { + $column = new Column(); + $column->setName('hash')->setType('binary'); + $column->setOptions(['fixed' => true, 'limit' => 20]); + + $this->assertTrue($column->getFixed()); + $this->assertSame(20, $column->getLimit()); + } + + public function testToArrayIncludesFixed(): void + { + $column = new Column(); + $column->setName('hash')->setType('binary')->setFixed(true)->setLimit(20); + + $result = $column->toArray(); + $this->assertTrue($result['fixed']); + } + + public function testToArrayFixedNullByDefault(): void + { + $column = new Column(); + $column->setName('data')->setType('binary')->setLimit(20); + + $result = $column->toArray(); + $this->assertNull($result['fixed']); + } } diff --git a/tests/TestCase/View/Helper/MigrationHelperTest.php b/tests/TestCase/View/Helper/MigrationHelperTest.php index c7800936..fd2dfe91 100644 --- a/tests/TestCase/View/Helper/MigrationHelperTest.php +++ b/tests/TestCase/View/Helper/MigrationHelperTest.php @@ -456,4 +456,38 @@ public function testGetColumnOptionConvertsCollateToCollation(): void $this->assertArrayHasKey('collation', $result, 'collation should be set from collate value'); $this->assertSame('en_US.UTF-8', $result['collation']); } + + /** + * Test that getColumnOption includes the fixed option for binary columns + */ + public function testGetColumnOptionIncludesFixed(): void + { + $options = [ + 'length' => 20, + 'null' => true, + 'default' => null, + 'fixed' => true, + ]; + + $result = $this->helper->getColumnOption($options); + + $this->assertArrayHasKey('fixed', $result); + $this->assertTrue($result['fixed']); + } + + /** + * Test that getColumnOption excludes fixed when not set + */ + public function testGetColumnOptionExcludesFixedWhenNotSet(): void + { + $options = [ + 'length' => 20, + 'null' => true, + 'default' => null, + ]; + + $result = $this->helper->getColumnOption($options); + + $this->assertArrayNotHasKey('fixed', $result); + } } From ca1b74e523c105a12aa35e6c61d196b6997742f8 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 7 Mar 2026 19:21:57 +0100 Subject: [PATCH 6/8] Fix misleading next steps message in upgrade command (#1037) Only show 'Set legacyTables => false' step after tables have been dropped, as the upgrade command becomes unavailable once this config is set. Fixes #1036 --- src/Command/UpgradeCommand.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Command/UpgradeCommand.php b/src/Command/UpgradeCommand.php index 5897c458..dece3c09 100644 --- a/src/Command/UpgradeCommand.php +++ b/src/Command/UpgradeCommand.php @@ -156,10 +156,13 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $io->success('Upgrade complete!'); $io->out(''); $io->out('Next steps:'); - $io->out(' 1. Set \'Migrations\' => [\'legacyTables\' => false] in your config'); - $io->out(' 2. Test your application'); - if (!$dropTables) { - $io->out(' 3. Optionally drop the empty phinxlog tables (re-run `bin/cake migrations upgrade --drop-tables`)'); + if ($dropTables) { + $io->out(' 1. Set \'Migrations\' => [\'legacyTables\' => false] in your config'); + $io->out(' 2. Test your application'); + } else { + $io->out(' 1. Test your application'); + $io->out(' 2. Drop the phinxlog tables (re-run `bin/cake migrations upgrade --drop-tables`)'); + $io->out(' 3. Set \'Migrations\' => [\'legacyTables\' => false] in your config'); } } else { $io->out(''); From 8d27e00a7ea0db8bae4d808fd6bcc2847bab40fc Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 7 Mar 2026 19:34:14 +0100 Subject: [PATCH 7/8] Fix upgrade command not matching plugins with slashes (#1039) * Fix upgrade command not matching plugins with slashes When upgrading from legacy phinxlog tables, the plugin name was being derived by camelizing the table prefix (e.g. cake_d_c_users -> CakeDCUsers). This didn't work for plugins with slashes in their names like CakeDC/Users since the slash was replaced with underscore during table name generation. This fix builds a map of loaded plugin names to their expected table prefixes, allowing proper matching of plugins like CakeDC/Users. Fixes #1038 * Fix test for SQL Server compatibility --- src/Command/UpgradeCommand.php | 34 ++++++++++- tests/TestCase/Command/UpgradeCommandTest.php | 61 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/Command/UpgradeCommand.php b/src/Command/UpgradeCommand.php index dece3c09..140eb02f 100644 --- a/src/Command/UpgradeCommand.php +++ b/src/Command/UpgradeCommand.php @@ -17,6 +17,7 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; +use Cake\Core\Plugin; use Cake\Database\Connection; use Cake\Database\Exception\QueryException; use Cake\Datasource\ConnectionManager; @@ -184,13 +185,24 @@ protected function findLegacyTables(Connection $connection): array $tables = $schema->listTables(); $legacyTables = []; + // Build a map of expected table prefixes to plugin names for loaded plugins + // This allows matching plugins with special characters like CakeDC/Users + $pluginPrefixMap = $this->buildPluginPrefixMap(); + foreach ($tables as $table) { if ($table === 'phinxlog') { $legacyTables[$table] = null; } elseif (str_ends_with($table, '_phinxlog')) { // Extract plugin name from table name $prefix = substr($table, 0, -9); // Remove '_phinxlog' - $plugin = Inflector::camelize($prefix); + + // Try to match against loaded plugins first + if (isset($pluginPrefixMap[$prefix])) { + $plugin = $pluginPrefixMap[$prefix]; + } else { + // Fall back to camelizing the prefix + $plugin = Inflector::camelize($prefix); + } $legacyTables[$table] = $plugin; } } @@ -198,6 +210,26 @@ protected function findLegacyTables(Connection $connection): array return $legacyTables; } + /** + * Build a map of table prefixes to plugin names for all loaded plugins. + * + * This handles plugins with special characters like CakeDC/Users where + * the table prefix is cake_d_c_users but the plugin name is CakeDC/Users. + * + * @return array Map of table prefix => plugin name + */ + protected function buildPluginPrefixMap(): array + { + $map = []; + foreach (Plugin::loaded() as $plugin) { + $prefix = Inflector::underscore($plugin); + $prefix = str_replace(['\\', '/', '.'], '_', $prefix); + $map[$prefix] = $plugin; + } + + return $map; + } + /** * Check if a table exists. * diff --git a/tests/TestCase/Command/UpgradeCommandTest.php b/tests/TestCase/Command/UpgradeCommandTest.php index ceddd953..c7900bd7 100644 --- a/tests/TestCase/Command/UpgradeCommandTest.php +++ b/tests/TestCase/Command/UpgradeCommandTest.php @@ -166,4 +166,65 @@ public function testExecuteWithMigrations(): void $this->assertCount(1, $rows); } + + /** + * Test that plugins with slashes (like CakeDC/Users) are correctly identified + * during upgrade from legacy phinxlog tables. + */ + public function testExecuteWithSlashInPluginName(): void + { + Configure::write('Migrations.legacyTables', true); + + // Create the plugin's phinxlog table using the adapter for cross-database compatibility + $config = ConnectionManager::getConfig('test'); + $environment = new Environment('default', [ + 'connection' => 'test', + 'database' => $config['database'], + 'migration_table' => 'cake_d_c_users_phinxlog', + ]); + $adapter = $environment->getAdapter(); + try { + $adapter->createSchemaTable(); + } catch (Exception $e) { + // Table probably exists + } + + // Insert a migration record + $adapter->getInsertBuilder() + ->insert(['version', 'migration_name', 'breakpoint']) + ->into('cake_d_c_users_phinxlog') + ->values([ + 'version' => '20250118143003', + 'migration_name' => 'SlashPluginMigration', + 'breakpoint' => 0, + ]) + ->execute(); + + // Load a fake plugin with a slash in the name using loadPlugins + // which properly integrates with the console application + $this->loadPlugins(['CakeDC/Users' => ['path' => TMP]]); + + try { + $this->exec('migrations upgrade -c test'); + $this->assertExitSuccess(); + + $this->assertOutputContains('cake_d_c_users_phinxlog (CakeDC/Users)'); + + // Verify the plugin column has the correct value with slash + $rows = $this->getAdapter()->getSelectBuilder() + ->select(['version', 'migration_name', 'plugin']) + ->from('cake_migrations') + ->where(['migration_name' => 'SlashPluginMigration']) + ->all(); + + $this->assertCount(1, $rows); + $this->assertSame('CakeDC/Users', $rows[0]['plugin']); + } finally { + // Cleanup + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $connection->execute('DROP TABLE ' . $connection->getDriver()->quoteIdentifier('cake_d_c_users_phinxlog')); + $this->removePlugins(['CakeDC/Users']); + } + } } From 612764c7d69601b78b8efb43de861fc97cce1b03 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 7 Mar 2026 19:38:22 +0100 Subject: [PATCH 8/8] Add TYPE_BIT constant to AdapterInterface (#1013) * Add TYPE_BIT constant to AdapterInterface Adds the TYPE_BIT constant mapping to TableSchemaInterface::TYPE_BIT for consistency with the core BIT type support added in cakephp/cakephp#19223. This allows migration users to reference AdapterInterface::TYPE_BIT when defining BIT columns in MySQL migrations. * Bump cakephp/database constraint to ^5.3.2 for TYPE_BIT support --- composer.json | 2 +- src/Db/Adapter/AdapterInterface.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0d0597f1..1c3779aa 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "require": { "php": ">=8.2", "cakephp/cache": "^5.3.0", - "cakephp/database": "^5.3.0", + "cakephp/database": "^5.3.2", "cakephp/orm": "^5.3.0" }, "require-dev": { diff --git a/src/Db/Adapter/AdapterInterface.php b/src/Db/Adapter/AdapterInterface.php index 74d43406..0dad1ca3 100644 --- a/src/Db/Adapter/AdapterInterface.php +++ b/src/Db/Adapter/AdapterInterface.php @@ -63,6 +63,7 @@ interface AdapterInterface // only for mysql so far public const TYPE_YEAR = TableSchemaInterface::TYPE_YEAR; + public const TYPE_BIT = TableSchemaInterface::TYPE_BIT; // only for postgresql so far public const TYPE_CIDR = TableSchemaInterface::TYPE_CIDR;