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
24 changes: 24 additions & 0 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
:::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
38 changes: 36 additions & 2 deletions packages/database/src/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
71 changes: 71 additions & 0 deletions packages/database/src/QueryStatements/CreateTableStatement.php
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*
Expand Down Expand Up @@ -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.
*
Expand Down
31 changes: 31 additions & 0 deletions packages/database/src/QueryStatements/UuidStatement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\QueryStatements;

use Tempest\Database\Config\DatabaseDialect;
use Tempest\Database\QueryStatement;

/**
* A UUID column that is not a primary key. Uses `CHAR(36)` for MySQL, `UUID` for PostgreSQL, and `TEXT` for SQLite.
*/
final readonly class UuidStatement implements QueryStatement
{
public function __construct(
private string $name,
private bool $nullable = false,
) {}

public function compile(DatabaseDialect $dialect): string
{
$name = $dialect->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}",
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
<<<SQL
CREATE TABLE `books` (
`id` CHAR(36) PRIMARY KEY,
`author_id` CHAR(36) NOT NULL,
CONSTRAINT `fk_authors_books_author_id` FOREIGN KEY books(author_id) REFERENCES authors(id) ON DELETE CASCADE ON UPDATE NO ACTION,
`name` VARCHAR(255) NOT NULL
);
SQL,
];

yield 'postgresql' => [
DatabaseDialect::POSTGRESQL,
<<<SQL
CREATE TABLE "books" (
"id" UUID PRIMARY KEY,
"author_id" UUID NOT NULL,
CONSTRAINT "fk_authors_books_author_id" FOREIGN KEY(author_id) REFERENCES authors(id) ON DELETE CASCADE ON UPDATE NO ACTION,
"name" VARCHAR(255) NOT NULL
);
SQL,
];

yield 'sqlite' => [
DatabaseDialect::SQLITE,
<<<SQL
CREATE TABLE `books` (
`id` TEXT PRIMARY KEY,
`author_id` TEXT NOT NULL,
`name` VARCHAR(255) NOT NULL
);
SQL,
];
}

#[DataProvider('provide_datetime_current_database_dialects')]
#[Test]
public function datetime_current_default(DatabaseDialect $dialect, string $validSql): void
Expand Down
53 changes: 53 additions & 0 deletions packages/database/tests/QueryStatements/UuidStatementTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Tests\QueryStatements;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Tempest\Database\Config\DatabaseDialect;
use Tempest\Database\QueryStatements\UuidStatement;

/**
* @internal
*/
final class UuidStatementTest extends TestCase
{
#[Test]
public function mysql_compilation(): void
{
$statement = new UuidStatement('author_id');
$compiled = $statement->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));
}
}
Loading
Loading