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
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 `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.
Expand Down
183 changes: 123 additions & 60 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->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: [
Expand Down
4 changes: 2 additions & 2 deletions packages/database/src/Config/DatabaseConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,7 +27,7 @@ interface DatabaseConfig extends HasTag
/**
* The naming strategy for migration file prefixes.
*/
public MigrationNamingStrategy $migrationNamingStrategy {
public MigrationPrefixStrategy $migrationPrefixStrategy {
get;
}

Expand Down
8 changes: 4 additions & 4 deletions packages/database/src/Config/MysqlConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,7 +32,7 @@ final class MysqlConfig implements DatabaseConfig
get => $this->persistent;
}

public MigrationNamingStrategy $migrationNamingStrategy {
public MigrationPrefixStrategy $migrationPrefixStrategy {
get => $this->migrationNaming;
}

Expand Down Expand Up @@ -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(
Expand All @@ -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,
) {}
}
8 changes: 4 additions & 4 deletions packages/database/src/Config/PostgresConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,7 +33,7 @@ final class PostgresConfig implements DatabaseConfig
get => $this->persistent;
}

public MigrationNamingStrategy $migrationNamingStrategy {
public MigrationPrefixStrategy $migrationPrefixStrategy {
get => $this->migrationNaming;
}

Expand All @@ -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(
Expand All @@ -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,
) {}
}
Loading