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
27 changes: 27 additions & 0 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,33 @@ final class CreateBooksTable implements MigratesUp
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`.
:::

### Using unsigned integers as primary keys

By default, Tempest uses signed auto-incrementing integers as primary keys. On MySQL, you may use unsigned integers instead by specifying `unsigned: true` to the `primary()` method. Foreign keys referencing an unsigned primary key must be unsigned as well, so specify `unsigned: true` to `belongsTo()` or `foreignId()` too:

```php app/Books/CreateBooksTable.php
use Tempest\Database\MigratesUp;
use Tempest\Database\QueryStatement;
use Tempest\Database\QueryStatements\CreateTableStatement;

final class CreateBooksTable implements MigratesUp
{
public string $name = '2024-08-12_create_books_table';

public function up(): QueryStatement
{
return new CreateTableStatement('books')
->primary(unsigned: true)
->foreignId('author_id', constrainedOn: 'authors', unsigned: true)
->text('title');
}
}
```

:::info
PostgreSQL and SQLite have no unsigned integer type, so this option has no effect on primary keys for those databases.
:::

### Table names

Tempest infers the table name for a model class based on the model's classname. By default, the table name is the pluralized, `snake_cased` version of the base class name. This can be overridden using the {b`Tempest\Database\Table`} attribute:
Expand Down
31 changes: 23 additions & 8 deletions packages/database/src/QueryStatements/CreateTableStatement.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@ public static function forModel(string $modelClass): self
*
* By default, MySQL and SQLite use an auto-incrementing `INTEGER` column, and PostgreSQL uses `SERIAL`.
* When setting `uuid` to `true`, MySQL will use `VARCHAR(36)`, PostgreSQL will use `UUID`, and SQLite will use `TEXT`.
* When setting `unsigned` to `true`, MySQL will use `INTEGER UNSIGNED`. This has no effect on PostgreSQL and SQLite, or when `uuid` is `true`.
*/
public function primary(string $name = 'id', bool $uuid = false): self
public function primary(string $name = 'id', bool $uuid = false, bool $unsigned = false): self
{
if ($uuid) {
$this->uuid($name);
} else {
$this->statements[] = new PrimaryKeyStatement($name);
$this->statements[] = new PrimaryKeyStatement($name, $unsigned);
}

return $this;
Expand All @@ -69,12 +70,19 @@ public function uuid(string $name = 'id'): self
*
* @param string $local The local column in the format `this_table.foreign_id`.
* @param string $foreign The foreign column in the format `other_table.id`.
* @param bool $unsigned Whether the local column is unsigned. Must match the referenced primary key on MySQL.
*/
public function belongsTo(string $local, string $foreign, OnDelete $onDelete = OnDelete::RESTRICT, OnUpdate $onUpdate = OnUpdate::NO_ACTION, bool $nullable = false): self
{
public function belongsTo(
string $local,
string $foreign,
OnDelete $onDelete = OnDelete::RESTRICT,
OnUpdate $onUpdate = OnUpdate::NO_ACTION,
bool $nullable = false,
bool $unsigned = false,
): self {
[, $localKey] = explode('.', $local);

$this->integer($localKey, nullable: $nullable);
$this->integer($localKey, unsigned: $unsigned, nullable: $nullable);

$this->statements[] = new BelongsToStatement(
local: $local,
Expand All @@ -101,9 +109,16 @@ public function belongsTo(string $local, string $foreign, OnDelete $onDelete = O
*
* @param string $local The local column in the format `[this_table.]foreign_id`.
* @param string $constrainedOn The foreign table in the format `other_table[.id]`.
* @param bool $unsigned Whether the local column is unsigned. Must match the referenced primary key on MySQL.
*/
public function foreignId(string $local, string $constrainedOn, OnDelete $onDelete = OnDelete::RESTRICT, OnUpdate $onUpdate = OnUpdate::NO_ACTION, bool $nullable = false): self
{
public function foreignId(
string $local,
string $constrainedOn,
OnDelete $onDelete = OnDelete::RESTRICT,
OnUpdate $onUpdate = OnUpdate::NO_ACTION,
bool $nullable = false,
bool $unsigned = false,
): self {
if (! str_contains($local, '.')) {
$local = $this->tableName . '.' . $local;
}
Expand All @@ -112,7 +127,7 @@ public function foreignId(string $local, string $constrainedOn, OnDelete $onDele
$constrainedOn .= '.id';
}

return $this->belongsTo($local, $constrainedOn, $onDelete, $onUpdate, $nullable);
return $this->belongsTo($local, $constrainedOn, $onDelete, $onUpdate, $nullable, $unsigned);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@
{
public function __construct(
private string $name = 'id',
private bool $unsigned = false,
) {}

public function compile(DatabaseDialect $dialect): string
{
$name = $dialect->quoteIdentifier($this->name);

return match ($dialect) {
DatabaseDialect::MYSQL => "{$name} INTEGER PRIMARY KEY AUTO_INCREMENT",
DatabaseDialect::MYSQL => $this->unsigned
? "{$name} INTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENT"
: "{$name} INTEGER PRIMARY KEY AUTO_INCREMENT",
// Postgres and SQLite have no unsigned integer type, so omit the keyword there.
DatabaseDialect::POSTGRESQL => "{$name} SERIAL PRIMARY KEY",
DatabaseDialect::SQLITE => "{$name} INTEGER PRIMARY KEY AUTOINCREMENT",
};
Expand Down
104 changes: 104 additions & 0 deletions packages/database/tests/QueryStatements/CreateTableStatementTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,110 @@ public static function provide_uuid_primary_database_dialects(): iterable
];
}

#[DataProvider('provide_unsigned_fk_create_table_database_drivers')]
#[Test]
public function create_an_unsigned_foreign_key_constraint(DatabaseDialect $dialect, string $validSql): void
{
$statement = new CreateTableStatement('books')
->primary(unsigned: true)
->belongsTo('books.author_id', 'authors.id', OnDelete::CASCADE, unsigned: true)
->varchar('name')
->compile($dialect);

$this->assertSame($validSql, $statement);

$statement = new CreateTableStatement('books')
->primary(unsigned: true)
->foreignId('author_id', constrainedOn: 'authors', onDelete: OnDelete::CASCADE, unsigned: true)
->varchar('name')
->compile($dialect);

$this->assertSame($validSql, $statement);
}

public static function provide_unsigned_fk_create_table_database_drivers(): Generator
{
yield 'mysql' => [
DatabaseDialect::MYSQL,
<<<SQL
CREATE TABLE `books` (
`id` INTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`author_id` INTEGER UNSIGNED 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" SERIAL PRIMARY KEY,
"author_id" INTEGER 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` INTEGER PRIMARY KEY AUTOINCREMENT,
`author_id` INTEGER UNSIGNED NOT NULL,
`name` VARCHAR(255) NOT NULL
);
SQL,
];
}

#[DataProvider('provide_unsigned_primary_database_dialects')]
#[Test]
public function create_table_with_unsigned_primary(DatabaseDialect $dialect, string $validSql): void
{
$statement = new CreateTableStatement('users')
->primary(unsigned: true)
->text('name')
->compile($dialect);

$this->assertSame($validSql, $statement);
}

public static function provide_unsigned_primary_database_dialects(): iterable
{
yield 'mysql' => [
DatabaseDialect::MYSQL,
<<<SQL
CREATE TABLE `users` (
`id` INTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`name` TEXT NOT NULL
);
SQL,
];

yield 'postgresql' => [
DatabaseDialect::POSTGRESQL,
<<<SQL
CREATE TABLE "users" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL
);
SQL,
];

yield 'sqlite' => [
DatabaseDialect::SQLITE,
<<<SQL
CREATE TABLE `users` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`name` TEXT NOT NULL
);
SQL,
];
}

#[DataProvider('provide_datetime_current_database_dialects')]
#[Test]
public function datetime_current_default(DatabaseDialect $dialect, string $validSql): void
Expand Down