From 68f0052e4911a8aab7d877c4e1d314cc95b8597e Mon Sep 17 00:00:00 2001 From: Clemens Kreikler Date: Fri, 9 Oct 2026 10:23:17 +0200 Subject: [PATCH] Avoid Oracle LOB conversion errors when changing nullability Apply nullability independently of type and default changes, and avoid redeclaring unchanged LOB types. Cover NCLOB, CLOB and BLOB transitions with command regressions and live Oracle tests. --- .../OracleColumnAlterationTests.cs | 108 ++++++++++++++++++ ...ransformationProvider_ChangeColumnTests.cs | 42 ++++++- .../Oracle/OracleTransformationProvider.cs | 24 +++- 3 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 src/Migrator.Tests/OracleColumnAlterationTests.cs diff --git a/src/Migrator.Tests/OracleColumnAlterationTests.cs b/src/Migrator.Tests/OracleColumnAlterationTests.cs new file mode 100644 index 00000000..3561a63d --- /dev/null +++ b/src/Migrator.Tests/OracleColumnAlterationTests.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Data; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using NUnit.Framework; + +namespace Migrator.Tests; + +public class OracleColumnAlterationTests +{ + private sealed class RecordingProvider(Column existing) + : OracleTransformationProvider(new OracleDialect(), (IDbConnection)null, null, "default", null) + { + public List Commands { get; } = []; + public override Column GetColumnByName(string table, string columnName) => existing; + public override int ExecuteNonQuery(string sql) + { + Commands.Add(sql); + return 0; + } + } + + [TestCase(DbType.String, false)] + [TestCase(DbType.String, true)] + [TestCase(DbType.AnsiString, false)] + [TestCase(DbType.AnsiString, true)] + [TestCase(DbType.Binary, false)] + [TestCase(DbType.Binary, true)] + public void UnchangedLobTypeChangesNullabilityWithoutRestatingType(DbType type, bool nullable) + { + var existing = new Column("Status", type, int.MaxValue) { IsNullable = !nullable }; + var requested = new Column("Status", type, int.MaxValue) { IsNullable = nullable }; + using var provider = new RecordingProvider(existing); + + provider.ChangeColumn("Nagel_AzureServiceBusIn", requested); + + Assert.That(provider.Commands, Is.EqualTo(new[] + { + "ALTER TABLE Nagel_AzureServiceBusIn MODIFY (Status DEFAULT NULL)", + "ALTER TABLE Nagel_AzureServiceBusIn MODIFY (Status " + (nullable ? "NULL" : "NOT NULL") + ")" + })); + Assert.That(existing.IsNullable, Is.EqualTo(!nullable)); + Assert.That(requested.IsNullable, Is.EqualTo(nullable)); + Assert.That(requested.Size, Is.EqualTo(int.MaxValue)); + } + + [Test] + public void UnchangedLobWithUnchangedNullabilityOnlyRemovesDefault() + { + using var provider = new RecordingProvider(new Column("Status", DbType.String, int.MaxValue) { IsNullable = false }); + provider.ChangeColumn("Example", new Column("Status", DbType.String, int.MaxValue) { IsNullable = false }); + Assert.That(provider.Commands, Is.EqualTo(new[] { "ALTER TABLE Example MODIFY (Status DEFAULT NULL)" })); + } + + [Test] + public void UnchangedLobAppliesDefaultSeparatelyFromNullability() + { + using var provider = new RecordingProvider(new Column("Status", DbType.String, int.MaxValue)); + var requested = new Column("Status", DbType.String, int.MaxValue, "pending") { IsNullable = false }; + provider.ChangeColumn("Example", requested); + Assert.That(provider.Commands, Is.EqualTo(new[] + { + "ALTER TABLE Example MODIFY (Status DEFAULT 'pending')", + "ALTER TABLE Example MODIFY (Status NOT NULL)" + })); + Assert.That(requested.DefaultValue, Is.EqualTo("pending")); + } + + [Test] + public void TypeConversionAndNullabilityUseSeparateStatements() + { + using var provider = new RecordingProvider(new Column("Status", DbType.String, 80)); + provider.ChangeColumn("Example", new Column("Status", DbType.String, int.MaxValue) { IsNullable = false }); + Assert.That(provider.Commands, Is.EqualTo(new[] + { + "ALTER TABLE Example MODIFY (Status DEFAULT NULL)", + "ALTER TABLE Example MODIFY (Status NCLOB)", + "ALTER TABLE Example MODIFY (Status NOT NULL)" + })); + } + + [Test] + public void ScalarTypeAndDefaultChangesArePreserved() + { + using var provider = new RecordingProvider(new Column("Amount", DbType.Int32)); + provider.ChangeColumn("Example", new Column("Amount", DbType.Decimal) + { + Precision = 12, Scale = 4, DefaultValue = 29, IsNullable = false + }); + Assert.That(provider.Commands, Is.EqualTo(new[] + { + "ALTER TABLE Example MODIFY (Amount NUMBER(12, 4) DEFAULT 29)", + "ALTER TABLE Example MODIFY (Amount NOT NULL)" + })); + } + + [Test] + public void QualifiedTableAndQuotedColumnArePreserved() + { + using var provider = new RecordingProvider(new Column("select", DbType.String, int.MaxValue)); + provider.ChangeColumn("audit.Example", new Column("select", DbType.String, int.MaxValue) { IsNullable = false }); + Assert.That(provider.Commands, Is.EqualTo(new[] + { + "ALTER TABLE audit.Example MODIFY (\"select\" DEFAULT NULL)", + "ALTER TABLE audit.Example MODIFY (\"select\" NOT NULL)" + })); + } +} diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_ChangeColumnTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_ChangeColumnTests.cs index 9b753c9b..35616c3e 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_ChangeColumnTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_ChangeColumnTests.cs @@ -1,4 +1,7 @@ -using System.Threading.Tasks; +using System.Data; +using System.Data.Common; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; using Migrator.Tests.Providers.Generic; using NUnit.Framework; @@ -9,8 +12,37 @@ namespace Migrator.Tests.Providers.OracleProvider; public class OracleTransformationProvider_ChangeColumn_Tests : Generic_ChangeColumnTestsBase { [SetUp] - public async Task SetUpAsync() + public async Task SetUpAsync() { - await BeginOracleTransactionAsync(); - } -} \ No newline at end of file + await BeginOracleTransactionAsync(); + } + + [TestCase(DbType.String)] + [TestCase(DbType.AnsiString)] + [TestCase(DbType.Binary)] + public void ChangeColumn_LobNullabilityPreservesDataAndSupportsRepeatedChanges(DbType type) + { + Provider.AddTable("Test", new Column("Status", type, int.MaxValue)); + object value = type == DbType.Binary ? new byte[] { 1, 2, 3 } : "pending"; + Provider.Insert("Test", ["Status"], [value]); + + foreach (var nullable in new[] { false, false, true, true }) + { + Provider.ChangeColumn("Test", new Column("Status", type, int.MaxValue) { IsNullable = nullable }); + var actual = Provider.ReadLegacyColumn("Test", "Status"); + Assert.That(actual.IsNullable, Is.EqualTo(nullable)); + Assert.That(actual.Type, Is.EqualTo(type)); + Assert.That(actual.Size, Is.EqualTo(int.MaxValue)); + if (!nullable) + { + Assert.Catch(() => Provider.Insert("Test", ["Status"], [null])); + } + using var command = Provider.CreateCommand(); + using var reader = Provider.Select(command, "Test", ["Status"]); + Assert.That(reader.Read(), Is.True); + Assert.That(reader.GetValue(0), Is.EqualTo(value)); + } + + Provider.Insert("Test", ["Status"], [null]); + } +} diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index aaf54fa8..24c16369 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -153,8 +153,8 @@ public override void ChangeColumn(string table, Column column) { RemoveColumnDefaultValue(table, definition.Name); } - // Oracle rejects restating an existing NOT NULL constraint. Render type/default - // separately and change nullability only when its value actually changes. + // Oracle rejects LOB type declarations combined with nullability changes. + // Keep the type/default operation separate from the constraint operation. definition.IsNullable = true; var mapper = _dialect.GetAndMapColumnProperties(definition); var sql = mapper.ColumnSql; @@ -163,12 +163,26 @@ public override void ChangeColumn(string table, Column column) sql = sql[..^5]; } - if (existing.IsNullable != column.IsNullable) + var existingType = _dialect.GetColumnMapper(existing).Type; + if (mapper.Type is "CLOB" or "NCLOB" or "BLOB" && mapper.Type == existingType && + definition.Collation == null && !definition.IsIdentity && !definition.IsUnsigned) { - sql += column.IsNullable ? " NULL" : " NOT NULL"; + // Restating an unchanged LOB type is unnecessary and can invoke Oracle's + // restricted LOB conversion path. Defaults still need to be applied. + if (definition.DefaultValue != null) + { + ChangeColumn(table, QuoteColumnNameIfRequired(definition.Name) + " " + _dialect.Default(definition.DefaultValue)); + } + } + else + { + ChangeColumn(table, sql); } - ChangeColumn(table, sql); + if (existing.IsNullable != column.IsNullable) + { + ChangeColumn(table, QuoteColumnNameIfRequired(column.Name) + (column.IsNullable ? " NULL" : " NOT NULL")); + } } private void CopyDataFromOneColumnToAnother(string table, string fromColumn, string toColumn)