From 86ae9c222bd7ce01a80c8354320782f77475769c Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Jan 2026 21:57:35 +0100 Subject: [PATCH 1/7] feat(database): add alter migration stubs and fix name placeholder --- .../src/Stubs/ObjectAlterMigrationStub.php | 27 +++++++++++++++++++ .../src/Stubs/ObjectMigrationStub.php | 2 +- .../src/Stubs/UpAlterMigrationStub.php | 21 +++++++++++++++ .../database/src/Stubs/UpMigrationStub.php | 2 +- .../src/Stubs/migration.alter.stub.sql | 1 + 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 packages/database/src/Stubs/ObjectAlterMigrationStub.php create mode 100644 packages/database/src/Stubs/UpAlterMigrationStub.php create mode 100644 packages/database/src/Stubs/migration.alter.stub.sql diff --git a/packages/database/src/Stubs/ObjectAlterMigrationStub.php b/packages/database/src/Stubs/ObjectAlterMigrationStub.php new file mode 100644 index 000000000..0f574c10e --- /dev/null +++ b/packages/database/src/Stubs/ObjectAlterMigrationStub.php @@ -0,0 +1,27 @@ + Date: Thu, 29 Jan 2026 21:58:16 +0100 Subject: [PATCH 2/7] refactor(database): reorder MigrationType enum cases --- packages/database/src/Enums/MigrationType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/src/Enums/MigrationType.php b/packages/database/src/Enums/MigrationType.php index 230a0710f..7ee655b67 100644 --- a/packages/database/src/Enums/MigrationType.php +++ b/packages/database/src/Enums/MigrationType.php @@ -9,7 +9,7 @@ */ enum MigrationType: string { - case RAW = 'raw'; case OBJECT = 'class'; + case RAW = 'raw'; case UP = 'up'; } From d06f69c449671e9ebec84f7f844f83f04db5fecf Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Jan 2026 23:57:08 +0100 Subject: [PATCH 3/7] refactor(database): rename to MigrationPrefixStrategy --- packages/database/src/Config/DatabaseConfig.php | 4 ++-- packages/database/src/Config/MysqlConfig.php | 8 ++++---- packages/database/src/Config/PostgresConfig.php | 8 ++++---- packages/database/src/Config/SQLiteConfig.php | 8 ++++---- packages/database/src/Migrations/DatePrefixStrategy.php | 2 +- ...tionNamingStrategy.php => MigrationPrefixStrategy.php} | 2 +- packages/database/src/Migrations/Uuidv7PrefixStrategy.php | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) rename packages/database/src/Migrations/{MigrationNamingStrategy.php => MigrationPrefixStrategy.php} (90%) diff --git a/packages/database/src/Config/DatabaseConfig.php b/packages/database/src/Config/DatabaseConfig.php index 581a5b878..ffab07f89 100644 --- a/packages/database/src/Config/DatabaseConfig.php +++ b/packages/database/src/Config/DatabaseConfig.php @@ -5,7 +5,7 @@ namespace Tempest\Database\Config; use Tempest\Container\HasTag; -use Tempest\Database\Migrations\MigrationNamingStrategy; +use Tempest\Database\Migrations\MigrationPrefixStrategy; use Tempest\Database\Tables\NamingStrategy; interface DatabaseConfig extends HasTag @@ -27,7 +27,7 @@ interface DatabaseConfig extends HasTag /** * The naming strategy for migration file prefixes. */ - public MigrationNamingStrategy $migrationNamingStrategy { + public MigrationPrefixStrategy $migrationPrefixStrategy { get; } diff --git a/packages/database/src/Config/MysqlConfig.php b/packages/database/src/Config/MysqlConfig.php index e146df034..dec706018 100644 --- a/packages/database/src/Config/MysqlConfig.php +++ b/packages/database/src/Config/MysqlConfig.php @@ -8,7 +8,7 @@ use Pdo\Mysql; use SensitiveParameter; use Tempest\Database\Migrations\DatePrefixStrategy; -use Tempest\Database\Migrations\MigrationNamingStrategy; +use Tempest\Database\Migrations\MigrationPrefixStrategy; use Tempest\Database\Tables\NamingStrategy; use Tempest\Database\Tables\PluralizedSnakeCaseStrategy; use UnitEnum; @@ -32,7 +32,7 @@ final class MysqlConfig implements DatabaseConfig get => $this->persistent; } - public MigrationNamingStrategy $migrationNamingStrategy { + public MigrationPrefixStrategy $migrationPrefixStrategy { get => $this->migrationNaming; } @@ -76,7 +76,7 @@ final class MysqlConfig implements DatabaseConfig * @param string|null $clientCertificate Path to the client's SSL certificate file. Used for mutual TLS authentication. * @param string|null $clientKey Path to the client's SSL private key file. Used for mutual TLS authentication. * @param NamingStrategy $namingStrategy The naming strategy for database tables and columns. - * @param MigrationNamingStrategy $migrationNaming The naming strategy for migration file prefixes. + * @param MigrationPrefixStrategy $migrationNaming The naming strategy for migration file prefixes. * @param string|UnitEnum|null $tag An optional tag to identify this database configuration. */ public function __construct( @@ -96,7 +96,7 @@ public function __construct( public ?string $clientCertificate = null, public ?string $clientKey = null, public NamingStrategy $namingStrategy = new PluralizedSnakeCaseStrategy(), - public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(), + public MigrationPrefixStrategy $migrationNaming = new DatePrefixStrategy(), public null|string|UnitEnum $tag = null, ) {} } diff --git a/packages/database/src/Config/PostgresConfig.php b/packages/database/src/Config/PostgresConfig.php index 173b1475f..b9f114eac 100644 --- a/packages/database/src/Config/PostgresConfig.php +++ b/packages/database/src/Config/PostgresConfig.php @@ -7,7 +7,7 @@ use PDO; use SensitiveParameter; use Tempest\Database\Migrations\DatePrefixStrategy; -use Tempest\Database\Migrations\MigrationNamingStrategy; +use Tempest\Database\Migrations\MigrationPrefixStrategy; use Tempest\Database\Tables\NamingStrategy; use Tempest\Database\Tables\PluralizedSnakeCaseStrategy; use UnitEnum; @@ -33,7 +33,7 @@ final class PostgresConfig implements DatabaseConfig get => $this->persistent; } - public MigrationNamingStrategy $migrationNamingStrategy { + public MigrationPrefixStrategy $migrationPrefixStrategy { get => $this->migrationNaming; } @@ -57,7 +57,7 @@ final class PostgresConfig implements DatabaseConfig * @param string $database The database name to connect to. * @param bool $persistent Whether to use persistent connections. Persistent connections are not closed at the end of the script and are cached for reuse when another script requests a connection using the same credentials. * @param NamingStrategy $namingStrategy The naming strategy for database tables and columns. - * @param MigrationNamingStrategy $migrationNaming The naming strategy for migration file prefixes. + * @param MigrationPrefixStrategy $migrationNaming The naming strategy for migration file prefixes. * @param string|UnitEnum|null $tag An optional tag to identify this database configuration. */ public function __construct( @@ -73,7 +73,7 @@ public function __construct( public string $database = 'app', public bool $persistent = false, public NamingStrategy $namingStrategy = new PluralizedSnakeCaseStrategy(), - public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(), + public MigrationPrefixStrategy $migrationNaming = new DatePrefixStrategy(), public null|string|UnitEnum $tag = null, ) {} } diff --git a/packages/database/src/Config/SQLiteConfig.php b/packages/database/src/Config/SQLiteConfig.php index 6b6cd72ef..436992a81 100644 --- a/packages/database/src/Config/SQLiteConfig.php +++ b/packages/database/src/Config/SQLiteConfig.php @@ -7,7 +7,7 @@ use PDO; use SensitiveParameter; use Tempest\Database\Migrations\DatePrefixStrategy; -use Tempest\Database\Migrations\MigrationNamingStrategy; +use Tempest\Database\Migrations\MigrationPrefixStrategy; use Tempest\Database\Tables\NamingStrategy; use Tempest\Database\Tables\PluralizedSnakeCaseStrategy; use UnitEnum; @@ -37,7 +37,7 @@ final class SQLiteConfig implements DatabaseConfig get => $this->persistent; } - public MigrationNamingStrategy $migrationNamingStrategy { + public MigrationPrefixStrategy $migrationPrefixStrategy { get => $this->migrationNaming; } @@ -57,7 +57,7 @@ final class SQLiteConfig implements DatabaseConfig * @param string $path Path to the SQLite database file. Use ':memory:' for an in-memory database. * @param bool $persistent Whether to use persistent connections. Persistent connections are not closed at the end of the script and are cached for reuse when another script requests a connection using the same credentials. * @param NamingStrategy $namingStrategy The naming strategy for database tables and columns. - * @param MigrationNamingStrategy $migrationNaming The naming strategy for migration file prefixes. + * @param MigrationPrefixStrategy $migrationNaming The naming strategy for migration file prefixes. * @param string|UnitEnum|null $tag An optional tag to identify this database configuration. */ public function __construct( @@ -65,7 +65,7 @@ public function __construct( public string $path = 'localhost', public bool $persistent = false, public NamingStrategy $namingStrategy = new PluralizedSnakeCaseStrategy(), - public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(), + public MigrationPrefixStrategy $migrationNaming = new DatePrefixStrategy(), public null|string|UnitEnum $tag = null, ) {} } diff --git a/packages/database/src/Migrations/DatePrefixStrategy.php b/packages/database/src/Migrations/DatePrefixStrategy.php index b0afc7ce0..eca4baf87 100644 --- a/packages/database/src/Migrations/DatePrefixStrategy.php +++ b/packages/database/src/Migrations/DatePrefixStrategy.php @@ -7,7 +7,7 @@ /** * Generates a date-based prefix for migration names. */ -final class DatePrefixStrategy implements MigrationNamingStrategy +final class DatePrefixStrategy implements MigrationPrefixStrategy { public function generatePrefix(): string { diff --git a/packages/database/src/Migrations/MigrationNamingStrategy.php b/packages/database/src/Migrations/MigrationPrefixStrategy.php similarity index 90% rename from packages/database/src/Migrations/MigrationNamingStrategy.php rename to packages/database/src/Migrations/MigrationPrefixStrategy.php index 7bef9bb46..fe11b7024 100644 --- a/packages/database/src/Migrations/MigrationNamingStrategy.php +++ b/packages/database/src/Migrations/MigrationPrefixStrategy.php @@ -7,7 +7,7 @@ /** * Represents a strategy for naming database migrations. This is used to create sortable, unique migration identifiers. */ -interface MigrationNamingStrategy +interface MigrationPrefixStrategy { /** * Generates the prefix for a migration name. diff --git a/packages/database/src/Migrations/Uuidv7PrefixStrategy.php b/packages/database/src/Migrations/Uuidv7PrefixStrategy.php index 23e3d0252..b1a76c046 100644 --- a/packages/database/src/Migrations/Uuidv7PrefixStrategy.php +++ b/packages/database/src/Migrations/Uuidv7PrefixStrategy.php @@ -9,7 +9,7 @@ /** * Generates a UUIDv7 prefix for migration names. */ -final class Uuidv7PrefixStrategy implements MigrationNamingStrategy +final class Uuidv7PrefixStrategy implements MigrationPrefixStrategy { public function generatePrefix(): string { From de369d855220b4c705d223fd020df99279dfc534 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Jan 2026 23:57:20 +0100 Subject: [PATCH 4/7] feat(database): add useTime option to DatePrefixStrategy --- packages/database/src/Migrations/DatePrefixStrategy.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/database/src/Migrations/DatePrefixStrategy.php b/packages/database/src/Migrations/DatePrefixStrategy.php index eca4baf87..fa9ad9d9e 100644 --- a/packages/database/src/Migrations/DatePrefixStrategy.php +++ b/packages/database/src/Migrations/DatePrefixStrategy.php @@ -9,8 +9,12 @@ */ final class DatePrefixStrategy implements MigrationPrefixStrategy { + public function __construct( + private bool $useTime = false, + ) {} + public function generatePrefix(): string { - return date('Y-m-d'); + return date($this->useTime ? 'Y-m-d_His' : 'Y-m-d'); } } From 988d6850b680555c5bb48a47cf5076c30f776a52 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Jan 2026 23:57:28 +0100 Subject: [PATCH 5/7] feat(database): add TableGuesser for migration name analysis --- .../database/src/Migrations/TableGuess.php | 13 ++++ .../database/src/Migrations/TableGuesser.php | 29 +++++++ .../tests/Migrations/TableGuesserTest.php | 77 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 packages/database/src/Migrations/TableGuess.php create mode 100644 packages/database/src/Migrations/TableGuesser.php create mode 100644 packages/database/tests/Migrations/TableGuesserTest.php diff --git a/packages/database/src/Migrations/TableGuess.php b/packages/database/src/Migrations/TableGuess.php new file mode 100644 index 000000000..7c4eed52d --- /dev/null +++ b/packages/database/src/Migrations/TableGuess.php @@ -0,0 +1,13 @@ +assertNotNull($result); + $this->assertSame($expectedTable, $result->table); + $this->assertTrue($result->isCreate); + } + + public static function provide_create_patterns(): array + { + return [ + 'create_table_with_suffix' => ['create_books_table', 'books'], + 'create_without_suffix' => ['create_books', 'books'], + 'create_users_table' => ['create_users_table', 'users'], + 'create_users' => ['create_users', 'users'], + 'create_multi_word_table' => ['create_blog_posts_table', 'blog_posts'], + 'create_multi_word' => ['create_blog_posts', 'blog_posts'], + ]; + } + + #[Test] + #[DataProvider('provide_change_patterns')] + public function it_detects_change_migrations(string $migration, string $expectedTable): void + { + $result = TableGuesser::guess($migration); + + $this->assertNotNull($result); + $this->assertSame($expectedTable, $result->table); + $this->assertFalse($result->isCreate); + } + + public static function provide_change_patterns(): array + { + return [ + 'add_column_to_table' => ['add_short_summary_to_books_table', 'books'], + 'add_column_to' => ['add_short_summary_to_books', 'books'], + 'remove_column_from_table' => ['remove_name_from_users_table', 'users'], + 'remove_column_from' => ['remove_name_from_users', 'users'], + 'change_column_in_table' => ['change_email_in_accounts_table', 'accounts'], + 'change_column_in' => ['change_email_in_accounts', 'accounts'], + 'rename_to_multi_word' => ['rename_shortsum_to_blog_posts', 'blog_posts'], + ]; + } + + #[Test] + #[DataProvider('provide_unmatched_patterns')] + public function it_returns_null_for_unmatched_patterns(string $migration): void + { + $this->assertNull(TableGuesser::guess($migration)); + } + + public static function provide_unmatched_patterns(): array + { + return [ + 'plain_table_name' => ['books'], + 'plain_multi_word' => ['blog_posts'], + 'random_name' => ['fix_something'], + 'update_without_preposition' => ['update_books'], + ]; + } +} From 2f6f83cb435783c2c258b7b71fdd7f02da821e84 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Jan 2026 23:57:36 +0100 Subject: [PATCH 6/7] feat(database): add interactive flow to make:migration --- .../src/Commands/MakeMigrationCommand.php | 183 +++++-- .../Commands/MakeMigrationCommandTest.php | 518 ++++++++++++++++-- 2 files changed, 604 insertions(+), 97 deletions(-) diff --git a/packages/database/src/Commands/MakeMigrationCommand.php b/packages/database/src/Commands/MakeMigrationCommand.php index 4edf9fc41..7c424b10d 100644 --- a/packages/database/src/Commands/MakeMigrationCommand.php +++ b/packages/database/src/Commands/MakeMigrationCommand.php @@ -4,20 +4,20 @@ namespace Tempest\Database\Commands; -use InvalidArgumentException; use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; use Tempest\Core\PublishesFiles; use Tempest\Database\Config\DatabaseConfig; use Tempest\Database\Enums\MigrationType; +use Tempest\Database\Migrations\TableGuesser; +use Tempest\Database\Stubs\ObjectAlterMigrationStub; use Tempest\Database\Stubs\ObjectMigrationStub; +use Tempest\Database\Stubs\UpAlterMigrationStub; use Tempest\Database\Stubs\UpMigrationStub; use Tempest\Discovery\SkipDiscovery; use Tempest\Generation\Php\ClassManipulator; use Tempest\Generation\Php\DataObjects\StubFile; use Tempest\Generation\Php\Exceptions\FileGenerationFailedException; -use Tempest\Generation\Php\Exceptions\FileGenerationWasAborted; -use Tempest\Support\Str; use Tempest\Validation\Rules\EndsWith; use Tempest\Validation\Rules\IsNotEmptyString; @@ -28,7 +28,7 @@ final class MakeMigrationCommand use PublishesFiles; public function __construct( - private DatabaseConfig $databaseConfig, + private readonly DatabaseConfig $databaseConfig, ) {} #[ConsoleCommand( @@ -37,95 +37,158 @@ public function __construct( aliases: ['migration:make', 'migration:create', 'create:migration'], )] public function __invoke( - #[ConsoleArgument(description: 'The file name of the migration')] - string $fileName, + #[ConsoleArgument(description: 'The name of the migration')] + ?string $name = null, #[ConsoleArgument(name: 'type', description: 'The type of the migration to create')] - MigrationType $migrationType = MigrationType::OBJECT, + ?MigrationType $migrationType = null, + #[ConsoleArgument(description: 'The table name', aliases: ['-t'])] + ?string $table = null, + #[ConsoleArgument(description: 'Create an alter migration', aliases: ['-a'])] + ?bool $alter = null, + #[ConsoleArgument(description: 'Skip interactive prompts, use defaults', aliases: ['-y'])] + bool $yes = false, ): void { try { - $stub = match ($migrationType) { - MigrationType::RAW => StubFile::from(dirname(__DIR__) . '/Stubs/migration.stub.sql'), - MigrationType::OBJECT => StubFile::from(ObjectMigrationStub::class), - MigrationType::UP => StubFile::from(UpMigrationStub::class), - }; + if ($yes && $name === null) { + $this->error('The migration name is required when using -y.'); + + return; + } + + $migrationType ??= $yes + ? MigrationType::OBJECT + : $this->ask( + question: 'Choose the migration type', + options: MigrationType::class, + default: MigrationType::OBJECT, + ); + + $name ??= $this->ask( + question: 'Enter the migration name', + validation: [new IsNotEmptyString()], + ); + + $snakeName = str($name)->afterLast(['\\', '/'])->snake()->toString(); + $guess = TableGuesser::guess($snakeName); + $guessedTable = $this->resolveTableName($guess->table ?? $snakeName); + + $table ??= $yes + ? $guessedTable + : $this->ask( + question: 'Enter the table name', + default: $guessedTable, + validation: [new IsNotEmptyString()], + ); + + $alter ??= $yes + ? $guess !== null && ! $guess->isCreate + : $this->confirm( + question: 'Is this an alteration?', + default: $guess !== null && ! $guess->isCreate, + ); + + $table = $this->resolveTableName($table); + [$migrationName, $className] = $this->resolveNames($name, $alter); + $stub = $this->resolveStub($migrationType, $alter); $targetPath = match ($migrationType) { - MigrationType::RAW => $this->generateRawFile($fileName, $stub), - default => $this->generateClassFile($fileName, $stub), + MigrationType::RAW => $this->generateRawFile($stub, $migrationName, $table, skipPrompts: $yes), + default => $this->generateClassFile($name, $stub, $className, $migrationName, $table, skipPrompts: $yes), }; $this->success(sprintf('Migration file successfully created at "%s".', $targetPath)); - } catch (FileGenerationWasAborted|FileGenerationFailedException|InvalidArgumentException $e) { + } catch (FileGenerationFailedException $e) { $this->error($e->getMessage()); } } - private function generateRawFile(string $filename, StubFile $stubFile): string + private function resolveNames(string $name, bool $alter): array { - $tableName = str($filename) - ->snake() - ->stripStart('create') - ->stripEnd('table') - ->stripStart('_') - ->stripEnd('_') - ->toString(); + $baseName = str($name)->afterLast(['\\', '/']); + $className = $baseName->pascal()->toString(); - $filename = str($filename) - ->start('create_') - ->finish('_table') - ->toString(); + if ($alter) { + return [$baseName->snake()->toString(), $className]; + } - $suggestedPath = Str\replace( - string: $this->getSuggestedPath('Dummy'), - search: ['Dummy', '.php'], - replace: [$this->databaseConfig->migrationNamingStrategy->generatePrefix() . '_' . $filename, '.sql'], - ); + $entityName = str($className); + + if ($entityName->startsWith('Create')) { + $entityName = $entityName->afterFirst('Create'); + } + + if ($entityName->endsWith('Table')) { + $entityName = $entityName->beforeLast('Table'); + } + + $className = 'Create' . $entityName->pluralizeLastWord()->toString() . 'Table'; + + return [str($className)->snake()->toString(), $className]; + } + + private function resolveTableName(string $name): string + { + $entityName = str($name)->singularizeLastWord()->pascal()->toString(); - $targetPath = $this->promptTargetPath($suggestedPath, rules: [ - new IsNotEmptyString(), - new EndsWith('.sql'), - ]); + return $this->databaseConfig->namingStrategy->getName($entityName); + } + + private function resolveStub(MigrationType $type, bool $alter): StubFile + { + $source = match ($type) { + MigrationType::RAW => $alter + ? dirname(__DIR__) . '/Stubs/migration.alter.stub.sql' + : dirname(__DIR__) . '/Stubs/migration.stub.sql', + MigrationType::OBJECT => $alter ? ObjectAlterMigrationStub::class : ObjectMigrationStub::class, + MigrationType::UP => $alter ? UpAlterMigrationStub::class : UpMigrationStub::class, + }; + + return StubFile::from($source); + } + + private function generateRawFile(StubFile $stub, string $migrationName, string $tableName, bool $skipPrompts = false): string + { + $prefix = $this->databaseConfig->migrationPrefixStrategy->generatePrefix(); + $suggestedPath = str($this->getSuggestedPath('Dummy')) + ->replace(['Dummy', '.php'], ["{$prefix}_{$migrationName}", '.sql']) + ->toString(); + + $targetPath = $skipPrompts + ? $suggestedPath + : $this->promptTargetPath($suggestedPath, rules: [ + new IsNotEmptyString(), + new EndsWith('.sql'), + ]); $this->stubFileGenerator->generateRawFile( - stubFile: $stubFile, + stubFile: $stub, targetPath: $targetPath, - shouldOverride: $this->askForOverride($targetPath), - replacements: [ - 'DummyTableName' => $tableName, - ], + shouldOverride: $skipPrompts || $this->askForOverride($targetPath), + replacements: ['DummyTableName' => $tableName], ); return $targetPath; } - private function generateClassFile(string $filename, StubFile $stubFile): string + private function generateClassFile(string $name, StubFile $stub, string $className, string $migrationName, string $tableName, bool $skipPrompts = false): string { - $tableName = str($filename) - ->snake() - ->stripStart('create') - ->stripEnd('table') - ->stripStart('_') - ->stripEnd('_') - ->toString(); - - $filename = str($filename) - ->afterLast(['\\', '/']) - ->start('Create') - ->finish('Table') + $classFileName = str($className) ->when( - condition: Str\contains($filename, ['\\', '/']), - callback: fn ($path) => $path->prepend(Str\before_last($filename, ['\\', '/']), '/'), + condition: str($name)->contains(['\\', '/']), + callback: fn ($path) => $path->prepend(str($name)->beforeLast(['\\', '/'])->toString(), '/'), ) ->toString(); - $targetPath = $this->promptTargetPath($this->getSuggestedPath($filename)); + $suggestedPath = $this->getSuggestedPath($classFileName); + $targetPath = $skipPrompts ? $suggestedPath : $this->promptTargetPath($suggestedPath); $this->stubFileGenerator->generateClassFile( - stubFile: $stubFile, + stubFile: $stub, targetPath: $targetPath, - shouldOverride: $this->askForOverride($targetPath), + shouldOverride: $skipPrompts || $this->askForOverride($targetPath), replacements: [ - 'dummy-date' => $this->databaseConfig->migrationNamingStrategy->generatePrefix(), + 'dummy-date' => $this->databaseConfig->migrationPrefixStrategy->generatePrefix(), + 'dummy-migration-name' => $migrationName, 'dummy-table-name' => $tableName, ], manipulations: [ diff --git a/tests/Integration/Database/Commands/MakeMigrationCommandTest.php b/tests/Integration/Database/Commands/MakeMigrationCommandTest.php index 846727422..2b6ae5c78 100644 --- a/tests/Integration/Database/Commands/MakeMigrationCommandTest.php +++ b/tests/Integration/Database/Commands/MakeMigrationCommandTest.php @@ -4,11 +4,12 @@ namespace Tests\Tempest\Integration\Database\Commands; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\Attributes\PreCondition; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; +use Tempest\Database\Config\SQLiteConfig; +use Tempest\Database\Tables\PascalCaseStrategy; use Tempest\Support\Namespace\Psr4Namespace; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -33,57 +34,138 @@ protected function cleanup(): void } #[Test] - #[DataProvider('command_input_provider')] - public function make_command(string $commandArgs, string $expectedPath, string $expectedNamespace, string $expectContains): void + public function object_create_migration_implements_both_interfaces(): void { $this->console - ->call("make:migration {$commandArgs}") + ->call('make:migration Books class --table=books --no-alter') ->submit(); $this->installer - ->assertFileExists($expectedPath) - ->assertFileNotContains($expectedPath, 'SkipDiscovery') - ->assertFileContains($expectedPath, 'namespace ' . $expectedNamespace . ';') - ->assertFileContains($expectedPath, $expectContains); + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileContains('App/CreateBooksTable.php', 'MigratesUp, MigratesDown') + ->assertFileNotContains('App/CreateBooksTable.php', 'SkipDiscovery'); } - public static function command_input_provider(): array + #[Test] + public function object_create_migration_has_create_and_drop_statements(): void + { + $this->console + ->call('make:migration Books class --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileContains('App/CreateBooksTable.php', 'CreateTableStatement') + ->assertFileContains('App/CreateBooksTable.php', 'DropTableStatement') + ->assertFileContains('App/CreateBooksTable.php', "'books'"); + } + + #[Test] + public function object_create_migration_includes_timestamps(): void + { + $this->console + ->call('make:migration Books class --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileContains('App/CreateBooksTable.php', 'created_at') + ->assertFileContains('App/CreateBooksTable.php', 'updated_at'); + } + + #[Test] + public function object_create_migration_has_date_prefixed_name_property(): void + { + $this->console + ->call('make:migration Books class --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileContains('App/CreateBooksTable.php', sprintf("'%s_create_books_table'", date('Y-m-d'))); + } + + #[Test] + public function object_create_migration_sets_correct_namespace(): void + { + $this->console + ->call('make:migration Books class --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileContains('App/CreateBooksTable.php', 'namespace App;'); + } + + #[Test] + public function object_alter_migration_implements_both_interfaces(): void + { + $this->console + ->call('make:migration AddShortSummaryToBooks class --table=books --alter') + ->submit(); + + $this->installer + ->assertFileExists('App/AddShortSummaryToBooks.php') + ->assertFileContains('App/AddShortSummaryToBooks.php', 'MigratesUp, MigratesDown'); + } + + #[Test] + public function object_alter_migration_uses_alter_table_statement(): void + { + $this->console + ->call('make:migration AddShortSummaryToBooks class --table=books --alter') + ->submit(); + + $this->installer + ->assertFileContains('App/AddShortSummaryToBooks.php', 'AlterTableStatement') + ->assertFileContains('App/AddShortSummaryToBooks.php', "'books'") + ->assertFileNotContains('App/AddShortSummaryToBooks.php', 'CreateTableStatement') + ->assertFileNotContains('App/AddShortSummaryToBooks.php', 'DropTableStatement'); + } + + #[Test] + public function up_create_migration_only_implements_migrates_up(): void + { + $this->console + ->call('make:migration CreateBooksTable up --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileContains('App/CreateBooksTable.php', 'MigratesUp') + ->assertFileNotContains('App/CreateBooksTable.php', 'MigratesDown'); + } + + #[Test] + public function up_create_migration_has_create_table_statement(): void + { + $this->console + ->call('make:migration CreateBooksTable up --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileContains('App/CreateBooksTable.php', 'CreateTableStatement') + ->assertFileContains('App/CreateBooksTable.php', "'books'"); + } + + #[Test] + public function up_alter_migration_has_alter_statement_without_down(): void { - return [ - 'make_with_defaults' => [ - 'commandArgs' => 'Books', - 'expectedPath' => 'App/CreateBooksTable.php', - 'expectedNamespace' => 'App', - 'expectContains' => 'MigratesUp, MigratesDown', - ], - 'make_up' => [ - 'commandArgs' => 'CreateBooksTable up', - 'expectedPath' => 'App/CreateBooksTable.php', - 'expectedNamespace' => 'App', - 'expectContains' => 'MigratesUp', - ], - 'make_with_other_namespace' => [ - 'commandArgs' => 'Books\\CreateBooksTable', - 'expectedPath' => 'App/Books/CreateBooksTable.php', - 'expectedNamespace' => 'App\\Books', - 'expectContains' => 'MigratesUp, MigratesDown', - ], - 'make_with_input_path' => [ - 'commandArgs' => 'Books/CreateBooksTable', - 'expectedPath' => 'App/Books/CreateBooksTable.php', - 'expectedNamespace' => 'App\\Books', - 'expectContains' => 'MigratesUp, MigratesDown', - ], - ]; + $this->console + ->call('make:migration SomeMigration up --table=books --alter') + ->submit(); + + $this->installer + ->assertFileExists('App/SomeMigration.php') + ->assertFileContains('App/SomeMigration.php', 'MigratesUp') + ->assertFileContains('App/SomeMigration.php', 'AlterTableStatement') + ->assertFileContains('App/SomeMigration.php', "'books'") + ->assertFileNotContains('App/SomeMigration.php', 'MigratesDown'); } #[Test] #[TestWith(['create_books_table', 'create_books_table'])] #[TestWith(['books', 'create_books_table'])] - public function raw_migration(string $filename, string $expectedFilename): void + public function raw_create_migration(string $filename, string $expectedFilename): void { $this->console - ->call("make:migration {$filename} raw") + ->call("make:migration {$filename} raw --table=books --no-alter") ->submit(); $filePath = sprintf('App/%s_%s.sql', date('Y-m-d'), $expectedFilename); @@ -92,4 +174,366 @@ public function raw_migration(string $filename, string $expectedFilename): void ->assertFileExists($filePath) ->assertFileContains($filePath, 'CREATE TABLE books'); } + + #[Test] + public function raw_alter_migration_produces_alter_sql(): void + { + $this->console + ->call('make:migration add_short_summary_to_books raw --table=books --alter') + ->submit(); + + $filePath = sprintf('App/%s_add_short_summary_to_books.sql', date('Y-m-d')); + + $this->installer + ->assertFileExists($filePath) + ->assertFileContains($filePath, 'ALTER TABLE books') + ->assertFileNotContains($filePath, 'CREATE TABLE'); + } + + #[Test] + public function simple_name_gets_create_table_wrapping(): void + { + $this->console + ->call('make:migration Book class --table=book --no-alter') + ->submit(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileContains('App/CreateBooksTable.php', 'CreateTableStatement') + ->assertFileContains('App/CreateBooksTable.php', "'books'"); + } + + #[Test] + public function plural_name_gets_create_table_wrapping(): void + { + $this->console + ->call('make:migration Books class --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php'); + } + + #[Test] + public function table_name_is_pluralized_from_singular_input(): void + { + $this->console + ->call('make:migration CreateBookTable up --table=book --no-alter') + ->submit(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileContains('App/CreateBooksTable.php', "'books'"); + } + + #[Test] + public function alter_migration_name_is_not_wrapped(): void + { + $this->console + ->call('make:migration SomeMigration up --table=books --alter') + ->submit(); + + $this->installer + ->assertFileExists('App/SomeMigration.php'); + } + + #[Test] + public function already_complete_name_is_not_double_wrapped(): void + { + $this->console + ->call('make:migration CreateBooksTable class --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileNotContains('App/CreateBooksTable.php', 'CreateCreateBooksTable'); + } + + #[Test] + public function backslash_namespace_creates_subdirectory(): void + { + $this->console + ->call('make:migration Books\\CreateBooksTable class --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileExists('App/Books/CreateBooksTable.php') + ->assertFileContains('App/Books/CreateBooksTable.php', 'namespace App\\Books;'); + } + + #[Test] + public function forward_slash_path_creates_subdirectory(): void + { + $this->console + ->call('make:migration Books/CreateBooksTable class --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileExists('App/Books/CreateBooksTable.php') + ->assertFileContains('App/Books/CreateBooksTable.php', 'namespace App\\Books;'); + } + + #[Test] + public function deeply_nested_namespace(): void + { + $this->console + ->call('make:migration Database/Migrations/Books/CreateBooksTable class --table=books --no-alter') + ->submit(); + + $this->installer + ->assertFileExists('App/Database/Migrations/Books/CreateBooksTable.php') + ->assertFileContains('App/Database/Migrations/Books/CreateBooksTable.php', 'namespace App\\Database\\Migrations\\Books;'); + } + + #[Test] + public function yes_flag_defaults_to_object_type(): void + { + $this->console + ->call('make:migration Books -y') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileContains('App/CreateBooksTable.php', 'MigratesUp, MigratesDown') + ->assertFileContains('App/CreateBooksTable.php', "'books'"); + } + + #[Test] + public function yes_flag_with_explicit_table(): void + { + $this->console + ->call('make:migration Books -y --table=novels') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileContains('App/CreateBooksTable.php', "'novels'"); + } + + #[Test] + public function yes_flag_with_explicit_up_type(): void + { + $this->console + ->call('make:migration CreateBooksTable up -y') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileContains('App/CreateBooksTable.php', 'MigratesUp') + ->assertFileNotContains('App/CreateBooksTable.php', 'MigratesDown'); + } + + #[Test] + public function yes_flag_guesses_create_from_name(): void + { + $this->console + ->call('make:migration CreateUsersTable -y') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/CreateUsersTable.php') + ->assertFileContains('App/CreateUsersTable.php', 'CreateTableStatement') + ->assertFileContains('App/CreateUsersTable.php', "'users'"); + } + + #[Test] + public function yes_flag_guesses_alter_from_to_preposition(): void + { + $this->console + ->call('make:migration AddSummaryToBooks -y') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/AddSummaryToBooks.php') + ->assertFileContains('App/AddSummaryToBooks.php', 'AlterTableStatement') + ->assertFileContains('App/AddSummaryToBooks.php', "'books'"); + } + + #[Test] + public function yes_flag_guesses_alter_from_from_preposition(): void + { + $this->console + ->call('make:migration RemoveColumnFromBooks -y') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/RemoveColumnFromBooks.php') + ->assertFileContains('App/RemoveColumnFromBooks.php', 'AlterTableStatement') + ->assertFileContains('App/RemoveColumnFromBooks.php', "'books'"); + } + + #[Test] + public function yes_flag_guesses_alter_from_in_preposition(): void + { + $this->console + ->call('make:migration UpdateStatusInOrders -y') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/UpdateStatusInOrders.php') + ->assertFileContains('App/UpdateStatusInOrders.php', 'AlterTableStatement') + ->assertFileContains('App/UpdateStatusInOrders.php', "'orders'"); + } + + #[Test] + public function yes_flag_without_name_shows_error(): void + { + $this->console + ->call('make:migration -y') + ->assertContains('required'); + } + + #[Test] + public function yes_flag_with_raw_type(): void + { + $this->console + ->call('make:migration create_books raw -y') + ->assertSuccess(); + + $filePath = sprintf('App/%s_create_books_table.sql', date('Y-m-d')); + + $this->installer + ->assertFileExists($filePath) + ->assertFileContains($filePath, 'CREATE TABLE books'); + } + + #[Test] + public function yes_flag_with_raw_alter_type(): void + { + $this->console + ->call('make:migration add_column_to_books raw -y') + ->assertSuccess(); + + $filePath = sprintf('App/%s_add_column_to_books.sql', date('Y-m-d')); + + $this->installer + ->assertFileExists($filePath) + ->assertFileContains($filePath, 'ALTER TABLE books'); + } + + #[Test] + public function yes_flag_with_unguessable_name_defaults_to_create(): void + { + $this->console + ->call('make:migration Foo -y') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/CreateFoosTable.php') + ->assertFileContains('App/CreateFoosTable.php', 'CreateTableStatement'); + } + + #[Test] + public function yes_flag_explicit_alter_overrides_create_guess(): void + { + $this->console + ->call('make:migration CreateBooks -y --alter') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/CreateBooks.php') + ->assertFileContains('App/CreateBooks.php', 'AlterTableStatement') + ->assertFileNotContains('App/CreateBooks.php', 'CreateTableStatement'); + } + + #[Test] + public function yes_flag_explicit_no_alter_overrides_alter_guess(): void + { + $this->console + ->call('make:migration AddSummaryToBooks -y --no-alter') + ->assertSuccess(); + + $this->installer + ->assertFileContains('App/CreateAddSummaryToBooksTable.php', 'CreateTableStatement') + ->assertFileNotContains('App/CreateAddSummaryToBooksTable.php', 'AlterTableStatement'); + } + + #[Test] + public function create_migration_respects_pascal_case_strategy(): void + { + $this->container->config(new SQLiteConfig( + namingStrategy: new PascalCaseStrategy(), + )); + + $this->console + ->call('make:migration CreateBookTable up --table=book --no-alter') + ->submit(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileContains('App/CreateBooksTable.php', "'Book'"); + } + + #[Test] + public function yes_flag_respects_pascal_case_strategy(): void + { + $this->container->config(new SQLiteConfig( + namingStrategy: new PascalCaseStrategy(), + )); + + $this->console + ->call('make:migration Books -y') + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php') + ->assertFileContains('App/CreateBooksTable.php', "'Book'"); + } + + #[Test] + public function alter_migration_respects_pascal_case_strategy(): void + { + $this->container->config(new SQLiteConfig( + namingStrategy: new PascalCaseStrategy(), + )); + + $this->console + ->call('make:migration AddSummaryToBooks up --table=books --alter') + ->submit(); + + $this->installer + ->assertFileExists('App/AddSummaryToBooks.php') + ->assertFileContains('App/AddSummaryToBooks.php', "'Book'"); + } + + #[Test] + public function raw_migration_respects_pascal_case_strategy(): void + { + $this->container->config(new SQLiteConfig( + namingStrategy: new PascalCaseStrategy(), + )); + + $this->console + ->call('make:migration create_books raw --table=book --no-alter') + ->submit(); + + $filePath = sprintf('App/%s_create_books_table.sql', date('Y-m-d')); + + $this->installer + ->assertFileExists($filePath) + ->assertFileContains($filePath, 'CREATE TABLE Book'); + } + + #[Test] + #[TestWith(['migration:make'])] + #[TestWith(['migration:create'])] + #[TestWith(['create:migration'])] + public function command_aliases_work(string $alias): void + { + $this->console + ->call("{$alias} Books -y") + ->assertSuccess(); + + $this->installer + ->assertFileExists('App/CreateBooksTable.php'); + } + + #[Test] + public function displays_success_message_on_creation(): void + { + $this->console + ->call('make:migration Books -y') + ->assertSuccess() + ->assertContains('successfully created'); + } } From 758f08ac8b63d59eb7db8fcfa6ab171883afdb9e Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Jan 2026 00:01:32 +0100 Subject: [PATCH 7/7] docs(database): add migration prefix strategy section --- docs/1-essentials/03-database.md | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 793273d15..de2c9362c 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -342,6 +342,44 @@ return new SQLiteConfig( ::: +### Migration prefixes + +When generating a migration file via `make:migration`, Tempest prefixes the file name with a sortable identifier so that migrations run in the correct order. By default, a date-based prefix is used (e.g. `2025-06-15_create_books_table`). + +The prefix format is determined by the `migrationPrefixStrategy` property of your database configuration, which accepts any {b`Tempest\Database\Migrations\MigrationPrefixStrategy`} instance. + +Tempest ships with two built-in strategies: + +- {b`Tempest\Database\Migrations\DatePrefixStrategy`} — generates a `Y-m-d` date prefix (default). Pass `useTime: true` to include hours, minutes and seconds (`Y-m-d_His`). +- {b`Tempest\Database\Migrations\Uuidv7PrefixStrategy`} — generates a UUIDv7 prefix, which is both unique and time-ordered. + +You can also implement your own strategy: + +:::code-group + +```php app/Database/IncrementingPrefixStrategy.php +use Tempest\Database\Migrations\MigrationPrefixStrategy; + +final class IncrementingPrefixStrategy implements MigrationPrefixStrategy +{ + public function generatePrefix(): string + { + return sprintf('%06d', /* resolve the next sequence number */); + } +} +``` + +```php app/database.config.php +use Tempest\Database\Config\SQLiteConfig; + +return new SQLiteConfig( + path: __DIR__ . '/../database.sqlite', + migrationPrefixStrategy: new IncrementingPrefixStrategy(), +); +``` + +::: + ### Data transfer object properties Arbitrary objects can be stored in a `json` column when they are not part of the relational schema. Annotate the class with {b`#[Tempest\Mapper\SerializeAs]`} and provide a unique identifier to represent the object. The identifier must map to a single, distinct class.