Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
74a30e9
Add additional check for empty array
Feb 24, 2026
3d97a9b
Merge pull request #1028 from cakephp/fix/adapter-empty-not-null-conf…
dereuromark Feb 24, 2026
69dde0e
add using when changing column type to json (#1031)
swiffer Mar 3, 2026
944c4e2
Fix CI failures on MySQL/MariaDB (#1034)
dereuromark Mar 5, 2026
d6720e1
Fix TEXT column variants not round-tripping correctly (#1032)
dereuromark Mar 5, 2026
9d74ef7
Add fixed option for binary column type (#1014)
dereuromark Mar 7, 2026
ca1b74e
Fix misleading next steps message in upgrade command (#1037)
dereuromark Mar 7, 2026
8d27e00
Fix upgrade command not matching plugins with slashes (#1039)
dereuromark Mar 7, 2026
612764c
Add TYPE_BIT constant to AdapterInterface (#1013)
dereuromark Mar 7, 2026
69446ab
Bump PHPStan level +1
Feb 24, 2026
027c7c8
Make props non-nullable
Feb 24, 2026
65dcf7e
Add null guards for getConstraint() in BakeMigrationDiffCommand
Mar 11, 2026
e143793
Fix nullable return types in Column and ForeignKey
Feb 24, 2026
dc5ea90
Fix null safety in Db adapters and domain classes
Feb 24, 2026
61d82f1
Fix remaining PHPStan level 8 null safety issues
Mar 11, 2026
2ceab0b
Replace assert with RuntimeException in AbstractAdapter::getConnection()
Feb 24, 2026
fe754cd
Use truthiness checks for optional nullable getters
Mar 11, 2026
84e4c4f
Throw on null for required Column::getName() and FK::getReferencedTab…
Feb 24, 2026
14b239a
Narrow ForeignKey::getColumns() return type and remove null guards
Mar 10, 2026
fd66adf
Narrow Column::getName() return type and remove dead null checks
Mar 11, 2026
7bb0e02
Add null guards for column diff loop in BakeMigrationDiffCommand
Mar 11, 2026
4582350
Cast nullable getName() to string for drop FK/index instructions
Mar 11, 2026
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
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
2 changes: 1 addition & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ includes:
- phpstan-baseline.neon

parameters:
level: 7
level: 8
paths:
- src/
bootstrapFiles:
Expand Down
4 changes: 2 additions & 2 deletions src/BaseSeed.php
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,8 @@ protected function runCall(string $seeder, array $options = []): void

$options += [
'connection' => $connection,
'plugin' => $pluginName ?? $config['plugin'],
'source' => $config['source'],
'plugin' => $pluginName ?? ($config !== null ? $config['plugin'] : null),
'source' => $config !== null ? $config['source'] : null,
];
$factory = new ManagerFactory([
'connection' => $options['connection'],
Expand Down
109 changes: 96 additions & 13 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,19 +278,28 @@ 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);
if ($column === null) {
continue;
}

if (!in_array($columnName, $oldColumns, true)) {
continue;
}

$oldColumn = $this->safeGetColumn($this->dumpSchema[$table], $columnName);
if ($oldColumn === null) {
continue;
}

unset(
$column['collate'],
$column['fixed'],
$oldColumn['collate'],
$oldColumn['fixed'],
);

if (
in_array($columnName, $oldColumns, true) &&
$column !== $oldColumn
) {
if ($column !== $oldColumn) {
$changedAttributes = array_diff_assoc($column, $oldColumn);

foreach (['type', 'length', 'null', 'default'] as $attribute) {
Expand Down Expand Up @@ -351,7 +364,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 @@ -381,9 +394,10 @@ protected function getConstraints(): void
// brand new constraints
$addedConstraints = array_diff($currentConstraints, $oldConstraints);
foreach ($addedConstraints as $constraintName) {
$this->templateData[$table]['constraints']['add'][$constraintName] =
$currentSchema->getConstraint($constraintName);
$constraint = $currentSchema->getConstraint($constraintName);
if ($constraint === null) {
continue;
}
if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
$this->templateData[$table]['constraints']['add'][$constraintName] = $constraint;
} else {
Expand All @@ -395,13 +409,18 @@ protected function getConstraints(): void
// if present in both, check if they are the same : if not, remove the old one and add the new one
foreach ($currentConstraints as $constraintName) {
$constraint = $currentSchema->getConstraint($constraintName);
if ($constraint === null) {
continue;
}

$oldConstraint = $this->dumpSchema[$table]->getConstraint($constraintName);
if (
in_array($constraintName, $oldConstraints, true) &&
$constraint !== $this->dumpSchema[$table]->getConstraint($constraintName)
$constraint !== $oldConstraint
) {
$this->templateData[$table]['constraints']['remove'][$constraintName] =
$this->dumpSchema[$table]->getConstraint($constraintName);
if ($oldConstraint !== null) {
$this->templateData[$table]['constraints']['remove'][$constraintName] = $oldConstraint;
}
$this->templateData[$table]['constraints']['add'][$constraintName] =
$constraint;
}
Expand All @@ -411,6 +430,9 @@ protected function getConstraints(): void
$removedConstraints = array_diff($oldConstraints, $currentConstraints);
foreach ($removedConstraints as $constraintName) {
$constraint = $this->dumpSchema[$table]->getConstraint($constraintName);
if ($constraint === null) {
continue;
}
if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
$this->templateData[$table]['constraints']['remove'][$constraintName] = $constraint;
} else {
Expand Down Expand Up @@ -621,6 +643,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
8 changes: 4 additions & 4 deletions src/Command/BakeSimpleMigrationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,16 @@ abstract class BakeSimpleMigrationCommand extends SimpleBakeCommand
/**
* Console IO
*
* @var \Cake\Console\ConsoleIo|null
* @var \Cake\Console\ConsoleIo
*/
protected ?ConsoleIo $io = null;
protected ConsoleIo $io;

/**
* Arguments
*
* @var \Cake\Console\Arguments|null
* @var \Cake\Console\Arguments
*/
protected ?Arguments $args = null;
protected Arguments $args;

/**
* @inheritDoc
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/Action/ChangeColumn.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function __construct(TableMetadata $table, string $columnName, Column $co
$this->column = $column;

// if the name was omitted use the existing column name
if ($column->getName() === null || strlen((string)$column->getName()) === 0) {
if (strlen($column->getName()) === 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (strlen($column->getName()) === 0) {
if ($column->getName() === '') {

$column->setName($columnName);
}
}
Expand Down
15 changes: 10 additions & 5 deletions src/Db/Adapter/AbstractAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ public function getConnection(): Connection
$this->connection = $this->getOption('connection');
$this->connect();
}
if ($this->connection === null) {
throw new RuntimeException('Unable to establish database connection. Ensure a connection is configured.');
}

return $this->connection;
}
Expand Down Expand Up @@ -745,7 +748,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 Expand Up @@ -1687,17 +1690,19 @@ public function executeActions(TableMetadata $table, array $actions): void

case $action instanceof DropForeignKey && $action->getForeignKey()->getName():
/** @var \Migrations\Db\Action\DropForeignKey $action */
$fkName = (string)$action->getForeignKey()->getName();
$instructions->merge($this->getDropForeignKeyInstructions(
$table->getName(),
(string)$action->getForeignKey()->getName(),
$fkName,
));
break;

case $action instanceof DropIndex && $action->getIndex()->getName():
/** @var \Migrations\Db\Action\DropIndex $action */
$indexName = (string)$action->getIndex()->getName();
$instructions->merge($this->getDropIndexByNameInstructions(
$table->getName(),
(string)$action->getIndex()->getName(),
$indexName,
));
break;

Expand All @@ -1720,15 +1725,15 @@ public function executeActions(TableMetadata $table, array $actions): void
/** @var \Migrations\Db\Action\RemoveColumn $action */
$instructions->merge($this->getDropColumnInstructions(
$table->getName(),
(string)$action->getColumn()->getName(),
$action->getColumn()->getName(),
));
break;

case $action instanceof RenameColumn:
/** @var \Migrations\Db\Action\RenameColumn $action */
$instructions->merge($this->getRenameColumnInstructions(
$table->getName(),
(string)$action->getColumn()->getName(),
$action->getColumn()->getName(),
$action->getNewName(),
));
break;
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
Loading
Loading