diff --git a/docs/en/writing-migrations.rst b/docs/en/writing-migrations.rst index 308962c4..8df520ec 100644 --- a/docs/en/writing-migrations.rst +++ b/docs/en/writing-migrations.rst @@ -706,7 +706,69 @@ You can limit the maximum length of a column by using the ``limit`` option:: Changing Column Attributes -------------------------- -To change column type or options on an existing column, use the ``changeColumn()`` method. +There are two methods for modifying existing columns: + +Updating Columns (Recommended) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To modify specific column attributes while preserving others, use the ``updateColumn()`` method. +This method automatically preserves unspecified attributes like defaults, nullability, limits, etc.:: + + table('users'); + // Make email nullable, preserving all other attributes + $users->updateColumn('email', null, ['null' => true]) + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + $users = $this->table('users'); + $users->updateColumn('email', null, ['null' => false]) + ->save(); + } + } + +You can pass ``null`` as the column type to preserve the existing type, or specify a new type:: + + // Preserve type and other attributes, only change nullability + $table->updateColumn('email', null, ['null' => true]); + + // Change type to biginteger, preserve default and other attributes + $table->updateColumn('user_id', 'biginteger'); + + // Change default value, preserve everything else + $table->updateColumn('status', null, ['default' => 'active']); + +The following attributes are automatically preserved by ``updateColumn()``: + +- Default values +- NULL/NOT NULL constraint +- Column limit/length +- Decimal scale/precision +- Comments +- Signed/unsigned (for numeric types) +- Collation and encoding +- Enum/set values + +Changing Columns (Traditional) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To completely replace a column definition, use the ``changeColumn()`` method. +This method requires you to specify all desired column attributes. See :ref:`valid-column-types` and `Valid Column Options`_ for allowed values:: table('users'); - $users->changeColumn('email', 'string', ['limit' => 255]) + // Must specify all attributes + $users->changeColumn('email', 'string', [ + 'limit' => 255, + 'null' => true, + 'default' => null, + ]) ->save(); } @@ -734,6 +801,20 @@ See :ref:`valid-column-types` and `Valid Column Options`_ for allowed values:: } } +You can enable attribute preservation with ``changeColumn()`` by passing +``'preserveUnspecified' => true`` in the options:: + + $table->changeColumn('email', 'string', [ + 'null' => true, + 'preserveUnspecified' => true, + ]); + +.. note:: + + For most use cases, ``updateColumn()`` is recommended as it is safer and requires + less code. Use ``changeColumn()`` when you need to completely redefine a column + or when working with legacy code that expects the traditional behavior. + Working With Indexes -------------------- diff --git a/src/Db/Table.php b/src/Db/Table.php index 0ff5ea96..852d1ecd 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -389,19 +389,78 @@ public function renameColumn(string $oldName, string $newName) return $this; } + /** + * Update a table column, preserving unspecified attributes. + * + * This is the recommended method for modifying columns as it automatically + * preserves existing column attributes (default, null, limit, etc.) unless + * explicitly overridden. + * + * @param string $columnName Column Name + * @param string|\Migrations\Db\Table\Column|null $newColumnType New Column Type (pass null to preserve existing type) + * @param array $options Options + * @return $this + */ + public function updateColumn(string $columnName, string|Column|null $newColumnType, array $options = []) + { + if (!($newColumnType instanceof Column)) { + $options['preserveUnspecified'] = true; + } + + return $this->changeColumn($columnName, $newColumnType, $options); + } + /** * Change a table column type. * + * Note: This method replaces the column definition. Consider using updateColumn() + * instead, which preserves unspecified attributes by default. + * * @param string $columnName Column Name - * @param string|\Migrations\Db\Table\Column $newColumnType New Column Type + * @param string|\Migrations\Db\Table\Column|null $newColumnType New Column Type (pass null to preserve existing type) * @param array $options Options * @return $this */ - public function changeColumn(string $columnName, string|Column $newColumnType, array $options = []) + public function changeColumn(string $columnName, string|Column|null $newColumnType, array $options = []) { if ($newColumnType instanceof Column) { + if ($options) { + throw new InvalidArgumentException( + 'Cannot specify options array when passing a Column object. ' . + 'Set all properties directly on the Column object instead.', + ); + } $action = new ChangeColumn($this->table, $columnName, $newColumnType); } else { + // Check if we should preserve existing column attributes + $preserveUnspecified = $options['preserveUnspecified'] ?? false; // Default to false for BC + unset($options['preserveUnspecified']); + + // If type is null, preserve the existing type + if ($newColumnType === null) { + if (!$this->hasColumn($columnName)) { + throw new RuntimeException( + "Cannot preserve column type for '$columnName' - column does not exist in table '{$this->getName()}'", + ); + } + $existingColumn = $this->getColumn($columnName); + if ($existingColumn === null) { + throw new RuntimeException( + "Cannot retrieve column definition for '$columnName' in table '{$this->getName()}'", + ); + } + $newColumnType = $existingColumn->getType(); + } + + if ($preserveUnspecified && $this->hasColumn($columnName)) { + // Get existing column definition + $existingColumn = $this->getColumn($columnName); + if ($existingColumn !== null) { + // Merge existing attributes with new ones + $options = $this->mergeColumnOptions($existingColumn, $newColumnType, $options); + } + } + $action = ChangeColumn::build($this->table, $columnName, $newColumnType, $options); } $this->actions->addAction($action); @@ -829,4 +888,88 @@ protected function executeActions(bool $exists): void $plan = new Plan($this->actions); $plan->execute($this->getAdapter()); } + + /** + * Merges existing column options with new options. + * Only attributes that are explicitly specified in the new options will override existing ones. + * + * @param \Migrations\Db\Table\Column $existingColumn Existing column definition + * @param string $newColumnType New column type + * @param array $options New options + * @return array Merged options + */ + protected function mergeColumnOptions(Column $existingColumn, string $newColumnType, array $options): array + { + // Determine if type is changing + $newTypeString = (string)$newColumnType; + $existingTypeString = (string)$existingColumn->getType(); + $typeChanging = $newTypeString !== $existingTypeString; + + // Build array of existing column attributes + $existingOptions = []; + + // Only preserve limit if type is not changing or limit is not explicitly set + if (!$typeChanging && !array_key_exists('limit', $options) && !array_key_exists('length', $options)) { + $limit = $existingColumn->getLimit(); + if ($limit !== null) { + $existingOptions['limit'] = $limit; + } + } + + // Preserve default if not explicitly set + if (!array_key_exists('default', $options)) { + $existingOptions['default'] = $existingColumn->getDefault(); + } + + // Preserve null if not explicitly set + if (!isset($options['null'])) { + $existingOptions['null'] = $existingColumn->getNull(); + } + + // Preserve scale/precision if not explicitly set + if (!array_key_exists('scale', $options) && !array_key_exists('precision', $options)) { + $scale = $existingColumn->getScale(); + if ($scale !== null) { + $existingOptions['scale'] = $scale; + } + $precision = $existingColumn->getPrecision(); + if ($precision !== null) { + $existingOptions['precision'] = $precision; + } + } + + // Preserve comment if not explicitly set + if (!array_key_exists('comment', $options)) { + $comment = $existingColumn->getComment(); + if ($comment !== null) { + $existingOptions['comment'] = $comment; + } + } + + // Preserve signed if not explicitly set (always has a value) + if (!isset($options['signed'])) { + $existingOptions['signed'] = $existingColumn->getSigned(); + } + + // Preserve collation if not explicitly set + if (!isset($options['collation'])) { + $collation = $existingColumn->getCollation(); + if ($collation !== null) { + $existingOptions['collation'] = $collation; + } + } + + // Preserve encoding if not explicitly set + if (!isset($options['encoding'])) { + $encoding = $existingColumn->getEncoding(); + if ($encoding !== null) { + $existingOptions['encoding'] = $encoding; + } + } + + // Note: enum/set values are not preserved as schema reflection doesn't populate them + + // New options override existing ones + return array_merge($existingOptions, $options); + } } diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index 27f68869..699ff7c9 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -22,6 +22,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; +use RuntimeException; class MysqlAdapterTest extends TestCase { @@ -954,6 +955,249 @@ public function testChangeColumnDefaultToNull() $this->assertNull($rows[1]['Default']); } + public function testChangeColumnPreservesDefaultValue() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'original_default', 'null' => false, 'limit' => 100]) + ->save(); + + // Use updateColumn which preserves by default + $table->updateColumn('column1', 'string', ['null' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('original_default', $rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + } + + public function testChangeColumnPreservesDefaultValueWithDifferentType() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['default' => 42, 'null' => false]) + ->save(); + + // Use updateColumn to preserve default when changing type + $table->updateColumn('column1', 'biginteger', [])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('42', $rows[1]['Default']); + $this->assertEquals('NO', $rows[1]['Null']); + } + + public function testChangeColumnCanExplicitlyOverrideDefault() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'original_default']) + ->save(); + + // Explicitly change the default + $table->changeColumn('column1', 'string', ['default' => 'new_default'])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('new_default', $rows[1]['Default']); + } + + public function testChangeColumnCanDisablePreserveUnspecified() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'original_default', 'limit' => 100]) + ->save(); + + // Disable preservation, default should be removed + $table->changeColumn('column1', 'string', ['null' => true, 'preserveUnspecified' => false])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertNull($rows[1]['Default']); + } + + public function testChangeColumnWithNullTypePreservesType() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100]) + ->save(); + + // Use updateColumn with null type to preserve everything + $table->updateColumn('column1', null, ['null' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); + } + + public function testChangeColumnWithNullTypeOnNonExistentColumnThrows() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Cannot preserve column type for 'nonexistent'"); + + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string')->save(); + + // Try to use null type on non-existent column + $table->changeColumn('nonexistent', null, ['null' => true])->save(); + } + + public function testUpdateColumnPreservesAttributes() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100, 'null' => false]) + ->save(); + + // updateColumn should preserve by default + $table->updateColumn('column1', null, ['null' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); + } + + public function testChangeColumnDoesNotPreserveByDefault() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100]) + ->save(); + + // changeColumn should NOT preserve by default (backwards compatible) + $table->changeColumn('column1', 'string', ['null' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Default should be lost + $this->assertNull($rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); + } + + public function testChangeColumnWithPreserveUnspecifiedTrue() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100]) + ->save(); + + // changeColumn with explicit preserveUnspecified => true + $table->changeColumn('column1', 'string', ['null' => true, 'preserveUnspecified' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Default should be preserved + $this->assertEquals('test', $rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); + } + + public function testUpdateColumnWithColumnObject() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100, 'null' => false]) + ->save(); + + // Use updateColumn with a Column object + $newColumn = new Column(); + $newColumn->setName('column1') + ->setType('string') + ->setLimit(255) + ->setNull(true); + $table->updateColumn('column1', $newColumn)->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(255)', $rows[1]['Type']); + $this->assertEquals('YES', $rows[1]['Null']); + } + + public function testUpdateColumnWithColumnObjectAndOptionsThrows() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot specify options array when passing a Column object'); + + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100]) + ->save(); + + // Passing both Column object and options array should throw an exception + $newColumn = new Column(); + $newColumn->setName('column1') + ->setType('string') + ->setLimit(200); + + $table->updateColumn('column1', $newColumn, ['limit' => 500]); + } + + public function testUpdateColumnWithTypeChangeToText() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['limit' => 100, 'default' => 'test']) + ->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + + // Change type to text (limit doesn't apply to TEXT types) + $table->updateColumn('column1', 'text')->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // TEXT type in MySQL doesn't have a length specifier + $this->assertEquals('text', $rows[1]['Type']); + // TEXT columns in MySQL quote the default value + $this->assertStringContainsString('test', $rows[1]['Default']); // Default should be preserved + } + + public function testUpdateColumnCanRemoveLengthConstraintWithoutChangingType() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['limit' => 100, 'default' => 'test']) + ->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + + // Try to remove length constraint without changing type by passing length => null + // This tests the array_key_exists fix - isset() would fail here + $table->updateColumn('column1', 'string', ['length' => null])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Without explicit length, MySQL uses default varchar(255) + $this->assertEquals('varchar(255)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); // Default should be preserved + } + + public function testUpdateColumnCanRemoveScaleAndPrecision() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'decimal', ['precision' => 10, 'scale' => 2, 'default' => '123.45']) + ->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('decimal(10,2)', $rows[1]['Type']); + $this->assertEquals('123.45', $rows[1]['Default']); + + // Try to remove scale/precision by passing null + $table->updateColumn('column1', 'decimal', ['precision' => null, 'scale' => null])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Without explicit precision/scale, MySQL uses default decimal(10,0) + $this->assertEquals('decimal(10,0)', $rows[1]['Type']); + $this->assertEquals('123', $rows[1]['Default']); // Default should be preserved (truncated to integer) + } + + public function testUpdateColumnCanRemoveComment() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['limit' => 100, 'comment' => 'Original comment', 'default' => 'test']) + ->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + // MySQL doesn't show comments in SHOW COLUMNS, but we can verify it was set + + // Try to remove comment by passing null + $table->updateColumn('column1', 'string', ['comment' => null])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Verify limit and default are preserved + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + } + public function testChangeColumnEnum() { $table = new Table('t', [], $this->adapter);