diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 0d550996239..09d58cee962 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -357,6 +357,18 @@ protected override void Generate( || operation.Collation != operation.OldColumn.Collation || HasDifferences(newAnnotations, oldAnnotations); + // SQL Server cannot ALTER COLUMN on a computed column (the type is derived from the + // expression, not user-configurable). When source and target are both computed with the + // same expression and persistence (the drop+add path above didn't trigger), suppress + // the ALTER statement — type/precision/scale/nullability/collation/annotation diffs + // either don't apply or cannot be applied this way; emitting ALTER COLUMN would fail + // with "Cannot alter column ... because it is 'COMPUTED'". Comment changes are handled + // separately via sp_addextendedproperty below and still apply. See #33425. + if (operation.ComputedColumnSql != null && operation.OldColumn.ComputedColumnSql != null) + { + alterStatementNeeded = false; + } + var (oldDefaultValue, oldDefaultValueSql) = (operation.OldColumn.DefaultValue, operation.OldColumn.DefaultValueSql); if (alterStatementNeeded diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index 655c1d30cfb..e5467b24957 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -833,6 +833,37 @@ await Test( """); } + [ConditionalFact] + public virtual async Task Alter_computed_column_clr_type_only_change_is_noop() + { + // Regression test for #33425: when only the CLR type of a property mapped to a computed + // column changes (the expression and IsStored are unchanged), SQL Server has nothing to + // alter — the column's type is derived from the expression. The migration must complete + // without emitting `ALTER TABLE ... ALTER COLUMN`, which would fail with + // "Cannot alter column ... because it is 'COMPUTED'". + await Test( + builder => builder.Entity( + "People", x => + { + x.Property("Id"); + x.Property("Calc").HasComputedColumnSql("[Id] * 2"); + }), + builder => builder.Entity( + "People", x => + { + x.Property("Id"); + x.Property("Calc").HasComputedColumnSql("[Id] * 2"); + }), + model => + { + var table = Assert.Single(model.Tables); + var column = Assert.Single(table.Columns, c => c.Name == "Calc"); + Assert.Equal("([Id]*(2))", column.ComputedColumnSql); + }); + + AssertSql(); + } + public override async Task Add_column_with_required() { await base.Add_column_with_required(); diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/SqlServerMigrationsSqlGeneratorTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/SqlServerMigrationsSqlGeneratorTest.cs index f35ef7fd416..d9adad69a29 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/SqlServerMigrationsSqlGeneratorTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/SqlServerMigrationsSqlGeneratorTest.cs @@ -326,6 +326,73 @@ public override void AddForeignKeyOperation_without_principal_columns() """); } + [ConditionalFact] + public virtual void AlterColumnOperation_computed_column_with_only_clr_type_change_is_noop() + { + // Regression test for #33425: when the CLR type of a property mapped to a computed column + // changes (e.g. int → long for a column with .HasComputedColumnSql("DATALENGTH(...)")) but + // the expression and IsStored are unchanged, no SQL should be emitted. SQL Server rejects + // ALTER COLUMN on computed columns with "Cannot alter column ... because it is 'COMPUTED'", + // and the underlying database column's type is derived from the expression — there is + // nothing to change. + Generate( + new AlterColumnOperation + { + Table = "Files", + Name = "FileSize", + ClrType = typeof(long), + ColumnType = "bigint", + IsNullable = false, + ComputedColumnSql = "DATALENGTH([FileContents])", + OldColumn = new AddColumnOperation + { + ClrType = typeof(int), + ColumnType = "int", + IsNullable = false, + ComputedColumnSql = "DATALENGTH([FileContents])" + } + }); + + AssertSql(""); + } + + [ConditionalFact] + public virtual void AlterColumnOperation_computed_column_with_changed_expression_drops_and_adds() + { + // Regression guard: when the computed column expression itself changes, SQL Server still + // cannot ALTER COLUMN — but a drop+add is required to apply the new expression. This path + // must remain intact. + Generate( + new AlterColumnOperation + { + Table = "Files", + Name = "FileSize", + ClrType = typeof(long), + ColumnType = "bigint", + IsNullable = false, + ComputedColumnSql = "LEN([FileContents])", + OldColumn = new AddColumnOperation + { + ClrType = typeof(int), + ColumnType = "int", + IsNullable = false, + ComputedColumnSql = "DATALENGTH([FileContents])" + } + }); + + AssertSql( + """ +DECLARE @var nvarchar(max); +SELECT @var = QUOTENAME([d].[name]) +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Files]') AND [c].[name] = N'FileSize'); +IF @var IS NOT NULL EXEC(N'ALTER TABLE [Files] DROP CONSTRAINT ' + @var + ';'); +ALTER TABLE [Files] DROP COLUMN [FileSize]; +ALTER TABLE [Files] ADD [FileSize] AS LEN([FileContents]); +"""); + } + [ConditionalFact] public virtual void AlterColumnOperation_with_identity_legacy() {