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