Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/BaseMigration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/**
Expand Down
34 changes: 34 additions & 0 deletions src/Command/BakeMigrationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down Expand Up @@ -196,13 +201,42 @@ public function getOptionParser(): ConsoleOptionParser
Create a migration that adds (<warning>name VARCHAR(128)</warning> and a <warning>UNIQUE<.warning index)
to the <warning>projects</warning> table.

<info>Migration Styles</info>

You can generate migrations in different styles:

<warning>bin/cake bake migration --style=anonymous CreatePosts</warning>
Creates an anonymous class migration with readable file naming (2024_12_08_120000_CreatePosts.php)

<warning>bin/cake bake migration --style=traditional CreatePosts</warning>
Creates a traditional class-based migration (20241208120000_create_posts.php)

You can set the default style in your configuration:
<warning>Configure::write('Migrations.style', 'anonymous');</warning>

TEXT;

$parser->setDescription($text);

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
*
Expand Down
29 changes: 29 additions & 0 deletions src/Command/BakeSimpleMigrationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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';

Expand Down
51 changes: 42 additions & 9 deletions src/Migration/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 <info>$filePath</info>.");
$migration = $migrationInstance;
$migration->setVersion($version);
} elseif (class_exists($class)) {
// Fall back to traditional class-based migration
$io->verbose("Constructing <info>$class</info>.");
$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 <info>$class</info>.");
$migration = new $class($version);
/** @var \Migrations\MigrationInterface $migration */
$config = $this->getConfig();
$migration->setConfig($config);
Expand Down Expand Up @@ -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(
Expand Down
29 changes: 27 additions & 2 deletions src/Util/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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)) {
Expand All @@ -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);
}

/**
Expand Down
75 changes: 75 additions & 0 deletions templates/bake/config/skeleton-anonymous.twig
Original file line number Diff line number Diff line change
@@ -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) %}
<?php
declare(strict_types=1);

use Migrations\BaseMigration;

return new class extends BaseMigration
{
{% if tableMethod == 'create' and columns['primaryKey'] %}
public bool $autoId = false;

{% endif %}
/**
* Change Method.
*
* More information on this method is available here:
* https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method
* @return void
*/
public function change(): void
{
{% for table in tables %}
$table = $this->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 %}
}
};
35 changes: 35 additions & 0 deletions tests/TestCase/Command/BakeMigrationCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading