diff --git a/docs/en/index.rst b/docs/en/index.rst index 429c03ba..0eff6a92 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -218,7 +218,7 @@ also edit the migration after generation to add or customize the columns Columns on the command line follow the following pattern:: - fieldName:fieldType?[length]:indexType:indexName + fieldName:fieldType?[length]:default[value]:indexType:indexName For instance, the following are all valid ways of specifying an email field: @@ -238,6 +238,16 @@ Columns with a question mark after the fieldType will make the column nullable. The ``length`` part is optional and should always be written between bracket. +The ``default[value]`` part is optional and sets the default value for the column. +Supported value types include: + +* Booleans: ``true`` or ``false`` - e.g., ``active:boolean:default[true]`` +* Integers: ``0``, ``123``, ``-456`` - e.g., ``count:integer:default[0]`` +* Floats: ``1.5``, ``-2.75`` - e.g., ``rate:decimal:default[1.5]`` +* Strings: ``'hello'`` or ``"world"`` (quoted) - e.g., ``status:string:default['pending']`` +* Null: ``null`` or ``NULL`` - e.g., ``description:text?:default[null]`` +* SQL expressions: ``CURRENT_TIMESTAMP`` - e.g., ``created_at:datetime:default[CURRENT_TIMESTAMP]`` + Fields named ``created`` and ``modified``, as well as any field with a ``_at`` suffix, will automatically be set to the type ``datetime``. @@ -318,6 +328,39 @@ will generate:: } } +Adding a column with a default value +------------------------------------- + +You can specify default values for columns using the ``default[value]`` syntax: + +.. code-block:: bash + + bin/cake bake migration AddActiveToUsers active:boolean:default[true] + +will generate:: + + table('users'); + $table->addColumn('active', 'boolean', [ + 'default' => true, + 'null' => false, + ]); + $table->update(); + } + } + +You can combine default values with other options like nullable and indexes: + +.. code-block:: bash + + bin/cake bake migration AddStatusToOrders status:string:default['pending']:unique + Altering a column ----------------- diff --git a/src/Command/BakeMigrationCommand.php b/src/Command/BakeMigrationCommand.php index 024b5222..dd0e7683 100644 --- a/src/Command/BakeMigrationCommand.php +++ b/src/Command/BakeMigrationCommand.php @@ -174,7 +174,7 @@ public function getOptionParser(): ConsoleOptionParser When describing columns you can use the following syntax: -{name}:{primary}{type}{nullable}[{length}]:{index}:{indexName} +{name}:{type}{nullable}[{length}]:default[{value}]:{index}:{indexName} All sections other than name are optional. @@ -182,6 +182,9 @@ public function getOptionParser(): ConsoleOptionParser * The ? value indicates if a column is nullable. e.g. role:string?. * Length option must be enclosed in [], for example: name:string?[100]. +* The default[value] option sets a default value for the column. + Supports booleans (true/false), integers, floats, strings, and null. + e.g. active:boolean:default[true], count:integer:default[0]. * The index attribute can define the column as having a unique key with unique or a primary key with primary. * Use references type to create a foreign key constraint. @@ -214,6 +217,12 @@ public function getOptionParser(): ConsoleOptionParser Create a migration that adds a foreign key column (category_id) to the articles table referencing the categories table. +bin/cake bake migration AddActiveToUsers active:boolean:default[true] +Create a migration that adds an active column with a default value of true. + +bin/cake bake migration AddCountToProducts count:integer:default[0]:unique +Create a migration that adds a count column with default 0 and a unique index. + Migration Styles You can generate migrations in different styles: diff --git a/src/Util/ColumnParser.php b/src/Util/ColumnParser.php index fa8acd64..e0c7ca1e 100644 --- a/src/Util/ColumnParser.php +++ b/src/Util/ColumnParser.php @@ -28,6 +28,7 @@ class ColumnParser (?:,(?:[0-9]|[1-9][0-9]+))? \])? ))? + (?::default\[([^\]]+)\])? (?::(\w+))? (?::(\w+))? $ @@ -54,7 +55,8 @@ public function parseFields(array $arguments): array preg_match($this->regexpParseColumn, $field, $matches); $field = $matches[1]; $type = Hash::get($matches, 2, ''); - $indexType = Hash::get($matches, 3); + $defaultValue = Hash::get($matches, 3); + $indexType = Hash::get($matches, 4); $typeIsPk = in_array($type, ['primary', 'primary_key'], true); $isPrimaryKey = false; @@ -80,7 +82,7 @@ public function parseFields(array $arguments): array 'columnType' => $type, 'options' => [ 'null' => $nullable, - 'default' => null, + 'default' => $this->parseDefaultValue($defaultValue, $type ?? 'string'), ], ]; @@ -114,8 +116,8 @@ public function parseIndexes(array $arguments): array preg_match($this->regexpParseColumn, $field, $matches); $field = $matches[1]; $type = Hash::get($matches, 2); - $indexType = Hash::get($matches, 3); - $indexName = Hash::get($matches, 4); + $indexType = Hash::get($matches, 4); + $indexName = Hash::get($matches, 5); // Skip references - they create foreign keys, not indexes if ($type && str_starts_with($type, 'references')) { @@ -168,7 +170,7 @@ public function parsePrimaryKey(array $arguments): array preg_match($this->regexpParseColumn, $field, $matches); $field = $matches[1]; $type = Hash::get($matches, 2); - $indexType = Hash::get($matches, 3); + $indexType = Hash::get($matches, 4); if ( in_array($type, ['primary', 'primary_key'], true) @@ -196,8 +198,8 @@ public function parseForeignKeys(array $arguments): array preg_match($this->regexpParseColumn, $field, $matches); $fieldName = $matches[1]; $type = Hash::get($matches, 2, ''); - $indexType = Hash::get($matches, 3); - $indexName = Hash::get($matches, 4); + $indexType = Hash::get($matches, 4); + $indexName = Hash::get($matches, 5); // Check if type is 'references' or 'references?' $isReference = str_starts_with($type, 'references'); @@ -250,17 +252,20 @@ public function validArguments(array $arguments): array * * @param string $field Name of field * @param string|null $type User-specified type - * @return array First value is the field type, second value is the field length. If no length + * @return array{0: string|null, 1: int|array|null} First value is the field type, second value is the field length. If no length * can be extracted, null is returned for the second value */ public function getTypeAndLength(string $field, ?string $type): array { if ($type && preg_match($this->regexpParseField, $type, $matches)) { - if (str_contains($matches[2], ',')) { - $matches[2] = explode(',', $matches[2]); + $length = $matches[2]; + if (str_contains($length, ',')) { + $length = array_map('intval', explode(',', $length)); + } else { + $length = (int)$length; } - return [$matches[1], $matches[2]]; + return [$matches[1], $length]; } /** @var string $fieldType */ @@ -352,4 +357,61 @@ public function getIndexName(string $field, ?string $indexType, ?string $indexNa return $indexName; } + + /** + * Parses a default value string into the appropriate PHP type. + * + * Supports: + * - Booleans: true, false + * - Null: null, NULL + * - Integers: 123, -123 + * - Floats: 1.5, -1.5 + * - Strings: 'hello' (quoted) or unquoted values + * + * @param string|null $value The raw default value from the command line + * @param string $columnType The column type to help with type coercion + * @return string|int|float|bool|null The parsed default value + */ + public function parseDefaultValue(?string $value, string $columnType): string|int|float|bool|null + { + if ($value === null || $value === '') { + return null; + } + + $lowerValue = strtolower($value); + + // Handle null + if ($lowerValue === 'null') { + return null; + } + + // Handle booleans + if ($lowerValue === 'true') { + return true; + } + if ($lowerValue === 'false') { + return false; + } + + // Handle quoted strings - strip quotes + if ( + (str_starts_with($value, "'") && str_ends_with($value, "'")) || + (str_starts_with($value, '"') && str_ends_with($value, '"')) + ) { + return substr($value, 1, -1); + } + + // Handle integers + if (preg_match('/^-?[0-9]+$/', $value)) { + return (int)$value; + } + + // Handle floats + if (preg_match('/^-?[0-9]+\.[0-9]+$/', $value)) { + return (float)$value; + } + + // Return as-is for SQL expressions like CURRENT_TIMESTAMP + return $value; + } } diff --git a/tests/TestCase/Util/ColumnParserTest.php b/tests/TestCase/Util/ColumnParserTest.php index 1a31d671..d56d0616 100644 --- a/tests/TestCase/Util/ColumnParserTest.php +++ b/tests/TestCase/Util/ColumnParserTest.php @@ -346,6 +346,36 @@ public function testGetTypeAndLength() $this->assertEquals(['decimal', [10, 6]], $this->columnParser->getTypeAndLength('latitude', 'decimal[10,6]')); } + public function testGetTypeAndLengthReturnsIntegerTypes() + { + // Test that lengths are returned as integers, not strings + [, $length] = $this->columnParser->getTypeAndLength('name', 'string[128]'); + $this->assertIsInt($length); + $this->assertSame(128, $length); + + [, $length] = $this->columnParser->getTypeAndLength('count', 'integer[9]'); + $this->assertIsInt($length); + $this->assertSame(9, $length); + + // Test that precision/scale arrays contain integers + [, $length] = $this->columnParser->getTypeAndLength('amount', 'decimal[10,6]'); + $this->assertIsArray($length); + $this->assertCount(2, $length); + $this->assertIsInt($length[0]); + $this->assertIsInt($length[1]); + $this->assertSame(10, $length[0]); + $this->assertSame(6, $length[1]); + + // Test default lengths are also integers + [, $length] = $this->columnParser->getTypeAndLength('name', 'string'); + $this->assertIsInt($length); + $this->assertSame(255, $length); + + [, $length] = $this->columnParser->getTypeAndLength('id', 'integer'); + $this->assertIsInt($length); + $this->assertSame(11, $length); + } + public function testGetLength() { $this->assertSame(255, $this->columnParser->getLength('string')); @@ -413,6 +443,190 @@ public function testParseFieldsWithReferences() $this->assertEquals($expected, $actual); } + public function testParseFieldsWithDefaultValues() + { + // Test boolean default true + $expected = [ + 'active' => [ + 'columnType' => 'boolean', + 'options' => [ + 'null' => false, + 'default' => true, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['active:boolean:default[true]']); + $this->assertEquals($expected, $actual); + + // Test boolean default false + $expected = [ + 'skip_updates' => [ + 'columnType' => 'boolean', + 'options' => [ + 'null' => false, + 'default' => false, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['skip_updates:boolean:default[false]']); + $this->assertEquals($expected, $actual); + + // Test integer default + $expected = [ + 'count' => [ + 'columnType' => 'integer', + 'options' => [ + 'null' => false, + 'default' => 0, + 'limit' => 11, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['count:integer:default[0]']); + $this->assertEquals($expected, $actual); + + // Test string default with quotes + $expected = [ + 'status' => [ + 'columnType' => 'string', + 'options' => [ + 'null' => false, + 'default' => 'pending', + 'limit' => 255, + ], + ], + ]; + $actual = $this->columnParser->parseFields(["status:string:default['pending']"]); + $this->assertEquals($expected, $actual); + + // Test nullable with default + $expected = [ + 'role' => [ + 'columnType' => 'string', + 'options' => [ + 'null' => true, + 'default' => 'user', + 'limit' => 255, + ], + ], + ]; + $actual = $this->columnParser->parseFields(["role:string?:default['user']"]); + $this->assertEquals($expected, $actual); + + // Test default with index + $expected = [ + 'email' => [ + 'columnType' => 'string', + 'options' => [ + 'null' => false, + 'default' => null, + 'limit' => 255, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['email:string:default[null]:unique']); + $this->assertEquals($expected, $actual); + + // Test float default + $expected = [ + 'rate' => [ + 'columnType' => 'decimal', + 'options' => [ + 'null' => false, + 'default' => 1.5, + 'precision' => 10, + 'scale' => 6, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['rate:decimal:default[1.5]']); + $this->assertEquals($expected, $actual); + + // Test length with default + $expected = [ + 'code' => [ + 'columnType' => 'string', + 'options' => [ + 'null' => false, + 'default' => 'ABC', + 'limit' => 10, + ], + ], + ]; + $actual = $this->columnParser->parseFields(["code:string[10]:default['ABC']"]); + $this->assertEquals($expected, $actual); + } + + public function testParseDefaultValue() + { + // Test null and empty values + $this->assertNull($this->columnParser->parseDefaultValue(null, 'string')); + $this->assertNull($this->columnParser->parseDefaultValue('', 'string')); + $this->assertNull($this->columnParser->parseDefaultValue('null', 'string')); + $this->assertNull($this->columnParser->parseDefaultValue('NULL', 'string')); + + // Test boolean values + $this->assertTrue($this->columnParser->parseDefaultValue('true', 'boolean')); + $this->assertTrue($this->columnParser->parseDefaultValue('TRUE', 'boolean')); + $this->assertFalse($this->columnParser->parseDefaultValue('false', 'boolean')); + $this->assertFalse($this->columnParser->parseDefaultValue('FALSE', 'boolean')); + + // Test integer values + $this->assertSame(0, $this->columnParser->parseDefaultValue('0', 'integer')); + $this->assertSame(123, $this->columnParser->parseDefaultValue('123', 'integer')); + $this->assertSame(-456, $this->columnParser->parseDefaultValue('-456', 'integer')); + + // Test float values + $this->assertSame(1.5, $this->columnParser->parseDefaultValue('1.5', 'decimal')); + $this->assertSame(-2.75, $this->columnParser->parseDefaultValue('-2.75', 'decimal')); + + // Test quoted strings + $this->assertSame('hello', $this->columnParser->parseDefaultValue("'hello'", 'string')); + $this->assertSame('world', $this->columnParser->parseDefaultValue('"world"', 'string')); + + // Test SQL expressions (returned as-is) + $this->assertSame('CURRENT_TIMESTAMP', $this->columnParser->parseDefaultValue('CURRENT_TIMESTAMP', 'datetime')); + } + + public function testParseIndexesWithDefaultValues() + { + // Ensure indexes still work with default values in the definition + $expected = [ + 'UNIQUE_EMAIL' => [ + 'columns' => ['email'], + 'options' => ['unique' => true, 'name' => 'UNIQUE_EMAIL'], + ], + ]; + $actual = $this->columnParser->parseIndexes(['email:string:default[null]:unique']); + $this->assertEquals($expected, $actual); + + // Test with custom index name + $expected = [ + 'IDX_COUNT' => [ + 'columns' => ['count'], + 'options' => ['unique' => false, 'name' => 'IDX_COUNT'], + ], + ]; + $actual = $this->columnParser->parseIndexes(['count:integer:default[0]:index:IDX_COUNT']); + $this->assertEquals($expected, $actual); + } + + public function testValidArgumentsWithDefaultValues() + { + $this->assertEquals( + ['active:boolean:default[true]'], + $this->columnParser->validArguments(['active:boolean:default[true]']), + ); + $this->assertEquals( + ['count:integer:default[0]:unique'], + $this->columnParser->validArguments(['count:integer:default[0]:unique']), + ); + $this->assertEquals( + ["status:string:default['pending']:index:IDX_STATUS"], + $this->columnParser->validArguments(["status:string:default['pending']:index:IDX_STATUS"]), + ); + } + public function testParseForeignKeys() { // Test basic reference - infer table name from field