diff --git a/src/BaseMigration.php b/src/BaseMigration.php index 9172b1ef..b5df6c62 100644 --- a/src/BaseMigration.php +++ b/src/BaseMigration.php @@ -85,12 +85,14 @@ class BaseMigration implements MigrationInterface /** * Constructor * - * @param int $version The version this migration is + * @param int|null $version The version this migration is (null for anonymous migrations) */ - public function __construct(int $version) + public function __construct(?int $version = null) { - $this->validateVersion($version); - $this->version = $version; + if ($version !== null) { + $this->validateVersion($version); + $this->version = $version; + } } /** diff --git a/src/Command/BakeMigrationCommand.php b/src/Command/BakeMigrationCommand.php index 0c3cf082..89eb1eb3 100644 --- a/src/Command/BakeMigrationCommand.php +++ b/src/Command/BakeMigrationCommand.php @@ -60,6 +60,11 @@ public function bake(string $name, Arguments $args, ConsoleIo $io): void */ public function template(): string { + $style = $this->args->getOption('style') ?? Configure::read('Migrations.style', 'traditional'); + if ($style === 'anonymous') { + return 'Migrations.config/skeleton-anonymous'; + } + return 'Migrations.config/skeleton'; } @@ -196,6 +201,19 @@ public function getOptionParser(): ConsoleOptionParser Create a migration that adds (name VARCHAR(128) and a UNIQUE<.warning index) to the projects table. +Migration Styles + +You can generate migrations in different styles: + +bin/cake bake migration --style=anonymous CreatePosts +Creates an anonymous class migration with readable file naming (2024_12_08_120000_CreatePosts.php) + +bin/cake bake migration --style=traditional CreatePosts +Creates a traditional class-based migration (20241208120000_create_posts.php) + +You can set the default style in your configuration: +Configure::write('Migrations.style', 'anonymous'); + TEXT; $parser->setDescription($text); @@ -203,6 +221,22 @@ public function getOptionParser(): ConsoleOptionParser return $parser; } + /** + * @inheritDoc + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser = parent::buildOptionParser($parser); + + $parser->addOption('style', [ + 'help' => 'Migration style to use (traditional or anonymous).', + 'default' => 'traditional', + 'choices' => ['traditional', 'anonymous'], + ]); + + return $parser; + } + /** * Detects the action and table from the name of a migration * diff --git a/src/Command/BakeSimpleMigrationCommand.php b/src/Command/BakeSimpleMigrationCommand.php index 8ed82d76..622b61fc 100644 --- a/src/Command/BakeSimpleMigrationCommand.php +++ b/src/Command/BakeSimpleMigrationCommand.php @@ -20,8 +20,11 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; +use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Utility\Inflector; +use DateTime; +use DateTimeZone; use Migrations\Util\Util; /** @@ -74,6 +77,32 @@ public function name(): string public function fileName($name): string { $name = $this->getMigrationName($name); + $style = $this->args->getOption('style') ?? Configure::read('Migrations.style', 'traditional'); + + if ($style === 'anonymous') { + // Use readable format: 2024_12_08_120000_CamelCaseName.php + $timestamp = Util::getCurrentTimestamp(); + $dt = new DateTime(); + $dt->setTimestamp((int)substr($timestamp, 0, 10)); + $dt->setTimezone(new DateTimeZone('UTC')); + + $readableDate = $dt->format('Y_m_d'); + $time = substr($timestamp, 8); + $camelName = Inflector::camelize($name); + + $path = $this->getPath($this->args); + $offset = 0; + while (glob($path . $readableDate . '_' . $time . '_*.php')) { + $timestamp = Util::getCurrentTimestamp(++$offset); + $dt->setTimestamp((int)substr($timestamp, 0, 10)); + $readableDate = $dt->format('Y_m_d'); + $time = substr($timestamp, 8); + } + + return $readableDate . '_' . $time . '_' . $camelName . '.php'; + } + + // Traditional format $timestamp = Util::getCurrentTimestamp(); $suffix = '_' . Inflector::camelize($name) . '.php'; diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index 7a4d33e5..b2d067eb 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -227,9 +227,27 @@ public function markMigrated(int $version, string $path): bool $migrationFile = $migrationFile[0]; $className = $this->getMigrationClassName($migrationFile); - require_once $migrationFile; - $migration = new $className($version); + // For anonymous classes, we need to use require instead of require_once + $migrationInstance = null; + if (!class_exists($className)) { + $migrationInstance = require $migrationFile; + } else { + require_once $migrationFile; + } + + // Check if the file returns an anonymous class instance + if (is_object($migrationInstance) && $migrationInstance instanceof MigrationInterface) { + $migration = $migrationInstance; + $migration->setVersion($version); + } elseif (class_exists($className)) { + $migration = new $className($version); + } else { + throw new RuntimeException( + sprintf('Could not find class `%s` in file `%s` and file did not return a migration instance', $className, $migrationFile), + ); + } + /** @var \Migrations\MigrationInterface $migration */ $config = $this->getConfig(); $migration->setConfig($config); @@ -850,19 +868,35 @@ function ($phpFile) { // load the migration file $orig_display_errors_setting = ini_get('display_errors'); ini_set('display_errors', 'On'); - /** @noinspection PhpIncludeInspection */ - require_once $filePath; - ini_set('display_errors', $orig_display_errors_setting); + + // For anonymous classes, we need to use require instead of require_once + // to get the returned instance + $migrationInstance = null; if (!class_exists($class)) { + $migrationInstance = require $filePath; + } else { + require_once $filePath; + } + + ini_set('display_errors', $orig_display_errors_setting); + + // Check if the file returns an anonymous class instance + if (is_object($migrationInstance) && $migrationInstance instanceof MigrationInterface) { + $io->verbose("Using anonymous class from $filePath."); + $migration = $migrationInstance; + $migration->setVersion($version); + } elseif (class_exists($class)) { + // Fall back to traditional class-based migration + $io->verbose("Constructing $class."); + $migration = new $class($version); + } else { throw new InvalidArgumentException(sprintf( - 'Could not find class `%s` in file `%s`', + 'Could not find class `%s` in file `%s` and file did not return a migration instance', $class, $filePath, )); } - $io->verbose("Constructing $class."); - $migration = new $class($version); /** @var \Migrations\MigrationInterface $migration */ $config = $this->getConfig(); $migration->setConfig($config); @@ -976,7 +1010,6 @@ public function getSeeds(): array $fileNames[$class] = basename($filePath); // load the seed file - /** @noinspection PhpIncludeInspection */ require_once $filePath; if (!class_exists($class)) { throw new InvalidArgumentException(sprintf( diff --git a/src/Util/Util.php b/src/Util/Util.php index a0435b41..c14a30e3 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -37,6 +37,15 @@ class Util */ protected const MIGRATION_FILE_NAME_NO_NAME_PATTERN = '/^[0-9]{14}\.php$/'; + /** + * Enhanced migration file name pattern with readable timestamp and CamelCase + * Example: 2024_12_08_120000_CreateUsersTable.php + * + * @var string + * @phpstan-var non-empty-string + */ + protected const READABLE_MIGRATION_FILE_NAME_PATTERN = '/^(\d{4})_(\d{2})_(\d{2})_(\d{6})_([A-Z][a-zA-Z\d]*)\.php$/'; + /** * @var string * @phpstan-var non-empty-string @@ -95,7 +104,16 @@ public static function getExistingMigrationClassNames(string $path): array public static function getVersionFromFileName(string $fileName): int { $matches = []; - preg_match('/^[0-9]+/', basename($fileName), $matches); + $baseName = basename($fileName); + + // Check for readable format: 2024_12_08_120000_CreateUsersTable.php + if (preg_match(static::READABLE_MIGRATION_FILE_NAME_PATTERN, $baseName, $matches)) { + // Convert to standard format: 20241208120000 + return (int)($matches[1] . $matches[2] . $matches[3] . $matches[4]); + } + + // Standard format + preg_match('/^[0-9]+/', $baseName, $matches); $value = (int)($matches[0] ?? null); if (!$value) { throw new RuntimeException(sprintf('Cannot get a valid version from filename `%s`', $fileName)); @@ -135,6 +153,12 @@ public static function mapClassNameToFileName(string $className): string public static function mapFileNameToClassName(string $fileName): string { $matches = []; + + // Check for readable format first: 2024_12_08_120000_CreateUsersTable.php + if (preg_match(static::READABLE_MIGRATION_FILE_NAME_PATTERN, $fileName, $matches)) { + return $matches[5]; // Return the CamelCase class name directly + } + if (preg_match(static::MIGRATION_FILE_NAME_PATTERN, $fileName, $matches)) { $fileName = $matches[1]; } elseif (preg_match(static::MIGRATION_FILE_NAME_NO_NAME_PATTERN, $fileName)) { @@ -153,7 +177,8 @@ public static function mapFileNameToClassName(string $fileName): string public static function isValidMigrationFileName(string $fileName): bool { return (bool)preg_match(static::MIGRATION_FILE_NAME_PATTERN, $fileName) - || (bool)preg_match(static::MIGRATION_FILE_NAME_NO_NAME_PATTERN, $fileName); + || (bool)preg_match(static::MIGRATION_FILE_NAME_NO_NAME_PATTERN, $fileName) + || (bool)preg_match(static::READABLE_MIGRATION_FILE_NAME_PATTERN, $fileName); } /** diff --git a/templates/bake/config/skeleton-anonymous.twig b/templates/bake/config/skeleton-anonymous.twig new file mode 100644 index 00000000..a96bd86e --- /dev/null +++ b/templates/bake/config/skeleton-anonymous.twig @@ -0,0 +1,75 @@ +{# +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +#} +{% set wantedOptions = {'length': '', 'limit': '', 'default': '', 'unsigned': '', 'null': '', 'comment': '', 'autoIncrement': '', 'precision': '', 'scale': ''} %} +{% set tableMethod = Migration.tableMethod(action) %} +{% set columnMethod = Migration.columnMethod(action) %} +{% set indexMethod = Migration.indexMethod(action) %} +table('{{ table }}'); +{% if tableMethod != 'drop' %} +{% if columnMethod == 'removeColumn' %} +{% for column, config in columns['fields'] %} + $table->{{ columnMethod }}('{{ column }}'); +{% endfor %} +{% for column, config in columns['indexes'] %} + $table->{{ indexMethod }}([{{ + Migration.stringifyList(config['columns']) | raw + }}]); +{% endfor %} +{% else %} +{% for column, config in columns['fields'] %} + $table->{{ columnMethod }}('{{ column }}', '{{ config['columnType'] }}', [{{ + Migration.stringifyList(config['options'], {'indent': 3}, wantedOptions) | raw + }}]); +{% endfor %} +{% for column, config in columns['indexes'] %} + $table->{{ indexMethod }}([{{ + Migration.stringifyList(config['columns'], {'indent': 3}) | raw }} + ], [{{ + Migration.stringifyList(config['options'], {'indent': 3}) | raw + }}]); +{% endfor %} +{% if tableMethod == 'create' and columns['primaryKey'] is not empty %} + $table->addPrimaryKey([{{ + Migration.stringifyList(columns['primaryKey'], {'indent': 3}) | raw + }}]); +{% endif %} +{% endif %} +{% endif %} + $table->{{ tableMethod }}(){% if tableMethod == 'drop' %}->save(){% endif %}; +{% endfor %} + } +}; \ No newline at end of file diff --git a/tests/TestCase/Command/BakeMigrationCommandTest.php b/tests/TestCase/Command/BakeMigrationCommandTest.php index 1cd994f0..b9e6c74e 100644 --- a/tests/TestCase/Command/BakeMigrationCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationCommandTest.php @@ -49,6 +49,14 @@ public function tearDown(): void } } + // Also clean up readable format files + $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '????_??_??_??????_*Users.php'); + if ($files) { + foreach ($files as $file) { + unlink($file); + } + } + $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_PrefixNew.php'); if ($files) { foreach ($files as $file) { @@ -384,6 +392,33 @@ public function testActionWithoutValidPrefix() $this->assertErrorContains('When applying fields the migration name should start with one of the following prefixes: `Create`, `Drop`, `Add`, `Remove`, `Alter`.'); } + /** + * Test creating migrations with anonymous style + * + * @return void + */ + public function testCreateAnonymousStyle() + { + $this->exec('bake migration CreateUsers name:string --style=anonymous --connection test'); + + $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '????_??_??_??????_CreateUsers.php'); + $this->assertCount(1, $files); + + $filePath = current($files); + $fileName = basename($filePath); + + // Check the file name format + $this->assertMatchesRegularExpression('/^\d{4}_\d{2}_\d{2}_\d{6}_CreateUsers\.php$/', $fileName); + + $this->assertExitCode(BaseCommand::CODE_SUCCESS); + $result = file_get_contents($filePath); + + // Check that it returns an anonymous class directly + $this->assertStringContainsString('return new class extends BaseMigration', $result); + $this->assertStringNotContainsString('class CreateUsers extends', $result); + $this->assertStringNotContainsString('function (int $version)', $result); + } + public function testBakeMigrationWithoutBake() { // Make sure to unload the Bake plugin diff --git a/tests/TestCase/Migration/ManagerTest.php b/tests/TestCase/Migration/ManagerTest.php index 42d806c5..731aa02a 100644 --- a/tests/TestCase/Migration/ManagerTest.php +++ b/tests/TestCase/Migration/ManagerTest.php @@ -635,6 +635,26 @@ public function testGetMigrationsWithInvalidMigrationClassName() $manager->getMigrations(); } + public function testGetMigrationsWithAnonymousClass() + { + $config = new Config(['paths' => ['migrations' => ROOT . '/config/AnonymousMigrations']]); + $manager = new Manager($config, $this->io); + + $migrations = $manager->getMigrations(); + + // Should have one migration + $this->assertCount(1, $migrations); + + // Get the migration + $migration = reset($migrations); + + // Check that it's a valid migration object + $this->assertInstanceOf('\Migrations\MigrationInterface', $migration); + + // Check the version was set correctly (2024_12_08_150000 => 20241208150000) + $this->assertEquals(20241208150000, $migration->getVersion()); + } + public function testGettingAValidEnvironment() { $this->assertInstanceOf( diff --git a/tests/TestCase/Util/UtilTest.php b/tests/TestCase/Util/UtilTest.php index 31ef22b3..c017c764 100644 --- a/tests/TestCase/Util/UtilTest.php +++ b/tests/TestCase/Util/UtilTest.php @@ -57,6 +57,13 @@ public function testGetVersionFromFileName(): void $this->assertSame(20221130101652, Util::getVersionFromFileName('20221130101652_test.php')); } + public function testGetVersionFromReadableFileName(): void + { + // Test readable format: 2024_12_08_120000_CreateUsersTable.php + $this->assertSame(20241208120000, Util::getVersionFromFileName('2024_12_08_120000_CreateUsersTable.php')); + $this->assertSame(20231225235959, Util::getVersionFromFileName('2023_12_25_235959_AddFieldToProducts.php')); + } + public function testGetVersionFromFileNameErrorNoVersion(): void { $this->expectException(RuntimeException::class); @@ -102,6 +109,14 @@ public function testMapFileNameToClassName(string $fileName, string $className) $this->assertEquals($className, Util::mapFileNameToClassName($fileName)); } + public function testMapReadableFileNameToClassName(): void + { + // Test readable format: 2024_12_08_120000_CreateUsersTable.php + $this->assertEquals('CreateUsersTable', Util::mapFileNameToClassName('2024_12_08_120000_CreateUsersTable.php')); + $this->assertEquals('AddFieldToProducts', Util::mapFileNameToClassName('2023_12_25_235959_AddFieldToProducts.php')); + $this->assertEquals('DropOrdersTable', Util::mapFileNameToClassName('2024_01_01_000000_DropOrdersTable.php')); + } + public function testGlobPath() { $files = Util::glob(__DIR__ . '/_files/migrations/empty.txt'); @@ -143,4 +158,22 @@ public function testGetFiles() $this->assertEquals('not_a_migration.php', basename($files[2])); $this->assertEquals('foobar.php', basename($files[3])); } + + public function testIsValidMigrationFileName(): void + { + // Traditional format + $this->assertTrue(Util::isValidMigrationFileName('20221130101652_create_users_table.php')); + $this->assertTrue(Util::isValidMigrationFileName('20120111235330_test_migration.php')); + + // No name format + $this->assertTrue(Util::isValidMigrationFileName('20221130101652.php')); + + // Readable format + $this->assertTrue(Util::isValidMigrationFileName('2024_12_08_120000_CreateUsersTable.php')); + $this->assertTrue(Util::isValidMigrationFileName('2023_12_25_235959_AddFieldToProducts.php')); + + // Invalid formats + $this->assertFalse(Util::isValidMigrationFileName('not_a_migration.php')); + $this->assertFalse(Util::isValidMigrationFileName('2024_12_08_120000_camelCaseShouldStartWithCapital.php')); + } } diff --git a/tests/test_app/config/AnonymousMigrations/2024_12_08_150000_CreateTestAnonymousTable.php b/tests/test_app/config/AnonymousMigrations/2024_12_08_150000_CreateTestAnonymousTable.php new file mode 100644 index 00000000..3f139819 --- /dev/null +++ b/tests/test_app/config/AnonymousMigrations/2024_12_08_150000_CreateTestAnonymousTable.php @@ -0,0 +1,28 @@ +table('test_anonymous'); + $table->addColumn('name', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => false, + ]); + $table->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => false, + ]); + $table->create(); + } +}; \ No newline at end of file