diff --git a/src/Database/Constants/ColumnAction.php b/src/Database/Constants/ColumnAction.php new file mode 100644 index 00000000..36d93d76 --- /dev/null +++ b/src/Database/Constants/ColumnAction.php @@ -0,0 +1,16 @@ +name; } - public function getOptions(): array - { - return $this->options; - } - abstract public function getType(): string; - public function nullable(): static - { - $this->options['null'] = true; - - return $this; - } - public function comment(string $comment): static { $this->options['comment'] = $comment; @@ -109,36 +90,4 @@ public function limit(int $limit): static return $this; } - - public function setAdapter(AdapterInterface $adapter): static - { - $this->adapter = $adapter; - - return $this; - } - - public function getAdapter(): ?AdapterInterface - { - return $this->adapter; - } - - public function isMysql(): bool - { - return $this->adapter instanceof MysqlAdapter; - } - - public function isPostgres(): bool - { - return $this->adapter instanceof PostgresAdapter; - } - - public function isSQLite(): bool - { - return $this->adapter instanceof SQLiteAdapter; - } - - public function isSqlServer(): bool - { - return $this->adapter instanceof SqlServerAdapter; - } } diff --git a/src/Database/Migrations/Columns/Concerns/WithConvenience.php b/src/Database/Migrations/Columns/Concerns/WithConvenience.php index c76ed3da..88a0d3c5 100644 --- a/src/Database/Migrations/Columns/Concerns/WithConvenience.php +++ b/src/Database/Migrations/Columns/Concerns/WithConvenience.php @@ -5,13 +5,13 @@ namespace Phenix\Database\Migrations\Columns\Concerns; use Phenix\Database\Migrations\Columns\Timestamp; -use Phenix\Database\Migrations\Columns\UnsignedInteger; +use Phenix\Database\Migrations\Columns\UnsignedBigInteger; trait WithConvenience { - public function id(string $name = 'id'): UnsignedInteger + public function id(string $name = 'id'): UnsignedBigInteger { - return $this->addColumnWithAdapter(new UnsignedInteger($name, null, true)); + return $this->addColumnWithAdapter(new UnsignedBigInteger($name, true)); } public function timestamps(bool $timezone = false): self diff --git a/src/Database/Migrations/Columns/Concerns/WithForeignKeys.php b/src/Database/Migrations/Columns/Concerns/WithForeignKeys.php new file mode 100644 index 00000000..afd4bec1 --- /dev/null +++ b/src/Database/Migrations/Columns/Concerns/WithForeignKeys.php @@ -0,0 +1,24 @@ +addForeignKeyWithAdapter(new ForeignKey($columns, $referencedTable, $referencedColumns, $options)); + } + + public function foreign(string|array $columns): ForeignKey + { + return $this->addForeignKeyWithAdapter(new ForeignKey($columns, '', 'id')); + } +} diff --git a/src/Database/Migrations/ForeignKey.php b/src/Database/Migrations/ForeignKey.php new file mode 100644 index 00000000..10481f22 --- /dev/null +++ b/src/Database/Migrations/ForeignKey.php @@ -0,0 +1,78 @@ +options = $options; + } + + public function getColumns(): string|array + { + return $this->columns; + } + + public function getReferencedTable(): string + { + return $this->referencedTable; + } + + public function getReferencedColumns(): string|array + { + return $this->referencedColumns; + } + + public function onDelete(string|ColumnAction $action): static + { + $this->options['delete'] = $action instanceof ColumnAction ? $action->value : $action; + + return $this; + } + + public function onUpdate(string|ColumnAction $action): static + { + $this->options['update'] = $action instanceof ColumnAction ? $action->value : $action; + + return $this; + } + + public function constraint(string $name): static + { + $this->options['constraint'] = $name; + + return $this; + } + + public function deferrable(string $deferrable = 'DEFERRED'): static + { + if ($this->isPostgres()) { + $this->options['deferrable'] = $deferrable; + } + + return $this; + } + + public function references(string|array $columns): static + { + $this->referencedColumns = $columns; + + return $this; + } + + public function on(string $table): static + { + $this->referencedTable = $table; + + return $this; + } +} diff --git a/src/Database/Migrations/Table.php b/src/Database/Migrations/Table.php index cf3024c3..5bdf579e 100644 --- a/src/Database/Migrations/Table.php +++ b/src/Database/Migrations/Table.php @@ -8,6 +8,7 @@ use Phenix\Database\Migrations\Columns\Concerns\WithBinary; use Phenix\Database\Migrations\Columns\Concerns\WithConvenience; use Phenix\Database\Migrations\Columns\Concerns\WithDateTime; +use Phenix\Database\Migrations\Columns\Concerns\WithForeignKeys; use Phenix\Database\Migrations\Columns\Concerns\WithJson; use Phenix\Database\Migrations\Columns\Concerns\WithNetwork; use Phenix\Database\Migrations\Columns\Concerns\WithNumeric; @@ -20,6 +21,7 @@ class Table extends PhinxTable use WithBinary; use WithConvenience; use WithDateTime; + use WithForeignKeys; use WithJson; use WithNetwork; use WithNumeric; @@ -31,11 +33,57 @@ class Table extends PhinxTable */ protected array $columns = []; + /** + * @var array + */ + protected array $foreignKeys = []; + + protected bool $executed = false; + + public function __destruct() + { + if (! $this->executed) { + $this->save(); + } + } + public function getColumnBuilders(): array { return $this->columns; } + public function getForeignKeyBuilders(): array + { + return $this->foreignKeys; + } + + public function create(): void + { + $this->addColumnFromBuilders(); + + parent::create(); + + $this->executed = true; + } + + public function update(): void + { + $this->addColumnFromBuilders(); + + parent::update(); + + $this->executed = true; + } + + public function save(): void + { + $this->addColumnFromBuilders(); + + parent::save(); + + $this->executed = true; + } + /** * @template T of Column * @param T $column @@ -49,4 +97,34 @@ protected function addColumnWithAdapter(Column $column): Column return $column; } + + /** + * @template T of ForeignKey + * @param T $foreignKey + * @return T + */ + protected function addForeignKeyWithAdapter(ForeignKey $foreignKey): ForeignKey + { + $foreignKey->setAdapter($this->getAdapter()); + + $this->foreignKeys[] = $foreignKey; + + return $foreignKey; + } + + protected function addColumnFromBuilders(): void + { + foreach ($this->columns as $column) { + $this->addColumn($column->getName(), $column->getType(), $column->getOptions()); + } + + foreach ($this->foreignKeys as $foreignKey) { + $this->addForeignKey( + $foreignKey->getColumns(), + $foreignKey->getReferencedTable(), + $foreignKey->getReferencedColumns(), + $foreignKey->getOptions() + ); + } + } } diff --git a/src/Database/Migrations/TableColumn.php b/src/Database/Migrations/TableColumn.php new file mode 100644 index 00000000..7f8e7d78 --- /dev/null +++ b/src/Database/Migrations/TableColumn.php @@ -0,0 +1,62 @@ +options['null'] = true; + + return $this; + } + + public function getOptions(): array + { + return $this->options; + } + + public function setAdapter(AdapterInterface $adapter): static + { + $this->adapter = $adapter; + + return $this; + } + + public function getAdapter(): ?AdapterInterface + { + return $this->adapter; + } + + public function isMysql(): bool + { + return $this->adapter instanceof MysqlAdapter; + } + + public function isPostgres(): bool + { + return $this->adapter instanceof PostgresAdapter; + } + + public function isSQLite(): bool + { + return $this->adapter instanceof SQLiteAdapter; + } + + public function isSqlServer(): bool + { + return $this->adapter instanceof SqlServerAdapter; + } +} diff --git a/tests/Unit/Database/Migrations/ForeignKeyTest.php b/tests/Unit/Database/Migrations/ForeignKeyTest.php new file mode 100644 index 00000000..e01cddda --- /dev/null +++ b/tests/Unit/Database/Migrations/ForeignKeyTest.php @@ -0,0 +1,188 @@ +mockAdapter = $this->getMockBuilder(AdapterInterface::class)->getMock(); + + $this->mockAdapter->expects($this->any()) + ->method('hasTable') + ->willReturn(false); + + $this->mockAdapter->expects($this->any()) + ->method('isValidColumnType') + ->willReturn(true); + + $this->mockAdapter->expects($this->any()) + ->method('execute') + ->willReturnCallback(function ($sql) { + return true; + }); +}); + +it('can create a simple foreign key', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + + expect($foreignKey->getColumns())->toEqual('user_id'); + expect($foreignKey->getReferencedTable())->toEqual('users'); + expect($foreignKey->getReferencedColumns())->toEqual('id'); + expect($foreignKey->getOptions())->toEqual([]); +}); + +it('can create a foreign key with multiple columns', function (): void { + $foreignKey = new ForeignKey(['user_id', 'role_id'], 'user_roles', ['user_id', 'role_id']); + + expect($foreignKey->getColumns())->toEqual(['user_id', 'role_id']); + expect($foreignKey->getReferencedTable())->toEqual('user_roles'); + expect($foreignKey->getReferencedColumns())->toEqual(['user_id', 'role_id']); +}); + +it('can set delete and update actions with strings', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + $foreignKey->onDelete('CASCADE')->onUpdate('SET_NULL'); + + $options = $foreignKey->getOptions(); + expect($options['delete'])->toEqual('CASCADE'); + expect($options['update'])->toEqual('SET_NULL'); +}); + +it('can set constraint name', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + $foreignKey->constraint('fk_posts_user_id'); + + expect($foreignKey->getOptions()['constraint'])->toEqual('fk_posts_user_id'); +}); + +it('can use fluent interface with references and on', function (): void { + $foreignKey = new ForeignKey('user_id'); + $foreignKey->references('id')->on('users'); + + expect($foreignKey->getColumns())->toEqual('user_id'); + expect($foreignKey->getReferencedTable())->toEqual('users'); + expect($foreignKey->getReferencedColumns())->toEqual('id'); +}); + +it('can set deferrable option for PostgreSQL', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + + $postgresAdapter = $this->getMockBuilder(PostgresAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $foreignKey->setAdapter($postgresAdapter); + $foreignKey->deferrable('IMMEDIATE'); + + expect($foreignKey->getOptions()['deferrable'])->toEqual('IMMEDIATE'); + expect($foreignKey->isPostgres())->toBeTrue(); +}); + +it('ignores deferrable option for non-PostgreSQL adapters', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + + $mysqlAdapter = $this->getMockBuilder(MysqlAdapter::class) + ->disableOriginalConstructor() + ->getMock(); + + $foreignKey->setAdapter($mysqlAdapter); + $foreignKey->deferrable('IMMEDIATE'); + + expect($foreignKey->getOptions())->not->toHaveKey('deferrable'); + expect($foreignKey->isMysql())->toBeTrue(); +}); + +it('can add foreign key to table using foreignKey method', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreignKey('user_id', 'users', 'id', ['delete' => 'CASCADE']); + + expect($foreignKey)->toBeInstanceOf(ForeignKey::class); + expect($foreignKey->getColumns())->toEqual('user_id'); + expect($foreignKey->getReferencedTable())->toEqual('users'); + expect($foreignKey->getReferencedColumns())->toEqual('id'); + expect($foreignKey->getOptions()['delete'])->toEqual('CASCADE'); + + $foreignKeys = $table->getForeignKeyBuilders(); + expect(count($foreignKeys))->toEqual(1); + expect($foreignKeys[0])->toEqual($foreignKey); +}); + +it('can add foreign key to table using foreign method with fluent interface', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreign('user_id')->references('id')->on('users')->onDelete('CASCADE'); + + expect($foreignKey->getColumns())->toEqual('user_id'); + expect($foreignKey->getReferencedTable())->toEqual('users'); + expect($foreignKey->getReferencedColumns())->toEqual('id'); + expect($foreignKey->getOptions()['delete'])->toEqual('CASCADE'); + + $foreignKeys = $table->getForeignKeyBuilders(); + expect(count($foreignKeys))->toEqual(1); + expect($foreignKeys[0])->toEqual($foreignKey); +}); + +it('can create foreign key with multiple columns using fluent interface', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreign(['user_id', 'role_id']) + ->references(['user_id', 'role_id']) + ->on('user_roles') + ->onDelete(ColumnAction::NO_ACTION) + ->onUpdate(ColumnAction::NO_ACTION) + ->constraint('fk_posts_user_role'); + + expect($foreignKey->getColumns())->toEqual(['user_id', 'role_id']); + expect($foreignKey->getReferencedTable())->toEqual('user_roles'); + expect($foreignKey->getReferencedColumns())->toEqual(['user_id', 'role_id']); + expect($foreignKey->getOptions())->toEqual([ + 'delete' => 'NO_ACTION', + 'update' => 'NO_ACTION', + 'constraint' => 'fk_posts_user_role', + ]); +}); + +it('sets adapter correctly when added to table', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreignKey('user_id', 'users'); + + expect($foreignKey->getAdapter())->not->toBeNull(); +}); + +it('can use ColumnAction enum constants for onDelete and onUpdate', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + $foreignKey->onDelete(ColumnAction::CASCADE)->onUpdate(ColumnAction::SET_NULL); + + $options = $foreignKey->getOptions(); + expect($options['delete'])->toEqual('CASCADE'); + expect($options['update'])->toEqual('SET_NULL'); +}); + +it('can use mixed string and ColumnAction enum parameters', function (): void { + $foreignKey = new ForeignKey('user_id', 'users', 'id'); + $foreignKey->onDelete('RESTRICT')->onUpdate(ColumnAction::NO_ACTION); + + $options = $foreignKey->getOptions(); + expect($options['delete'])->toEqual('RESTRICT'); + expect($options['update'])->toEqual('NO_ACTION'); +}); + +it('can use ColumnAction enum in fluent interface', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $foreignKey = $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete(ColumnAction::CASCADE) + ->onUpdate(ColumnAction::RESTRICT); + + expect($foreignKey->getOptions()['delete'])->toEqual('CASCADE'); + expect($foreignKey->getOptions()['update'])->toEqual('RESTRICT'); +}); diff --git a/tests/Unit/Database/Migrations/TableTest.php b/tests/Unit/Database/Migrations/TableTest.php index 51a739d1..f0b18a5b 100644 --- a/tests/Unit/Database/Migrations/TableTest.php +++ b/tests/Unit/Database/Migrations/TableTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Phenix\Database\Constants\ColumnAction; use Phenix\Database\Migration; use Phenix\Database\Migrations\Columns\BigInteger; use Phenix\Database\Migrations\Columns\Binary; @@ -350,9 +351,9 @@ $column = $table->id('user_id'); - expect($column)->toBeInstanceOf(UnsignedInteger::class); + expect($column)->toBeInstanceOf(UnsignedBigInteger::class); expect($column->getName())->toBe('user_id'); - expect($column->getType())->toBe('integer'); + expect($column->getType())->toBe('biginteger'); expect($column->getOptions())->toBe([ 'null' => false, 'signed' => false, @@ -1030,3 +1031,40 @@ expect($migration->table('users'))->toBeInstanceOf(Table::class); }); + +it('can add foreign key using table methods', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $table->string('title'); + $table->foreignKey('user_id', 'users', 'id', ['delete' => ColumnAction::CASCADE->value]); + + $columns = $table->getColumnBuilders(); + $foreignKeys = $table->getForeignKeyBuilders(); + + expect(count($columns))->toBe(1); + expect(count($foreignKeys))->toBe(1); + + $foreignKey = $foreignKeys[0]; + expect($foreignKey->getColumns())->toBe('user_id'); + expect($foreignKey->getReferencedTable())->toBe('users'); + expect($foreignKey->getReferencedColumns())->toBe('id'); + expect($foreignKey->getOptions()['delete'])->toBe('CASCADE'); +}); + +it('can add foreign key using fluent interface', function (): void { + $table = new Table('posts', adapter: $this->mockAdapter); + + $table->string('title'); + $table->foreign('author_id')->references('id')->on('authors')->onDelete(ColumnAction::SET_NULL)->constraint('fk_post_author'); + + $foreignKeys = $table->getForeignKeyBuilders(); + + expect(count($foreignKeys))->toBe(1); + + $foreignKey = $foreignKeys[0]; + expect($foreignKey->getColumns())->toBe('author_id'); + expect($foreignKey->getReferencedTable())->toBe('authors'); + expect($foreignKey->getReferencedColumns())->toBe('id'); + expect($foreignKey->getOptions()['delete'])->toBe('SET_NULL'); + expect($foreignKey->getOptions()['constraint'])->toBe('fk_post_author'); +});