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/phpstan.neon b/phpstan.neon index d3803bde..b35bf011 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,7 +2,7 @@ includes: - phpstan-baseline.neon parameters: - level: 7 + level: 8 paths: - src/ bootstrapFiles: diff --git a/src/BaseSeed.php b/src/BaseSeed.php index 146abc4e..ab7c9304 100644 --- a/src/BaseSeed.php +++ b/src/BaseSeed.php @@ -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'], diff --git a/src/Command/BakeMigrationDiffCommand.php b/src/Command/BakeMigrationDiffCommand.php index 68bcd71b..bf4c2d3a 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,20 @@ 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'], @@ -283,10 +299,7 @@ protected function getColumns(): void $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) { @@ -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) { @@ -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 { @@ -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; } @@ -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 { @@ -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|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/src/Command/BakeSimpleMigrationCommand.php b/src/Command/BakeSimpleMigrationCommand.php index 622b61fc..08b703e3 100644 --- a/src/Command/BakeSimpleMigrationCommand.php +++ b/src/Command/BakeSimpleMigrationCommand.php @@ -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 diff --git a/src/Command/UpgradeCommand.php b/src/Command/UpgradeCommand.php index 5897c458..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; @@ -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 \'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(''); @@ -181,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; } } @@ -195,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/src/Db/Action/ChangeColumn.php b/src/Db/Action/ChangeColumn.php index 267e30aa..3fd96033 100644 --- a/src/Db/Action/ChangeColumn.php +++ b/src/Db/Action/ChangeColumn.php @@ -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) { $column->setName($columnName); } } diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 3bb4a81b..367e89cc 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -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; } @@ -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.', @@ -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; @@ -1720,7 +1725,7 @@ 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; @@ -1728,7 +1733,7 @@ public function executeActions(TableMetadata $table, array $actions): void /** @var \Migrations\Db\Action\RenameColumn $action */ $instructions->merge($this->getRenameColumnInstructions( $table->getName(), - (string)$action->getColumn()->getName(), + $action->getColumn()->getName(), $action->getNewName(), )); break; 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; diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index b1d8a182..6dd6c0ba 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]; @@ -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; } @@ -1180,8 +1197,9 @@ protected function getIndexSqlDefinition(Index $index): string protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string { $def = ''; - if ($foreignKey->getName()) { - $def .= ' CONSTRAINT ' . $this->quoteColumnName((string)$foreignKey->getName()); + $name = $foreignKey->getName(); + if ($name) { + $def .= ' CONSTRAINT ' . $this->quoteColumnName($name); } $columnNames = []; foreach ($foreignKey->getColumns() as $column) { @@ -1192,7 +1210,11 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string foreach ($foreignKey->getReferencedColumns() as $column) { $refColumnNames[] = $this->quoteColumnName($column); } - $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()) . ' (' . implode(',', $refColumnNames) . ')'; + $referencedTable = $foreignKey->getReferencedTable(); + if ($referencedTable === null) { + throw new InvalidArgumentException('Foreign key must have a referenced table.'); + } + $def .= ' REFERENCES ' . $this->quoteTableName($referencedTable) . ' (' . implode(',', $refColumnNames) . ')'; $onDelete = $foreignKey->getOnDelete(); if ($onDelete) { $def .= ' ON DELETE ' . $onDelete; diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index 5251ce4a..f349767c 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -455,9 +455,9 @@ protected function getChangeColumnInstructions( $columnSql = $dialect->columnDefinitionSql($this->mapColumnData($newColumn->toArray())); // Remove the column name from $columnSql - $columnType = preg_replace('/^"?(?:[^"]+)"?\s+/', '', $columnSql); + $columnType = (string)preg_replace('/^"?(?:[^"]+)"?\s+/', '', $columnSql); // Remove generated clause - $columnType = preg_replace('/GENERATED (?:ALWAYS|BY DEFAULT) AS IDENTITY/', '', $columnType); + $columnType = (string)preg_replace('/GENERATED (?:ALWAYS|BY DEFAULT) AS IDENTITY/', '', $columnType); $sql = sprintf( 'ALTER COLUMN %s TYPE %s', @@ -476,11 +476,17 @@ 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); + $sql = (string)preg_replace('/ NOT NULL/', '', $sql); + $sql = (string)preg_replace('/ DEFAULT NULL/', '', $sql); // If it is set, DEFAULT is the last definition - $sql = preg_replace('/DEFAULT .*/', '', $sql); + $sql = (string)preg_replace('/DEFAULT .*/', '', $sql); if ($newColumn->getType() === 'boolean') { $sql .= sprintf( ' USING (CASE WHEN %s IS NULL THEN NULL WHEN %s::int=0 THEN FALSE ELSE TRUE END)', @@ -499,11 +505,11 @@ protected function getChangeColumnInstructions( 'ALTER COLUMN %s', $quotedColumnName, ); - if ($newColumn->isIdentity() && $newColumn->getGenerated() !== null) { + if ($newColumn->isIdentity() && ($generated = $newColumn->getGenerated()) !== null) { if ($column->isIdentity()) { - $sql .= sprintf(' SET GENERATED %s', (string)$newColumn->getGenerated()); + $sql .= sprintf(' SET GENERATED %s', $generated); } else { - $sql .= sprintf(' ADD GENERATED %s AS IDENTITY', (string)$newColumn->getGenerated()); + $sql .= sprintf(' ADD GENERATED %s AS IDENTITY', $generated); } } else { $sql .= ' DROP IDENTITY IF EXISTS'; @@ -540,12 +546,13 @@ protected function getChangeColumnInstructions( } // rename column - if ($columnName !== $newColumn->getName()) { + $newColumnName = $newColumn->getName(); + if ($columnName !== $newColumnName) { $instructions->addPostStep(sprintf( 'ALTER TABLE %s RENAME COLUMN %s TO %s', $this->quoteTableName($tableName), $quotedColumnName, - $this->quoteColumnName((string)$newColumn->getName()), + $this->quoteColumnName($newColumnName), )); } @@ -867,6 +874,7 @@ public function dropDatabase($name): void */ protected function getColumnCommentSqlDefinition(Column $column, string $tableName): string { + $columnName = $column->getName(); $comment = (string)$column->getComment(); // passing 'null' is to remove column comment $comment = strcasecmp($comment, 'NULL') !== 0 @@ -876,7 +884,7 @@ protected function getColumnCommentSqlDefinition(Column $column, string $tableNa return sprintf( 'COMMENT ON COLUMN %s.%s IS %s;', $this->quoteTableName($tableName), - $this->quoteColumnName((string)$column->getName()), + $this->quoteColumnName($columnName), $comment, ); } @@ -917,9 +925,10 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin } else { $createIndexSentence .= '(%s)%s%s;'; } - $where = (string)$index->getWhere(); - if ($where) { - $where = ' WHERE ' . $where; + $where = ''; + $whereClause = $index->getWhere(); + if ($whereClause) { + $where = ' WHERE ' . $whereClause; } return sprintf( @@ -950,9 +959,13 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta ); $columnList = implode(', ', array_map($this->quoteColumnName(...), $foreignKey->getColumns())); $refColumnList = implode(', ', array_map($this->quoteColumnName(...), $foreignKey->getReferencedColumns())); + $referencedTable = $foreignKey->getReferencedTable(); + if ($referencedTable === null) { + throw new InvalidArgumentException('Foreign key must have a referenced table.'); + } $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName) . ' FOREIGN KEY (' . $columnList . ')' . - ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()) . ' (' . $refColumnList . ')'; + ' REFERENCES ' . $this->quoteTableName($referencedTable) . ' (' . $refColumnList . ')'; if ($foreignKey->getOnDelete()) { $def .= " ON DELETE {$foreignKey->getOnDelete()}"; } @@ -1321,7 +1334,7 @@ protected function getConflictClause( } $quotedConflictColumns = array_map($this->quoteColumnName(...), $conflictColumns); $updates = []; - foreach ($updateColumns as $column) { + foreach ($updateColumns ?? [] as $column) { $quotedColumn = $this->quoteColumnName($column); $updates[] = $quotedColumn . ' = EXCLUDED.' . $quotedColumn; } diff --git a/src/Db/Adapter/RecordingAdapter.php b/src/Db/Adapter/RecordingAdapter.php index aab4b41b..6eb493e2 100644 --- a/src/Db/Adapter/RecordingAdapter.php +++ b/src/Db/Adapter/RecordingAdapter.php @@ -12,6 +12,7 @@ use Migrations\Db\Action\AddForeignKey; use Migrations\Db\Action\AddIndex; use Migrations\Db\Action\CreateTable; +use InvalidArgumentException; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; use Migrations\Db\Action\DropTable; @@ -89,7 +90,7 @@ public function getInvertedCommands(): Intent case $command instanceof RenameColumn: /** @var \Migrations\Db\Action\RenameColumn $command */ $column = clone $command->getColumn(); - $name = (string)$column->getName(); + $name = $column->getName(); $column->setName($command->getNewName()); $inverted->addAction(new RenameColumn($command->getTable(), $column, $name)); break; diff --git a/src/Db/Adapter/SqliteAdapter.php b/src/Db/Adapter/SqliteAdapter.php index 9145e0cb..2cd6894d 100644 --- a/src/Db/Adapter/SqliteAdapter.php +++ b/src/Db/Adapter/SqliteAdapter.php @@ -570,7 +570,7 @@ protected function getAddColumnInstructions(TableMetadata $table, Column $column return $state; } $finalColumnName = end($columns)->getName(); - $sql = preg_replace( + $sql = (string)preg_replace( sprintf( "/(%s(?:\/\*.*?\*\/|\([^)]+\)|'[^']*?'|[^,])+)([,)])/", $this->quoteColumnName((string)$finalColumnName), @@ -619,7 +619,7 @@ protected function getDeclaringSql(string $tableName): string $columnNamePattern = "\"$columnName\"|`$columnName`|\\[$columnName\\]|$columnName"; $columnNamePattern = "#([\(,]+\\s*)($columnNamePattern)(\\s)#iU"; - $sql = preg_replace_callback( + $sql = (string)preg_replace_callback( $columnNamePattern, function ($matches) use ($column) { return $matches[1] . $this->quoteColumnName($column['name']) . $matches[3]; @@ -631,7 +631,7 @@ function ($matches) use ($column) { $tableNamePattern = "\"$tableName\"|`$tableName`|\\[$tableName\\]|$tableName"; $tableNamePattern = "#^(CREATE TABLE)\s*($tableNamePattern)\s*(\()#Ui"; - $sql = preg_replace($tableNamePattern, "$1 `$tableName` $3", $sql, 1); + $sql = (string)preg_replace($tableNamePattern, "$1 `$tableName` $3", $sql, 1); return $sql; } @@ -1112,10 +1112,10 @@ protected function getChangeColumnInstructions(string $tableName, string $column { $instructions = $this->beginAlterByCopyTable($tableName); - $newColumnName = (string)$newColumn->getName(); + $newColumnName = $newColumn->getName(); $instructions->addPostStep(function ($state) use ($columnName, $newColumn) { $dialect = $this->getSchemaDialect(); - $sql = preg_replace( + $sql = (string)preg_replace( sprintf("/%s(?:\/\*.*?\*\/|\([^)]+\)|'[^']*?'|[^,])+([,)])/", $this->quoteColumnName($columnName)), sprintf('%s$1', $dialect->columnDefinitionSql($newColumn->toArray())), (string)$state['createSQL'], @@ -1149,7 +1149,7 @@ protected function getDropColumnInstructions(string $tableName, string $columnNa }); $instructions->addPostStep(function ($state) use ($columnName) { - $sql = preg_replace( + $sql = (string)preg_replace( sprintf("/%s\s\w+.*(,\s(?!')|\)$)/U", preg_quote($this->quoteColumnName($columnName))), '', (string)$state['createSQL'], @@ -1214,9 +1214,10 @@ protected function getAddIndexInstructions(TableMetadata $table, Index $index): $indexColumnArray[] = sprintf('%s ASC', $this->quoteColumnName($column)); } $indexColumns = implode(',', $indexColumnArray); - $where = (string)$index->getWhere(); - if ($where) { - $where = ' WHERE ' . $where; + $where = ''; + $whereClause = $index->getWhere(); + if ($whereClause) { + $where = ' WHERE ' . $whereClause; } $sql = sprintf( 'CREATE %s ON %s (%s)%s', @@ -1675,8 +1676,9 @@ public function getColumnTypes(): array protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string { $def = ''; - if ($foreignKey->getName()) { - $def .= ' CONSTRAINT ' . $this->quoteColumnName((string)$foreignKey->getName()); + $name = $foreignKey->getName(); + if ($name) { + $def .= ' CONSTRAINT ' . $this->quoteColumnName($name); } $columnNames = []; foreach ($foreignKey->getColumns() as $column) { @@ -1687,7 +1689,11 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string foreach ($foreignKey->getReferencedColumns() as $column) { $refColumnNames[] = $this->quoteColumnName($column); } - $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()) . ' (' . implode(',', $refColumnNames) . ')'; + $referencedTable = $foreignKey->getReferencedTable(); + if ($referencedTable === null) { + throw new InvalidArgumentException('Foreign key must have a referenced table.'); + } + $def .= ' REFERENCES ' . $this->quoteTableName($referencedTable) . ' (' . implode(',', $refColumnNames) . ')'; if ($foreignKey->getOnDelete()) { $def .= ' ON DELETE ' . $foreignKey->getOnDelete(); } diff --git a/src/Db/Adapter/SqlserverAdapter.php b/src/Db/Adapter/SqlserverAdapter.php index e804d590..43ede29c 100644 --- a/src/Db/Adapter/SqlserverAdapter.php +++ b/src/Db/Adapter/SqlserverAdapter.php @@ -213,8 +213,13 @@ protected function getChangeCommentInstructions(TableMetadata $table, ?string $n */ protected function getColumnCommentSqlDefinition(Column $column, ?string $tableName): string { + $columnName = $column->getName(); + if ($tableName === null) { + throw new InvalidArgumentException('Table name must be set.'); + } + // passing 'null' is to remove column comment - $currentComment = $this->getColumnComment((string)$tableName, $column->getName()); + $currentComment = $this->getColumnComment($tableName, $columnName); $comment = strcasecmp((string)$column->getComment(), 'NULL') !== 0 ? $this->quoteString((string)$column->getComment()) : '\'\''; $command = $currentComment === null ? 'sp_addextendedproperty' : 'sp_updateextendedproperty'; @@ -224,8 +229,8 @@ protected function getColumnCommentSqlDefinition(Column $column, ?string $tableN $command, $comment, $this->schema, - (string)$tableName, - (string)$column->getName(), + $tableName, + $columnName, ); } @@ -338,7 +343,7 @@ protected function parseDefault(?string $default): int|string|null $result = preg_replace(["/\('(.*)'\)/", "/\(\((.*)\)\)/", "/\((.*)\)/"], '$1', $default); - if (strtoupper($result) === 'NULL') { + if (strtoupper((string)$result) === 'NULL') { $result = null; } elseif (is_numeric($result)) { $result = (int)$result; @@ -420,12 +425,13 @@ protected function getChangeDefault(string $tableName, Column $newColumn): Alter return $instructions; } + $newColumnName = $newColumn->getName(); $instructions->addPostStep(sprintf( 'ALTER TABLE %s ADD CONSTRAINT %s %s FOR %s', $this->quoteTableName($tableName), $constraintName, $default, - $this->quoteColumnName((string)$newColumn->getName()), + $this->quoteColumnName($newColumnName), )); return $instructions; @@ -455,14 +461,15 @@ protected function getChangeColumnInstructions(string $tableName, string $column $instructions = new AlterInstructions(); $dialect = $this->getSchemaDialect(); - if ($columnName !== $newColumn->getName()) { + $newColumnName = $newColumn->getName(); + if ($columnName !== $newColumnName) { $instructions->merge( - $this->getRenameColumnInstructions($tableName, $columnName, (string)$newColumn->getName()), + $this->getRenameColumnInstructions($tableName, $columnName, $newColumnName), ); } if ($changeDefault) { - $instructions->merge($this->getDropDefaultConstraint($tableName, (string)$newColumn->getName())); + $instructions->merge($this->getDropDefaultConstraint($tableName, $newColumnName)); } // Sqlserver doesn't support defaults @@ -475,7 +482,7 @@ protected function getChangeColumnInstructions(string $tableName, string $column $dialect->columnDefinitionSql($columnData), ); $alterColumn = preg_replace('/DEFAULT NULL/', '', $alterColumn); - $instructions->addPostStep($alterColumn); + $instructions->addPostStep((string)$alterColumn); // change column comment if needed if ($newColumn->getComment()) { @@ -839,9 +846,10 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin $include = $index->getInclude(); $includedColumns = $include ? sprintf(' INCLUDE ([%s])', implode('],[', $include)) : ''; - $where = (string)$index->getWhere(); - if ($where) { - $where = ' WHERE ' . $where; + $where = ''; + $whereClause = $index->getWhere(); + if ($whereClause) { + $where = ' WHERE ' . $whereClause; } return sprintf( @@ -870,7 +878,11 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName); $def .= ' FOREIGN KEY (' . $columnList . ')'; - $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()) . ' (' . $refColumnList . ')'; + $referencedTable = $foreignKey->getReferencedTable(); + if ($referencedTable === null) { + throw new InvalidArgumentException('Foreign key must have a referenced table.'); + } + $def .= ' REFERENCES ' . $this->quoteTableName($referencedTable) . ' (' . $refColumnList . ')'; if ($foreignKey->getOnDelete()) { $def .= " ON DELETE {$foreignKey->getOnDelete()}"; } diff --git a/src/Db/Adapter/TimedOutputAdapter.php b/src/Db/Adapter/TimedOutputAdapter.php index f5e11be6..fa5b47a1 100644 --- a/src/Db/Adapter/TimedOutputAdapter.php +++ b/src/Db/Adapter/TimedOutputAdapter.php @@ -78,7 +78,7 @@ function ($value) { return; } - $this->getIo()->verbose(' -- ' . $command); + $this->getIo()?->verbose(' -- ' . $command); } /** diff --git a/src/Db/Table.php b/src/Db/Table.php index aeeb0906..c83d7fd5 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -358,19 +358,21 @@ public function addPrimaryKey(string|array $columns) */ public function addColumn(string|Column $columnName, ?string $type = null, array $options = []) { - assert($columnName instanceof Column || $type !== null); if ($columnName instanceof Column) { $action = new AddColumn($this->table, $columnName); + } elseif ($type === null) { + throw new InvalidArgumentException('Column type must not be null when column name is a string.'); } else { $action = new AddColumn($this->table, $this->getAdapter()->getColumnForType($columnName, $type, $options)); } // Delegate to Adapters to check column type - if (!$this->getAdapter()->isValidColumnType($action->getColumn())) { + $column = $action->getColumn(); + if (!$this->getAdapter()->isValidColumnType($column)) { throw new InvalidArgumentException(sprintf( 'An invalid column type "%s" was specified for column "%s".', - (string)$action->getColumn()->getType(), - (string)$action->getColumn()->getName(), + $column->getType(), + $column->getName(), )); } diff --git a/src/Db/Table/Column.php b/src/Db/Table/Column.php index 98f28b31..7e2004dc 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 * @@ -177,9 +182,12 @@ public function setName(string $name) /** * Gets the column name. * - * @return string|null + * Narrows the return type from the parent's ?string to string, + * since $name is typed as string (not ?string) in this class. + * + * @return string */ - public function getName(): ?string + public function getName(): string { return $this->name; } @@ -229,7 +237,7 @@ public function setNull(bool $null) */ public function getNull(): bool { - return $this->null; + return $this->null ?? false; } /** @@ -772,6 +780,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 +835,7 @@ protected function getValidOptions(): array 'generated', 'algorithm', 'lock', + 'fixed', ]; } @@ -894,6 +928,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/Db/Table/ForeignKey.php b/src/Db/Table/ForeignKey.php index 907e5f37..425b9f68 100644 --- a/src/Db/Table/ForeignKey.php +++ b/src/Db/Table/ForeignKey.php @@ -86,6 +86,19 @@ public function __construct( } } + /** + * {@inheritDoc} + * + * Narrows the return type from the parent's ?array to array, + * since $columns is always initialized as [] in this class. + * + * @return array + */ + public function getColumns(): array + { + return $this->columns; + } + /** * Utility method that maps an array of index options to this object's methods. * @@ -246,7 +259,9 @@ public function setOnDelete(string $onDelete) */ public function getOnDelete(): ?string { - return $this->mapAction($this->getDelete()); + $delete = $this->getDelete(); + + return $delete !== null ? $this->mapAction($delete) : null; } /** @@ -271,6 +286,8 @@ public function setOnUpdate(string $onUpdate) */ public function getOnUpdate(): ?string { - return $this->mapAction($this->getUpdate()); + $update = $this->getUpdate(); + + return $update !== null ? $this->mapAction($update) : null; } } diff --git a/src/Util/ColumnParser.php b/src/Util/ColumnParser.php index 97b9efdc..e16d1ab8 100644 --- a/src/Util/ColumnParser.php +++ b/src/Util/ColumnParser.php @@ -213,7 +213,7 @@ public function parseForeignKeys(array $arguments): array $referencedTable = $indexType; if (!$referencedTable) { // Remove common suffixes like _id and pluralize - $referencedTable = preg_replace('/_id$/', '', $fieldName); + $referencedTable = (string)preg_replace('/_id$/', '', $fieldName); $referencedTable = Inflector::pluralize($referencedTable); } diff --git a/src/Util/TableFinder.php b/src/Util/TableFinder.php index a96b4e03..5e75c8d6 100644 --- a/src/Util/TableFinder.php +++ b/src/Util/TableFinder.php @@ -183,9 +183,10 @@ public function fetchTableName(string $className, ?string $pluginName = null): a $table = TableRegistry::getTableLocator()->get($className); foreach ($table->associations()->keys() as $key) { - if ($table->associations()->get($key)->type() === 'belongsToMany') { + $association = $table->associations()->get($key); + if ($association !== null && $association->type() === 'belongsToMany') { /** @var \Cake\ORM\Association\BelongsToMany $belongsToMany */ - $belongsToMany = $table->associations()->get($key); + $belongsToMany = $association; $tables[] = $belongsToMany->junction()->getTable(); } } diff --git a/src/View/Helper/MigrationHelper.php b/src/View/Helper/MigrationHelper.php index 3302e7e5..c933c627 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 = []; @@ -692,7 +693,7 @@ public function getCreateTableData(TableSchemaInterface|string $table): array $indexes = $this->indexes($table); $foreignKeys = []; foreach ($constraints as $constraint) { - if ($constraint['type'] === 'foreign') { + if (isset($constraint['type']) && $constraint['type'] === 'foreign') { $foreignKeys[] = $constraint['columns']; } } 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']); + } + } } diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index aeee6316..1ab36b5c 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(); @@ -3080,6 +3111,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 @@ -3478,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/Db/Table/ForeignKeyTest.php b/tests/TestCase/Db/Table/ForeignKeyTest.php index c2fce98a..6ce31b66 100644 --- a/tests/TestCase/Db/Table/ForeignKeyTest.php +++ b/tests/TestCase/Db/Table/ForeignKeyTest.php @@ -151,4 +151,5 @@ public function testThrowsErrorForInvalidDeferrableValue(): void $this->expectException(InvalidArgumentException::class); $this->fk->setDeferrableMode('invalid_value'); } + } diff --git a/tests/TestCase/Db/Table/TableTest.php b/tests/TestCase/Db/Table/TableTest.php index 21912fd5..1be0f352 100644 --- a/tests/TestCase/Db/Table/TableTest.php +++ b/tests/TestCase/Db/Table/TableTest.php @@ -80,6 +80,15 @@ public function testAddColumnWithColumnObject() $this->assertSame($column, $actions[0]->getColumn()); } + public function testAddColumnWithNullTypeThrows() + { + $adapter = new MysqlAdapter([]); + $table = new Table('ntable', [], $adapter); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Column type must not be null when column name is a string.'); + $table->addColumn('email', null); + } + public function testAddColumnWithNoAdapterSpecified() { try { 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(); 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); + } }