Skip to content

Commit b4c0c49

Browse files
committed
feat(database): support uuids as primary columns
1 parent 38ea582 commit b4c0c49

File tree

10 files changed

+373
-11
lines changed

10 files changed

+373
-11
lines changed

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 & 6 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
}
@@ -435,10 +442,6 @@ private function resolveObjectData(object $model): array
435442

436443
$column = $propertyName;
437444

438-
if ($property->getType()->getName() === PrimaryKey::class && $value === null) {
439-
continue;
440-
}
441-
442445
if ($definition->isRelation($property)) {
443446
[$column, $value] = $this->resolveRelationProperty($definition, $property, $value);
444447
} else {

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)