diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 15e13044..305eb9ee 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -754,36 +754,6 @@ parameters: identifier: method.childParameterType path: src/Database/Relations/MorphToMany.php - - - message: '#^Access to an undefined property Illuminate\\Database\\Schema\\ColumnDefinition\:\:\$name\.$#' - identifier: property.notFound - path: src/Database/Schema/Grammars/MariaDbGrammar.php - - - - message: '#^Access to an undefined property Illuminate\\Database\\Schema\\ColumnDefinition\:\:\$renameTo\.$#' - identifier: property.notFound - path: src/Database/Schema/Grammars/MariaDbGrammar.php - - - - message: '#^Access to an undefined property Illuminate\\Database\\Schema\\ColumnDefinition\:\:\$name\.$#' - identifier: property.notFound - path: src/Database/Schema/Grammars/MySqlGrammar.php - - - - message: '#^Access to an undefined property Illuminate\\Database\\Schema\\ColumnDefinition\:\:\$renameTo\.$#' - identifier: property.notFound - path: src/Database/Schema/Grammars/MySqlGrammar.php - - - - message: '#^Access to an undefined property Illuminate\\Database\\Schema\\ColumnDefinition\:\:\$name\.$#' - identifier: property.notFound - path: src/Database/Schema/Grammars/PostgresGrammar.php - - - - message: '#^Access to an undefined property Illuminate\\Database\\Schema\\ColumnDefinition\:\:\$name\.$#' - identifier: property.notFound - path: src/Database/Schema/Grammars/SqlServerGrammar.php - - message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:getSortOrderColumn\(\)\.$#' identifier: method.notFound diff --git a/src/Database/Schema/BlueprintState.php b/src/Database/Schema/BlueprintState.php index 09b999c9..275d3d4d 100644 --- a/src/Database/Schema/BlueprintState.php +++ b/src/Database/Schema/BlueprintState.php @@ -65,8 +65,10 @@ protected function preserveExistingColumnAttributes(Fluent $column): void continue; } + $attributes = $column->getAttributes(); + foreach ($this->preservedAttributes as $attribute) { - if (!isset($column[$attribute]) && isset($existing[$attribute])) { + if (!array_key_exists($attribute, $attributes) && isset($existing[$attribute])) { $column[$attribute] = $existing[$attribute]; } } diff --git a/src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php b/src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php index 6f749de7..41ba6a3d 100644 --- a/src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php +++ b/src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php @@ -2,8 +2,10 @@ namespace Winter\Storm\Database\Schema\Grammars\Concerns; +use Illuminate\Database\MySqlConnection; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Database\Schema\ColumnDefinition; +use Illuminate\Database\Schema\Grammars\MariaDbGrammar; use Illuminate\Support\Fluent; trait MySqlBasedGrammar @@ -18,42 +20,73 @@ trait MySqlBasedGrammar * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command * @return array|string - * - * @throws \RuntimeException */ public function compileChange(Blueprint $blueprint, Fluent $command) { - $columns = []; - $schema = $this->connection->getSchemaBuilder(); - $table = $blueprint->getTable(); - - $oldColumns = collect($schema->getColumns($table)); + /** @var \Illuminate\Database\Schema\ColumnDefinition $column */ + $column = clone $command->get('column'); - foreach ($blueprint->getChangedColumns() as $column) { - $sql = sprintf( - '%s %s%s %s', - is_null($column->renameTo) ? 'modify' : 'change', - $this->wrap($column->name), - is_null($column->renameTo) ? '' : ' '.$this->wrap($column->renameTo), - $this->getType($column) - ); + $existing = collect($this->connection->getSchemaBuilder()->getColumns($blueprint->getTable())) + ->firstWhere('name', $column->get('name')); - $oldColumn = $oldColumns->where('name', $column->name)->first(); - if (!$oldColumn instanceof ColumnDefinition) { - $oldColumn = new ColumnDefinition($oldColumn); - } + if ($existing) { + $attributes = $column->getAttributes(); - foreach ($this->modifiers as $modifier) { - if (method_exists($this, $method = "modify{$modifier}")) { - $mod = strtolower($modifier); - $col = isset($oldColumn->{$mod}) && !isset($column->{$mod}) ? $oldColumn : $column; - $sql .= $this->{$method}($blueprint, $col); + foreach ($this->getKeptColumnAttributes($existing, $column) as $attribute => $value) { + if (!array_key_exists($attribute, $attributes)) { + $column[$attribute] = $value; } } - $columns[] = $sql; } - return 'alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns); + return parent::compileChange($blueprint, new Fluent(['column' => $column] + $command->getAttributes())); + } + + /** + * Get the attributes of an existing column, as reported by the schema builder, that a change keeps + * when the new definition doesn't set them. + * + * @param array|\ArrayAccess $existing + * @param \Illuminate\Support\Fluent $column + * @return array + */ + protected function getKeptColumnAttributes($existing, Fluent $column): array + { + $attributes = [ + 'nullable' => (bool) $existing['nullable'], + 'default' => $this->getKeptColumnDefault($existing['default']), + 'comment' => $existing['comment'], + 'unsigned' => str_contains(strtolower((string) $existing['type']), 'unsigned') ?: null, + ]; + + // Like Laravel 9, only keep the collation while the column stays a text column + if (in_array($column->get('type'), ['char', 'string', 'tinyText', 'text', 'mediumText', 'longText', 'enum', 'set'])) { + $attributes['collation'] = $existing['collation']; + } + + return array_filter($attributes, fn ($value) => !is_null($value)); + } + + /** + * Turn the default of an existing column, as reported by the schema builder, back into a definition. + * + * MariaDB reports every default as an SQL expression ('text', NULL, current_timestamp()). MySQL reports + * a literal default as its raw value, and an expression such as CURRENT_TIMESTAMP as is. + * + * @param mixed $default + * @return mixed + */ + protected function getKeptColumnDefault($default) + { + if (!is_string($default)) { + return $default; + } + + if ($this instanceof MariaDbGrammar || ($this->connection instanceof MySqlConnection && $this->connection->isMaria())) { + return $default === 'NULL' ? null : new Expression($default); + } + + return str_starts_with(strtolower($default), 'current_timestamp') ? new Expression($default) : $default; } /** diff --git a/src/Database/Schema/Grammars/PostgresGrammar.php b/src/Database/Schema/Grammars/PostgresGrammar.php index faf156f0..ab4851e3 100755 --- a/src/Database/Schema/Grammars/PostgresGrammar.php +++ b/src/Database/Schema/Grammars/PostgresGrammar.php @@ -2,8 +2,8 @@ namespace Winter\Storm\Database\Schema\Grammars; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Database\Schema\ColumnDefinition; use Illuminate\Database\Schema\Grammars\PostgresGrammar as BasePostgresGrammar; use Illuminate\Support\Fluent; @@ -16,48 +16,56 @@ class PostgresGrammar extends BasePostgresGrammar * This restores Laravel previous behavior where existing column attributes are kept * unless they get changed by the new Blueprint. * + * The kept attributes are set on the column itself, so that the comment command that runs + * after the change keeps the existing comment as well. + * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command * @return array|string - * - * @throws \RuntimeException */ public function compileChange(Blueprint $blueprint, Fluent $command) { - $columns = []; - $schema = $this->connection->getSchemaBuilder(); - $table = $blueprint->getTable(); + /** @var \Illuminate\Database\Schema\ColumnDefinition $column */ + $column = $command->get('column'); - $oldColumns = collect($schema->getColumns($table)); + $existing = collect($this->connection->getSchemaBuilder()->getColumns($blueprint->getTable())) + ->firstWhere('name', $column->get('name')); - foreach ($blueprint->getChangedColumns() as $column) { - $changes = ['type '.$this->getType($column).$this->modifyCollate($blueprint, $column)]; - - $oldColumn = $oldColumns->where('name', $column->name)->first(); - if (!$oldColumn instanceof ColumnDefinition) { - $oldColumn = new ColumnDefinition($oldColumn); - } + if ($existing) { + $attributes = $column->getAttributes(); - foreach ($this->modifiers as $modifier) { - if ($modifier === 'Collate') { - continue; + foreach ($this->getKeptColumnAttributes($existing, $column) as $attribute => $value) { + if (!array_key_exists($attribute, $attributes)) { + $column[$attribute] = $value; } + } + } - if (method_exists($this, $method = "modify{$modifier}")) { - $mod = strtolower($modifier); - $col = isset($oldColumn->{$mod}) && ! isset($column->{$mod}) ? $oldColumn : $column; - $constraints = (array) $this->{$method}($blueprint, $col); + return parent::compileChange($blueprint, $command); + } - foreach ($constraints as $constraint) { - $changes[] = $constraint; - } - } - } + /** + * Get the attributes of an existing column, as reported by the schema builder, that a change keeps + * when the new definition doesn't set them. + * + * @param array|\ArrayAccess $existing + * @param \Illuminate\Support\Fluent $column + * @return array + */ + protected function getKeptColumnAttributes($existing, Fluent $column): array + { + $attributes = [ + 'nullable' => (bool) $existing['nullable'], + // PostgreSQL reports every default as an SQL expression, such as 'text'::character varying + 'default' => is_null($existing['default']) ? null : new Expression($existing['default']), + 'comment' => $existing['comment'], + ]; - $columns[] = implode(', ', $this->prefixArray('alter column '.$this->wrap($column->name), $changes)); + if (in_array($column->get('type'), ['char', 'string', 'tinyText', 'text', 'mediumText', 'longText', 'enum', 'set'])) { + $attributes['collation'] = $existing['collation']; } - return 'alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns); + return array_filter($attributes, fn ($value) => !is_null($value)); } /** diff --git a/src/Database/Schema/Grammars/SqlServerGrammar.php b/src/Database/Schema/Grammars/SqlServerGrammar.php index 46ea1604..2495ccd9 100755 --- a/src/Database/Schema/Grammars/SqlServerGrammar.php +++ b/src/Database/Schema/Grammars/SqlServerGrammar.php @@ -2,8 +2,8 @@ namespace Winter\Storm\Database\Schema\Grammars; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Database\Schema\ColumnDefinition; use Illuminate\Database\Schema\Grammars\SqlServerGrammar as BaseSqlServerGrammar; use Illuminate\Support\Fluent; @@ -16,45 +16,55 @@ class SqlServerGrammar extends BaseSqlServerGrammar * This restores Laravel previous behavior where existing column attributes are kept * unless they get changed by the new Blueprint. * + * The kept attributes are set on the column itself, so that the default command that runs + * after the change adds the existing default back once the change has dropped its constraint. + * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command * @return array|string - * - * @throws \RuntimeException */ public function compileChange(Blueprint $blueprint, Fluent $command) { - $changes = (array) $this->compileDropDefaultConstraint($blueprint, $command); - $schema = $this->connection->getSchemaBuilder(); - $table = $blueprint->getTable(); - - $oldColumns = collect($schema->getColumns($table)); + /** @var \Illuminate\Database\Schema\ColumnDefinition $column */ + $column = $command->get('column'); - foreach ($blueprint->getChangedColumns() as $column) { - $sql = sprintf( - 'alter table %s alter column %s %s', - $this->wrapTable($blueprint), - $this->wrap($column->name), - $this->getType($column) - ); + $existing = collect($this->connection->getSchemaBuilder()->getColumns($blueprint->getTable())) + ->firstWhere('name', $column->get('name')); - $oldColumn = $oldColumns->where('name', $column->name)->first(); - if (!$oldColumn instanceof ColumnDefinition) { - $oldColumn = new ColumnDefinition($oldColumn); - } + if ($existing) { + $attributes = $column->getAttributes(); - foreach ($this->modifiers as $modifier) { - if (method_exists($this, $method = "modify{$modifier}")) { - $mod = strtolower($modifier); - $col = isset($oldColumn->{$mod}) && !isset($column->{$mod}) ? $oldColumn : $column; - $sql .= $this->{$method}($blueprint, $col); + foreach ($this->getKeptColumnAttributes($existing, $column) as $attribute => $value) { + if (!array_key_exists($attribute, $attributes)) { + $column[$attribute] = $value; } } + } + + return parent::compileChange($blueprint, $command); + } + + /** + * Get the attributes of an existing column, as reported by the schema builder, that a change keeps + * when the new definition doesn't set them. + * + * @param array|\ArrayAccess $existing + * @param \Illuminate\Support\Fluent $column + * @return array + */ + protected function getKeptColumnAttributes($existing, Fluent $column): array + { + $attributes = [ + 'nullable' => (bool) $existing['nullable'], + // SQL Server reports a default as the expression of its constraint, such as ('text') + 'default' => is_null($existing['default']) ? null : new Expression($existing['default']), + ]; - $changes[] = $sql; + if (in_array($column->get('type'), ['char', 'string', 'tinyText', 'text', 'mediumText', 'longText', 'enum', 'set'])) { + $attributes['collation'] = $existing['collation']; } - return $changes; + return array_filter($attributes, fn ($value) => !is_null($value)); } /** diff --git a/tests/Database/Schema/Grammars/MariaDbSchemaGrammarTest.php b/tests/Database/Schema/Grammars/MariaDbSchemaGrammarTest.php new file mode 100644 index 00000000..2ccf123d --- /dev/null +++ b/tests/Database/Schema/Grammars/MariaDbSchemaGrammarTest.php @@ -0,0 +1,62 @@ +grammarClass = MariaDbGrammar::class; + $this->builderClass = MariaDbBuilder::class; + + parent::setUp(); + } + + public function testChangeKeepsANullableColumnWithoutADefault() + { + // MariaDB reports a nullable column without a default as the expression NULL + $this->existingColumns([$this->mariaDbColumn('name', 'varchar(255)', nullable: true, default: 'NULL')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('name', 100)->change(); + + $this->assertSame('alter table `users` modify `name` varchar(100) null', $this->runBlueprint($blueprint)[0]); + } + + public function testChangeKeepsAQuotedDefault() + { + $this->existingColumns([$this->mariaDbColumn('name', 'varchar(255)', default: "'O''Brien'")]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('name', 100)->change(); + + $this->assertSame("alter table `users` modify `name` varchar(100) not null default 'O''Brien'", $this->runBlueprint($blueprint)[0]); + } + + public function testChangeKeepsACurrentTimestampDefault() + { + $this->existingColumns([$this->mariaDbColumn('created_at', 'timestamp', default: 'current_timestamp()')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->timestamp('created_at')->nullable()->change(); + + $this->assertSame('alter table `users` modify `created_at` timestamp null default current_timestamp()', $this->runBlueprint($blueprint)[0]); + } + + /** + * A column as MariaDB's getColumns() reports it: every default as an SQL expression. + */ + protected function mariaDbColumn(string $name, string $type, bool $nullable = false, ?string $default = null): array + { + return compact('name', 'type', 'nullable', 'default') + [ + 'type_name' => strtok($type, '('), + 'collation' => null, + 'auto_increment' => false, + 'comment' => null, + 'generation' => null, + ]; + } +} diff --git a/tests/Database/Schema/Grammars/MySqlSchemaGrammarTest.php b/tests/Database/Schema/Grammars/MySqlSchemaGrammarTest.php index 7ee243c6..f054bf81 100644 --- a/tests/Database/Schema/Grammars/MySqlSchemaGrammarTest.php +++ b/tests/Database/Schema/Grammars/MySqlSchemaGrammarTest.php @@ -70,4 +70,76 @@ public function testQuoteInDefaultValue() $statements = $this->runBlueprint($initialBlueprint); $this->assertSame("alter table `users` add `name` varchar(255) not null default 'O''Brian'", $statements[0]); } + + public function testChangeCanRemoveDefaultWithNull() + { + $initialBlueprint = $this->getBlueprint('users'); + $initialBlueprint->string('name')->default('admin'); + + $statements = $this->runBlueprint($initialBlueprint); + $this->assertSame("alter table `users` add `name` varchar(255) not null default 'admin'", $statements[0]); + + $changedBlueprint = $this->getBlueprint('users'); + $changedBlueprint->string('name')->nullable()->default(null)->change(); + + $statements = $this->runBlueprint($changedBlueprint); + $this->assertSame('alter table `users` modify `name` varchar(255) null', $statements[0]); + } + + public function testChangeKeepsTheCollation() + { + $this->existingColumns([$this->mysqlColumn('name', 'varchar(255)', collation: 'utf8mb4_bin')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('name', 100)->change(); + + $this->assertSame("alter table `users` modify `name` varchar(100) collate 'utf8mb4_bin' not null", $this->runBlueprint($blueprint)[0]); + } + + public function testChangeToANonTextTypeDropsTheCollation() + { + $this->existingColumns([$this->mysqlColumn('code', 'varchar(255)', collation: 'utf8mb4_bin')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->integer('code')->change(); + + $this->assertSame('alter table `users` modify `code` int not null', $this->runBlueprint($blueprint)[0]); + } + + public function testChangeKeepsACurrentTimestampDefault() + { + $this->existingColumns([$this->mysqlColumn('created_at', 'timestamp', default: 'CURRENT_TIMESTAMP')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->timestamp('created_at')->nullable()->change(); + + $this->assertSame('alter table `users` modify `created_at` timestamp null default CURRENT_TIMESTAMP', $this->runBlueprint($blueprint)[0]); + } + + public function testChangingTwoColumnsCompilesOneStatementPerColumn() + { + $this->existingColumns([$this->mysqlColumn('first', 'varchar(255)'), $this->mysqlColumn('last', 'varchar(255)')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('first', 100)->change(); + $blueprint->string('last', 100)->change(); + + $this->assertSame([ + 'alter table `users` modify `first` varchar(100) not null', + 'alter table `users` modify `last` varchar(100) not null', + ], $this->runBlueprint($blueprint)); + } + + /** + * A column as MySQL's getColumns() reports it: literal defaults unquoted, expressions as is. + */ + protected function mysqlColumn(string $name, string $type, bool $nullable = false, ?string $default = null, ?string $collation = null): array + { + return compact('name', 'type', 'nullable', 'default', 'collation') + [ + 'type_name' => strtok($type, '('), + 'auto_increment' => false, + 'comment' => null, + 'generation' => null, + ]; + } } diff --git a/tests/Database/Schema/Grammars/PostgresSchemaGrammarTest.php b/tests/Database/Schema/Grammars/PostgresSchemaGrammarTest.php index e4227811..ba7857a2 100644 --- a/tests/Database/Schema/Grammars/PostgresSchemaGrammarTest.php +++ b/tests/Database/Schema/Grammars/PostgresSchemaGrammarTest.php @@ -48,7 +48,7 @@ public function testNullableInitialModifierAddDefault() $statements = $this->runBlueprint($changedBlueprint); $parts = explode(', ', $statements[0]); $this->assertSame('alter table "users" alter column "name" type varchar(255)', $parts[0]); - $this->assertSame('alter column "name" null', $parts[1]); + $this->assertSame('alter column "name" drop not null', $parts[1]); $this->assertSame("alter column \"name\" set default 'admin'", $parts[2]); } @@ -79,4 +79,96 @@ public function testQuoteInDefaultValue() $parts = explode(', ', $statements[0]); $this->assertSame("alter table \"users\" add column \"name\" varchar(255) not null default 'O''Brian'", $parts[0]); } + + public function testChangeCanRemoveDefaultWithNull() + { + $initialBlueprint = $this->getBlueprint('users'); + $initialBlueprint->string('name')->default('admin'); + + $statements = $this->runBlueprint($initialBlueprint); + $this->assertSame("alter table \"users\" add column \"name\" varchar(255) not null default 'admin'", $statements[0]); + + $changedBlueprint = $this->getBlueprint('users'); + $changedBlueprint->string('name')->nullable()->default(null)->change(); + + $statements = $this->runBlueprint($changedBlueprint); + $this->assertSame('alter table "users" alter column "name" type varchar(255), alter column "name" drop not null, alter column "name" drop default, alter column "name" drop identity if exists', $statements[0]); + } + + public function testChangeKeepsADefault() + { + $this->existingColumns([$this->postgresColumn('role', 'character varying(255)', default: "'O''Brien'::character varying")]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('role', 100)->change(); + + $this->assertSame( + 'alter table "users" alter column "role" type varchar(100), alter column "role" set not null, alter column "role" set default \'O\'\'Brien\'::character varying, alter column "role" drop identity if exists', + $this->runBlueprint($blueprint)[0] + ); + } + + public function testChangeKeepsACurrentTimestampDefault() + { + $this->existingColumns([$this->postgresColumn('created_at', 'timestamp(0) without time zone', default: 'CURRENT_TIMESTAMP')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->timestamp('created_at')->nullable()->change(); + + $this->assertSame( + 'alter table "users" alter column "created_at" type timestamp(0) without time zone, alter column "created_at" drop not null, alter column "created_at" set default CURRENT_TIMESTAMP, alter column "created_at" drop identity if exists', + $this->runBlueprint($blueprint)[0] + ); + } + + public function testChangeKeepsTheCollation() + { + $this->existingColumns([$this->postgresColumn('code', 'character varying(255)', collation: 'C')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('code', 100)->change(); + + $this->assertSame( + 'alter table "users" alter column "code" type varchar(100) collate "C", alter column "code" set not null, alter column "code" drop default, alter column "code" drop identity if exists', + $this->runBlueprint($blueprint)[0] + ); + } + + public function testChangeKeepsTheComment() + { + $this->existingColumns([$this->postgresColumn('note', 'character varying(255)', comment: 'a note')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('note', 100)->change(); + + $this->assertSame('comment on column "users"."note" is \'a note\'', $this->runBlueprint($blueprint)[1]); + } + + public function testChangingTwoColumnsCompilesOneStatementPerColumn() + { + $this->existingColumns([$this->postgresColumn('first', 'character varying(255)'), $this->postgresColumn('last', 'character varying(255)')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('first', 100)->change(); + $blueprint->string('last', 100)->change(); + + $this->assertSame([ + 'alter table "users" alter column "first" type varchar(100), alter column "first" set not null, alter column "first" drop default, alter column "first" drop identity if exists', + 'alter table "users" alter column "last" type varchar(100), alter column "last" set not null, alter column "last" drop default, alter column "last" drop identity if exists', + 'comment on column "users"."first" is NULL', + 'comment on column "users"."last" is NULL', + ], $this->runBlueprint($blueprint)); + } + + /** + * A column as PostgreSQL's getColumns() reports it: every default as an SQL expression. + */ + protected function postgresColumn(string $name, string $type, bool $nullable = false, ?string $default = null, ?string $collation = null, ?string $comment = null): array + { + return compact('name', 'type', 'nullable', 'default', 'collation', 'comment') + [ + 'type_name' => strtok($type, '('), + 'auto_increment' => false, + 'generation' => null, + ]; + } } diff --git a/tests/Database/Schema/Grammars/SQLiteSchemaGrammarTest.php b/tests/Database/Schema/Grammars/SQLiteSchemaGrammarTest.php index 9fa7be9f..87df2f6a 100644 --- a/tests/Database/Schema/Grammars/SQLiteSchemaGrammarTest.php +++ b/tests/Database/Schema/Grammars/SQLiteSchemaGrammarTest.php @@ -152,4 +152,16 @@ public function testChangeRebuildsTableExactlyOnce(): void $this->assertSame(1, $rebuilds, 'A single ->change() must rebuild the table exactly once.'); } + + public function testChangeCanRemoveDefaultWithNull(): void + { + [$pdo, , $builder] = $this->bootSqlite("CREATE TABLE users (id integer primary key, name varchar not null default 'bob')"); + + // An explicit null default must remove the existing default, not keep it. + $builder->table('users', fn (Blueprint $table) => $table->string('name')->nullable()->default(null)->change()); + + $info = $this->columnInfo($pdo, 'users', 'name'); + $this->assertSame(0, (int) $info['notnull']); + $this->assertNull($info['dflt_value']); + } } diff --git a/tests/Database/Schema/Grammars/SqlServerSchemaGrammarTest.php b/tests/Database/Schema/Grammars/SqlServerSchemaGrammarTest.php index b7ef82ea..7d1f682e 100644 --- a/tests/Database/Schema/Grammars/SqlServerSchemaGrammarTest.php +++ b/tests/Database/Schema/Grammars/SqlServerSchemaGrammarTest.php @@ -71,4 +71,85 @@ public function testQuoteInDefaultValue() $statements = $this->runBlueprint($initialBlueprint); $this->assertSame("alter table \"users\" add \"name\" nvarchar(255) not null default 'O''Brian'", $statements[0]); } + + public function testChangeCanRemoveDefaultWithNull() + { + $initialBlueprint = $this->getBlueprint('users'); + $initialBlueprint->string('name')->default('admin'); + + $statements = $this->runBlueprint($initialBlueprint); + $this->assertSame('alter table "users" add "name" nvarchar(255) not null default \'admin\'', $statements[0]); + + $changedBlueprint = $this->getBlueprint('users'); + $changedBlueprint->string('name')->nullable()->default(null)->change(); + + $statements = $this->runBlueprint($changedBlueprint); + $this->assertCount(2, $statements); + $this->assertSame('alter table "users" alter column "name" nvarchar(255) null', $statements[1]); + } + + public function testChangeKeepsADefault() + { + $this->existingColumns([$this->sqlServerColumn('role', 'nvarchar(510)', default: "('O''Brien')", collation: 'SQL_Latin1_General_CP1_CI_AS')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('role', 100)->change(); + + $statements = $this->runBlueprint($blueprint); + $this->assertSame('alter table "users" alter column "role" nvarchar(100) collate SQL_Latin1_General_CP1_CI_AS not null', $statements[1]); + $this->assertSame('alter table "users" add default (\'O\'\'Brien\') for "role"', $statements[2]); + } + + public function testChangeKeepsACurrentTimestampDefault() + { + $this->existingColumns([$this->sqlServerColumn('created_at', 'datetime', default: '(getdate())')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->timestamp('created_at')->nullable()->change(); + + $statements = $this->runBlueprint($blueprint); + $this->assertSame('alter table "users" alter column "created_at" datetime null', $statements[1]); + $this->assertSame('alter table "users" add default (getdate()) for "created_at"', $statements[2]); + } + + public function testChangeKeepsTheCollation() + { + $this->existingColumns([$this->sqlServerColumn('code', 'nvarchar(510)', collation: 'Latin1_General_BIN')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('code', 100)->change(); + + $statements = $this->runBlueprint($blueprint); + $this->assertCount(2, $statements); + $this->assertSame('alter table "users" alter column "code" nvarchar(100) collate Latin1_General_BIN not null', $statements[1]); + } + + public function testChangingTwoColumnsCompilesOneStatementPerColumn() + { + $this->existingColumns([$this->sqlServerColumn('first', 'nvarchar(510)'), $this->sqlServerColumn('last', 'nvarchar(510)')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('first', 100)->change(); + $blueprint->string('last', 100)->change(); + + $statements = $this->runBlueprint($blueprint); + $this->assertCount(4, $statements); + $this->assertStringContainsString("[name] in ('first')", $statements[0]); + $this->assertSame('alter table "users" alter column "first" nvarchar(100) not null', $statements[1]); + $this->assertStringContainsString("[name] in ('last')", $statements[2]); + $this->assertSame('alter table "users" alter column "last" nvarchar(100) not null', $statements[3]); + } + + /** + * A column as SQL Server's getColumns() reports it: a default as the expression of its constraint. + */ + protected function sqlServerColumn(string $name, string $type, bool $nullable = false, ?string $default = null, ?string $collation = null): array + { + return compact('name', 'type', 'nullable', 'default', 'collation') + [ + 'type_name' => strtok($type, '('), + 'auto_increment' => false, + 'comment' => null, + 'generation' => null, + ]; + } } diff --git a/tests/GrammarTestCase.php b/tests/GrammarTestCase.php index f3e91119..8150b6b2 100644 --- a/tests/GrammarTestCase.php +++ b/tests/GrammarTestCase.php @@ -64,6 +64,21 @@ protected function getSchemaBuilder($blueprint) ->getMock(); } + /** + * Report the given rows, in the format of Builder::getColumns(), as the table's existing columns. + * Call this before getBlueprint(). + */ + protected function existingColumns(array $columns): void + { + $builder = m::mock(Builder::class) + ->shouldReceive('getColumns')->andReturn($columns) + ->shouldReceive('getForeignKeys')->andReturn([]) + ->shouldReceive('getIndexes')->andReturn([]) + ->getMock(); + + $this->connection->shouldReceive('getSchemaBuilder')->andReturn($builder); + } + protected function runBlueprint(Blueprint $blueprint) { return $blueprint->toSql();