Skip to content

Commit 4456541

Browse files
authored
feat(database): support uuids as primary columns (#1807)
1 parent e1124fb commit 4456541

File tree

12 files changed

+426
-15
lines changed

12 files changed

+426
-15
lines changed

docs/1-essentials/03-database.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ Finally, you can make your own query builders if you want by implementing the {b
124124

125125
## Models
126126

127-
A common use case in many applications is to represent persisted data as objects within your codebase. This is where model classes come in. Tempest tries to decouple models as best as possible from the database, so any object with public typed properties can represent a model.
127+
A common use case in many applications is to represent persisted data as objects within your codebase. This is where model classes come in. Tempest tries to decouple models as best as possible from the database, so any object with public typed properties can represent a table.
128128

129129
These objects don't have to implement any interface—they may be plain-old PHP objects:
130130

@@ -432,7 +432,7 @@ final class Book
432432
}
433433
```
434434

435-
Thanks to the {b`Tempest\Database\IsDatabaseModel`} trait, you can directly interact with the database via the model class:
435+
Thanks to the {b`Tempest\Database\IsDatabaseModel`} trait, you can interact with the database directly via the model class:
436436

437437
```php
438438
$book = Book::create(
@@ -455,6 +455,51 @@ $books = Book::select()
455455
$books[0]->chapters[2]->delete();
456456
```
457457

458+
### Using UUIDs as primary keys
459+
460+
By default, Tempest uses auto-incrementing integers as primary keys. However, you can use UUIDs as primary keys instead by marking a {b`Tempest\Database\PrimaryKey`} property with the {b`#[Tempest\Database\Uuid]`} attribute. Tempest will automatically generate a UUID v7 value whenever a new model is created:
461+
462+
```php src/Books/Book.php
463+
use Tempest\Database\PrimaryKey;
464+
use Tempest\Database\Uuid;
465+
466+
final class Book
467+
{
468+
#[Uuid]
469+
public PrimaryKey $uuid;
470+
471+
public function __construct(
472+
public string $title,
473+
public string $author_name,
474+
) {}
475+
}
476+
```
477+
478+
Within migrations, you may specify `uuid: true` to the `primary()` method, or directly use `uuid()`:
479+
480+
```php src/Books/CreateBooksTable.php
481+
use Tempest\Database\MigratesUp;
482+
use Tempest\Database\QueryStatement;
483+
use Tempest\Database\QueryStatements\CreateTableStatement;
484+
485+
final class CreateBooksTable implements MigratesUp
486+
{
487+
public string $name = '2024-08-12_create_books_table';
488+
489+
public function up(): QueryStatement
490+
{
491+
return new CreateTableStatement('books')
492+
->primary('uuid', uuid: true)
493+
->text('title')
494+
->text('author_name');
495+
}
496+
}
497+
```
498+
499+
:::warning
500+
Currently, the `IsDatabaseModel` trait already provides a primary `$id` property. It is therefore not possible to use UUIDs alongside `IsDatabaseModel`.
501+
:::
502+
458503
## Migrations
459504

460505
When you're persisting objects to the database, you'll need table to store its data in. A migration is a file instructing the framework how to manage that database schema. Tempest uses migrations to create and update databases across different environments.

packages/database/src/Builder/ModelInspector.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Tempest\Database\PrimaryKey;
1212
use Tempest\Database\Relation;
1313
use Tempest\Database\Table;
14+
use Tempest\Database\Uuid;
1415
use Tempest\Database\Virtual;
1516
use Tempest\Mapper\SerializeAs;
1617
use Tempest\Mapper\SerializeWith;
@@ -549,4 +550,14 @@ public function getPrimaryKeyValue(): ?PrimaryKey
549550

550551
return $primaryKeyProperty->getValue($this->instance);
551552
}
553+
554+
public function hasUuidPrimaryKey(): bool
555+
{
556+
return $this->getPrimaryKeyProperty()?->hasAttribute(Uuid::class) ?? false;
557+
}
558+
559+
public function isUuidPrimaryKey(PropertyReflector $property): bool
560+
{
561+
return $property->getType()->matches(PrimaryKey::class) && $property->hasAttribute(Uuid::class);
562+
}
552563
}

packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Tempest\Reflection\PropertyReflector;
2222
use Tempest\Support\Arr;
2323
use Tempest\Support\Conditions\HasConditions;
24+
use Tempest\Support\Random;
2425
use Tempest\Support\Str\ImmutableString;
2526

2627
use function Tempest\Database\inspect;
@@ -401,16 +402,22 @@ private function resolveObjectData(object $model): array
401402
$entry = [];
402403

403404
foreach ($modelClass->getPublicProperties() as $property) {
405+
$propertyName = $property->getName();
406+
404407
if (! $property->isInitialized($model)) {
408+
if ($definition->isUuidPrimaryKey($property)) {
409+
$uuid = new PrimaryKey(Random\uuid());
410+
$property->setValue($model, $uuid);
411+
$entry[$propertyName] = $this->serializeValue($property, $uuid);
412+
}
413+
405414
continue;
406415
}
407416

408417
if ($property->isVirtual()) {
409418
continue;
410419
}
411420

412-
$propertyName = $property->getName();
413-
414421
if ($property->hasAttribute(Virtual::class)) {
415422
continue;
416423
}

packages/database/src/Builder/QueryBuilders/QueryBuilder.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,10 @@ public function create(mixed ...$params): object
264264

265265
if ($id !== null && $primaryKeyProperty !== null) {
266266
$primaryKeyName = $primaryKeyProperty->getName();
267-
$model->{$primaryKeyName} = new PrimaryKey($id);
267+
268+
if (! $inspector->hasUuidPrimaryKey() || $model->{$primaryKeyName} === null) {
269+
$model->{$primaryKeyName} = new PrimaryKey($id);
270+
}
268271
}
269272

270273
return $model;

packages/database/src/IsDatabaseModel.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,9 @@ public function save(): self
248248
->insert($this)
249249
->execute();
250250

251-
$primaryKeyProperty->setValue($this, $id);
251+
if (! $model->hasUuidPrimaryKey()) {
252+
$primaryKeyProperty->setValue($this, $id);
253+
}
252254

253255
return $this;
254256
}

packages/database/src/QueryStatements/CreateTableStatement.php

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,28 @@ public static function forModel(string $modelClass): self
3333
}
3434

3535
/**
36-
* Adds a primary key column to the table. MySQL and SQLite use an auto-incrementing `INTEGER` column, and PostgreSQL uses `SERIAL`.
36+
* Adds a primary key column to the table.
37+
*
38+
* By default, MySQL and SQLite use an auto-incrementing `INTEGER` column, and PostgreSQL uses `SERIAL`.
39+
* When setting `uuid` to `true`, MySQL will use `VARCHAR(36)`, PostgreSQL will use `UUID`, and SQLite will use `TEXT`.
40+
*/
41+
public function primary(string $name = 'id', bool $uuid = false): self
42+
{
43+
if ($uuid) {
44+
$this->uuid($name);
45+
} else {
46+
$this->statements[] = new PrimaryKeyStatement($name);
47+
}
48+
49+
return $this;
50+
}
51+
52+
/**
53+
* Adds a UUID v7 primary key column to the table. Uses `VARCHAR(36)` for MySQL, `UUID` for PostgreSQL, and `TEXT` for SQLite.
3754
*/
38-
public function primary(string $name = 'id'): self
55+
public function uuid(string $name = 'id'): self
3956
{
40-
$this->statements[] = new PrimaryKeyStatement($name);
57+
$this->statements[] = new UuidPrimaryKeyStatement($name);
4158

4259
return $this;
4360
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\QueryStatements;
6+
7+
use Tempest\Database\Config\DatabaseDialect;
8+
use Tempest\Database\QueryStatement;
9+
10+
final readonly class UuidPrimaryKeyStatement implements QueryStatement
11+
{
12+
public function __construct(
13+
private string $name = 'id',
14+
) {}
15+
16+
public function compile(DatabaseDialect $dialect): string
17+
{
18+
return match ($dialect) {
19+
DatabaseDialect::MYSQL => sprintf('`%s` VARCHAR(36) PRIMARY KEY', $this->name),
20+
DatabaseDialect::POSTGRESQL => sprintf('`%s` UUID PRIMARY KEY', $this->name),
21+
DatabaseDialect::SQLITE => sprintf('`%s` TEXT PRIMARY KEY', $this->name),
22+
};
23+
}
24+
}

packages/database/src/Uuid.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database;
6+
7+
use Attribute;
8+
9+
/**
10+
* Marks a primary key property to automatically generate UUID v7 values when the model is saved.
11+
* This must be applied to `PrimaryKey` properties that would use UUIDs instead of auto-incrementing integers.
12+
*
13+
* **Example**
14+
* ```php
15+
* final class User
16+
* {
17+
* #[Uuid]
18+
* public PrimaryKey $uuid;
19+
*
20+
* public function __construct(
21+
* public string $name,
22+
* ) {}
23+
* }
24+
* ```
25+
*/
26+
#[Attribute(Attribute::TARGET_PROPERTY)]
27+
final readonly class Uuid
28+
{
29+
}

packages/database/tests/QueryStatements/CreateTableStatementTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,59 @@ public static function provide_fk_create_table_database_drivers_explicit(): Gene
178178
SQL,
179179
];
180180
}
181+
182+
#[DataProvider('provide_uuid_primary_database_dialects')]
183+
public function test_create_table_with_uuid_primary(DatabaseDialect $dialect, string $validSql): void
184+
{
185+
$uuid = new CreateTableStatement('users')
186+
->uuid('uuid')
187+
->text('name')
188+
->text('email')
189+
->compile($dialect);
190+
191+
$primary = new CreateTableStatement('users')
192+
->primary('uuid', uuid: true)
193+
->text('name')
194+
->text('email')
195+
->compile($dialect);
196+
197+
$this->assertSame($validSql, $uuid);
198+
$this->assertSame($validSql, $primary);
199+
}
200+
201+
public static function provide_uuid_primary_database_dialects(): iterable
202+
{
203+
yield 'mysql' => [
204+
DatabaseDialect::MYSQL,
205+
<<<SQL
206+
CREATE TABLE `users` (
207+
`uuid` VARCHAR(36) PRIMARY KEY,
208+
`name` TEXT NOT NULL,
209+
`email` TEXT NOT NULL
210+
);
211+
SQL,
212+
];
213+
214+
yield 'postgresql' => [
215+
DatabaseDialect::POSTGRESQL,
216+
<<<SQL
217+
CREATE TABLE `users` (
218+
`uuid` UUID PRIMARY KEY,
219+
`name` TEXT NOT NULL,
220+
`email` TEXT NOT NULL
221+
);
222+
SQL,
223+
];
224+
225+
yield 'sqlite' => [
226+
DatabaseDialect::SQLITE,
227+
<<<SQL
228+
CREATE TABLE `users` (
229+
`uuid` TEXT PRIMARY KEY,
230+
`name` TEXT NOT NULL,
231+
`email` TEXT NOT NULL
232+
);
233+
SQL,
234+
];
235+
}
181236
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Tests\QueryStatements;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use PHPUnit\Framework\TestCase;
9+
use Tempest\Database\Config\DatabaseDialect;
10+
use Tempest\Database\QueryStatements\UuidPrimaryKeyStatement;
11+
12+
/**
13+
* @internal
14+
*/
15+
final class UuidPrimaryKeyStatementTest extends TestCase
16+
{
17+
#[Test]
18+
public function mysql_compilation(): void
19+
{
20+
$statement = new UuidPrimaryKeyStatement('uuid');
21+
$compiled = $statement->compile(DatabaseDialect::MYSQL);
22+
23+
$this->assertSame('`uuid` VARCHAR(36) PRIMARY KEY', $compiled);
24+
}
25+
26+
#[Test]
27+
public function postgresql_compilation(): void
28+
{
29+
$statement = new UuidPrimaryKeyStatement('uuid');
30+
$compiled = $statement->compile(DatabaseDialect::POSTGRESQL);
31+
32+
$this->assertSame('`uuid` UUID PRIMARY KEY', $compiled);
33+
}
34+
35+
#[Test]
36+
public function sqlite_compilation(): void
37+
{
38+
$statement = new UuidPrimaryKeyStatement('uuid');
39+
$compiled = $statement->compile(DatabaseDialect::SQLITE);
40+
41+
$this->assertSame('`uuid` TEXT PRIMARY KEY', $compiled);
42+
}
43+
44+
#[Test]
45+
public function default_column_name(): void
46+
{
47+
$statement = new UuidPrimaryKeyStatement();
48+
$compiled = $statement->compile(DatabaseDialect::MYSQL);
49+
50+
$this->assertSame('`id` VARCHAR(36) PRIMARY KEY', $compiled);
51+
}
52+
}

0 commit comments

Comments
 (0)