Skip to content
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
73 changes: 69 additions & 4 deletions src/Command/BakeMigrationDiffCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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'],
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, mixed>|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.
*
Expand Down
45 changes: 40 additions & 5 deletions src/Command/UpgradeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -156,10 +157,13 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
$io->success('Upgrade complete!');
$io->out('');
$io->out('Next steps:');
$io->out(' 1. Set <info>\'Migrations\' => [\'legacyTables\' => false]</info> 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 <info>\'Migrations\' => [\'legacyTables\' => false]</info> 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 <info>\'Migrations\' => [\'legacyTables\' => false]</info> in your config');
}
} else {
$io->out('');
Expand All @@ -181,20 +185,51 @@ 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;
}
}

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<string, string> 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.
*
Expand Down
2 changes: 1 addition & 1 deletion src/Db/Adapter/AbstractAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
1 change: 1 addition & 0 deletions src/Db/Adapter/AdapterInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions src/Db/Adapter/MysqlAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -637,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;
}
Expand Down
6 changes: 6 additions & 0 deletions src/Db/Adapter/PostgresAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions src/Db/Table/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ class Column extends DatabaseColumn
*/
protected ?string $lock = null;

/**
* @var bool|null
*/
protected ?bool $fixed = null;

/**
* Column constructor
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -802,6 +832,7 @@ protected function getValidOptions(): array
'generated',
'algorithm',
'lock',
'fixed',
];
}

Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/View/Helper/MigrationHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ public function getColumnOption(array $options): array
'scale',
'after',
'collate',
'fixed',
]);
$columnOptions = array_intersect_key($options, $wantedOptions);
if (empty($columnOptions['comment'])) {
Expand Down Expand Up @@ -495,7 +496,7 @@ public function attributes(TableSchemaInterface|string $table, string $column):
'comment', 'unsigned',
'signed', 'properties',
'autoIncrement', 'unique',
'collate',
'collate', 'fixed',
];

$attributes = [];
Expand Down
Loading
Loading