diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 4af4ef95c6..ebffe30919 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -406,6 +406,30 @@ final class CreateBooksTable implements MigratesUp } ``` +Columns referencing a UUID primary key need a UUID-compatible type. Use `uuidColumn()` for a plain UUID column, or `belongsToUuid()` and `foreignUuid()` as UUID counterparts of `belongsTo()` and `foreignId()`: + +```php app/Books/CreateChaptersTable.php +use Tempest\Database\MigratesUp; +use Tempest\Database\QueryStatement; +use Tempest\Database\QueryStatements\CreateTableStatement; +use Tempest\Database\QueryStatements\OnDelete; + +final class CreateChaptersTable implements MigratesUp +{ + public string $name = '2024-08-13_create_chapters_table'; + + public function up(): QueryStatement + { + return new CreateTableStatement('chapters') + ->uuid() + ->text('title') + ->foreignUuid('book_uuid', constrainedOn: 'books.uuid', onDelete: OnDelete::CASCADE); + } +} +``` + +UUID columns use `CHAR(36)` on MySQL, `UUID` on PostgreSQL, and `TEXT` on SQLite, so foreign keys referencing UUID primary keys are type-compatible on every supported dialect. + :::warning Currently, the [`IsDatabaseModel`](#the-is-database-model-trait) trait already provides a primary `$id` property. It is therefore not possible to use UUIDs alongside `IsDatabaseModel`. ::: diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index 1313de3cd0..1343a89c66 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -410,6 +410,12 @@ private function resolveIterableData(iterable $model): array $entry[$key] = $this->serializeIterableValue($key, $value); } + $primaryKey = $this->model->getPrimaryKey(); + + if ($primaryKey !== null && $this->model->hasUuidPrimaryKey() && ! isset($entry[$primaryKey])) { + $entry = [$primaryKey => Random\uuid(), ...$entry]; + } + return $entry; } diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index 6cf14d7dfc..51230f5168 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -5,6 +5,8 @@ namespace Tempest\Database; use Tempest\Database\Config\DatabaseDialect; +use Tempest\Database\QueryStatements\InsertStatement; +use Tempest\Support\Arr\ImmutableArray; use Tempest\Support\Str\ImmutableString; use function Tempest\Container\get; @@ -48,11 +50,43 @@ public function execute(mixed ...$bindings): ?PrimaryKey return null; } - return isset($query->bindings[$this->primaryKeyColumn]) - ? new PrimaryKey($query->bindings[$this->primaryKeyColumn]) + if (isset($query->bindings[$this->primaryKeyColumn])) { + return new PrimaryKey($query->bindings[$this->primaryKeyColumn]); + } + + // Insert bindings are positional; resolve the primary key through its column position. + $positionalValue = $this->resolvePositionalPrimaryKeyBinding($query); + + return $positionalValue !== null + ? new PrimaryKey($positionalValue) : $database->getLastInsertId(); } + private function resolvePositionalPrimaryKeyBinding(Query $query): mixed + { + if (! $this->sql instanceof InsertStatement) { + return null; + } + + $firstEntry = $this->sql->entries->first(); + + if ($firstEntry instanceof ImmutableArray) { + $firstEntry = $firstEntry->toArray(); + } + + if (! is_array($firstEntry)) { + return null; + } + + $index = array_search($this->primaryKeyColumn, array_keys($firstEntry), strict: true); + + if ($index === false) { + return null; + } + + return $query->bindings[$index] ?? null; + } + public function fetch(mixed ...$bindings): array { return $this->database->fetch($this->withBindings($bindings)); diff --git a/packages/database/src/QueryStatements/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php index ab883e3646..d1d2b11d55 100644 --- a/packages/database/src/QueryStatements/CreateTableStatement.php +++ b/packages/database/src/QueryStatements/CreateTableStatement.php @@ -59,6 +59,16 @@ public function uuid(string $name = 'id'): self return $this; } + /** + * Adds a UUID column to the table. Uses `CHAR(36)` for MySQL, `UUID` for PostgreSQL, and `TEXT` for SQLite. + */ + public function uuidColumn(string $name, bool $nullable = false): self + { + $this->statements[] = new UuidStatement($name, $nullable); + + return $this; + } + /** * Adds an integer column with a foreign key relationship to another table. This is an alias to `foreignId`. * @@ -115,6 +125,67 @@ public function foreignId(string $local, string $constrainedOn, OnDelete $onDele return $this->belongsTo($local, $constrainedOn, $onDelete, $onUpdate, $nullable); } + /** + * Adds a UUID column with a foreign key relationship to another table. This is an alias to `foreignUuid`. + * + * **Example** + * ```php + * $table->belongsToUuid('orders.customer_uuid', 'customers.uuid'); + * ``` + * + * @param string $local The local column in the format `this_table.foreign_uuid`. + * @param string $foreign The foreign column in the format `other_table.uuid`. + */ + public function belongsToUuid(string $local, string $foreign, OnDelete $onDelete = OnDelete::RESTRICT, OnUpdate $onUpdate = OnUpdate::NO_ACTION, bool $nullable = false): self + { + [, $localKey] = explode('.', $local); + + $this->uuidColumn($localKey, nullable: $nullable); + + $this->statements[] = new BelongsToStatement( + local: $local, + foreign: $foreign, + onDelete: $onDelete, + onUpdate: $onUpdate, + ); + + return $this; + } + + /** + * Adds a UUID column with a foreign key relationship to another table. + * + * **Example** + * ```php + * new CreateTableStatement('orders') + * ->foreignUuid('customer_uuid', constrainedOn: 'customers'); + * ``` + * ```php + * new CreateTableStatement('orders') + * ->foreignUuid('orders.customer_uuid', constrainedOn: 'customers.uuid'); + * ``` + * + * @param string $local The local column in the format `[this_table.]foreign_uuid`. + * @param string $constrainedOn The foreign table in the format `other_table[.uuid]`. + */ + public function foreignUuid( + string $local, + string $constrainedOn, + OnDelete $onDelete = OnDelete::RESTRICT, + OnUpdate $onUpdate = OnUpdate::NO_ACTION, + bool $nullable = false, + ): self { + if (! str_contains($local, '.')) { + $local = $this->tableName . '.' . $local; + } + + if (! str_contains($constrainedOn, '.')) { + $constrainedOn .= '.id'; + } + + return $this->belongsToUuid($local, $constrainedOn, $onDelete, $onUpdate, $nullable); + } + /** * Adds a foreign key constraint to another table. * diff --git a/packages/database/src/QueryStatements/UuidStatement.php b/packages/database/src/QueryStatements/UuidStatement.php new file mode 100644 index 0000000000..8e80b64b5e --- /dev/null +++ b/packages/database/src/QueryStatements/UuidStatement.php @@ -0,0 +1,31 @@ +quoteIdentifier($this->name); + $nullable = $this->nullable ? '' : ' NOT NULL'; + + return match ($dialect) { + DatabaseDialect::MYSQL => "{$name} CHAR(36){$nullable}", + DatabaseDialect::POSTGRESQL => "{$name} UUID{$nullable}", + DatabaseDialect::SQLITE => "{$name} TEXT{$nullable}", + }; + } +} diff --git a/packages/database/tests/QueryStatements/CreateTableStatementTest.php b/packages/database/tests/QueryStatements/CreateTableStatementTest.php index d184bed1f7..2db7f83327 100644 --- a/packages/database/tests/QueryStatements/CreateTableStatementTest.php +++ b/packages/database/tests/QueryStatements/CreateTableStatementTest.php @@ -240,6 +240,73 @@ public static function provide_uuid_primary_database_dialects(): iterable ]; } + #[DataProvider('provide_uuid_fk_create_table_database_dialects')] + #[Test] + public function create_a_uuid_foreign_key_constraint(DatabaseDialect $dialect, string $validSql): void + { + $statement = new CreateTableStatement('books') + ->uuid() + ->belongsToUuid('books.author_id', 'authors.id', OnDelete::CASCADE) + ->varchar('name') + ->compile($dialect); + + $this->assertSame($validSql, $statement); + + $statement = new CreateTableStatement('books') + ->uuid() + ->foreignUuid('author_id', constrainedOn: 'authors', onDelete: OnDelete::CASCADE) + ->varchar('name') + ->compile($dialect); + + $this->assertSame($validSql, $statement); + + $statement = new CreateTableStatement('books') + ->uuid() + ->foreignUuid('books.author_id', constrainedOn: 'authors.id', onDelete: OnDelete::CASCADE) + ->varchar('name') + ->compile($dialect); + + $this->assertSame($validSql, $statement); + } + + public static function provide_uuid_fk_create_table_database_dialects(): Generator + { + yield 'mysql' => [ + DatabaseDialect::MYSQL, + << [ + DatabaseDialect::POSTGRESQL, + << [ + DatabaseDialect::SQLITE, + <<compile(DatabaseDialect::MYSQL); + + $this->assertSame('`author_id` CHAR(36) NOT NULL', $compiled); + } + + #[Test] + public function postgresql_compilation(): void + { + $statement = new UuidStatement('author_id'); + $compiled = $statement->compile(DatabaseDialect::POSTGRESQL); + + $this->assertSame('"author_id" UUID NOT NULL', $compiled); + } + + #[Test] + public function sqlite_compilation(): void + { + $statement = new UuidStatement('author_id'); + $compiled = $statement->compile(DatabaseDialect::SQLITE); + + $this->assertSame('`author_id` TEXT NOT NULL', $compiled); + } + + #[Test] + public function nullable_compilation(): void + { + $statement = new UuidStatement('author_id', nullable: true); + + $this->assertSame('`author_id` CHAR(36)', $statement->compile(DatabaseDialect::MYSQL)); + $this->assertSame('"author_id" UUID', $statement->compile(DatabaseDialect::POSTGRESQL)); + $this->assertSame('`author_id` TEXT', $statement->compile(DatabaseDialect::SQLITE)); + } +} diff --git a/tests/Integration/Database/UuidPrimaryKeyTest.php b/tests/Integration/Database/UuidPrimaryKeyTest.php index 43cdf86a9c..a3979439c2 100644 --- a/tests/Integration/Database/UuidPrimaryKeyTest.php +++ b/tests/Integration/Database/UuidPrimaryKeyTest.php @@ -5,12 +5,15 @@ namespace Tests\Tempest\Integration\Database; use PHPUnit\Framework\Attributes\Test; +use Tempest\Database\BelongsToMany; +use Tempest\Database\HasMany; use Tempest\Database\IsDatabaseModel; use Tempest\Database\MigratesUp; use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\PrimaryKey; use Tempest\Database\QueryStatement; use Tempest\Database\QueryStatements\CreateTableStatement; +use Tempest\Database\QueryStatements\OnDelete; use Tempest\Database\Table; use Tempest\Database\Uuid; use Tempest\Support\Random; @@ -124,6 +127,72 @@ public function uuid_primary_key_without_is_database_model_trait(): void $this->assertNotNull($retrieved); $this->assertTrue($mage->uuid->equals($retrieved->uuid)); } + + #[Test] + public function uuid_primary_key_generated_for_iterable_insert(): void + { + $this->database->migrate(CreateMigrationsTable::class, CreateUuidRolesTableMigration::class); + + $id = query(UuidRole::class)->insert(name: 'admin')->execute(); + + $this->assertInstanceOf(PrimaryKey::class, $id); + $this->assertTrue(Random\is_uuid($id->value)); + + $role = query(UuidRole::class)->get($id); + + $this->assertNotNull($role); + $this->assertSame('admin', $role->name); + } + + #[Test] + public function uuid_primary_key_belongs_to_many_pivot_uses_generated_uuid(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateUuidUsersTableMigration::class, + CreateUuidRolesTableMigration::class, + CreateUuidUserRoleTableMigration::class, + ); + + $role = query(UuidRole::class)->create(name: 'admin'); + + $user = query(UuidUser::class)->create( + name: 'Frieren', + roles: [$role], + ); + + $this->assertTrue(Random\is_uuid($user->id->value)); + + $pivotRows = query('uuid_user_role')->select()->all(); + + $this->assertCount(1, $pivotRows); + $this->assertSame($user->id->value, $pivotRows[0]['uuid_user_id']); + $this->assertSame($role->id->value, $pivotRows[0]['uuid_role_id']); + } + + #[Test] + public function uuid_primary_key_has_many_uses_generated_uuid_as_foreign_key(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateUuidUsersTableMigration::class, + CreateUuidPostsTableMigration::class, + ); + + $user = query(UuidUser::class)->create( + name: 'Frieren', + posts: [ + ['title' => 'Grimoire Notes'], + ], + ); + + $this->assertTrue(Random\is_uuid($user->id->value)); + + $posts = query('uuid_posts')->select()->all(); + + $this->assertCount(1, $posts); + $this->assertSame($user->id->value, $posts[0]['uuid_user_id']); + } } #[Table('model')] @@ -164,3 +233,98 @@ public function up(): QueryStatement ->text('race'); } } + +final class UuidUser +{ + use IsDatabaseModel; + + #[Uuid] + public PrimaryKey $id; + + /** @var \Tests\Tempest\Integration\Database\UuidRole[] */ + #[BelongsToMany(pivot: 'uuid_user_role')] + public array $roles = []; + + /** @var \Tests\Tempest\Integration\Database\UuidPost[] */ + #[HasMany] + public array $posts = []; + + public function __construct( + public string $name, + ) {} +} + +final class UuidRole +{ + use IsDatabaseModel; + + #[Uuid] + public PrimaryKey $id; + + public function __construct( + public string $name, + ) {} +} + +final class UuidPost +{ + use IsDatabaseModel; + + #[Uuid] + public PrimaryKey $id; + + public ?PrimaryKey $uuid_user_id = null; + + public function __construct( + public string $title, + ) {} +} + +final class CreateUuidUsersTableMigration implements MigratesUp +{ + public string $name = '100_create_uuid_users'; + + public function up(): QueryStatement + { + return new CreateTableStatement('uuid_users') + ->uuid() + ->varchar('name'); + } +} + +final class CreateUuidRolesTableMigration implements MigratesUp +{ + public string $name = '101_create_uuid_roles'; + + public function up(): QueryStatement + { + return new CreateTableStatement('uuid_roles') + ->uuid() + ->varchar('name'); + } +} + +final class CreateUuidUserRoleTableMigration implements MigratesUp +{ + public string $name = '102_create_uuid_user_role'; + + public function up(): QueryStatement + { + return new CreateTableStatement('uuid_user_role') + ->belongsToUuid('uuid_user_role.uuid_user_id', 'uuid_users.id', onDelete: OnDelete::CASCADE) + ->belongsToUuid('uuid_user_role.uuid_role_id', 'uuid_roles.id', onDelete: OnDelete::CASCADE); + } +} + +final class CreateUuidPostsTableMigration implements MigratesUp +{ + public string $name = '103_create_uuid_posts'; + + public function up(): QueryStatement + { + return new CreateTableStatement('uuid_posts') + ->uuid() + ->varchar('title') + ->foreignUuid('uuid_user_id', constrainedOn: 'uuid_users', onDelete: OnDelete::CASCADE, nullable: true); + } +}