Skip to content
Merged
38 changes: 38 additions & 0 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `migrationNamingStrategy` property of your database configuration, which accepts any {b`Tempest\Database\Migrations\MigrationNamingStrategy`} 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\MigrationNamingStrategy;

final class IncrementingPrefixStrategy implements MigrationNamingStrategy
{
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',
migrationNaming: 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.
Expand Down
181 changes: 122 additions & 59 deletions packages/database/src/Commands/MakeMigrationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -28,7 +28,7 @@ final class MakeMigrationCommand
use PublishesFiles;

public function __construct(
private DatabaseConfig $databaseConfig,
private readonly DatabaseConfig $databaseConfig,
) {}

#[ConsoleCommand(
Expand All @@ -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->migrationNamingStrategy->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-migration-name' => $migrationName,
'dummy-table-name' => $tableName,
],
manipulations: [
Expand Down
2 changes: 1 addition & 1 deletion packages/database/src/Enums/MigrationType.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/
enum MigrationType: string
{
case RAW = 'raw';
case OBJECT = 'class';
case RAW = 'raw';
case UP = 'up';
}
6 changes: 5 additions & 1 deletion packages/database/src/Migrations/DatePrefixStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@
*/
final class DatePrefixStrategy implements MigrationNamingStrategy
{
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');
}
}
13 changes: 13 additions & 0 deletions packages/database/src/Migrations/TableGuess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Migrations;

final readonly class TableGuess
{
public function __construct(
public string $table,
public bool $isCreate,
) {}
}
29 changes: 29 additions & 0 deletions packages/database/src/Migrations/TableGuesser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Migrations;

use Tempest\Support\Str;

final class TableGuesser
{
private const array PREPOSITIONS = ['_to_', '_from_', '_in_'];

public static function guess(string $migration): ?TableGuess
{
if (Str\starts_with($migration, 'create_')) {
$table = Str\strip_end(Str\after_first($migration, 'create_'), '_table');

return ! Str\is_empty($table) ? new TableGuess($table, isCreate: true) : null;
}

if (! Str\contains($migration, self::PREPOSITIONS)) {
return null;
}

$table = Str\strip_end(Str\after_last($migration, self::PREPOSITIONS), '_table');

return ! Str\is_empty($table) ? new TableGuess($table, isCreate: false) : null;
}
}
27 changes: 27 additions & 0 deletions packages/database/src/Stubs/ObjectAlterMigrationStub.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Stubs;

use Tempest\Database\MigratesDown;
use Tempest\Database\MigratesUp;
use Tempest\Database\QueryStatement;
use Tempest\Database\QueryStatements\AlterTableStatement;
use Tempest\Discovery\SkipDiscovery;

#[SkipDiscovery]
final class ObjectAlterMigrationStub implements MigratesUp, MigratesDown
{
public string $name = 'dummy-date_dummy-migration-name';

public function up(): QueryStatement
{
return new AlterTableStatement('dummy-table-name');
}

public function down(): QueryStatement
{
return new AlterTableStatement('dummy-table-name');
}
}
2 changes: 1 addition & 1 deletion packages/database/src/Stubs/ObjectMigrationStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
#[SkipDiscovery]
final class ObjectMigrationStub implements MigratesUp, MigratesDown
{
public string $name = 'dummy-date_dummy-table-name';
public string $name = 'dummy-date_dummy-migration-name';

public function up(): QueryStatement
{
Expand Down
Loading