diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 4af4ef95c6..8c40ba882a 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -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: diff --git a/packages/database/src/QueryStatements/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php index ab883e3646..9c5696622d 100644 --- a/packages/database/src/QueryStatements/CreateTableStatement.php +++ b/packages/database/src/QueryStatements/CreateTableStatement.php @@ -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; @@ -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, @@ -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; } @@ -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); } /** diff --git a/packages/database/src/QueryStatements/PrimaryKeyStatement.php b/packages/database/src/QueryStatements/PrimaryKeyStatement.php index 528cdc6fac..530542a609 100644 --- a/packages/database/src/QueryStatements/PrimaryKeyStatement.php +++ b/packages/database/src/QueryStatements/PrimaryKeyStatement.php @@ -11,6 +11,7 @@ { public function __construct( private string $name = 'id', + private bool $unsigned = false, ) {} public function compile(DatabaseDialect $dialect): string @@ -18,7 +19,10 @@ 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", }; diff --git a/packages/database/tests/QueryStatements/CreateTableStatementTest.php b/packages/database/tests/QueryStatements/CreateTableStatementTest.php index d184bed1f7..72b2efd5fd 100644 --- a/packages/database/tests/QueryStatements/CreateTableStatementTest.php +++ b/packages/database/tests/QueryStatements/CreateTableStatementTest.php @@ -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, + << [ + DatabaseDialect::POSTGRESQL, + << [ + DatabaseDialect::SQLITE, + <<primary(unsigned: true) + ->text('name') + ->compile($dialect); + + $this->assertSame($validSql, $statement); + } + + public static function provide_unsigned_primary_database_dialects(): iterable + { + yield 'mysql' => [ + DatabaseDialect::MYSQL, + << [ + DatabaseDialect::POSTGRESQL, + << [ + DatabaseDialect::SQLITE, + <<