diff --git a/src/Phaseolies/Database/Migration/Blueprint.php b/src/Phaseolies/Database/Migration/Blueprint.php index 2a24b981..7b0166c3 100644 --- a/src/Phaseolies/Database/Migration/Blueprint.php +++ b/src/Phaseolies/Database/Migration/Blueprint.php @@ -21,6 +21,9 @@ class Blueprint /** @var string The primary key column name */ protected string $primaryKey = ''; + /** @var array Table level options (engine, charset, collation, comment, temporary) */ + protected array $tableOptions = []; + /** @var Grammar The grammar instance for the current database driver */ protected Grammar $grammar; @@ -232,55 +235,60 @@ public function date(string $column): ColumnDefinition * Create a DATETIME column (date and time, no timezone) * * @param string $column + * @param int|null $precision Fractional seconds digits (MySQL / PostgreSQL) * @return ColumnDefinition */ - public function dateTime(string $column): ColumnDefinition + public function dateTime(string $column, ?int $precision = null): ColumnDefinition { - return $this->addColumn('dateTime', $column); + return $this->addColumn('dateTime', $column, $this->precision($precision)); } /** * Create a DATETIME column with timezone awareness * * @param string $column + * @param int|null $precision Fractional seconds digits (MySQL / PostgreSQL) * @return ColumnDefinition */ - public function dateTimeTz(string $column): ColumnDefinition + public function dateTimeTz(string $column, ?int $precision = null): ColumnDefinition { - return $this->addColumn('dateTimeTz', $column); + return $this->addColumn('dateTimeTz', $column, $this->precision($precision)); } /** * Create a TIME column (time only, no date) * * @param string $column + * @param int|null $precision Fractional seconds digits (MySQL / PostgreSQL) * @return ColumnDefinition */ - public function time(string $column): ColumnDefinition + public function time(string $column, ?int $precision = null): ColumnDefinition { - return $this->addColumn('time', $column); + return $this->addColumn('time', $column, $this->precision($precision)); } /** * Create a TIME column with timezone awareness * * @param string $column + * @param int|null $precision Fractional seconds digits (MySQL / PostgreSQL) * @return ColumnDefinition */ - public function timeTz(string $column): ColumnDefinition + public function timeTz(string $column, ?int $precision = null): ColumnDefinition { - return $this->addColumn('timeTz', $column); + return $this->addColumn('timeTz', $column, $this->precision($precision)); } /** * Create a TIMESTAMP column with timezone awareness * * @param string $column + * @param int|null $precision Fractional seconds digits (MySQL / PostgreSQL) * @return ColumnDefinition */ - public function timestampTz(string $column): ColumnDefinition + public function timestampTz(string $column, ?int $precision = null): ColumnDefinition { - return $this->addColumn('timestampTz', $column); + return $this->addColumn('timestampTz', $column, $this->precision($precision)); } /** @@ -609,43 +617,543 @@ public function integer(string $column): ColumnDefinition * Create a timestamp column. * * @param string $column + * @param int|null $precision Fractional seconds digits (MySQL / PostgreSQL) * @return ColumnDefinition */ - public function timestamp(string $column): ColumnDefinition + public function timestamp(string $column, ?int $precision = null): ColumnDefinition { - return $this->addColumn('timestamp', $column); + return $this->addColumn('timestamp', $column, $this->precision($precision)); } /** * Add nullable creation and update timestamps to the table. * + * @param int|null $precision + * @return void + */ + public function timestamps(?int $precision = null): void + { + $this->timestamp('created_at', $precision)->nullable(); + $this->timestamp('updated_at', $precision)->nullable(); + } + + /** + * Alias of timestamps(). + * + * @param int|null $precision * @return void */ - public function timestamps(): void + public function nullableTimestamps(?int $precision = null): void { - $this->timestamp('created_at')->nullable(); - $this->timestamp('updated_at')->nullable(); + $this->timestamps($precision); } /** * Add created_at and updated_at with timezone awareness. * + * @param int|null $precision * @return void */ - public function timestampsTz(): void + public function timestampsTz(?int $precision = null): void { - $this->timestampTz('created_at')->nullable(); - $this->timestampTz('updated_at')->nullable(); + $this->timestampTz('created_at', $precision)->nullable(); + $this->timestampTz('updated_at', $precision)->nullable(); } /** * Add a nullable deletion timestamp to the table. * + * @param string $column + * @param int|null $precision + * @return ColumnDefinition + */ + public function softDeletes(string $column = 'deleted_at', ?int $precision = null): ColumnDefinition + { + return $this->timestamp($column, $precision)->nullable(); + } + + /** + * Add a nullable deletion timestamp with timezone awareness. + * + * @param string $column + * @param int|null $precision + * @return ColumnDefinition + */ + public function softDeletesTz(string $column = 'deleted_at', ?int $precision = null): ColumnDefinition + { + return $this->timestampTz($column, $precision)->nullable(); + } + + /** + * Add a nullable remember_token VARCHAR(100) column. + * + * @return ColumnDefinition + */ + public function rememberToken(): ColumnDefinition + { + return $this->string('remember_token', 100)->nullable(); + } + + /** + * Create an auto-incrementing INT UNSIGNED primary key column. + * + * @param string $column + * @return ColumnDefinition + */ + public function increments(string $column): ColumnDefinition + { + return $this->addColumn('increments', $column)->primary(); + } + + /** + * Alias of increments(). + * + * @param string $column + * @return ColumnDefinition + */ + public function integerIncrements(string $column): ColumnDefinition + { + return $this->addColumn('integerIncrements', $column)->primary(); + } + + /** + * Create an auto-incrementing TINYINT UNSIGNED primary key column. + * + * @param string $column + * @return ColumnDefinition + */ + public function tinyIncrements(string $column): ColumnDefinition + { + return $this->addColumn('tinyIncrements', $column)->primary(); + } + + /** + * Create an auto-incrementing SMALLINT UNSIGNED primary key column. + * + * @param string $column + * @return ColumnDefinition + */ + public function smallIncrements(string $column): ColumnDefinition + { + return $this->addColumn('smallIncrements', $column)->primary(); + } + + /** + * Create an auto-incrementing MEDIUMINT UNSIGNED primary key column. + * + * @param string $column + * @return ColumnDefinition + */ + public function mediumIncrements(string $column): ColumnDefinition + { + return $this->addColumn('mediumIncrements', $column)->primary(); + } + + /** + * Create a ULID column (stored as CHAR(26)). + * + * @param string $column + * @return ColumnDefinition + */ + public function ulid(string $column = 'ulid'): ColumnDefinition + { + return $this->addColumn('ulid', $column); + } + + /** + * Create an unsigned BIGINT column that can be constrained to another table: + * $table->foreignId('user_id')->constrained()->cascadeOnDelete() + * + * @param string $column + * @return ForeignIdColumnDefinition + */ + public function foreignId(string $column): ForeignIdColumnDefinition + { + return $this->addForeignIdColumn('unsignedBigInteger', $column); + } + + /** + * Create a UUID column that can be constrained to another table. + * + * @param string $column + * @return ForeignIdColumnDefinition + */ + public function foreignUuid(string $column): ForeignIdColumnDefinition + { + return $this->addForeignIdColumn('uuid', $column); + } + + /** + * Create a ULID column that can be constrained to another table. + * + * @param string $column + * @return ForeignIdColumnDefinition + */ + public function foreignUlid(string $column): ForeignIdColumnDefinition + { + return $this->addForeignIdColumn('ulid', $column); + } + + /** + * Add the {name}_type and {name}_id columns of a polymorphic relation + * together with a composite index on them. + * + * @param string $name + * @param string|null $indexName * @return void */ - public function softDeletes(): void + public function morphs(string $name, ?string $indexName = null): void { - $this->timestamp('deleted_at')->nullable(); + $this->string("{$name}_type"); + $this->unsignedBigInteger("{$name}_id"); + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Nullable version of morphs(). + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function nullableMorphs(string $name, ?string $indexName = null): void + { + $this->string("{$name}_type")->nullable(); + $this->unsignedBigInteger("{$name}_id")->nullable(); + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Polymorphic relation whose key is a UUID. + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function uuidMorphs(string $name, ?string $indexName = null): void + { + $this->string("{$name}_type"); + $this->uuid("{$name}_id"); + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Polymorphic relation whose key is a ULID. + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function ulidMorphs(string $name, ?string $indexName = null): void + { + $this->string("{$name}_type"); + $this->ulid("{$name}_id"); + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Set the storage engine of the table (MySQL only). + * + * @param string $engine + * @return static + */ + public function engine(string $engine): static + { + $this->tableOptions['engine'] = $engine; + + return $this; + } + + /** + * Set the default character set of the table (MySQL only). + * + * @param string $charset + * @return static + */ + public function charset(string $charset): static + { + $this->tableOptions['charset'] = $charset; + + return $this; + } + + /** + * Set the default collation of the table (MySQL only). + * + * @param string $collation + * @return static + */ + public function collation(string $collation): static + { + $this->tableOptions['collation'] = $collation; + + return $this; + } + + /** + * Add a comment to the table (MySQL and PostgreSQL). + * + * @param string $comment + * @return static + */ + public function comment(string $comment): static + { + $this->tableOptions['comment'] = $comment; + + return $this; + } + + /** + * Create the table as a TEMPORARY table. + * + * @return static + */ + public function temporary(): static + { + $this->tableOptions['temporary'] = true; + + return $this; + } + + /** + * Add a (composite) index. + * + * @param string|array $columns + * @param string|null $name + * @param string|null $algorithm + * @return IndexDefinition + */ + public function index(string|array $columns, ?string $name = null, ?string $algorithm = null): IndexDefinition + { + return $this->addIndexCommand('index', $columns, $name, $algorithm); + } + + /** + * Add a (composite) unique constraint. + * + * @param string|array $columns + * @param string|null $name + * @return IndexDefinition + */ + public function unique(string|array $columns, ?string $name = null): IndexDefinition + { + return $this->addIndexCommand('unique', $columns, $name); + } + + /** + * Add a (composite) primary key. + * + * @param string|array $columns + * @return IndexDefinition + */ + public function primary(string|array $columns): IndexDefinition + { + return $this->addIndexCommand('primary', $columns); + } + + /** + * Add a full text index. + * + * @param string|array $columns + * @param string|null $name + * @return IndexDefinition + */ + public function fullText(string|array $columns, ?string $name = null): IndexDefinition + { + return $this->addIndexCommand('fulltext', $columns, $name); + } + + /** + * Add a spatial index. + * + * @param string|array $columns + * @param string|null $name + * @return IndexDefinition + */ + public function spatialIndex(string|array $columns, ?string $name = null): IndexDefinition + { + return $this->addIndexCommand('spatial', $columns, $name); + } + + /** + * Drop one or more columns. + * + * @param string|array $columns + * @return void + */ + public function dropColumn(string|array $columns): void + { + $this->commands[] = ['command' => 'dropColumn', 'columns' => (array) $columns]; + } + + /** + * Rename a column. + * + * @param string $from + * @param string $to + * @return void + */ + public function renameColumn(string $from, string $to): void + { + $this->commands[] = ['command' => 'renameColumn', 'from' => $from, 'to' => $to]; + } + + /** + * Drop an index by name, or by the column list it was created with. + * + * @param string|array $index + * @return void + */ + public function dropIndex(string|array $index): void + { + $this->addDropIndexCommand('index', $index); + } + + /** + * Drop a unique constraint by name, or by the column list it was created with. + * + * @param string|array $index + * @return void + */ + public function dropUnique(string|array $index): void + { + $this->addDropIndexCommand('unique', $index); + } + + /** + * Drop a full text index by name, or by the column list it was created with. + * + * @param string|array $index + * @return void + */ + public function dropFullText(string|array $index): void + { + $this->addDropIndexCommand('fulltext', $index); + } + + /** + * Drop a spatial index by name, or by the column list it was created with. + * + * @param string|array $index + * @return void + */ + public function dropSpatialIndex(string|array $index): void + { + $this->addDropIndexCommand('spatial', $index); + } + + /** + * Drop the primary key. + * + * @return void + */ + public function dropPrimary(): void + { + $this->commands[] = ['command' => 'dropIndex', 'type' => 'primary', 'name' => '']; + } + + /** + * Drop a foreign key by constraint name, or by the column(s) it was created on. + * + * @param string|array $index + * @return void + */ + public function dropForeign(string|array $index): void + { + $name = is_array($index) ? 'fk_' . $this->table . '_' . implode('_', $index) : $index; + + $this->commands[] = ['command' => 'dropIndex', 'type' => 'foreign', 'name' => $name]; + } + + /** + * Drop a foreign key column together with its constraint + * (the default fk_{table}_{column} constraint name is used). + * + * @param string $column + * @return void + */ + public function dropConstrainedForeignId(string $column): void + { + $this->dropForeign([$column]); + $this->dropColumn($column); + } + + /** + * Rename an index. + * + * @param string $from + * @param string $to + * @return void + */ + public function renameIndex(string $from, string $to): void + { + $this->commands[] = ['command' => 'renameIndex', 'from' => $from, 'to' => $to]; + } + + /** + * Drop the created_at and updated_at columns. + * + * @return void + */ + public function dropTimestamps(): void + { + $this->dropColumn(['created_at', 'updated_at']); + } + + /** + * Drop the timezone aware created_at and updated_at columns. + * + * @return void + */ + public function dropTimestampsTz(): void + { + $this->dropTimestamps(); + } + + /** + * Drop the soft delete column. + * + * @param string $column + * @return void + */ + public function dropSoftDeletes(string $column = 'deleted_at'): void + { + $this->dropColumn($column); + } + + /** + * Drop the timezone aware soft delete column. + * + * @param string $column + * @return void + */ + public function dropSoftDeletesTz(string $column = 'deleted_at'): void + { + $this->dropSoftDeletes($column); + } + + /** + * Drop the remember_token column. + * + * @return void + */ + public function dropRememberToken(): void + { + $this->dropColumn('remember_token'); + } + + /** + * Drop the columns (and composite index) of a polymorphic relation. + * Pass the same index name that was given to morphs(), if any. + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function dropMorphs(string $name, ?string $indexName = null): void + { + $this->dropIndex($indexName ?? ["{$name}_type", "{$name}_id"]); + $this->dropColumn(["{$name}_type", "{$name}_id"]); } /** @@ -681,10 +1189,10 @@ public function foreignIdFor(string $model, bool $onDeleteCascade = false, bool /** * Create a foreign key constraint on the given column. * - * @param string $column + * @param string|array $column * @return ForeignKeyDefinition */ - public function foreign(string $column): ForeignKeyDefinition + public function foreign(string|array $column): ForeignKeyDefinition { $foreign = new ForeignKeyDefinition($this, $column); $this->commands[] = $foreign; @@ -709,6 +1217,64 @@ public function addColumn(string $type, string $name, array $parameters = []): C return $column; } + /** + * Add a column that can later be constrained with constrained(). + * + * @param string $type + * @param string $name + * @return ForeignIdColumnDefinition + */ + protected function addForeignIdColumn(string $type, string $name): ForeignIdColumnDefinition + { + $column = new ForeignIdColumnDefinition($this, compact('type', 'name')); + + $this->columns[] = $column; + + return $column; + } + + /** + * Register a table level index command. + * + * @param string $type + * @param string|array $columns + * @param string|null $name + * @param string|null $algorithm + * @return IndexDefinition + */ + protected function addIndexCommand(string $type, string|array $columns, ?string $name = null, ?string $algorithm = null): IndexDefinition + { + $index = new IndexDefinition($type, (array) $columns, $name, $algorithm); + $this->commands[] = $index; + + return $index; + } + + /** + * Register a DROP INDEX style command. + * + * @param string $type + * @param string|array $index + * @return void + */ + protected function addDropIndexCommand(string $type, string|array $index): void + { + $name = is_array($index) ? $this->grammar->indexName($this->table, $index, $type) : $index; + + $this->commands[] = ['command' => 'dropIndex', 'type' => $type, 'name' => $name]; + } + + /** + * Build the optional precision attribute of temporal columns. + * + * @param int|null $precision + * @return array + */ + protected function precision(?int $precision): array + { + return $precision === null ? [] : ['precision' => $precision]; + } + /** * Create an unsigned big integer column. * @@ -757,76 +1323,218 @@ public function jsonb(string $column): ColumnDefinition } /** - * Convert the blueprint to SQL statements. + * Convert the blueprint to a list of SQL statements, in execution order. * - * @return string + * @return array * @throws \RuntimeException */ - public function toSql(): string + public function toStatements(): array { $statements = []; // Convert all columns to their SQL representations $columns = array_values(array_filter($this->columns)); - if (empty($columns)) { + if (empty($columns) && ($this->creating || empty($this->commands))) { throw new \RuntimeException("No columns defined for table {$this->table}"); } if ($this->creating) { - // Create table with all columns - $columnDefinitions = []; - $primaryKeys = []; - - // First pass: collect column definitions and primary keys - foreach ($columns as $column) { - $columnSql = $column->toSql(); - - // If this is a primary key column, note it for later - if (isset($column->attributes['primary']) && $column->attributes['primary']) { - $columnName = trim(explode(' ', $column->name)[0], '`'); - $primaryKeys[] = $columnName; + $this->compileCreate($columns, $statements); + } else { + $this->compileAlter($columns, $statements); + } + + // Add any additional commands (indexes, foreign keys, drops, renames) + foreach ($this->commands as $command) { + $this->compileCommand($command, $statements); + } + + return array_values(array_filter($statements)); + } + + /** + * Convert the blueprint to SQL statements joined into one string. + * + * @return string + * @throws \RuntimeException + */ + public function toSql(): string + { + return implode(';' . PHP_EOL, $this->toStatements()) . ';'; + } + + /** + * Compile CREATE TABLE and everything that has to follow it. + * + * @param array $columns + * @param array $statements + * @return void + */ + protected function compileCreate(array $columns, array &$statements): void + { + // A table level primary key is the same as flagging its columns + foreach ($this->commands as $command) { + if ($command instanceof IndexDefinition && $command->type === 'primary') { + foreach ($command->columns as $name) { + $this->findColumn($columns, $name)->attributes['primary'] = true; } + } + } + + $primaryKeys = []; - $columnDefinitions[] = $columnSql; + foreach ($columns as $column) { + // If this is a primary key column, note it for later + if (!empty($column->attributes['primary'])) { + $primaryKeys[] = trim(explode(' ', $column->name)[0], '`'); } + } + + $this->grammar->setTableOptions($this->tableOptions); + + $create = $this->grammar->compileCreateTable($this->table, $columns, $primaryKeys); - // Let the grammar handle the table creation with primary keys - $statements[] = $this->grammar->compileCreateTable($this->table, $columns, $primaryKeys); + // Drivers that cannot ALTER TABLE ADD CONSTRAINT take foreign keys inline + if (!$this->grammar->supportsAddingForeignKey()) { + $inline = []; - // Add indexes and unique constraints - foreach ($columns as $column) { - $this->handleIndexAndUniqueColumn($column, $statements); + foreach ($this->commands as $command) { + if ($command instanceof ForeignKeyDefinition) { + $inline[] = $command->toConstraintSql(); + } } - } else { - // Modifying an existing table: - foreach ($columns as $column) { - // Only MySQL supports positioning an added column with - // AFTER, and only in this ALTER TABLE context. - $column->attributes['altering'] = true; - $columnSql = $column->toSql(); + if ($inline) { + $create = $this->grammar->appendTableConstraints($create, $inline); + } + } + + $statements[] = $create; + + foreach ($this->grammar->compileTableOptionStatements($this->table) as $statement) { + $statements[] = $statement; + } + + // Add indexes and unique constraints + foreach ($columns as $column) { + $this->handleIndexAndUniqueColumn($column, $statements); + + foreach ($this->grammar->compileColumnComment($this->table, $column) as $statement) { + $statements[] = $statement; + } + } + } + /** + * Compile the statements that add or modify columns of an existing table. + * + * @param array $columns + * @param array $statements + * @return void + */ + protected function compileAlter(array $columns, array &$statements): void + { + foreach ($columns as $column) { + // Only MySQL supports positioning an added column with + // AFTER, and only in this ALTER TABLE context. + $column->attributes['altering'] = true; + + if (!empty($column->attributes['change'])) { + foreach ($this->grammar->compileChangeColumn($this->table, $column) as $statement) { + $statements[] = $statement; + } + } else { // Skip if this is a primary key column and the grammar doesn't support adding it with ALTER - if ((isset($column->attributes['primary']) && $column->attributes['primary']) && + if ( + !empty($column->attributes['primary']) && !$this->grammar->supportsAddingPrimaryKey() ) { continue; } - $statements[] = $this->grammar->compileAddColumn($this->table, $columnSql); - $this->handleIndexAndUniqueColumn($column, $statements); + $statements[] = $this->grammar->compileAddColumn($this->table, $column->toSql()); + } + + $this->handleIndexAndUniqueColumn($column, $statements); + + foreach ($this->grammar->compileColumnComment($this->table, $column) as $statement) { + $statements[] = $statement; } } + } - // Add any additional commands (like foreign keys) - foreach ($this->commands as $command) { - if ($command instanceof ForeignKeyDefinition) { + /** + * Compile a single table level command. + * + * @param ForeignKeyDefinition|IndexDefinition|array $command + * @param array $statements + * @return void + */ + protected function compileCommand(ForeignKeyDefinition|IndexDefinition|array $command, array &$statements): void + { + if ($command instanceof ForeignKeyDefinition) { + if ($this->grammar->supportsAddingForeignKey()) { $statements[] = $command->toSql(); + } elseif (!$this->creating) { + throw new \RuntimeException( + "Cannot add a foreign key to existing table \"{$this->table}\" on this database driver. " . + "Declare it in the table's original CREATE TABLE migration instead." + ); + } + + return; + } + + if ($command instanceof IndexDefinition) { + if ($command->type === 'primary') { + if (!$this->creating) { + $statements[] = $this->grammar->compileAddPrimary($this->table, $command->columns); + } + + return; } + + $statements[] = $this->grammar->compileIndex($this->table, $command); + + return; } - return implode(';' . PHP_EOL, $statements) . ';'; + switch ($command['command']) { + case 'dropColumn': + foreach ($this->grammar->compileDropColumn($this->table, $command['columns']) as $statement) { + $statements[] = $statement; + } + break; + case 'renameColumn': + $statements[] = $this->grammar->compileRenameColumn($this->table, $command['from'], $command['to']); + break; + case 'dropIndex': + $statements[] = $this->grammar->compileDropIndex($this->table, $command['name'], $command['type']); + break; + case 'renameIndex': + $statements[] = $this->grammar->compileRenameIndex($this->table, $command['from'], $command['to']); + break; + } + } + + /** + * Find a declared column by name. + * + * @param array $columns + * @param string $name + * @return ColumnDefinition + * @throws \InvalidArgumentException + */ + protected function findColumn(array $columns, string $name): ColumnDefinition + { + foreach ($columns as $column) { + if ($column->name === $name) { + return $column; + } + } + + throw new \InvalidArgumentException("Column [{$name}] is not defined on table [{$this->table}]."); } /** @@ -838,12 +1546,22 @@ public function toSql(): string */ protected function handleIndexAndUniqueColumn(ColumnDefinition $column, array &$statements): void { - if (isset($column->attributes['index']) && $column->attributes['index']) { - $statements[] = $this->grammar->compileCreateIndex($this->table, $column->name); + $attributes = $column->attributes; + + if (!empty($attributes['index'])) { + $statements[] = $this->grammar->compileCreateIndex( + $this->table, + $column->name, + is_string($attributes['index']) ? $attributes['index'] : null + ); } - if (isset($column->attributes['unique']) && $column->attributes['unique']) { - $sql = $this->grammar->compileCreateUnique($this->table, $column->name); + if (!empty($attributes['unique'])) { + $sql = $this->grammar->compileCreateUnique( + $this->table, + $column->name, + is_string($attributes['unique']) ? $attributes['unique'] : null + ); if (!empty($sql)) { $statements[] = $sql; @@ -856,6 +1574,16 @@ protected function handleIndexAndUniqueColumn(ColumnDefinition $column, array &$ ); } } + + foreach (['fulltext', 'spatial'] as $type) { + if (!empty($attributes[$type])) { + $statements[] = $this->grammar->compileIndex($this->table, new IndexDefinition( + $type, + [$column->name], + is_string($attributes[$type]) ? $attributes[$type] : null + )); + } + } } /** diff --git a/src/Phaseolies/Database/Migration/ColumnDefinition.php b/src/Phaseolies/Database/Migration/ColumnDefinition.php index b8401a4d..305c42bf 100644 --- a/src/Phaseolies/Database/Migration/ColumnDefinition.php +++ b/src/Phaseolies/Database/Migration/ColumnDefinition.php @@ -31,11 +31,12 @@ public function __construct(array $attributes = []) /** * Set the column as nullable (allowing NULL values). * + * @param bool $value * @return self */ - public function nullable(): self + public function nullable(bool $value = true): self { - $this->attributes['nullable'] = true; + $this->attributes['nullable'] = $value; return $this; } @@ -60,25 +61,53 @@ public function default($value): self } /** - * Set the column as unique. + * Set the column as unique, optionally with a custom constraint name. * + * @param string|null $name * @return self */ - public function unique(): self + public function unique(?string $name = null): self { - $this->attributes['unique'] = true; + $this->attributes['unique'] = $name ?? true; return $this; } /** - * Set the column to be indexed. + * Set the column to be indexed, optionally with a custom index name. * + * @param string|null $name * @return self */ - public function index(): self + public function index(?string $name = null): self { - $this->attributes['index'] = true; + $this->attributes['index'] = $name ?? true; + + return $this; + } + + /** + * Add a full text index on the column. + * + * @param string|null $name + * @return self + */ + public function fullText(?string $name = null): self + { + $this->attributes['fulltext'] = $name ?? true; + + return $this; + } + + /** + * Add a spatial index on the column. + * + * @param string|null $name + * @return self + */ + public function spatialIndex(?string $name = null): self + { + $this->attributes['spatial'] = $name ?? true; return $this; } @@ -108,6 +137,178 @@ public function after(string $column): self return $this; } + /** + * Place the column first in the table (MySQL, ALTER TABLE only). + * + * @return self + */ + public function first(): self + { + $this->attributes['first'] = true; + + return $this; + } + + /** + * Add a comment to the column (MySQL and PostgreSQL). + * + * @param string $comment + * @return self + */ + public function comment(string $comment): self + { + $this->attributes['comment'] = $comment; + + return $this; + } + + /** + * Mark a numeric column as UNSIGNED (MySQL only, ignored elsewhere). + * + * @return self + */ + public function unsigned(): self + { + $this->attributes['unsigned'] = true; + + return $this; + } + + /** + * Use CURRENT_TIMESTAMP as the column default. + * + * @return self + */ + public function useCurrent(): self + { + $this->attributes['default'] = new RawExpression('CURRENT_TIMESTAMP'); + + return $this; + } + + /** + * Refresh the column with CURRENT_TIMESTAMP on every update (MySQL only). + * + * @return self + */ + public function useCurrentOnUpdate(): self + { + $this->attributes['useCurrentOnUpdate'] = true; + + return $this; + } + + /** + * Make an integer column auto-incrementing (it must also be a key in MySQL). + * + * @return self + */ + public function autoIncrement(): self + { + $this->attributes['autoIncrement'] = true; + + return $this; + } + + /** + * Set the character set of the column (MySQL only). + * + * @param string $charset + * @return self + */ + public function charset(string $charset): self + { + $this->attributes['charset'] = $charset; + + return $this; + } + + /** + * Set the collation of the column. + * + * @param string $collation + * @return self + */ + public function collation(string $collation): self + { + $this->attributes['collation'] = $collation; + + return $this; + } + + /** + * Create a stored generated column from the given SQL expression. + * + * @param string $expression + * @return self + */ + public function storedAs(string $expression): self + { + $this->attributes['storedAs'] = $expression; + + return $this; + } + + /** + * Create a virtual generated column from the given SQL expression. + * + * @param string $expression + * @return self + */ + public function virtualAs(string $expression): self + { + $this->attributes['virtualAs'] = $expression; + + return $this; + } + + /** + * Hide the column from SELECT * (MySQL 8+ only). + * + * @return self + */ + public function invisible(): self + { + $this->attributes['invisible'] = true; + + return $this; + } + + /** + * Modify the existing column to match this definition (Schema::table only). + * Every attribute must be restated: anything not declared, such as + * nullable() or default(), is reset to the column default. + * + * @return self + */ + public function change(): self + { + $this->attributes['change'] = true; + + return $this; + } + + /** + * Get the SQL for the DEFAULT value, or null when none is set. + * + * @return string|null + */ + public function getDefaultSql(): ?string + { + if (!isset($this->attributes['default'])) { + return null; + } + + $value = $this->attributes['default']; + + return match (true) { + $value instanceof RawExpression => (string) $value->getValue(), + is_string($value) => $this->getGrammar()->quoteString($value), + is_bool($value) => $value ? '1' : '0', + default => (string) $value, + }; + } + /** * Convert the column definition to its SQL representation. * @@ -118,8 +319,13 @@ public function toSql(): string $grammar = $this->getGrammar(); $sql = $this->name . ' ' . $grammar->getTypeDefinition($this); - // Add PRIMARY KEY constraint if specified - if (isset($this->attributes['primary']) && $this->attributes['primary']) { + // Add PRIMARY KEY inline when altering, or when the grammar wants it + // there; otherwise the grammar emits a table level PRIMARY KEY clause + // (so composite and table level primary keys never conflict with it). + if ( + !empty($this->attributes['primary']) && + ($grammar->shouldAddPrimaryInColumnDefinition() || !empty($this->attributes['altering'])) + ) { $sql .= ' PRIMARY KEY'; } @@ -139,27 +345,21 @@ public function toSql(): string $sql .= ' NOT NULL'; } - // Add DEFAULT value if specified - if (isset($this->attributes['default'])) { - $defaultValue = $this->attributes['default']; - - $default = match (true) { - $defaultValue instanceof RawExpression => $defaultValue->getValue(), - is_string($defaultValue) => "'" . addslashes($defaultValue) . "'", - is_bool($defaultValue) => $defaultValue ? '1' : '0', - is_null($defaultValue) => 'NULL', - default => $defaultValue, - }; + // Add DEFAULT value if specified (generated columns cannot have one) + $default = $this->getDefaultSql(); + if ($default !== null && !isset($this->attributes['storedAs']) && !isset($this->attributes['virtualAs'])) { $sql .= " DEFAULT {$default}"; } - if ( - $this->getDriver() === 'mysql' && - !empty($this->attributes['altering']) && - isset($this->attributes['after']) - ) { - $sql .= " AFTER {$this->attributes['after']}"; + $sql .= $grammar->compileColumnModifiers($this); + + if ($this->getDriver() === 'mysql' && !empty($this->attributes['altering'])) { + if (!empty($this->attributes['first'])) { + $sql .= ' FIRST'; + } elseif (isset($this->attributes['after'])) { + $sql .= " AFTER {$this->attributes['after']}"; + } } return $sql; diff --git a/src/Phaseolies/Database/Migration/ForeignIdColumnDefinition.php b/src/Phaseolies/Database/Migration/ForeignIdColumnDefinition.php new file mode 100644 index 00000000..879c2de2 --- /dev/null +++ b/src/Phaseolies/Database/Migration/ForeignIdColumnDefinition.php @@ -0,0 +1,66 @@ +blueprint = $blueprint; + } + + /** + * Add a foreign key constraint referencing the given table. + * Without arguments the table is guessed from the column name + * (user_id => users, category_id => categories). + * + * @param string|null $table + * @param string $column + * @param string|null $indexName + * @return ForeignKeyDefinition + */ + public function constrained(?string $table = null, string $column = 'id', ?string $indexName = null): ForeignKeyDefinition + { + $foreign = $this->references($column)->on($table ?? $this->guessTable()); + + return $indexName ? $foreign->name($indexName) : $foreign; + } + + /** + * Start a foreign key constraint on this column. + * + * @param string $column + * @return ForeignKeyDefinition + */ + public function references(string $column): ForeignKeyDefinition + { + return $this->blueprint->foreign($this->name)->references($column); + } + + /** + * Guess the referenced table name from the column name. + * + * @return string + */ + protected function guessTable(): string + { + $base = preg_replace('/_(id|uuid|ulid)$/', '', $this->name); + + return match (true) { + preg_match('/[^aeiou]y$/', $base) === 1 => substr($base, 0, -1) . 'ies', + preg_match('/(s|x|z|ch|sh)$/', $base) === 1 => $base . 'es', + default => $base . 's', + }; + } +} diff --git a/src/Phaseolies/Database/Migration/ForeignKeyDefinition.php b/src/Phaseolies/Database/Migration/ForeignKeyDefinition.php index 1ab0d87b..9095d640 100644 --- a/src/Phaseolies/Database/Migration/ForeignKeyDefinition.php +++ b/src/Phaseolies/Database/Migration/ForeignKeyDefinition.php @@ -7,14 +7,17 @@ class ForeignKeyDefinition /** @var Blueprint The table blueprint that contains this foreign key */ protected Blueprint $blueprint; - /** @var string The column name that will be the foreign key */ - protected string $column; + /** @var string|array The column name(s) that will be the foreign key */ + protected string|array $column; /** @var string The referenced table name */ - protected string $on; + protected string $on = ''; - /** @var string The referenced column name (default: 'id') */ - protected string $references = 'id'; + /** @var string|array The referenced column name(s) (default: 'id') */ + protected string|array $references = 'id'; + + /** @var string|null Custom constraint name */ + protected ?string $name = null; /** @var string|null The ON DELETE action (e.g., CASCADE, SET NULL) */ protected ?string $onDelete = null; @@ -26,21 +29,21 @@ class ForeignKeyDefinition * Create a new foreign key definition instance. * * @param Blueprint $blueprint - * @param string $column + * @param string|array $column */ - public function __construct(Blueprint $blueprint, string $column) + public function __construct(Blueprint $blueprint, string|array $column) { $this->blueprint = $blueprint; $this->column = $column; } /** - * Set the referenced column name in the foreign table. + * Set the referenced column name(s) in the foreign table. * - * @param string $column + * @param string|array $column * @return self */ - public function references(string $column): self + public function references(string|array $column): self { $this->references = $column; @@ -86,6 +89,39 @@ public function onUpdate(string $action): self return $this; } + /** + * Set a custom name for the foreign key constraint. + * + * @param string $name + * @return self + */ + public function name(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * Set ON DELETE to NO ACTION. + * + * @return self + */ + public function noActionOnDelete(): self + { + return $this->onDelete('NO ACTION'); + } + + /** + * Set ON UPDATE to NO ACTION. + * + * @return self + */ + public function noActionOnUpdate(): self + { + return $this->onUpdate('NO ACTION'); + } + /** * Set ON DELETE to CASCADE (delete referencing rows when referenced row is deleted). * @@ -147,30 +183,36 @@ public function nullOnUpdate(): self } /** - * Convert the foreign key definition to its SQL representation. + * Get the constraint name. + * + * @return string + */ + public function getName(): string + { + return $this->name ?? $this->getConstraintName(); + } + + /** + * Convert the constraint to its table level SQL fragment. * * @return string * @throws \RuntimeException */ - public function toSql(): string + public function toConstraintSql(): string { if (!$this->references || !$this->on) { throw new \RuntimeException('Foreign key constraint is incomplete. Missing references or on table.'); } - // Generate constraint name - $constraintName = $this->getConstraintName(); + $columns = implode(', ', (array) $this->column); + $references = implode(', ', (array) $this->references); - // Build the base ALTER TABLE statement - $sql = "ALTER TABLE {$this->blueprint->table} ADD CONSTRAINT {$constraintName} "; - $sql .= "FOREIGN KEY ({$this->column}) REFERENCES {$this->on} ({$this->references})"; + $sql = "CONSTRAINT {$this->getName()} FOREIGN KEY ({$columns}) REFERENCES {$this->on} ({$references})"; - // Add ON DELETE clause if specified if ($this->onDelete) { $sql .= " ON DELETE {$this->onDelete}"; } - // Add ON UPDATE clause if specified if ($this->onUpdate) { $sql .= " ON UPDATE {$this->onUpdate}"; } @@ -178,6 +220,17 @@ public function toSql(): string return $sql; } + /** + * Convert the foreign key definition to its SQL representation. + * + * @return string + * @throws \RuntimeException + */ + public function toSql(): string + { + return "ALTER TABLE {$this->blueprint->table} ADD " . $this->toConstraintSql(); + } + /** * Generate a standard name for the foreign key constraint. * @@ -185,6 +238,6 @@ public function toSql(): string */ protected function getConstraintName(): string { - return "fk_{$this->blueprint->table}_{$this->column}"; + return 'fk_' . $this->blueprint->table . '_' . implode('_', (array) $this->column); } } diff --git a/src/Phaseolies/Database/Migration/Grammars/Grammar.php b/src/Phaseolies/Database/Migration/Grammars/Grammar.php index 1bc2e144..3a8299a0 100644 --- a/src/Phaseolies/Database/Migration/Grammars/Grammar.php +++ b/src/Phaseolies/Database/Migration/Grammars/Grammar.php @@ -3,9 +3,486 @@ namespace Phaseolies\Database\Migration\Grammars; use Phaseolies\Database\Migration\ColumnDefinition; +use Phaseolies\Database\Migration\IndexDefinition; abstract class Grammar { + /** + * Column types that are auto-incrementing primary keys. + * + * @var array + */ + protected const INCREMENTING_TYPES = [ + 'id', + 'bigIncrements', + 'increments', + 'integerIncrements', + 'tinyIncrements', + 'smallIncrements', + 'mediumIncrements', + ]; + + /** + * Table level options (engine, charset, collation, comment, temporary) + * + * @var array + */ + protected array $tableOptions = []; + + /** + * Set the table level options used by the next CREATE TABLE statement. + * + * @param array $options + * @return static + */ + public function setTableOptions(array $options): static + { + $this->tableOptions = $options; + + return $this; + } + + /** + * Determine whether the given column type is an auto-incrementing key. + * + * @param string $type + * @return bool + */ + public function isIncrementing(string $type): bool + { + return in_array($type, static::INCREMENTING_TYPES, true); + } + + /** + * Quote an identifier (table / column / index name). + * + * @param string $name + * @return string + */ + public function quoteIdentifier(string $name): string + { + return '"' . str_replace('"', '""', trim($name, '"`')) . '"'; + } + + /** + * Quote a list of identifiers and join them with commas. + * + * @param array $names + * @return string + */ + public function quoteColumns(array $names): string + { + return implode(', ', array_map(fn($name) => $this->quoteIdentifier($name), $names)); + } + + /** + * Quote a string literal. + * + * @param string $value + * @return string + */ + public function quoteString(string $value): string + { + return "'" . str_replace("'", "''", $value) . "'"; + } + + /** + * Build the default name of an index for the given table and columns. + * The scheme matches the one used for single-column indexes + * (idx_{table}_{column}, {table}_{column}_unique) so that + * dropIndex(['col']) always resolves to the index that index(['col']) + * created. Names longer than the 63 byte identifier limit are shortened + * with a stable hash. + * + * @param string $table + * @param array $columns + * @param string $type + * @return string + */ + public function indexName(string $table, array $columns, string $type = 'index'): string + { + $base = $table . '_' . implode('_', $columns); + + $name = match ($type) { + 'index' => 'idx_' . $base, + default => $base . '_' . $type, + }; + + $name = preg_replace('/[^A-Za-z0-9_]/', '_', $name); + + if (strlen($name) > 63) { + $name = substr($name, 0, 54) . '_' . substr(md5($name), 0, 8); + } + + return $name; + } + + /** + * Get the SQL appended to a column type (collation, generated column). + * + * @param ColumnDefinition $column + * @return string + */ + public function compileTypeSuffix(ColumnDefinition $column): string + { + $sql = ''; + $attributes = $column->attributes; + + if (!empty($attributes['collation'])) { + $sql .= ' COLLATE ' . $this->formatCollation($attributes['collation']); + } + + $generated = $attributes['storedAs'] ?? $attributes['virtualAs'] ?? null; + + if ($generated !== null) { + $sql .= " GENERATED ALWAYS AS ({$generated}) " + . (isset($attributes['storedAs']) ? 'STORED' : 'VIRTUAL'); + } + + return $sql; + } + + /** + * Get driver specific modifiers appended after the DEFAULT clause. + * + * @param ColumnDefinition $column + * @return string + */ + public function compileColumnModifiers(ColumnDefinition $column): string + { + return ''; + } + + /** + * Whether PRIMARY KEY is declared inline in the column definition. + * Only SQLite needs this; the other grammars emit a table level clause. + * + * @return bool + */ + public function shouldAddPrimaryInColumnDefinition(): bool + { + return false; + } + + /** + * Whether foreign keys can be added to an existing table. + * + * @return bool + */ + public function supportsAddingForeignKey(): bool + { + return true; + } + + /** + * Append table level constraints to a compiled CREATE TABLE statement. + * + * @param string $createSql + * @param array $constraints + * @return string + */ + public function appendTableConstraints(string $createSql, array $constraints): string + { + $position = strrpos($createSql, ')'); + + return substr($createSql, 0, $position) . ', ' . implode(', ', $constraints) . substr($createSql, $position); + } + + /** + * Compile statements that must follow CREATE TABLE (table comment, ...). + * + * @param string $table + * @return array + */ + public function compileTableOptionStatements(string $table): array + { + return []; + } + + /** + * Compile statements that attach a comment to a column when the driver + * does not support inline comments. + * + * @param string $table + * @param ColumnDefinition $column + * @return array + */ + public function compileColumnComment(string $table, ColumnDefinition $column): array + { + return []; + } + + /** + * Compile a statement that creates a table (used to add TEMPORARY). + * + * @return string + */ + protected function createTableKeyword(): string + { + return !empty($this->tableOptions['temporary']) ? 'CREATE TEMPORARY TABLE' : 'CREATE TABLE'; + } + + /** + * Compile DROP COLUMN statements (one per column, SQLite has no multi-drop). + * + * @param string $table + * @param array $columns + * @return array + */ + public function compileDropColumn(string $table, array $columns): array + { + return array_map( + fn($column) => "ALTER TABLE {$this->quoteIdentifier($table)} DROP COLUMN {$this->quoteIdentifier($column)}", + $columns + ); + } + + /** + * Compile a RENAME COLUMN statement. + * + * @param string $table + * @param string $from + * @param string $to + * @return string + */ + public function compileRenameColumn(string $table, string $from, string $to): string + { + return "ALTER TABLE {$this->quoteIdentifier($table)} RENAME COLUMN " + . "{$this->quoteIdentifier($from)} TO {$this->quoteIdentifier($to)}"; + } + + /** + * Compile a RENAME TABLE statement. + * + * @param string $from + * @param string $to + * @return string + */ + public function compileRenameTable(string $from, string $to): string + { + return "ALTER TABLE {$this->quoteIdentifier($from)} RENAME TO {$this->quoteIdentifier($to)}"; + } + + /** + * Compile a DROP TABLE statement. + * + * @param string $table + * @param bool $ifExists + * @return string + */ + public function compileDropTable(string $table, bool $ifExists = false): string + { + return 'DROP TABLE ' . ($ifExists ? 'IF EXISTS ' : '') . $this->quoteIdentifier($table); + } + + /** + * Compile a table level index (index, unique, fulltext, spatial). + * + * @param string $table + * @param IndexDefinition $index + * @return string + */ + public function compileIndex(string $table, IndexDefinition $index): string + { + $name = $index->name ?? $this->indexName($table, $index->columns, $index->type); + + return match ($index->type) { + 'index' => $this->compileCreateIndexSql($table, $name, $index->columns, $index->algorithm), + 'unique' => $this->compileCreateUniqueSql($table, $name, $index->columns), + 'fulltext' => $this->compileFullTextIndex($table, $name, $index->columns), + 'spatial' => $this->compileSpatialIndex($table, $name, $index->columns), + default => throw new \InvalidArgumentException("Unknown index type [{$index->type}]."), + }; + } + + /** + * Compile CREATE INDEX for one or more columns. + * + * @param string $table + * @param string $name + * @param array $columns + * @param string|null $algorithm + * @return string + */ + protected function compileCreateIndexSql(string $table, string $name, array $columns, ?string $algorithm = null): string + { + return "CREATE INDEX {$this->quoteIdentifier($name)} ON {$this->quoteIdentifier($table)} " + . ($algorithm ? "USING {$this->safeKeyword($algorithm)} " : '') + . "({$this->quoteColumns($columns)})"; + } + + /** + * Compile a UNIQUE constraint for one or more columns. + * + * @param string $table + * @param string $name + * @param array $columns + * @return string + */ + protected function compileCreateUniqueSql(string $table, string $name, array $columns): string + { + return "ALTER TABLE {$this->quoteIdentifier($table)} ADD CONSTRAINT {$this->quoteIdentifier($name)} " + . "UNIQUE ({$this->quoteColumns($columns)})"; + } + + /** + * Compile a full text index. + * + * @param string $table + * @param string $name + * @param array $columns + * @return string + */ + protected function compileFullTextIndex(string $table, string $name, array $columns): string + { + throw new \RuntimeException('This database driver does not support full text indexes.'); + } + + /** + * Compile a spatial index. + * + * @param string $table + * @param string $name + * @param array $columns + * @return string + */ + protected function compileSpatialIndex(string $table, string $name, array $columns): string + { + throw new \RuntimeException('This database driver does not support spatial indexes.'); + } + + /** + * Compile a statement that drops an index of the given type. + * + * @param string $table + * @param string $name + * @param string $type index|unique|fulltext|spatial|primary|foreign + * @return string + */ + public function compileDropIndex(string $table, string $name, string $type = 'index'): string + { + return match ($type) { + 'primary' => $this->compileDropPrimary($table), + 'foreign' => $this->compileDropForeign($table, $name), + 'unique' => $this->compileDropUnique($table, $name), + default => 'DROP INDEX ' . $this->quoteIdentifier($name), + }; + } + + /** + * Compile a DROP UNIQUE statement. + * + * @param string $table + * @param string $name + * @return string + */ + protected function compileDropUnique(string $table, string $name): string + { + return "ALTER TABLE {$this->quoteIdentifier($table)} DROP CONSTRAINT {$this->quoteIdentifier($name)}"; + } + + /** + * Compile a DROP PRIMARY KEY statement. + * + * @param string $table + * @return string + */ + protected function compileDropPrimary(string $table): string + { + throw new \RuntimeException('This database driver cannot drop a primary key.'); + } + + /** + * Compile a DROP FOREIGN KEY statement. + * + * @param string $table + * @param string $name + * @return string + */ + protected function compileDropForeign(string $table, string $name): string + { + return "ALTER TABLE {$this->quoteIdentifier($table)} DROP CONSTRAINT {$this->quoteIdentifier($name)}"; + } + + /** + * Compile an ADD PRIMARY KEY statement. + * + * @param string $table + * @param array $columns + * @return string + */ + public function compileAddPrimary(string $table, array $columns): string + { + return "ALTER TABLE {$this->quoteIdentifier($table)} ADD PRIMARY KEY ({$this->quoteColumns($columns)})"; + } + + /** + * Compile a RENAME INDEX statement. + * + * @param string $table + * @param string $from + * @param string $to + * @return string + */ + public function compileRenameIndex(string $table, string $from, string $to): string + { + throw new \RuntimeException('This database driver cannot rename indexes.'); + } + + /** + * Compile the statements that modify an existing column. + * + * @param string $table + * @param ColumnDefinition $column + * @return array + */ + public function compileChangeColumn(string $table, ColumnDefinition $column): array + { + throw new \RuntimeException('This database driver cannot modify existing columns.'); + } + + /** + * SQL (with a single table-name binding) that lists the column names. + * + * @return string + */ + abstract public function compileGetColumns(): string; + + /** + * SQL (with a single table-name binding) that lists the index names. + * + * @return string + */ + abstract public function compileGetIndexes(): string; + + /** + * Format a collation name for use in COLLATE. + * + * @param string $collation + * @return string + */ + protected function formatCollation(string $collation): string + { + return $this->safeKeyword($collation); + } + + /** + * Allow only characters that are valid in keywords / charset names, to + * keep schema definitions from injecting SQL. + * + * @param string $value + * @return string + * @throws \InvalidArgumentException + */ + protected function safeKeyword(string $value): string + { + if (!preg_match('/^[A-Za-z0-9_.\-]+$/', $value)) { + throw new \InvalidArgumentException("Invalid identifier [{$value}]."); + } + + return $value; + } + /** * Get the SQL type definition for a column. * @@ -38,18 +515,20 @@ abstract public function compileAddColumn(string $table, string $columnSql): str * * @param string $table * @param string $column + * @param string|null $name * @return string */ - abstract public function compileCreateIndex(string $table, string $column): string; + abstract public function compileCreateIndex(string $table, string $column, ?string $name = null): string; /** * Get the SQL for creating a unique constraint. * * @param string $table * @param string $column + * @param string|null $name * @return string */ - abstract public function compileCreateUnique(string $table, string $column): string; + abstract public function compileCreateUnique(string $table, string $column, ?string $name = null): string; /** * Check if the grammar supports adding columns with AFTER. diff --git a/src/Phaseolies/Database/Migration/Grammars/MySQLGrammar.php b/src/Phaseolies/Database/Migration/Grammars/MySQLGrammar.php index 9d39b321..e1e4c3fd 100644 --- a/src/Phaseolies/Database/Migration/Grammars/MySQLGrammar.php +++ b/src/Phaseolies/Database/Migration/Grammars/MySQLGrammar.php @@ -21,12 +21,102 @@ public function getTypeDefinition(ColumnDefinition $column): string { $type = $this->mapType($column->type, $column->attributes); - // For auto-incrementing columns, add UNSIGNED for MySQL - if ($column->type === 'id' || $column->type === 'bigIncrements') { - $type = 'BIGINT UNSIGNED'; + // Auto-incrementing key columns are always unsigned + if ($this->isIncrementing($column->type)) { + $type = $this->incrementingType($column->type); + } elseif (!empty($column->attributes['unsigned']) && !str_contains($type, 'UNSIGNED')) { + if (preg_match('/^(TINYINT|SMALLINT|MEDIUMINT|INT|BIGINT|FLOAT|DOUBLE|DECIMAL)\b(?!\(1\))/', $type)) { + $type .= ' UNSIGNED'; + } } - return $type; + return $type . $this->compileTypeSuffix($column); + } + + /** + * Get the unsigned integer type backing an incrementing column. + * + * @param string $type + * @return string + */ + protected function incrementingType(string $type): string + { + return match ($type) { + 'increments', 'integerIncrements' => 'INT UNSIGNED', + 'tinyIncrements' => 'TINYINT UNSIGNED', + 'smallIncrements' => 'SMALLINT UNSIGNED', + 'mediumIncrements' => 'MEDIUMINT UNSIGNED', + default => 'BIGINT UNSIGNED', + }; + } + + /** + * Quote an identifier with backticks. + * + * @param string $name + * @return string + */ + public function quoteIdentifier(string $name): string + { + return '`' . str_replace('`', '``', trim($name, '"`')) . '`'; + } + + /** + * Quote a string literal (MySQL treats backslash as an escape character). + * + * @param string $value + * @return string + */ + public function quoteString(string $value): string + { + return "'" . str_replace(["\\", "'"], ["\\\\", "''"], $value) . "'"; + } + + /** + * Character set and collation are both column level in MySQL. + * + * @param ColumnDefinition $column + * @return string + */ + public function compileTypeSuffix(ColumnDefinition $column): string + { + $sql = ''; + + if (!empty($column->attributes['charset'])) { + $sql .= ' CHARACTER SET ' . $this->safeKeyword($column->attributes['charset']); + } + + return $sql . parent::compileTypeSuffix($column); + } + + /** + * MySQL specific column modifiers placed after DEFAULT. + * + * @param ColumnDefinition $column + * @return string + */ + public function compileColumnModifiers(ColumnDefinition $column): string + { + $attributes = $column->attributes; + $sql = ''; + + if (!empty($attributes['useCurrentOnUpdate'])) { + $sql .= ' ON UPDATE CURRENT_TIMESTAMP'; + } + + if ($this->isIncrementing($column->type) || !empty($attributes['autoIncrement'])) { + $sql .= ' AUTO_INCREMENT'; + } + + if (!empty($attributes['invisible'])) { + $sql .= ' INVISIBLE'; + } + + if (isset($attributes['comment'])) { + $sql .= ' COMMENT ' . $this->quoteString($attributes['comment']); + } + + return $sql; } @@ -46,10 +136,11 @@ public function compileCreateTable(string $table, array $columns, array $primary foreach ($columns as $column) { $columnSql = $column->toSql(); - if ($column->type === 'id' || $column->type === 'bigIncrements') { + if ($this->isIncrementing($column->type)) { $columnSql = sprintf( - '`%s` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT', - $column->name + '`%s` %s NOT NULL AUTO_INCREMENT', + $column->name, + $this->incrementingType($column->type) ); $primaryKeyColumns[] = trim($column->name, '`'); } elseif ($column->type === 'uuid' && !empty($column->attributes['primary'])) { @@ -71,7 +162,23 @@ public function compileCreateTable(string $table, array $columns, array $primary $primaryKeySql = ', PRIMARY KEY (`' . implode('`, `', $primaryKeyColumns) . '`)'; } - return "CREATE TABLE `{$table}` (" . implode(', ', $columnDefinitions) . $primaryKeySql . ") ENGINE={$this->engine}"; + $options = $this->tableOptions; + $sql = $this->createTableKeyword() . " `{$table}` (" . implode(', ', $columnDefinitions) . $primaryKeySql . ')'; + $sql .= ' ENGINE=' . $this->safeKeyword($options['engine'] ?? $this->engine); + + if (!empty($options['charset'])) { + $sql .= ' DEFAULT CHARSET=' . $this->safeKeyword($options['charset']); + } + + if (!empty($options['collation'])) { + $sql .= ' COLLATE=' . $this->safeKeyword($options['collation']); + } + + if (isset($options['comment'])) { + $sql .= ' COMMENT=' . $this->quoteString($options['comment']); + } + + return $sql; } /** @@ -93,11 +200,9 @@ public function compileAddColumn(string $table, string $columnSql): string * @param string $column * @return string */ - public function compileCreateIndex(string $table, string $column): string + public function compileCreateIndex(string $table, string $column, ?string $name = null): string { - $indexName = "idx_{$table}_{$column}"; - - return "CREATE INDEX `{$indexName}` ON `{$table}` (`{$column}`)"; + return $this->compileCreateIndexSql($table, $name ?? $this->indexName($table, [$column]), [$column]); } /** @@ -107,11 +212,84 @@ public function compileCreateIndex(string $table, string $column): string * @param string $column * @return string */ - public function compileCreateUnique(string $table, string $column): string + public function compileCreateUnique(string $table, string $column, ?string $name = null): string { - $constraintName = "{$table}_{$column}_unique"; + return $this->compileCreateUniqueSql($table, $name ?? $this->indexName($table, [$column], 'unique'), [$column]); + } - return "ALTER TABLE `{$table}` ADD CONSTRAINT `{$constraintName}` UNIQUE (`{$column}`)"; + /** + * MySQL builds hash/btree choice into the index definition. + * + * @param string $table + * @param string $name + * @param array $columns + * @param string|null $algorithm + * @return string + */ + protected function compileCreateIndexSql(string $table, string $name, array $columns, ?string $algorithm = null): string + { + return "CREATE INDEX {$this->quoteIdentifier($name)} ON {$this->quoteIdentifier($table)} " + . "({$this->quoteColumns($columns)})" + . ($algorithm ? ' USING ' . $this->safeKeyword($algorithm) : ''); + } + + protected function compileFullTextIndex(string $table, string $name, array $columns): string + { + return "CREATE FULLTEXT INDEX {$this->quoteIdentifier($name)} ON {$this->quoteIdentifier($table)} " + . "({$this->quoteColumns($columns)})"; + } + + protected function compileSpatialIndex(string $table, string $name, array $columns): string + { + return "CREATE SPATIAL INDEX {$this->quoteIdentifier($name)} ON {$this->quoteIdentifier($table)} " + . "({$this->quoteColumns($columns)})"; + } + + public function compileDropIndex(string $table, string $name, string $type = 'index'): string + { + return match ($type) { + 'primary' => $this->compileDropPrimary($table), + 'foreign' => $this->compileDropForeign($table, $name), + default => "DROP INDEX {$this->quoteIdentifier($name)} ON {$this->quoteIdentifier($table)}", + }; + } + + protected function compileDropPrimary(string $table): string + { + return "ALTER TABLE {$this->quoteIdentifier($table)} DROP PRIMARY KEY"; + } + + protected function compileDropForeign(string $table, string $name): string + { + return "ALTER TABLE {$this->quoteIdentifier($table)} DROP FOREIGN KEY {$this->quoteIdentifier($name)}"; + } + + public function compileRenameTable(string $from, string $to): string + { + return "RENAME TABLE {$this->quoteIdentifier($from)} TO {$this->quoteIdentifier($to)}"; + } + + public function compileRenameIndex(string $table, string $from, string $to): string + { + return "ALTER TABLE {$this->quoteIdentifier($table)} RENAME INDEX " + . "{$this->quoteIdentifier($from)} TO {$this->quoteIdentifier($to)}"; + } + + public function compileChangeColumn(string $table, ColumnDefinition $column): array + { + return ["ALTER TABLE {$this->quoteIdentifier($table)} MODIFY COLUMN {$column->toSql()}"]; + } + + public function compileGetColumns(): string + { + return 'SELECT COLUMN_NAME FROM information_schema.COLUMNS ' + . 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION'; + } + + public function compileGetIndexes(): string + { + return 'SELECT DISTINCT INDEX_NAME FROM information_schema.STATISTICS ' + . 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'; } /** @@ -157,12 +335,27 @@ protected function mapType(string $type, array $attributes): string return 'DECIMAL(' . ($attributes['precision'] ?? 10) . ',' . ($attributes['scale'] ?? 2) . ')'; case 'double': return 'DOUBLE' . $this->getPrecisionAndScale($attributes); + case 'timestamp': + case 'timestampTz': + return 'TIMESTAMP' . $this->getTimePrecision($attributes); + case 'dateTime': + case 'dateTimeTz': + return 'DATETIME' . $this->getTimePrecision($attributes); + case 'time': + case 'timeTz': + return 'TIME' . $this->getTimePrecision($attributes); } // Standard type mappings $map = [ 'id' => 'BIGINT', 'bigIncrements' => 'BIGINT', + 'increments' => 'INT', + 'integerIncrements' => 'INT', + 'tinyIncrements' => 'TINYINT', + 'smallIncrements' => 'SMALLINT', + 'mediumIncrements' => 'MEDIUMINT', + 'ulid' => 'CHAR(26)', 'text' => 'TEXT', 'mediumText' => 'MEDIUMTEXT', 'longText' => 'LONGTEXT', @@ -209,6 +402,17 @@ protected function mapType(string $type, array $attributes): string return $map[$type] ?? strtoupper($type); } + /** + * Get the fractional seconds precision suffix, e.g. (3). + * + * @param array $attributes + * @return string + */ + protected function getTimePrecision(array $attributes): string + { + return isset($attributes['precision']) ? '(' . (int) $attributes['precision'] . ')' : ''; + } + /** * Get the precision and scale for decimal/double types * diff --git a/src/Phaseolies/Database/Migration/Grammars/PostgreSQLGrammar.php b/src/Phaseolies/Database/Migration/Grammars/PostgreSQLGrammar.php index 46e38eb9..7bd34e5d 100644 --- a/src/Phaseolies/Database/Migration/Grammars/PostgreSQLGrammar.php +++ b/src/Phaseolies/Database/Migration/Grammars/PostgreSQLGrammar.php @@ -21,7 +21,52 @@ public function getTypeDefinition(ColumnDefinition $column): string $type .= ' ' . $this->compileEnumCheckClause($column->name, $values); } - return $type; + return $type . $this->compileTypeSuffix($column); + } + + /** + * Collation names are case sensitive in PostgreSQL and must be quoted. + * + * @param string $collation + * @return string + */ + protected function formatCollation(string $collation): string + { + return $this->quoteIdentifier($this->safeKeyword($collation)); + } + + /** + * Get the SERIAL type backing an incrementing column. + * + * @param string $type + * @return string + */ + protected function serialType(string $type): string + { + return match ($type) { + 'tinyIncrements', 'smallIncrements' => 'SMALLSERIAL', + 'increments', 'integerIncrements', 'mediumIncrements' => 'SERIAL', + default => 'BIGSERIAL', + }; + } + + public function compileColumnComment(string $table, ColumnDefinition $column): array + { + if (!isset($column->attributes['comment'])) { + return []; + } + + return ["COMMENT ON COLUMN {$this->quoteIdentifier($table)}.{$this->quoteIdentifier($column->name)} IS " + . $this->quoteString($column->attributes['comment'])]; + } + + public function compileTableOptionStatements(string $table): array + { + if (!isset($this->tableOptions['comment'])) { + return []; + } + + return ["COMMENT ON TABLE {$this->quoteIdentifier($table)} IS " . $this->quoteString($this->tableOptions['comment'])]; } /** @@ -40,10 +85,11 @@ public function compileCreateTable(string $table, array $columns, array $primary foreach ($columns as $column) { $columnSql = $column->toSql(); - if ($column->type === 'id' || $column->type === 'bigIncrements') { + if ($this->isIncrementing($column->type)) { $columnSql = sprintf( - '"%s" BIGSERIAL NOT NULL', - $column->name + '"%s" %s NOT NULL', + $column->name, + $this->serialType($column->type) ); $primaryKeyColumns[] = trim($column->name, '"'); } elseif ($column->type === 'uuid' && !empty($column->attributes['primary'])) { @@ -65,7 +111,7 @@ public function compileCreateTable(string $table, array $columns, array $primary $primaryKeySql = ', PRIMARY KEY ("' . implode('", "', $primaryKeyColumns) . '")'; } - return "CREATE TABLE \"{$table}\" (" . implode(', ', $columnDefinitions) . $primaryKeySql . ")"; + return $this->createTableKeyword() . " \"{$table}\" (" . implode(', ', $columnDefinitions) . $primaryKeySql . ')'; } /** @@ -85,13 +131,12 @@ public function compileAddColumn(string $table, string $columnSql): string * * @param string $table * @param string $column + * @param string|null $name * @return string */ - public function compileCreateIndex(string $table, string $column): string + public function compileCreateIndex(string $table, string $column, ?string $name = null): string { - $indexName = "idx_{$table}_{$column}"; - - return "CREATE INDEX \"{$indexName}\" ON \"{$table}\" (\"{$column}\")"; + return $this->compileCreateIndexSql($table, $name ?? $this->indexName($table, [$column]), [$column]); } /** @@ -99,13 +144,79 @@ public function compileCreateIndex(string $table, string $column): string * * @param string $table * @param string $column + * @param string|null $name * @return string */ - public function compileCreateUnique(string $table, string $column): string + public function compileCreateUnique(string $table, string $column, ?string $name = null): string + { + return $this->compileCreateUniqueSql($table, $name ?? $this->indexName($table, [$column], 'unique'), [$column]); + } + + protected function compileFullTextIndex(string $table, string $name, array $columns): string + { + $vector = implode(" || ' ' || ", array_map( + fn($column) => "coalesce({$this->quoteIdentifier($column)}, '')", + $columns + )); + + return "CREATE INDEX {$this->quoteIdentifier($name)} ON {$this->quoteIdentifier($table)} " + . "USING GIN (to_tsvector('english', {$vector}))"; + } + + protected function compileSpatialIndex(string $table, string $name, array $columns): string + { + return "CREATE INDEX {$this->quoteIdentifier($name)} ON {$this->quoteIdentifier($table)} " + . "USING GIST ({$this->quoteColumns($columns)})"; + } + + protected function compileDropPrimary(string $table): string + { + return "ALTER TABLE {$this->quoteIdentifier($table)} DROP CONSTRAINT {$this->quoteIdentifier($table . '_pkey')}"; + } + + public function compileRenameIndex(string $table, string $from, string $to): string + { + return "ALTER INDEX {$this->quoteIdentifier($from)} RENAME TO {$this->quoteIdentifier($to)}"; + } + + public function compileChangeColumn(string $table, ColumnDefinition $column): array + { + $t = $this->quoteIdentifier($table); + $c = $this->quoteIdentifier($column->name); + $type = $this->mapType($column->type, $column->attributes); + + if ($this->isIncrementing($column->type)) { + $type = match ($column->type) { + 'tinyIncrements', 'smallIncrements' => 'SMALLINT', + 'increments', 'integerIncrements', 'mediumIncrements' => 'INTEGER', + default => 'BIGINT', + }; + } elseif (!empty($column->attributes['autoIncrement'])) { + $type = $this->mapType($column->type, array_diff_key($column->attributes, ['autoIncrement' => 1])); + } + + $statements = ["ALTER TABLE {$t} ALTER COLUMN {$c} TYPE {$type} USING {$c}::{$type}"]; + + $statements[] = "ALTER TABLE {$t} ALTER COLUMN {$c} " + . (!empty($column->attributes['nullable']) ? 'DROP NOT NULL' : 'SET NOT NULL'); + + $default = $column->getDefaultSql(); + + $statements[] = "ALTER TABLE {$t} ALTER COLUMN {$c} " + . ($default !== null ? "SET DEFAULT {$default}" : 'DROP DEFAULT'); + + return array_merge($statements, $this->compileColumnComment($table, $column)); + } + + public function compileGetColumns(): string { - $constraintName = "{$table}_{$column}_unique"; + return 'SELECT column_name FROM information_schema.columns ' + . 'WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position'; + } - return "ALTER TABLE \"{$table}\" ADD CONSTRAINT \"{$constraintName}\" UNIQUE (\"{$column}\")"; + public function compileGetIndexes(): string + { + return 'SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND tablename = ?'; } /** @@ -153,6 +264,29 @@ protected function mapType(string $type, array $attributes): string return 'DECIMAL(' . ($attributes['precision'] ?? 10) . ',' . ($attributes['scale'] ?? 2) . ')'; case 'double': return 'DOUBLE PRECISION'; + case 'timestamp': + return 'TIMESTAMP' . $this->getTimePrecision($attributes); + case 'timestampTz': + return 'TIMESTAMPTZ' . $this->getTimePrecision($attributes); + case 'dateTime': + return 'TIMESTAMP' . $this->getTimePrecision($attributes); + case 'dateTimeTz': + return 'TIMESTAMPTZ' . $this->getTimePrecision($attributes); + case 'time': + return 'TIME' . $this->getTimePrecision($attributes); + case 'timeTz': + return 'TIMETZ' . $this->getTimePrecision($attributes); + case 'integer': + case 'bigInteger': + case 'smallInteger': + if (!empty($attributes['autoIncrement'])) { + return match ($type) { + 'bigInteger' => 'BIGSERIAL', + 'smallInteger' => 'SMALLSERIAL', + default => 'SERIAL', + }; + } + break; case 'jsonb': return 'JSONB'; } @@ -163,6 +297,10 @@ protected function mapType(string $type, array $attributes): string 'bigIncrements' => 'BIGSERIAL', 'increments' => 'SERIAL', 'integerIncrements' => 'SERIAL', + 'tinyIncrements' => 'SMALLSERIAL', + 'smallIncrements' => 'SMALLSERIAL', + 'mediumIncrements' => 'SERIAL', + 'ulid' => 'CHAR(26)', 'text' => 'TEXT', 'mediumText' => 'TEXT', 'longText' => 'TEXT', @@ -208,6 +346,17 @@ protected function mapType(string $type, array $attributes): string return $map[$type] ?? strtoupper($type); } + /** + * Get the fractional seconds precision suffix, e.g. (3). + * + * @param array $attributes + * @return string + */ + protected function getTimePrecision(array $attributes): string + { + return isset($attributes['precision']) ? '(' . (int) $attributes['precision'] . ')' : ''; + } + /** * Create an ENUM type definition for * diff --git a/src/Phaseolies/Database/Migration/Grammars/SQLiteGrammar.php b/src/Phaseolies/Database/Migration/Grammars/SQLiteGrammar.php index 835a1a0c..0a6b47a6 100644 --- a/src/Phaseolies/Database/Migration/Grammars/SQLiteGrammar.php +++ b/src/Phaseolies/Database/Migration/Grammars/SQLiteGrammar.php @@ -21,7 +21,18 @@ public function getTypeDefinition(ColumnDefinition $column): string $type .= ' ' . $this->compileEnumCheckClause($column->name, $values); } - return $type; + return $type . $this->compileTypeSuffix($column); + } + + /** + * Quote an identifier with backticks. + * + * @param string $name + * @return string + */ + public function quoteIdentifier(string $name): string + { + return '`' . str_replace('`', '``', trim($name, '"`')) . '`'; } /** @@ -48,11 +59,10 @@ public function compileCreateTable(string $table, array $columns, array $primary // Check if this is a primary key column $isPrimaryKey = !empty($column->attributes['primary']) || in_array($column->name, $primaryKeys) || - $column->type === 'id' || - $column->type === 'bigIncrements'; + $this->isIncrementing($column->type); if ($isPrimaryKey) { - if (($column->type === 'id' || $column->type === 'bigIncrements')) { + if ($this->isIncrementing($column->type)) { $cleanSql = preg_replace('/\s+PRIMARY\s+KEY/i', '', $originalSql); $columnSql = str_replace('INTEGER', 'INTEGER PRIMARY KEY AUTOINCREMENT', $cleanSql); $hasAutoIncrementId = true; @@ -92,7 +102,7 @@ public function compileCreateTable(string $table, array $columns, array $primary } } - return "CREATE TABLE `{$table}` (" . implode(', ', $columnDefinitions) . $primaryKeySql . ")"; + return $this->createTableKeyword() . " `{$table}` (" . implode(', ', $columnDefinitions) . $primaryKeySql . ')'; } /** @@ -112,13 +122,12 @@ public function compileAddColumn(string $table, string $columnSql): string * * @param string $table * @param string $column + * @param string|null $name * @return string */ - public function compileCreateIndex(string $table, string $column): string + public function compileCreateIndex(string $table, string $column, ?string $name = null): string { - $indexName = "idx_{$table}_{$column}"; - - return "CREATE INDEX `{$indexName}` ON `{$table}` (`{$column}`)"; + return $this->compileCreateIndexSql($table, $name ?? $this->indexName($table, [$column]), [$column]); } /** @@ -126,14 +135,90 @@ public function compileCreateIndex(string $table, string $column): string * * @param string $table * @param string $column + * @param string|null $name * @return string */ - public function compileCreateUnique(string $table, string $column): string + public function compileCreateUnique(string $table, string $column, ?string $name = null): string { // SQLite does not support adding constraints UNIQUE with ALTER TABLE return ''; } + /** + * SQLite has no ALTER TABLE ADD CONSTRAINT, but a unique index is + * equivalent and can be created at any time. + * + * @param string $table + * @param string $name + * @param array $columns + * @return string + */ + protected function compileCreateUniqueSql(string $table, string $name, array $columns): string + { + return "CREATE UNIQUE INDEX {$this->quoteIdentifier($name)} ON {$this->quoteIdentifier($table)} " + . "({$this->quoteColumns($columns)})"; + } + + protected function compileDropUnique(string $table, string $name): string + { + return 'DROP INDEX ' . $this->quoteIdentifier($name); + } + + public function compileAddPrimary(string $table, array $columns): string + { + throw new \RuntimeException( + 'SQLite cannot add a primary key to an existing table. Declare it in the CREATE TABLE migration.' + ); + } + + protected function compileDropPrimary(string $table): string + { + throw new \RuntimeException('SQLite cannot drop a primary key from an existing table.'); + } + + protected function compileDropForeign(string $table, string $name): string + { + throw new \RuntimeException('SQLite cannot drop a foreign key from an existing table.'); + } + + public function compileChangeColumn(string $table, ColumnDefinition $column): array + { + throw new \RuntimeException( + "SQLite cannot modify column \"{$column->name}\" on table \"{$table}\". " + . 'Create a new table, copy the data and drop the old one instead.' + ); + } + + /** + * SQLite cannot add a foreign key with ALTER TABLE; it must be part of CREATE TABLE. + * + * @return bool + */ + public function supportsAddingForeignKey(): bool + { + return false; + } + + /** + * SQLite keeps PRIMARY KEY in the column definition. + * + * @return bool + */ + public function shouldAddPrimaryInColumnDefinition(): bool + { + return true; + } + + public function compileGetColumns(): string + { + return 'SELECT name FROM pragma_table_info(?)'; + } + + public function compileGetIndexes(): string + { + return 'SELECT name FROM pragma_index_list(?)'; + } + /** * SQLite does not support adding primary key with ALTER TABLE. * @@ -166,6 +251,12 @@ protected function mapType(string $type, array $attributes): string $map = [ 'id' => 'INTEGER', 'bigIncrements' => 'INTEGER', + 'increments' => 'INTEGER', + 'integerIncrements' => 'INTEGER', + 'tinyIncrements' => 'INTEGER', + 'smallIncrements' => 'INTEGER', + 'mediumIncrements' => 'INTEGER', + 'ulid' => 'TEXT', 'string' => 'TEXT', 'char' => 'TEXT', 'text' => 'TEXT', diff --git a/src/Phaseolies/Database/Migration/IndexDefinition.php b/src/Phaseolies/Database/Migration/IndexDefinition.php new file mode 100644 index 00000000..7ae0435c --- /dev/null +++ b/src/Phaseolies/Database/Migration/IndexDefinition.php @@ -0,0 +1,48 @@ +name = $name; + + return $this; + } + + /** + * Set the index algorithm (e.g. btree, hash, gin, gist). + * + * @param string $algorithm + * @return self + */ + public function algorithm(string $algorithm): self + { + $this->algorithm = $algorithm; + + return $this; + } +} diff --git a/src/Phaseolies/Database/Migration/Schema.php b/src/Phaseolies/Database/Migration/Schema.php index 8da323fa..70d5bcef 100644 --- a/src/Phaseolies/Database/Migration/Schema.php +++ b/src/Phaseolies/Database/Migration/Schema.php @@ -3,6 +3,10 @@ namespace Phaseolies\Database\Migration; use Phaseolies\Support\Facades\DB; +use Phaseolies\Database\Database; +use Phaseolies\Database\Migration\Grammars\Grammar; +use Phaseolies\Database\Migration\Grammars\GrammarFactory; +use PDO; class Schema { @@ -35,7 +39,7 @@ public function create(string $table, callable $callback): void $callback($blueprint); - DB::connection($this->connection)->execute($blueprint->toSql()); + $this->run($blueprint); } /** @@ -50,9 +54,31 @@ public function table(string $table, callable $callback): void $callback($blueprint); - $statements = $blueprint->toSql(); + $this->run($blueprint); + } - DB::connection($this->connection)->execute($statements); + /** + * Execute the blueprint one statement at a time. Not every driver can run + * several statements in a single call (SQLite silently runs only the first). + * + * @param Blueprint $blueprint + * @return void + */ + protected function run(Blueprint $blueprint): void + { + foreach ($blueprint->toStatements() as $statement) { + DB::connection($this->connection)->execute($statement); + } + } + + /** + * Drop a table + * + * @param string $table + */ + public function drop(string $table): void + { + DB::connection($this->connection)->execute($this->grammar()->compileDropTable($table)); } /** @@ -65,6 +91,107 @@ public function dropIfExists(string $table): void DB::connection($this->connection)->execute("DROP TABLE IF EXISTS {$table}"); } + /** + * Rename a table + * + * @param string $from + * @param string $to + */ + public function rename(string $from, string $to): void + { + DB::connection($this->connection)->execute($this->grammar()->compileRenameTable($from, $to)); + } + + /** + * Drop the given columns from a table + * + * @param string $table + * @param string|array $columns + */ + public function dropColumns(string $table, string|array $columns): void + { + $this->table($table, fn(Blueprint $blueprint) => $blueprint->dropColumn($columns)); + } + + /** + * Rename a column + * + * @param string $table + * @param string $from + * @param string $to + */ + public function renameColumn(string $table, string $from, string $to): void + { + $this->table($table, fn(Blueprint $blueprint) => $blueprint->renameColumn($from, $to)); + } + + /** + * Determine if a table has a column + * + * @param string $table + * @param string $column + * @return bool + */ + public function hasColumn(string $table, string $column): bool + { + return in_array(strtolower($column), array_map('strtolower', $this->getColumnListing($table)), true); + } + + /** + * Determine if a table has all of the given columns + * + * @param string $table + * @param array $columns + * @return bool + */ + public function hasColumns(string $table, array $columns): bool + { + foreach ($columns as $column) { + if (!$this->hasColumn($table, $column)) { + return false; + } + } + + return true; + } + + /** + * Get the column names of a table + * + * @param string $table + * @return array + */ + public function getColumnListing(string $table): array + { + return $this->fetchNames($this->grammar()->compileGetColumns(), $table); + } + + /** + * Determine if a table has an index (or unique constraint) with the given + * name, or one that was created on exactly the given column list. + * + * @param string $table + * @param string|array $index + * @return bool + */ + public function hasIndex(string $table, string|array $index): bool + { + $grammar = $this->grammar(); + $existing = array_map('strtolower', $this->fetchNames($grammar->compileGetIndexes(), $table)); + + $names = is_array($index) + ? [$grammar->indexName($table, $index), $grammar->indexName($table, $index, 'unique')] + : [$index]; + + foreach ($names as $name) { + if (in_array(strtolower($name), $existing, true)) { + return true; + } + } + + return false; + } + /** * Check if a table exists in the database * @@ -96,6 +223,31 @@ public function enableForeignKeyConstraints(): void DB::connection($this->connection)->enableForeignKeyConstraints(); } + /** + * Get the grammar of the current connection + * + * @return Grammar + */ + protected function grammar(): Grammar + { + return GrammarFactory::make(DB::connection($this->connection)->getDriver()); + } + + /** + * Run an introspection query bound to a table name + * + * @param string $sql + * @param string $table + * @return array + */ + protected function fetchNames(string $sql, string $table): array + { + $statement = Database::getPdoInstance($this->connection)->prepare($sql); + $statement->execute([$table]); + + return $statement->fetchAll(PDO::FETCH_COLUMN); + } + /** * Get a new Schema instance for the specified connection * diff --git a/src/Phaseolies/Support/Facades/Schema.php b/src/Phaseolies/Support/Facades/Schema.php index 7e69ecdb..1d9ed2a2 100644 --- a/src/Phaseolies/Support/Facades/Schema.php +++ b/src/Phaseolies/Support/Facades/Schema.php @@ -7,7 +7,15 @@ /** * @method static void create(string $table, callable $callback) * @method static void table(string $table, callable $callback) + * @method static void drop(string $table) * @method static void dropIfExists(string $table) + * @method static void rename(string $from, string $to) + * @method static void dropColumns(string $table, string|array $columns) + * @method static void renameColumn(string $table, string $from, string $to) + * @method static bool hasColumn(string $table, string $column) + * @method static bool hasColumns(string $table, array $columns) + * @method static array getColumnListing(string $table) + * @method static bool hasIndex(string $table, string|array $index) * @method static bool hasTable(string $table) * @method static void disableForeignKeyConstraints() * @method static void enableForeignKeyConstraints() diff --git a/tests/Builder/MigrationFeaturesTest.php b/tests/Builder/MigrationFeaturesTest.php new file mode 100644 index 00000000..54ffb7a3 --- /dev/null +++ b/tests/Builder/MigrationFeaturesTest.php @@ -0,0 +1,489 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->bind('sqlite'); + } + + protected function tearDown(): void + { + $property = (new \ReflectionClass(Container::class))->getProperty('instance'); + $property->setValue(null, null); + } + + private function bind(string $driver): void + { + $this->driver = $driver; + $pdo = $this->pdo; + + $container = new Container(); + $container->bind('db', fn() => new class($pdo, $driver) { + public function __construct(private PDO $pdo, private string $driver) + { + } + + public function getConnection() + { + return $this->driver === 'sqlite' ? $this->pdo : new class($this->driver) { + public function __construct(private string $driver) + { + } + + public function getAttribute($attribute) + { + return $this->driver; + } + }; + } + }); + + Container::setInstance($container); + } + + private function blueprint(string $table, callable $callback, bool $creating = true): Blueprint + { + $blueprint = new Blueprint($table, $this->driver, $creating); + $callback($blueprint); + + return $blueprint; + } + + /** Run a blueprint against the SQLite database, statement by statement. */ + private function migrate(string $table, callable $callback, bool $creating = true): void + { + foreach ($this->blueprint($table, $callback, $creating)->toStatements() as $statement) { + $this->pdo->exec($statement); + } + } + + private function sql(string $driver, callable $callback, bool $creating = true, string $table = 'posts'): string + { + $this->bind($driver); + + return $this->blueprint($table, $callback, $creating)->toSql(); + } + + private function columns(string $table): array + { + return $this->pdo->query("SELECT name FROM pragma_table_info('{$table}')")->fetchAll(PDO::FETCH_COLUMN); + } + + private function indexes(string $table): array + { + return $this->pdo->query("SELECT name FROM pragma_index_list('{$table}')")->fetchAll(PDO::FETCH_COLUMN); + } + + // ------------------------------------------------------------ indexes + + public function testColumnIndexIsActuallyCreatedOnSqlite(): void + { + $this->migrate('posts', function (Blueprint $table) { + $table->id(); + $table->string('title')->index(); + $table->string('slug')->unique(); + }); + + $this->assertContains('idx_posts_title', $this->indexes('posts')); + } + + public function testColumnIndexAcceptsCustomName(): void + { + $this->migrate('posts', function (Blueprint $table) { + $table->id(); + $table->string('title')->index('my_title_idx'); + }); + + $this->assertContains('my_title_idx', $this->indexes('posts')); + } + + public function testCompositeIndexAndUniqueOnSqlite(): void + { + $this->migrate('posts', function (Blueprint $table) { + $table->id(); + $table->string('a'); + $table->string('b'); + $table->index(['a', 'b']); + $table->unique(['a', 'b'], 'posts_ab_uq'); + }); + + $this->assertContains('idx_posts_a_b', $this->indexes('posts')); + $this->assertContains('posts_ab_uq', $this->indexes('posts')); + + $this->pdo->exec("INSERT INTO posts (a, b) VALUES ('x', 'y')"); + $this->expectException(\PDOException::class); + $this->pdo->exec("INSERT INTO posts (a, b) VALUES ('x', 'y')"); + } + + public function testUniqueOnExistingSqliteTableUsesUniqueIndex(): void + { + $this->migrate('posts', fn(Blueprint $t) => [$t->id(), $t->string('email')]); + $this->migrate('posts', fn(Blueprint $t) => $t->unique('email'), false); + + $this->assertContains('posts_email_unique', $this->indexes('posts')); + } + + public function testDropIndexByColumnsAndByName(): void + { + $this->migrate('posts', function (Blueprint $table) { + $table->id(); + $table->string('a'); + $table->string('b'); + $table->index(['a', 'b']); + $table->unique('b'); + }); + + $this->migrate('posts', function (Blueprint $table) { + $table->dropIndex(['a', 'b']); + $table->dropUnique('posts_b_unique'); + }, false); + + $this->assertSame([], $this->indexes('posts')); + } + + public function testRenameAndDropColumnsOnSqlite(): void + { + $this->migrate('posts', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->string('legacy')->nullable(); + $table->timestamps(); + }); + + $this->migrate('posts', function (Blueprint $table) { + $table->renameColumn('title', 'headline'); + $table->dropColumn('legacy'); + $table->dropTimestamps(); + }, false); + + $this->assertSame(['id', 'headline'], $this->columns('posts')); + } + + public function testChangeColumnThrowsOnSqlite(): void + { + $this->migrate('posts', fn(Blueprint $t) => [$t->id(), $t->string('title')]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('SQLite cannot modify column'); + + $this->blueprint('posts', fn(Blueprint $t) => $t->string('title', 50)->change(), false)->toSql(); + } + + public function testDropOnlyBlueprintIsAllowedWhenAltering(): void + { + $sql = $this->sql('mysql', fn(Blueprint $t) => $t->dropColumn('legacy'), false); + + $this->assertSame('ALTER TABLE `posts` DROP COLUMN `legacy`;', $sql); + } + + // ------------------------------------------------------- foreign keys + + public function testConstrainedForeignIdOnSqliteIsInlinedInCreateTable(): void + { + $this->pdo->exec('PRAGMA foreign_keys = ON'); + + $this->migrate('users', fn(Blueprint $t) => $t->id()); + $this->migrate('posts', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + }); + + $this->pdo->exec('INSERT INTO users DEFAULT VALUES'); + $this->pdo->exec('INSERT INTO posts (user_id) VALUES (1)'); + $this->pdo->exec('DELETE FROM users WHERE id = 1'); + + $this->assertSame('0', (string) $this->pdo->query('SELECT COUNT(*) FROM posts')->fetchColumn()); + + $this->expectException(\PDOException::class); + $this->pdo->exec('INSERT INTO posts (user_id) VALUES (99)'); + } + + public function testForeignKeyOnExistingSqliteTableThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot add a foreign key'); + + $this->blueprint('posts', fn(Blueprint $t) => $t->foreignId('user_id')->constrained(), false)->toSql(); + } + + public function testConstrainedGuessesPluralTable(): void + { + $sql = $this->sql('mysql', function (Blueprint $table) { + $table->foreignId('category_id')->constrained(); + $table->foreignId('address_id')->constrained(); + $table->foreignId('author_id')->constrained('people', 'pk')->nullOnDelete(); + }); + + $this->assertStringContainsString('FOREIGN KEY (category_id) REFERENCES categories (id)', $sql); + $this->assertStringContainsString('FOREIGN KEY (address_id) REFERENCES addresses (id)', $sql); + $this->assertStringContainsString('FOREIGN KEY (author_id) REFERENCES people (pk) ON DELETE SET NULL', $sql); + } + + public function testCompositeForeignKey(): void + { + $sql = $this->sql('mysql', function (Blueprint $table) { + $table->integer('a'); + $table->integer('b'); + $table->foreign(['a', 'b'])->references(['x', 'y'])->on('other')->name('fk_custom'); + }); + + $this->assertStringContainsString( + 'ALTER TABLE posts ADD CONSTRAINT fk_custom FOREIGN KEY (a, b) REFERENCES other (x, y)', + $sql + ); + } + + public function testDropForeignPerDriver(): void + { + $mysql = $this->sql('mysql', fn(Blueprint $t) => $t->dropForeign(['user_id']), false); + $pgsql = $this->sql('pgsql', fn(Blueprint $t) => $t->dropForeign('fk_posts_user_id'), false); + + $this->assertSame('ALTER TABLE `posts` DROP FOREIGN KEY `fk_posts_user_id`;', $mysql); + $this->assertSame('ALTER TABLE "posts" DROP CONSTRAINT "fk_posts_user_id";', $pgsql); + } + + // ------------------------------------------------------------- MySQL + + public function testMysqlColumnModifiers(): void + { + $sql = $this->sql('mysql', function (Blueprint $table) { + $table->id(); + $table->integer('votes')->unsigned()->comment("it's"); + $table->timestamp('seen_at', 3)->useCurrent()->useCurrentOnUpdate(); + $table->string('code')->charset('utf8mb4')->collation('utf8mb4_bin'); + $table->integer('double_votes')->storedAs('votes * 2'); + $table->string('secret')->nullable()->invisible(); + $table->smallIncrements('small_id'); + }); + + $this->assertStringContainsString("votes INT UNSIGNED NOT NULL COMMENT 'it''s'", $sql); + $this->assertStringContainsString( + 'seen_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', + $sql + ); + $this->assertStringContainsString('code VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL', $sql); + $this->assertStringContainsString('double_votes INT GENERATED ALWAYS AS (votes * 2) STORED NOT NULL', $sql); + $this->assertStringContainsString('secret VARCHAR(255) NULL INVISIBLE', $sql); + $this->assertStringContainsString('`small_id` SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT', $sql); + } + + public function testMysqlTableOptions(): void + { + $sql = $this->sql('mysql', function (Blueprint $table) { + $table->engine('MyISAM')->charset('utf8mb4')->collation('utf8mb4_unicode_ci')->comment('Blog posts'); + $table->id(); + }); + + $this->assertStringContainsString( + ") ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Blog posts'", + $sql + ); + } + + public function testMysqlChangeAfterAndFirst(): void + { + $sql = $this->sql('mysql', function (Blueprint $table) { + $table->string('title', 100)->nullable()->change(); + $table->string('slug')->first(); + }, false); + + $this->assertStringContainsString('ALTER TABLE `posts` MODIFY COLUMN title VARCHAR(100) NULL', $sql); + $this->assertStringContainsString('ADD COLUMN slug VARCHAR(255) NOT NULL FIRST', $sql); + } + + public function testMysqlIndexTypesAndDrops(): void + { + $sql = $this->sql('mysql', function (Blueprint $table) { + $table->index(['a', 'b'])->algorithm('hash'); + $table->fullText('body'); + $table->spatialIndex('area'); + $table->dropIndex('old_idx'); + $table->dropUnique(['email']); + $table->dropPrimary(); + $table->renameIndex('a', 'b'); + }, false); + + $this->assertStringContainsString('CREATE INDEX `idx_posts_a_b` ON `posts` (`a`, `b`) USING hash', $sql); + $this->assertStringContainsString('CREATE FULLTEXT INDEX `posts_body_fulltext` ON `posts` (`body`)', $sql); + $this->assertStringContainsString('CREATE SPATIAL INDEX `posts_area_spatial` ON `posts` (`area`)', $sql); + $this->assertStringContainsString('DROP INDEX `old_idx` ON `posts`', $sql); + $this->assertStringContainsString('DROP INDEX `posts_email_unique` ON `posts`', $sql); + $this->assertStringContainsString('ALTER TABLE `posts` DROP PRIMARY KEY', $sql); + $this->assertStringContainsString('ALTER TABLE `posts` RENAME INDEX `a` TO `b`', $sql); + } + + public function testMysqlCompositePrimaryKeyInCreate(): void + { + $sql = $this->sql('mysql', function (Blueprint $table) { + $table->integer('a'); + $table->integer('b'); + $table->primary(['a', 'b']); + }); + + $this->assertStringContainsString('PRIMARY KEY (`a`, `b`)', $sql); + $this->assertSame(1, substr_count($sql, 'PRIMARY KEY')); + } + + public function testSingleColumnPrimaryKeyIsNotDuplicated(): void + { + $sql = $this->sql('mysql', fn(Blueprint $t) => $t->string('code')->primary()); + + $this->assertSame(1, substr_count($sql, 'PRIMARY KEY')); + } + + public function testMorphsCreateColumnsAndCompositeIndex(): void + { + $sql = $this->sql('mysql', fn(Blueprint $t) => $t->morphs('taggable')); + + $this->assertStringContainsString('taggable_type VARCHAR(255) NOT NULL', $sql); + $this->assertStringContainsString('taggable_id BIGINT UNSIGNED NOT NULL', $sql); + $this->assertStringContainsString( + 'CREATE INDEX `idx_posts_taggable_type_taggable_id` ON `posts` (`taggable_type`, `taggable_id`)', + $sql + ); + + $drop = $this->sql('mysql', fn(Blueprint $t) => $t->dropMorphs('taggable'), false); + $this->assertStringContainsString('DROP INDEX `idx_posts_taggable_type_taggable_id` ON `posts`', $drop); + $this->assertStringContainsString('DROP COLUMN `taggable_type`', $drop); + } + + public function testLongIndexNamesAreShortened(): void + { + $sql = $this->sql('mysql', fn(Blueprint $t) => $t->index([ + 'a_really_long_column_name_one', + 'a_really_long_column_name_two', + 'a_really_long_column_name_three', + ]), false); + + preg_match('/CREATE INDEX `([^`]+)`/', $sql, $match); + $this->assertLessThanOrEqual(63, strlen($match[1])); + } + + public function testStringDefaultsAreEscapedPerDriver(): void + { + $mysql = $this->sql('mysql', fn(Blueprint $t) => $t->string('a')->default("it's \\ ok")); + $pgsql = $this->sql('pgsql', fn(Blueprint $t) => $t->string('a')->default("it's")); + + $this->assertStringContainsString("DEFAULT 'it''s \\\\ ok'", $mysql); + $this->assertStringContainsString("DEFAULT 'it''s'", $pgsql); + } + + public function testInjectionInIdentifiersIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->sql('mysql', fn(Blueprint $t) => $t->string('a')->collation("x'; DROP TABLE users; --")); + } + + // -------------------------------------------------------- PostgreSQL + + public function testPostgresChangeColumn(): void + { + $sql = $this->sql('pgsql', fn(Blueprint $t) => $t->string('title', 100)->nullable()->default('x')->change(), false); + + $this->assertStringContainsString( + 'ALTER TABLE "posts" ALTER COLUMN "title" TYPE VARCHAR(100) USING "title"::VARCHAR(100)', + $sql + ); + $this->assertStringContainsString('ALTER COLUMN "title" DROP NOT NULL', $sql); + $this->assertStringContainsString("ALTER COLUMN \"title\" SET DEFAULT 'x'", $sql); + } + + public function testPostgresCommentsIndexesAndSerial(): void + { + $sql = $this->sql('pgsql', function (Blueprint $table) { + $table->comment('Posts'); + $table->increments('id'); + $table->string('title')->comment('Headline'); + $table->integer('seq')->autoIncrement(); + $table->fullText('title'); + $table->index('title')->algorithm('hash'); + $table->spatialIndex('area'); + $table->dropPrimary(); + }); + + $this->assertStringContainsString('"id" SERIAL NOT NULL', $sql); + $this->assertStringContainsString('seq SERIAL NOT NULL', $sql); + $this->assertStringContainsString('COMMENT ON COLUMN "posts"."title" IS \'Headline\'', $sql); + $this->assertStringContainsString('COMMENT ON TABLE "posts" IS \'Posts\'', $sql); + $this->assertStringContainsString("USING GIN (to_tsvector('english', coalesce(\"title\", '')))", $sql); + $this->assertStringContainsString('CREATE INDEX "idx_posts_title" ON "posts" USING hash ("title")', $sql); + $this->assertStringContainsString('USING GIST ("area")', $sql); + $this->assertStringContainsString('DROP CONSTRAINT "posts_pkey"', $sql); + } + + public function testPostgresTimePrecisionAndCollation(): void + { + $sql = $this->sql('pgsql', function (Blueprint $table) { + $table->timestampTz('seen_at', 6); + $table->string('name')->collation('en_US'); + }); + + $this->assertStringContainsString('seen_at TIMESTAMPTZ(6) NOT NULL', $sql); + $this->assertStringContainsString('name VARCHAR(255) COLLATE "en_US" NOT NULL', $sql); + } + + // --------------------------------------------------------------- misc + + public function testTemporaryTable(): void + { + $this->migrate('scratch', function (Blueprint $table) { + $table->temporary(); + $table->id(); + }); + + $this->assertSame( + 'temp', + $this->pdo->query("SELECT 'temp' FROM sqlite_temp_master WHERE name = 'scratch'")->fetchColumn() + ); + } + + public function testGeneratedColumnOnSqlite(): void + { + $this->migrate('posts', function (Blueprint $table) { + $table->id(); + $table->integer('price'); + $table->integer('with_tax')->virtualAs('price * 2')->nullable(); + }); + + $this->pdo->exec('INSERT INTO posts (price) VALUES (5)'); + $this->assertSame('10', (string) $this->pdo->query('SELECT with_tax FROM posts')->fetchColumn()); + } + + public function testRememberTokenSoftDeletesAndTimestampsHelpers(): void + { + $this->migrate('users', function (Blueprint $table) { + $table->id(); + $table->rememberToken(); + $table->softDeletes(); + $table->timestamps(); + $table->ulid(); + }); + + $this->assertSame( + ['id', 'remember_token', 'deleted_at', 'created_at', 'updated_at', 'ulid'], + $this->columns('users') + ); + } + + public function testNothingToDoThrows(): void + { + $this->expectException(\RuntimeException::class); + + $this->blueprint('posts', fn(Blueprint $t) => null, false)->toSql(); + } +}