diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a53ba4eb1..43d42405c 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 09b999c92..275d3d4dd 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/KeepsExistingColumnAttributes.php b/src/Database/Schema/Grammars/Concerns/KeepsExistingColumnAttributes.php new file mode 100644 index 000000000..877c94644 --- /dev/null +++ b/src/Database/Schema/Grammars/Concerns/KeepsExistingColumnAttributes.php @@ -0,0 +1,60 @@ +connection->getSchemaBuilder()->getColumns($blueprint->getTable())) + ->firstWhere('name', $column->get('name')); + + if (!$existing) { + return; + } + + $attributes = $column->getAttributes(); + + foreach ($this->getKeptColumnAttributes($existing, $column) as $attribute => $value) { + if (!is_null($value) && !array_key_exists($attribute, $attributes)) { + $column[$attribute] = $value; + } + } + } + + /** + * Whether the column is being changed to a type that accepts a collation. + * + * Like Laravel 9, the collation is only kept while the column stays a text column. + */ + protected function changesToATextColumn(Fluent $column): bool + { + return in_array($column->get('type'), [ + 'char', 'string', 'tinyText', 'text', 'mediumText', 'longText', 'enum', 'set', + ]); + } +} diff --git a/src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php b/src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php index 6f749de77..201b96c3e 100644 --- a/src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php +++ b/src/Database/Schema/Grammars/Concerns/MySqlBasedGrammar.php @@ -2,12 +2,16 @@ 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 { + use KeepsExistingColumnAttributes; + /** * Compile a change column command into a series of SQL statements. * @@ -18,42 +22,58 @@ 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(); + /** @var \Illuminate\Database\Schema\ColumnDefinition $column */ + $column = $command->get('column'); - $oldColumns = collect($schema->getColumns($table)); + $this->keepExistingColumnAttributes($blueprint, $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) - ); + return parent::compileChange($blueprint, $command); + } + + /** @inheritDoc */ + protected function getKeptColumnAttributes($existing, Fluent $column): array + { + $attributes = [ + 'nullable' => (bool) $existing['nullable'], + 'default' => $this->getKeptColumnDefault($existing['default']), + 'comment' => $existing['comment'], + ]; + + // Only keep the unsigned attribute while the column stays a numeric column, as no other type accepts it + if (in_array($column->get('type'), ['boolean', 'tinyInteger', 'smallInteger', 'mediumInteger', 'integer', 'bigInteger', 'decimal', 'float', 'double'])) { + $attributes['unsigned'] = str_contains(strtolower((string) $existing['type']), 'unsigned') ?: null; + } + + if ($this->changesToATextColumn($column)) { + $attributes['collation'] = $existing['collation']; + } - $oldColumn = $oldColumns->where('name', $column->name)->first(); - if (!$oldColumn instanceof ColumnDefinition) { - $oldColumn = new ColumnDefinition($oldColumn); - } + return $attributes; + } + + /** + * 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; + } - 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); - } - } - $columns[] = $sql; + if ($this instanceof MariaDbGrammar || ($this->connection instanceof MySqlConnection && $this->connection->isMaria())) { + return $default === 'NULL' ? null : new Expression($default); } - return 'alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns); + 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 faf156f09..532095fa5 100755 --- a/src/Database/Schema/Grammars/PostgresGrammar.php +++ b/src/Database/Schema/Grammars/PostgresGrammar.php @@ -2,13 +2,15 @@ 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; class PostgresGrammar extends BasePostgresGrammar { + use Concerns\KeepsExistingColumnAttributes; + /** * Compile a change column command into a series of SQL statements. * @@ -16,48 +18,38 @@ 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(); - - $oldColumns = collect($schema->getColumns($table)); + /** @var \Illuminate\Database\Schema\ColumnDefinition $column */ + $column = $command->get('column'); - foreach ($blueprint->getChangedColumns() as $column) { - $changes = ['type '.$this->getType($column).$this->modifyCollate($blueprint, $column)]; + $this->keepExistingColumnAttributes($blueprint, $column); - $oldColumn = $oldColumns->where('name', $column->name)->first(); - if (!$oldColumn instanceof ColumnDefinition) { - $oldColumn = new ColumnDefinition($oldColumn); - } - - foreach ($this->modifiers as $modifier) { - if ($modifier === 'Collate') { - continue; - } - - 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; - } - } - } + /** @inheritDoc */ + 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 ($this->changesToATextColumn($column)) { + $attributes['collation'] = $existing['collation']; } - return 'alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns); + return $attributes; } /** diff --git a/src/Database/Schema/Grammars/SqlServerGrammar.php b/src/Database/Schema/Grammars/SqlServerGrammar.php index 46ea16049..50f92f888 100755 --- a/src/Database/Schema/Grammars/SqlServerGrammar.php +++ b/src/Database/Schema/Grammars/SqlServerGrammar.php @@ -2,13 +2,15 @@ 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; class SqlServerGrammar extends BaseSqlServerGrammar { + use Concerns\KeepsExistingColumnAttributes; + /** * Compile a change column command into a series of SQL statements. * @@ -16,45 +18,37 @@ 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(); + /** @var \Illuminate\Database\Schema\ColumnDefinition $column */ + $column = $command->get('column'); - $oldColumns = collect($schema->getColumns($table)); + $this->keepExistingColumnAttributes($blueprint, $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) - ); - - $oldColumn = $oldColumns->where('name', $column->name)->first(); - if (!$oldColumn instanceof ColumnDefinition) { - $oldColumn = new ColumnDefinition($oldColumn); - } + return parent::compileChange($blueprint, $command); + } - 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); - } - } + /** @inheritDoc */ + 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 ($this->changesToATextColumn($column)) { + $attributes['collation'] = $existing['collation']; } - return $changes; + return $attributes; } /** diff --git a/tests/Database/Schema/Grammars/MariaDbSchemaGrammarTest.php b/tests/Database/Schema/Grammars/MariaDbSchemaGrammarTest.php new file mode 100644 index 000000000..0f44d9715 --- /dev/null +++ b/tests/Database/Schema/Grammars/MariaDbSchemaGrammarTest.php @@ -0,0 +1,63 @@ +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 7ee243c66..ef518c694 100644 --- a/tests/Database/Schema/Grammars/MySqlSchemaGrammarTest.php +++ b/tests/Database/Schema/Grammars/MySqlSchemaGrammarTest.php @@ -2,7 +2,9 @@ namespace Tests\Database\Schema\Grammars; +use Illuminate\Database\MySqlConnection; use Illuminate\Database\Schema\MySqlBuilder; +use Mockery as m; use Winter\Storm\Database\Schema\Grammars\MySqlGrammar; use Winter\Storm\Tests\GrammarTestCase; @@ -70,4 +72,117 @@ 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 testChangeKeepsTheUnsignedAttribute() + { + $this->existingColumns([$this->mysqlColumn('amount', 'decimal(8,2) unsigned')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->decimal('amount', 10, 2)->change(); + + $this->assertSame('alter table `users` modify `amount` decimal(10, 2) unsigned not null', $this->runBlueprint($blueprint)[0]); + } + + public function testChangeToANonNumericTypeDropsTheUnsignedAttribute() + { + // Only a numeric column accepts the unsigned attribute, so keeping it here would be a syntax error + $this->existingColumns([$this->mysqlColumn('code', 'int unsigned')]); + + $blueprint = $this->getBlueprint('users'); + $blueprint->string('code', 50)->change(); + + $this->assertSame('alter table `users` modify `code` varchar(50) not null', $this->runBlueprint($blueprint)[0]); + } + + public function testChangeOnAMariaDbServerTreatsTheReportedDefaultAsAnExpression() + { + // The mysql driver can point at a MariaDB server, which reports no default as the expression NULL + $this->connection = m::mock(MySqlConnection::class) + ->shouldReceive('getTablePrefix')->andReturn('') + ->shouldReceive('getServerVersion')->andReturn('11.4.13') + ->shouldReceive('isMaria')->andReturn(true) + ->shouldReceive('scalar')->andReturn('') + ->getMock(); + + $this->connection->shouldReceive('getSchemaGrammar')->andReturn($this->getSchemaGrammar()); + + $this->existingColumns([$this->mysqlColumn('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 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 e42278119..ba7857a21 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 9fa7be9f8..87df2f6a1 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 b7ef82eac..7d1f682e9 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 f3e91119c..d34b3c558 100644 --- a/tests/GrammarTestCase.php +++ b/tests/GrammarTestCase.php @@ -14,6 +14,13 @@ class GrammarTestCase extends TestCase protected $grammarClass = null; protected $builderClass = null; + /** + * The rows existingColumns() reports as the table's existing columns, if any. + * + * @var array>|null + */ + protected $reportedColumns = null; + public function setUp(): void { parent::setUp(); @@ -57,13 +64,21 @@ protected function getSchemaGrammar() protected function getSchemaBuilder($blueprint) { return m::mock(Builder::class) - ->shouldReceive('getColumns')->andReturn($blueprint->getColumns()) + ->shouldReceive('getColumns')->andReturn($this->reportedColumns ?? $blueprint->getColumns()) ->shouldReceive('getForeignKeys')->andReturn([]) ->shouldReceive('getIndexes')->andReturn([]) ->shouldReceive('parseSchemaAndTable')->andReturn([null, $blueprint->getTable()]) ->getMock(); } + /** + * Report the given rows, in the format of Builder::getColumns(), as the table's existing columns. + */ + protected function existingColumns(array $columns): void + { + $this->reportedColumns = $columns; + } + protected function runBlueprint(Blueprint $blueprint) { return $blueprint->toSql();