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)