From 7d84abe109a303ceb85c18ae71ee7f36ae293ce7 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Wed, 23 Sep 2026 17:18:58 +0200 Subject: [PATCH] Expand behavioral coverage and fix data safety regressions Add 210 test cases for actual SQLite data/schema changes, provider command contracts, data-transfer validation, metadata decoding, logging, and nullable record conversions. Verify persisted rows, constraint definitions, rollback, parameter types, reader/command disposal, and borrowed connection ownership. Fix defects exposed by the new tests: - Group view fields by relationship, assign distinct join aliases, qualify base-table columns, and honor explicit parent aliases in chained joins. - Generate IS NULL predicates for parameterized deletes and reject missing, empty, or mismatched predicate arrays before SQL execution. Missing values must never turn a filtered delete into a whole-table delete. - Rebuild SQLite fallback column renames and their dependent tables in one transaction, update self-referencing keys, and preserve caller transactions. - Implement atomic SQLite removal of incoming/outgoing foreign keys for a column, preserving unrelated constraints and restoring foreign-key settings. Validation: all 1,100 Unit + SQLite tests pass, with no failures or skips; git diff --check passes. Same-filter Coverlet comparison against b540baf: - Line coverage: 48.14% (5,047/10,484) -> 56.13% (5,918/10,543). - Branch coverage: 41.22% (2,132/5,172) -> 49.87% (2,608/5,230). - Passing tests: 890 -> 1,100. Coverage configuration and exclusions are unchanged. These local results exclude external database suites; the full CI matrix must establish the new combined coverage percentage. MySQL/MariaDB metadata and cross-provider SQL contract tests supplement, rather than replace, live engine validation. --- .../DataRecordConversionTests.cs | 83 ++++++ .../FluentSchemaLifecycleTests.cs | 264 ++++++++++++++++++ src/Migrator.Tests/LoggerBehaviorTests.cs | 65 +++++ .../MySqlMetadataContractTests.cs | 144 ++++++++++ .../ProviderCommandContractTests.cs | 186 ++++++++++++ .../ProviderDataTransferContractTests.cs | 101 +++++++ src/Migrator.Tests/SQLiteDataBehaviorTests.cs | 191 +++++++++++++ .../SQLiteFallbackAlterationTests.cs | 168 +++++++++++ src/Migrator.Tests/SQLiteViewBehaviorTests.cs | 107 +++++++ .../SQLite/SQLiteTransformationProvider.cs | 92 ++++-- .../Providers/TransformationProvider.cs | 24 +- 11 files changed, 1397 insertions(+), 28 deletions(-) create mode 100644 src/Migrator.Tests/DataRecordConversionTests.cs create mode 100644 src/Migrator.Tests/FluentSchemaLifecycleTests.cs create mode 100644 src/Migrator.Tests/LoggerBehaviorTests.cs create mode 100644 src/Migrator.Tests/MySqlMetadataContractTests.cs create mode 100644 src/Migrator.Tests/ProviderCommandContractTests.cs create mode 100644 src/Migrator.Tests/ProviderDataTransferContractTests.cs create mode 100644 src/Migrator.Tests/SQLiteDataBehaviorTests.cs create mode 100644 src/Migrator.Tests/SQLiteFallbackAlterationTests.cs create mode 100644 src/Migrator.Tests/SQLiteViewBehaviorTests.cs diff --git a/src/Migrator.Tests/DataRecordConversionTests.cs b/src/Migrator.Tests/DataRecordConversionTests.cs new file mode 100644 index 00000000..95bb1ccc --- /dev/null +++ b/src/Migrator.Tests/DataRecordConversionTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Data; +using DotNetProjects.Migrator.Framework; +using NUnit.Framework; + +namespace Migrator.Tests; + +public class DataRecordConversionTests +{ + private static T Read(object value, Func fallback = null) + { + using var table = new DataTable(); + table.Columns.Add("Value", typeof(object)); + table.Rows.Add(value); + using var reader = table.CreateDataReader(); + Assert.That(reader.Read(), Is.True); + return fallback == null ? reader.TryParse("Value") : reader.TryParse("Value", fallback); + } + + [Test] + public void DatabaseNullUsesLazyFallbackAndPreservesNullableDefaults() + { + Assert.That(Read(DBNull.Value), Is.Null); + Assert.That(Read(DBNull.Value), Is.Zero); + Assert.That(Read(DBNull.Value), Is.Null); + var calls = 0; + Assert.That(Read(DBNull.Value, () => { calls++; return 42; }), Is.EqualTo(42)); + Assert.That(Read(7, () => { calls++; return -1; }), Is.EqualTo(7)); + Assert.That(calls, Is.EqualTo(1)); + } + + [Test] + public void NumericAndDateConversionsPreserveNullableAndNonNullableValues() + { + Assert.That(Read("2147483647"), Is.EqualTo(int.MaxValue)); + Assert.That(Read((short)-7), Is.EqualTo(-7)); + Assert.That(Read("9223372036854775807"), Is.EqualTo(long.MaxValue)); + Assert.That(Read(int.MinValue), Is.EqualTo((long)int.MinValue)); + var date = new DateTime(2024, 2, 29, 12, 34, 56); + Assert.That(Read(date), Is.EqualTo(date)); + Assert.That(Read(date), Is.EqualTo(date)); + Assert.That(Read(123), Is.EqualTo("123")); + Assert.That(Read(12.5m), Is.EqualTo(12.5m)); + } + + [Test] + public void GuidConversionAcceptsDatabaseBinaryAndTextRepresentations() + { + var guid = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + Assert.That(Read(guid.ToByteArray()), Is.EqualTo(guid)); + Assert.That(Read(guid.ToString()), Is.EqualTo(guid)); + Assert.That(Read(guid), Is.EqualTo(guid)); + Assert.Throws(() => Read("invalid-guid")); + Assert.Throws(() => Read(new byte[15])); + } + + [TestCase(0, false)] + [TestCase(-2, true)] + [TestCase(0L, false)] + [TestCase(2L, true)] + [TestCase((short)-1, true)] + [TestCase((ushort)0, false)] + [TestCase(1U, true)] + [TestCase(0UL, false)] + [TestCase("TRUE", true)] + [TestCase("false", false)] + [TestCase(true, true)] + public void BooleanConversionSupportsDriverRepresentations(object input, bool expected) + { + Assert.That(Read(input), Is.EqualTo(expected)); + Assert.That(Read(input), Is.EqualTo(expected)); + } + + [Test] + public void InvalidConversionsDoNotSilentlyReturnDefaults() + { + Assert.Throws(() => Read(long.MaxValue)); + Assert.Throws(() => Read("not a number")); + var error = Assert.Throws(() => Read("not a duration")); + Assert.That(error.InnerException, Is.TypeOf()); + Assert.That(error.Message, Does.Contain("Value").And.Contain("System.TimeSpan")); + } +} diff --git a/src/Migrator.Tests/FluentSchemaLifecycleTests.cs b/src/Migrator.Tests/FluentSchemaLifecycleTests.cs new file mode 100644 index 00000000..090bdcfe --- /dev/null +++ b/src/Migrator.Tests/FluentSchemaLifecycleTests.cs @@ -0,0 +1,264 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Framework.Models; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Microsoft.Data.Sqlite; +using NSubstitute; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class FluentSchemaLifecycleTests +{ + private SqliteConnection connection; + private SQLiteTransformationProvider provider; + + [SetUp] + public void SetUp() + { + connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + connection.Open(); + provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); + } + + [TearDown] + public void TearDown() { provider.Dispose(); connection.Dispose(); } + + private sealed class InventoryMigration : FluentMigration + { + public override void BuildUp(MigrationBuilder migration) + { + if (Schema.Table("Inventory").Exists()) return; + Assert.That(Context.Connection.State, Is.EqualTo(ConnectionState.Open)); + migration.Create.Table("Inventory") + .WithColumn("Id").AsInt64().NotNullable() + .WithColumn("Name").AsString(40).Nullable() + .WithColumn("Active").AsBoolean().WithDefaultValue(true) + .WithCheckConstraint("CK_Positive", "Id > 0"); + migration.Create.PrimaryKey("PK_Inventory").OnTable("Inventory").WithColumns("Id"); + migration.Create.UniqueConstraint("UQ_Name").OnTable("Inventory").WithColumns("Name"); + migration.Create.Index("IX_Active").OnTable("Inventory").WithColumns("Active"); + } + public override void BuildDown(MigrationBuilder migration) => migration.Delete.Table("Inventory"); + } + + [Test] + public void FluentMigrationAndInspectorTrackRealSchemaThroughUpAndDown() + { + var migration = new InventoryMigration { Database = provider }; + migration.Up(); migration.Up(); + var schema = new SchemaInspector(provider); + var table = schema.Table("Inventory"); + Assert.That(schema.Tables(), Does.Contain("Inventory")); + Assert.That(table.Exists(), Is.True); + Assert.That(table.ColumnExists("Name"), Is.True); + Assert.That(table.ConstraintExists("CK_Positive"), Is.True); + Assert.That(table.PrimaryKeyExists("PK_Inventory"), Is.True); + Assert.That(table.IndexExists("IX_Active"), Is.True); + Assert.That(table.Constraints(), Does.Contain("CK_Positive").And.Contain("PK_Inventory").And.Contain("UQ_Name")); + Assert.That(table.ConstraintDefinitions().OfType().Single().CheckConstraintString, Does.Contain("Id > 0")); + Assert.That(table.Indexes().Single(i => i.Name == "IX_Active").KeyColumns, Is.EqualTo(new[] { "Active" })); + Assert.That(table.ForeignKeys(), Is.Empty); + Assert.That(table.NullableContentSize("Name"), Is.Null); + provider.Insert("Inventory", new[] { "Id", "Name" }, new object[] { 1, "sample" }); + Assert.That(table.ContentSize("Name"), Is.EqualTo(6)); + Assert.That(table.NullableContentSize("Name"), Is.EqualTo(6)); + Assert.That(schema.Scalar($"SELECT {schema.Concatenate("Name", "'!' ")} FROM Inventory"), Is.EqualTo("sample!")); + Assert.That(schema.QuoteColumns("select", "two words"), Is.EqualTo(new[] { "\"select\"", "\"two words\"" })); + Assert.That(schema.QuoteColumn("two words"), Is.EqualTo("\"two words\"")); + Assert.That(schema.QuoteTable("two words"), Is.EqualTo("\"two words\"")); + Assert.That(schema.ParameterName(3), Is.EqualTo("@p3")); + migration.Down(); + Assert.That(table.Exists(), Is.False); + } + + [TestCase(RemoveKind.ForeignKey)] + [TestCase(RemoveKind.ForeignKeysForColumn)] + [TestCase(RemoveKind.Constraint)] + [TestCase(RemoveKind.PrimaryKey)] + [TestCase(RemoveKind.Default)] + [TestCase(RemoveKind.Index)] + [TestCase(RemoveKind.AllIndexes)] + [TestCase(RemoveKind.AllConstraints)] + public void FluentRemovalChangesOnlyTheRequestedSchemaFeature(RemoveKind kind) + { + provider.ExecuteNonQuery(""" + CREATE TABLE Parent (Id INTEGER PRIMARY KEY); INSERT INTO Parent VALUES (1); + CREATE TABLE Items (Id INTEGER, ParentId INTEGER, Value TEXT DEFAULT 'default', + CONSTRAINT PK_Items PRIMARY KEY(Id), CONSTRAINT UQ_Value UNIQUE(Value), + CONSTRAINT FK_Parent FOREIGN KEY(ParentId) REFERENCES Parent(Id)); + CREATE INDEX IX_Parent ON Items(ParentId); + INSERT INTO Items VALUES (1, 1, 'keep'); + """); + var builder = new MigrationBuilder(); + switch (kind) + { + case RemoveKind.ForeignKey: builder.Delete.ForeignKey("FK_Parent").FromTable("Items"); break; + case RemoveKind.ForeignKeysForColumn: builder.Delete.ForeignKeysForColumn("ParentId").FromTable("Items"); break; + case RemoveKind.Constraint: builder.Delete.Constraint("UQ_Value").FromTable("Items"); break; + case RemoveKind.PrimaryKey: builder.Delete.PrimaryKey().FromTable("Items"); break; + case RemoveKind.Default: builder.Delete.DefaultValue("Value").FromTable("Items"); break; + case RemoveKind.Index: builder.Delete.Index("IX_Parent").FromTable("Items"); break; + case RemoveKind.AllIndexes: builder.Delete.AllIndexes().FromTable("Items"); break; + case RemoveKind.AllConstraints: builder.Delete.AllConstraints().FromTable("Items"); break; + } + builder.Apply(provider); + Assert.That(provider.ExecuteScalar("SELECT Value FROM Items WHERE Id=1"), Is.EqualTo("keep")); + var constraints = provider.GetConstraints("Items"); + Assert.That(constraints.Contains("FK_Parent"), Is.EqualTo(kind is not (RemoveKind.ForeignKey or RemoveKind.ForeignKeysForColumn or RemoveKind.AllConstraints))); + Assert.That(constraints.Contains("PK_Items"), Is.EqualTo(kind is not (RemoveKind.PrimaryKey or RemoveKind.AllConstraints))); + Assert.That(constraints.Contains("UQ_Value"), Is.EqualTo(kind is not (RemoveKind.Constraint or RemoveKind.AllConstraints))); + Assert.That(provider.IndexExists("Items", "IX_Parent"), Is.EqualTo(kind is not (RemoveKind.Index or RemoveKind.AllIndexes))); + provider.Insert("Items", new[] { "Id", "ParentId" }, new object[] { 2, 1 }); + Assert.That(provider.ExecuteScalar("SELECT Value FROM Items WHERE Id=2"), Is.EqualTo(kind == RemoveKind.Default ? DBNull.Value : "default")); + Assert.That(provider.CheckForeignKeyIntegrity(), Is.True); + } + + [Test] + public void AlterAndDeleteColumnPreserveDataAndHonorNewDefault() + { + provider.ExecuteNonQuery("CREATE TABLE Items (Id INTEGER, Value TEXT, Obsolete TEXT); INSERT INTO Items VALUES (1, 'keep', 'remove')"); + var definition = new Column("Value", DbType.String, 80, "new default"); + var builder = new MigrationBuilder(); + builder.Alter.Column(definition).OnTable("Items"); + builder.Delete.Column("Obsolete").FromTable("Items"); + definition.DefaultValue = "mutated"; + builder.Apply(provider); + Assert.That(provider.ColumnExists("Items", "Obsolete"), Is.False); + provider.Insert("Items", new[] { "Id" }, new object[] { 2 }); + Assert.That(provider.ExecuteStringQuery("SELECT Value FROM Items ORDER BY Id"), Is.EqualTo(new[] { "keep", "new default" })); + } + + [TestCase(false)] + [TestCase(true)] + public void RemoveForeignKeysForColumnHandlesIncomingAndOutgoingReferences(bool callerTransaction) + { + provider.ExecuteNonQuery(""" + CREATE TABLE Root (Id INTEGER PRIMARY KEY); + CREATE TABLE Middle (Id INTEGER PRIMARY KEY REFERENCES Root(Id), Other INTEGER REFERENCES Root(Id)); + CREATE TABLE Leaf (Id INTEGER PRIMARY KEY, MiddleId INTEGER REFERENCES Middle(Id), RootId INTEGER REFERENCES Root(Id)); + INSERT INTO Root VALUES (1); INSERT INTO Middle VALUES (1, 1); INSERT INTO Leaf VALUES (1, 1, 1); + """); + if (callerTransaction) { provider.SetPragmaForeignKeys(false); provider.BeginTransaction(); } + provider.RemoveAllForeignKeys("mIdDlE", "iD"); + Assert.That(provider.GetForeignKeyConstraints("Middle").Single().ChildColumns, Is.EqualTo(new[] { "Other" })); + Assert.That(provider.GetForeignKeyConstraints("Leaf").Single().ParentTable, Is.EqualTo("Root")); + Assert.That(provider.ExecuteScalar("SELECT MiddleId FROM Leaf"), Is.EqualTo(1L)); + Assert.That(provider.HasActiveTransaction, Is.EqualTo(callerTransaction)); + if (callerTransaction) + { + provider.Rollback(); + Assert.That(provider.GetForeignKeyConstraints("Middle"), Has.Length.EqualTo(2)); + Assert.That(provider.GetForeignKeyConstraints("Leaf"), Has.Length.EqualTo(2)); + } + else Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + } + + [Test] + public void RemovingForeignKeysRollsBackAllTablesWhenOneCannotBeRebuilt() + { + provider.ExecuteNonQuery(""" + CREATE TABLE Parent (Id INTEGER PRIMARY KEY); + CREATE TABLE FirstChild (Id INTEGER REFERENCES Parent(Id)); + CREATE TABLE SecondChild (Id INTEGER PRIMARY KEY REFERENCES Parent(Id)) WITHOUT ROWID; + INSERT INTO Parent VALUES (1); INSERT INTO FirstChild VALUES (1); INSERT INTO SecondChild VALUES (1); + """); + Assert.Throws(() => provider.RemoveAllForeignKeys("Parent", null)); + Assert.That(provider.GetForeignKeyConstraints("FirstChild"), Has.Length.EqualTo(1)); + Assert.That(provider.GetForeignKeyConstraints("SecondChild"), Has.Length.EqualTo(1)); + Assert.That(provider.ExecuteScalar("SELECT Id FROM FirstChild"), Is.EqualTo(1L)); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + Assert.That(provider.HasActiveTransaction, Is.False); + } + + [Test] + public void RemovingAllForeignKeysWithoutColumnAlsoRemovesIncomingReferences() + { + provider.ExecuteNonQuery("CREATE TABLE Parent (Id INTEGER PRIMARY KEY); CREATE TABLE Child (Id INTEGER REFERENCES Parent(Id)); INSERT INTO Parent VALUES (1); INSERT INTO Child VALUES (1)"); + provider.RemoveAllForeignKeys("Parent", null); + Assert.That(provider.GetForeignKeyConstraints("Child"), Is.Empty); + provider.ExecuteNonQuery("INSERT INTO Child VALUES (999)"); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Child"), Is.EqualTo(2L)); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + } + + [Test] + public void ForeignKeyRemovalRejectsUnsafeCallerTransactionBeforeChangingSchema() + { + provider.ExecuteNonQuery("CREATE TABLE Parent (Id INTEGER PRIMARY KEY); CREATE TABLE Child (Id INTEGER REFERENCES Parent(Id))"); + provider.BeginTransaction(); + Assert.Throws(() => provider.RemoveAllForeignKeys("Parent", "Id")); + Assert.That(provider.HasActiveTransaction, Is.True); + Assert.That(provider.GetForeignKeyConstraints("Child"), Has.Length.EqualTo(1)); + provider.Rollback(); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + } + + [Test] + public void RemovingUnmatchedForeignKeysDoesNotStartATransaction() + { + provider.ExecuteNonQuery("CREATE TABLE Items (Id INTEGER)"); + provider.RemoveAllForeignKeys("Items", "Id"); + Assert.That(provider.HasActiveTransaction, Is.False); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + Assert.That(provider.TableExists("Items"), Is.True); + } + + [Test] + public void CopyUpdateAndTruncateOperateOnActualRows() + { + provider.ExecuteNonQuery("CREATE TABLE Source (Id INTEGER, Value TEXT); CREATE TABLE Target (Key INTEGER, Label TEXT); INSERT INTO Source VALUES (2, 'second'), (1, 'first')"); + var copy = new MigrationBuilder(); + copy.Execute.CopyDataFromTable("Source").ToTable("Target").WithColumns(new[] { "Id", "Value" }, new[] { "Key", "Label" }); + copy.Apply(provider); + Assert.That(provider.ExecuteStringQuery("SELECT Label FROM Target ORDER BY Key"), Is.EqualTo(new[] { "first", "second" })); + provider.ExecuteNonQuery("UPDATE Source SET Value='changed' WHERE Id=2"); + var update = new MigrationBuilder(); + update.Execute.UpdateTable("Target").FromTable("Source") + .Set(new ColumnPair { ColumnNameSource = "Value", ColumnNameTarget = "Label" }) + .Match(new ColumnPair { ColumnNameSource = "Id", ColumnNameTarget = "Key" }); + update.Apply(provider); + Assert.That(provider.ExecuteStringQuery("SELECT Label FROM Target ORDER BY Key"), Is.EqualTo(new[] { "first", "changed" })); + var truncate = new MigrationBuilder(); truncate.Execute.Truncate("Target"); truncate.Apply(provider); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Target"), Is.EqualTo(0L)); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Source"), Is.EqualTo(2L)); + } +} + +public class FluentCallbackOwnershipTests +{ + [TestCase(false)] + [TestCase(true)] + public void CommandCallbackOwnsCommandEvenOnFailure(bool fail) + { + var provider = Substitute.For(); + var command = Substitute.For(); provider.CreateCommand().Returns(command); + var builder = new MigrationBuilder(); + builder.Execute.WithCommand(c => + { + c.CommandText = "UPDATE Items SET Value=1"; + c.ExecuteNonQuery(); + if (fail) throw new InvalidOperationException("callback failed"); + }); + if (fail) Assert.Throws(() => builder.Apply(provider)); + else builder.Apply(provider); + command.Received(1).ExecuteNonQuery(); + command.Received(1).Dispose(); + } + + [Test] + public void ConnectionCallbackBorrowsConnection() + { + var provider = Substitute.For(); + var connection = Substitute.For(); provider.Connection.Returns(connection); + var builder = new MigrationBuilder(); + builder.Execute.WithConnection(c => { Assert.That(c, Is.SameAs(connection)); throw new InvalidOperationException("callback failed"); }); + Assert.Throws(() => builder.Apply(provider)); + connection.DidNotReceive().Dispose(); + connection.DidNotReceive().Close(); + } +} diff --git a/src/Migrator.Tests/LoggerBehaviorTests.cs b/src/Migrator.Tests/LoggerBehaviorTests.cs new file mode 100644 index 00000000..8b3c1500 --- /dev/null +++ b/src/Migrator.Tests/LoggerBehaviorTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using DotNetProjects.Migrator.Framework.Loggers; +using NUnit.Framework; + +namespace Migrator.Tests; + +public class LoggerBehaviorTests +{ + private sealed class Writer : ILogWriter + { + public readonly StringBuilder Output = new(); + public void Write(string message, params object[] args) => Output.Append(args.Length == 0 ? message : string.Format(CultureInfo.InvariantCulture, message, args)); + public void WriteLine(string message, params object[] args) { Write(message, args); Output.AppendLine(); } + } + + [TestCase(false)] + [TestCase(true)] + public void TraceRespectsConfigurationAndDetachedWritersStopReceivingMessages(bool trace) + { + var first = new Writer(); var second = new Writer(); + var logger = new Logger(trace, first); + logger.Attach(second); + logger.Trace("trace {0}", 7); + logger.Warn("warning {0}", 8); + logger.Detach(first); + logger.Log("after detach"); + Assert.That(first.Output.ToString().Contains("trace 7"), Is.EqualTo(trace)); + Assert.That(second.Output.ToString().Contains("trace 7"), Is.EqualTo(trace)); + Assert.That(first.Output.ToString(), Does.Contain("Warning! : warning 8").And.Not.Contain("after detach")); + Assert.That(second.Output.ToString(), Does.Contain("after detach")); + } + + [TestCase(false)] + [TestCase(true)] + public void ErrorOutputRetainsEveryNestedCause(bool versioned) + { + var writer = new Writer(); var logger = new Logger(false, writer); + var cause = new Exception("inner-most"); + var error = new InvalidOperationException("outer", new Exception("middle", cause)); + if (versioned) logger.Exception(42, "Migration", error); + else logger.Exception("Migration failed", error); + var text = writer.Output.ToString(); + Assert.That(text, Does.Contain(versioned ? "Error in migration: 42" : "Error: Migration failed")); + Assert.That(text, Does.Contain("outer").And.Contain("middle").And.Contain("inner-most")); + } + + [Test] + public void ProgressOutputDistinguishesEmptyHistoryFromAppliedVersions() + { + var writer = new Writer(); var logger = new Logger(false, writer); + logger.Started(new List(), 10); + logger.Started(new List { 2, 7 }, 10); + logger.Started(7, 10); + logger.MigrateUp(10, "AddUsers"); + logger.MigrateDown(7, "RemoveOld"); + logger.Skipping(8); logger.RollingBack(7); logger.Finished(7, 10); + Assert.That(writer.Output.ToString(), Does.Contain("No migrations applied yet!") + .And.Contain("Latest version applied : 7").And.Contain("Current version : 7") + .And.Contain("Applying 10: AddUsers").And.Contain("Removing 7: RemoveOld") + .And.Contain("8 ").And.Contain("Rolling back to migration 7").And.Contain("Migrated to version 10")); + } +} diff --git a/src/Migrator.Tests/MySqlMetadataContractTests.cs b/src/Migrator.Tests/MySqlMetadataContractTests.cs new file mode 100644 index 00000000..5ae36423 --- /dev/null +++ b/src/Migrator.Tests/MySqlMetadataContractTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.Mysql; +using NSubstitute; +using NSubstitute.Extensions; +using NUnit.Framework; + +namespace Migrator.Tests; + +[TestFixture(false)] +[TestFixture(true)] +public class MySqlMetadataContractTests(bool mariaDb) +{ + private MySqlTransformationProvider provider; + private IDbCommand command; + private readonly List tables = new(); + + [SetUp] + public void SetUp() + { + var connection = Substitute.For(); + connection.State.Returns(ConnectionState.Open); connection.Database.Returns("Example"); + command = Substitute.For(); connection.CreateCommand().Returns(command); + Dialect dialect = mariaDb ? new MariaDBDialect() : new MysqlDialect(); + provider = Substitute.ForPartsOf(dialect, connection, "default", null); + provider.Configure().TableExists(Arg.Any()).Returns(true); + provider.Configure().ExecuteNonQuery(Arg.Any()).Returns(1); + provider.Configure().ExecuteScalar("SELECT DATABASE()").Returns("Example"); + } + + [TearDown] + public void TearDown() { provider.Dispose(); foreach (var table in tables) table.Dispose(); tables.Clear(); } + + private DataTable Data(string[] names, params object[][] rows) + { + var table = new DataTable(); tables.Add(table); + foreach (var name in names) table.Columns.Add(name, typeof(object)); + foreach (var row in rows) table.Rows.Add(row); + return table; + } + + [TestCase("PRIMARY KEY", "DROP PRIMARY KEY")] + [TestCase("FOREIGN KEY", "DROP FOREIGN KEY `constraint`")] + [TestCase("UNIQUE", "DROP INDEX `constraint`")] + [TestCase("CHECK", "check")] + public void ConstraintRemovalSelectsTheDialectSpecificStatement(string type, string action) + { + provider.Configure().ExecuteScalar(Arg.Any()).Returns(type); + provider.RemoveConstraint("Order", "constraint"); + if (action == "check") action = mariaDb ? "DROP CONSTRAINT `constraint`" : "DROP CHECK `constraint`"; + provider.Received(1).ExecuteNonQuery("ALTER TABLE `Order` " + action); + } + + [Test] + public void MissingConstraintFailsWithoutIssuingDdl() + { + provider.Configure().ExecuteScalar(Arg.Any()).Returns(DBNull.Value); + Assert.Throws(() => provider.RemoveConstraint("Items", "Missing")); + provider.DidNotReceiveWithAnyArgs().ExecuteNonQuery(default(string)); + } + + [TestCase(true)] + [TestCase(false)] + public void ForeignKeyLookupIsCaseInsensitiveAndClosesReader(bool exists) + { + var table = Data(new[] { "CONSTRAINT_NAME" }, new object[] { "FK_Other" }, new object[] { "FK_Parent" }); + using var reader = table.CreateDataReader(); + provider.Configure().ExecuteQuery(Arg.Any(), Arg.Any()).Returns(reader); + Assert.That(provider.ForeignKeyExists("Items", exists ? "fk_PARENT" : "FK_Missing"), Is.EqualTo(exists)); + Assert.That(reader.IsClosed, Is.True); + command.Received(1).Dispose(); + } + + [Test] + public void MissingTableDoesNotAttemptForeignKeyMetadataQuery() + { + provider.Configure().TableExists("Missing").Returns(false); + Assert.That(provider.ForeignKeyExists("Missing", "FK_Name"), Is.False); + provider.DidNotReceiveWithAnyArgs().ExecuteQuery(default, default); + } + + [TestCase(null)] + [TestCase("ParentId")] + public void BulkForeignKeyRemovalClosesEnumerationBeforeIssuingDrops(string column) + { + var metadata = Data(new[] { "TABLE_NAME", "CONSTRAINT_NAME" }, new object[] { "Items", "FK_Parent" }, new object[] { "Children", "FK_Items" }); + var names = Data(new[] { "CONSTRAINT_NAME" }, new object[] { "FK_Parent" }, new object[] { "FK_Items" }); + using var enumeration = metadata.CreateDataReader(); + provider.Configure().ExecuteQuery(Arg.Any(), Arg.Any()).Returns(c => + ((string)c[1]).Contains("SELECT k.TABLE_NAME") ? enumeration : names.CreateDataReader()); + provider.Configure().ExecuteNonQuery(Arg.Any()).Returns(_ => { Assert.That(enumeration.IsClosed, Is.True); return 1; }); + provider.RemoveAllForeignKeys("Items", column); + provider.Received(1).ExecuteNonQuery("ALTER TABLE Items DROP FOREIGN KEY `FK_Parent`"); + provider.Received(1).ExecuteNonQuery("ALTER TABLE Children DROP FOREIGN KEY `FK_Items`"); + } + + [Test] + public void IndexCleanupDistinguishesPrimaryUniqueAndForeignKeys() + { + provider.Configure().IndexExists("Items", "UQ_Code").Returns(true); + var metadata = Data(new[] { "TABLE_NAME", "CONSTRAINT_NAME", "CONSTRAINT_TYPE" }, + new object[] { "Items", "PRIMARY", "PRIMARY KEY" }, + new object[] { "Items", "UQ_Code", "UNIQUE" }, + new object[] { "Children", "FK_Items", "FOREIGN KEY" }); + var names = Data(new[] { "CONSTRAINT_NAME" }, new object[] { "FK_Items" }); + using var enumeration = metadata.CreateDataReader(); + provider.Configure().ExecuteQuery(Arg.Any(), Arg.Any()).Returns(c => + ((string)c[1]).Contains("SELECT k.TABLE_NAME") ? enumeration : names.CreateDataReader()); + provider.Configure().ExecuteNonQuery(Arg.Any()).Returns(_ => { Assert.That(enumeration.IsClosed, Is.True); return 1; }); + provider.RemoveAllIndexes("Items"); + provider.Received(1).ExecuteNonQuery("ALTER TABLE Items DROP PRIMARY KEY"); + provider.Received(1).ExecuteNonQuery("DROP INDEX `UQ_Code` ON Items"); + provider.Received(1).ExecuteNonQuery("ALTER TABLE Children DROP FOREIGN KEY `FK_Items`"); + } + + [Test, SetCulture("de-DE")] + public void ColumnMetadataRetainsNumericPrecisionNullabilityAndGeneratedDefaults() + { + var metadata = Data(new[] { "COLUMN_NAME", "DATA_TYPE", "IS_NULLABLE", "COLUMN_DEFAULT", "EXTRA", "CHARACTER_MAXIMUM_LENGTH", "COLUMN_KEY", "COLUMN_TYPE", "NUMERIC_PRECISION", "NUMERIC_SCALE" }, + new object[] { "Id", "bigint", "NO", DBNull.Value, "auto_increment", DBNull.Value, "PRI", "bigint", 19, 0 }, + new object[] { "Amount", "decimal", "YES", "123.45", "", DBNull.Value, "", "decimal(12,2)", 12, 2 }, + new object[] { "Name", "varchar", "YES", mariaDb ? "'O''Brien'" : "O'Brien", "", 4294967295L, "", "varchar", DBNull.Value, DBNull.Value }, + new object[] { "Created", "timestamp", "NO", "current_timestamp()", "DEFAULT_GENERATED", DBNull.Value, "", "timestamp", DBNull.Value, DBNull.Value }, + new object[] { "Enabled", "tinyint", "NO", "0", "", DBNull.Value, "", "tinyint(1)", 1, 0 }); + using var reader = metadata.CreateDataReader(); + provider.Configure().ExecuteQuery(Arg.Any(), Arg.Any()).Returns(reader); +#pragma warning disable CS0618 + var columns = provider.GetColumns("Items"); +#pragma warning restore CS0618 + Assert.That(columns.Select(c => c.Name), Is.EqualTo(new[] { "Id", "Amount", "Name", "Created", "Enabled" })); + Assert.That(columns[0].IsIdentity, Is.True); Assert.That(columns[0].IsNullable, Is.False); + Assert.That(columns[1].Precision, Is.EqualTo(12)); Assert.That(columns[1].Scale, Is.EqualTo(2)); + Assert.That(columns[1].DefaultValue, Is.EqualTo(123.45m)); Assert.That(columns[1].IsNullable, Is.True); + Assert.That(columns[2].DefaultValue, Is.EqualTo("O'Brien")); Assert.That(columns[2].Size, Is.EqualTo(int.MaxValue)); + Assert.That(columns[3].DefaultValue, Is.Not.TypeOf()); + Assert.That(provider.Dialect.Default(columns[3].DefaultValue), Is.EqualTo("DEFAULT current_timestamp()")); + Assert.That(columns[4].Type, Is.EqualTo(DbType.Boolean)); Assert.That(columns[4].DefaultValue, Is.False); + Assert.That(reader.IsClosed, Is.True); + } +} diff --git a/src/Migrator.Tests/ProviderCommandContractTests.cs b/src/Migrator.Tests/ProviderCommandContractTests.cs new file mode 100644 index 00000000..7a7ae1b9 --- /dev/null +++ b/src/Migrator.Tests/ProviderCommandContractTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +using NSubstitute; +using NUnit.Framework; + +namespace Migrator.Tests; + +public class ProviderCommandContractTests +{ + private sealed class Provider(IDbConnection connection) + : TransformationProvider(new SqlServerDialect(), connection, "dbo", "default") + { + public override List GetDatabases() => new() { "Example", "Archive" }; + public override bool ConstraintExists(string table, string name) => false; + public override bool IndexExists(string table, string name) => false; + } + + private IDbConnection connection; + private IDbCommand command; + private Provider provider; + + [SetUp] + public void SetUp() + { + connection = Substitute.For(); + connection.State.Returns(ConnectionState.Open); + command = Substitute.For(); + var values = new List(); + var parameters = Substitute.For(); + parameters.Add(Arg.Any()).Returns(call => { values.Add(call[0]); return values.Count - 1; }); + parameters[Arg.Any()].Returns(call => values[(int)call[0]]); + command.Parameters.Returns(parameters); + command.CreateParameter().Returns(_ => Substitute.For()); + command.ExecuteNonQuery().Returns(1); + connection.CreateCommand().Returns(command); + provider = new Provider(connection) { CommandTimeout = 17 }; + } + + [TearDown] + public void TearDown() => provider.Dispose(); + + private static IEnumerable Parameters() + { + yield return new TestCaseData(null, DbType.String, DBNull.Value).SetName("Parameter_Null"); + yield return new TestCaseData(DBNull.Value, DbType.String, DBNull.Value).SetName("Parameter_DbNull"); + var guid = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + yield return new TestCaseData(guid, DbType.Guid, guid); + yield return new TestCaseData(new byte[] { 0, 255 }, DbType.Binary, new byte[] { 0, 255 }); + yield return new TestCaseData(byte.MaxValue, DbType.Byte, byte.MaxValue); + yield return new TestCaseData(sbyte.MinValue, DbType.Int16, (short)sbyte.MinValue); + yield return new TestCaseData(short.MinValue, DbType.Int16, short.MinValue); + yield return new TestCaseData(int.MinValue, DbType.Int32, int.MinValue); + yield return new TestCaseData(long.MaxValue, DbType.Int64, long.MaxValue); + yield return new TestCaseData(ushort.MaxValue, DbType.UInt16, ushort.MaxValue); + yield return new TestCaseData(uint.MaxValue, DbType.UInt32, uint.MaxValue); + yield return new TestCaseData(ulong.MaxValue, DbType.UInt64, ulong.MaxValue); + yield return new TestCaseData(1.25f, DbType.Single, 1.25f); + yield return new TestCaseData(1.25d, DbType.Double, 1.25d); + yield return new TestCaseData(decimal.MaxValue, DbType.Decimal, decimal.MaxValue); + yield return new TestCaseData("O'Brien", DbType.String, "O'Brien"); + var date = new DateTime(2024, 2, 29, 12, 0, 0, DateTimeKind.Utc); + yield return new TestCaseData(date, DbType.DateTime, date); + var time = new TimeOnly(23, 59, 59).Add(TimeSpan.FromTicks(1234567)); + yield return new TestCaseData(time, DbType.Time, time.ToTimeSpan()); + yield return new TestCaseData(TimeSpan.FromHours(-51), DbType.Int64, TimeSpan.FromHours(-51).Ticks); + var offset = new DateTimeOffset(2024, 2, 29, 12, 0, 0, TimeSpan.FromHours(5.5)); + yield return new TestCaseData(offset, DbType.DateTimeOffset, offset.ToUniversalTime()); + yield return new TestCaseData(true, DbType.Boolean, true); + } + + [TestCaseSource(nameof(Parameters))] + public void WritesBindTypedParametersWithoutInterpolatingValues(object input, DbType type, object expected) + { + Assert.That(provider.ExecuteNonQuery("INSERT INTO [dbo].[Items] ([Value]) VALUES (@p0)", 17, new[] { input }), Is.EqualTo(1)); + Assert.That(command.CommandText, Is.EqualTo("INSERT INTO [dbo].[Items] ([Value]) VALUES (@p0)")); + var parameter = (IDbDataParameter)command.Parameters[0]; + Assert.That(parameter.ParameterName, Is.EqualTo("@p0")); + if (expected != DBNull.Value) Assert.That(parameter.DbType, Is.EqualTo(type)); + Assert.That(parameter.Value, Is.EqualTo(expected)); + Assert.That(command.CommandTimeout, Is.EqualTo(17)); + command.Received(1).Dispose(); + } + + [Test] + public void UnsupportedParameterFailsBeforeExecutionAndDisposesCommand() + { + Assert.Throws(() => provider.Insert("Items", new[] { "Value" }, new object[] { new Version(1, 2) })); + command.DidNotReceive().ExecuteNonQuery(); + command.Received(1).Dispose(); + } + + [TestCase(true)] + [TestCase(false)] + public void CommandsEnlistInTransactionAndCompleteExactlyOnce(bool commit) + { + var transaction = Substitute.For(); + connection.BeginTransaction(IsolationLevel.ReadCommitted).Returns(transaction); + provider.BeginTransaction(); + provider.BeginTransaction(); + provider.Insert("Items", new[] { "Value" }, new object[] { 1 }); + Assert.That(command.Transaction, Is.SameAs(transaction)); + if (commit) { provider.Commit(); provider.Commit(); transaction.Received(1).Commit(); transaction.DidNotReceive().Rollback(); } + else { provider.Rollback(); provider.Rollback(); transaction.Received(1).Rollback(); transaction.DidNotReceive().Commit(); } + transaction.Received(1).Dispose(); + connection.Received(1).BeginTransaction(IsolationLevel.ReadCommitted); + Assert.That(provider.HasActiveTransaction, Is.False); + } + + [Test] + public void DisposeRollsBackOutstandingWorkButLeavesBorrowedConnectionOpen() + { + var transaction = Substitute.For(); + connection.BeginTransaction(IsolationLevel.ReadCommitted).Returns(transaction); + provider.BeginTransaction(); + provider.Dispose(); + provider.Dispose(); + transaction.Received(1).Rollback(); + transaction.Received(1).Dispose(); + connection.DidNotReceive().Dispose(); + connection.DidNotReceive().Close(); + } + + [Test] + public void FailedNonQueryPreservesCauseAndReleasesCommand() + { + var cause = new InvalidOperationException("database failure"); + command.ExecuteNonQuery().Returns(_ => throw cause); + var error = Assert.Throws(() => provider.ExecuteNonQuery("UPDATE Items SET Value=@p0", 9, 42)); + Assert.That(error.InnerException, Is.SameAs(cause)); + Assert.That(command.CommandTimeout, Is.EqualTo(9)); + Assert.That(((IDataParameter)command.Parameters[0]).Value, Is.EqualTo(42)); + command.Received(1).Dispose(); + } + + [Test] + public void FailedScalarPreservesTheOriginalExceptionAndReleasesCommand() + { + var cause = new InvalidOperationException("database failure"); + command.ExecuteScalar().Returns(_ => throw cause); + Assert.That(Assert.Throws(() => provider.ExecuteScalar("SELECT 1")), Is.SameAs(cause)); + command.Received(1).Dispose(); + } + + [Test] + public void GenericColumnMetadataPreservesOrderNullabilityAndReleasesReader() + { + using var table = new DataTable(); + table.Columns.Add("COLUMN_NAME"); table.Columns.Add("IS_NULLABLE"); + table.Rows.Add("First", "NO"); table.Rows.Add("Second", "YES"); + using var reader = table.CreateDataReader(); + command.ExecuteReader().Returns(reader); +#pragma warning disable CS0618 + var columns = provider.GetColumns("Items"); +#pragma warning restore CS0618 + Assert.That(columns.Select(c => c.Name), Is.EqualTo(new[] { "First", "Second" })); + Assert.That(columns.Select(c => c.IsNullable), Is.EqualTo(new[] { false, true })); + Assert.That(reader.IsClosed, Is.True); + command.Received(1).Dispose(); + } + + [Test] + public void GenericConstraintEnumerationDisposesItsReader() + { + using var table = new DataTable(); table.Columns.Add("CONSTRAINT_NAME"); + table.Rows.Add("PK_Items"); table.Rows.Add("CK_Positive"); + using var reader = table.CreateDataReader(); command.ExecuteReader().Returns(reader); + Assert.That(provider.GetConstraints("Items"), Is.EqualTo(new[] { "PK_Items", "CK_Positive" })); + Assert.That(reader.IsClosed, Is.True); + command.Received(1).Dispose(); + } + + [Test] + public void DatabaseLookupIsCaseInsensitiveAndSwitchUsesTheConnection() + { + Assert.That(provider.DatabaseExists("eXAMPLE"), Is.True); + Assert.That(provider.DatabaseExists("Missing"), Is.False); + provider.SwitchDatabase("Archive"); + connection.Received(1).ChangeDatabase("Archive"); + command.DidNotReceive().ExecuteNonQuery(); + } +} diff --git a/src/Migrator.Tests/ProviderDataTransferContractTests.cs b/src/Migrator.Tests/ProviderDataTransferContractTests.cs new file mode 100644 index 00000000..922afdab --- /dev/null +++ b/src/Migrator.Tests/ProviderDataTransferContractTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Data; +using DotNetProjects.Migrator.Framework.Models; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +using NSubstitute; +using NSubstitute.Extensions; +using NUnit.Framework; + +namespace Migrator.Tests; + +// These exercise provider validation and generated SQL without a server. Live suites +// remain responsible for executing each dialect against its database engine. +[TestFixture("SqlServer")] +[TestFixture("Oracle")] +[TestFixture("PostgreSQL")] +[TestFixture("SQLite")] +public class ProviderDataTransferContractTests(string database) +{ + private TransformationProvider provider; + + [SetUp] + public void SetUp() + { + var connection = Substitute.For(); + provider = database switch + { + "SqlServer" => Substitute.ForPartsOf(new SqlServerDialect(), connection, null, "default", null), + "Oracle" => Substitute.ForPartsOf(new OracleDialect(), connection, null, "default", null), + "PostgreSQL" => Substitute.ForPartsOf(new PostgreSQLDialect(), connection, null, "default", null), + _ => Substitute.ForPartsOf(new SQLiteDialect(), connection, "default", null) + }; + provider.Configure().TableExists(Arg.Any()).Returns(c => (string)c[0] is "SourceTable" or "TargetTable"); + provider.Configure().ColumnExists(Arg.Any(), Arg.Any()).Returns(c => (string)c[1] is "Id" or "Value" or "Label" or "Other"); + provider.Configure().ExecuteNonQuery(Arg.Any()).Returns(1); + } + + [TearDown] + public void TearDown() => provider.Dispose(); + + [TestCase("source-table", "Source")] + [TestCase("target-table", "Target")] + [TestCase("source-column", "source")] + [TestCase("target-column", "target")] + [TestCase("order-column", "source")] + [TestCase("order-not-copied", "orderBySourceColumns")] + public void CopyRejectsInvalidMetadataBeforeWriting(string invalid, string message) + { + var source = invalid == "source-table" ? "Missing" : "SourceTable"; + var target = invalid == "target-table" ? "Missing" : "TargetTable"; + var sourceColumns = new List { invalid == "source-column" ? "Missing" : "Value" }; + var targetColumns = new List { invalid == "target-column" ? "Missing" : "Label" }; + var order = invalid == "order-column" ? new List { "Missing" } + : invalid == "order-not-copied" ? new List { "Other" } : null; + var error = Assert.Catch(() => provider.CopyDataFromTableToTable(source, sourceColumns, target, targetColumns, order)); + Assert.That(error.Message, Does.Contain(message)); + provider.DidNotReceiveWithAnyArgs().ExecuteNonQuery(default(string)); + } + + [TestCase(false)] + [TestCase(true)] + public void CopyKeepsColumnMappingAndOptionalSortOrder(bool ordered) + { + provider.CopyDataFromTableToTable("SourceTable", new List { "Id", "Value" }, "TargetTable", new List { "Id", "Label" }, ordered ? new List { "Id" } : null); + var expected = database switch + { + "SqlServer" => "INSERT INTO [TargetTable] ([Id], [Label]) SELECT [Id], [Value] FROM [SourceTable]" + (ordered ? " ORDER BY [Id]" : ""), + "Oracle" => "INSERT INTO TargetTable (Id, \"Label\") SELECT Id, \"Value\" FROM SourceTable" + (ordered ? " ORDER BY Id" : ""), + _ => "INSERT INTO TargetTable (Id, Label) SELECT Id, Value FROM SourceTable" + (ordered ? " ORDER BY Id" : "") + }; + provider.Received(1).ExecuteNonQuery(expected); + } + + [TestCase("source-table", "tableSourceNotQuoted")] + [TestCase("target-table", "tableTargetNotQuoted")] + [TestCase("empty-set", "fromSourceToTargetColumnPairs")] + [TestCase("blank-set-source", "fromSourceToTargetColumnPairs")] + [TestCase("blank-set-target", "fromSourceToTargetColumnPairs")] + [TestCase("empty-match", "conditionColumnPairs")] + [TestCase("blank-match-source", "conditionColumnPairs")] + [TestCase("blank-match-target", "conditionColumnPairs")] + public void UpdateRejectsMissingPredicatesAndMappingsBeforeWriting(string invalid, string message) + { + var set = new[] { new ColumnPair { ColumnNameSource = "Value", ColumnNameTarget = "Label" } }; + var match = new[] { new ColumnPair { ColumnNameSource = "Id", ColumnNameTarget = "Id" } }; + if (invalid == "empty-set") set = Array.Empty(); + if (invalid == "empty-match") match = Array.Empty(); + if (invalid == "blank-set-source") set[0].ColumnNameSource = " "; + if (invalid == "blank-set-target") set[0].ColumnNameTarget = null; + if (invalid == "blank-match-source") match[0].ColumnNameSource = " "; + if (invalid == "blank-match-target") match[0].ColumnNameTarget = null; + var error = Assert.Catch(() => provider.UpdateTargetFromSource(invalid == "source-table" ? "Missing" : "SourceTable", + invalid == "target-table" ? "Missing" : "TargetTable", set, match)); + Assert.That(error.Message, Does.Contain(message)); + provider.DidNotReceiveWithAnyArgs().ExecuteNonQuery(default(string)); + } +} diff --git a/src/Migrator.Tests/SQLiteDataBehaviorTests.cs b/src/Migrator.Tests/SQLiteDataBehaviorTests.cs new file mode 100644 index 00000000..aaa9d3b9 --- /dev/null +++ b/src/Migrator.Tests/SQLiteDataBehaviorTests.cs @@ -0,0 +1,191 @@ +using System; +using System.Data; +using System.IO; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Microsoft.Data.Sqlite; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class SQLiteDataBehaviorTests +{ + private SqliteConnection connection; + private SQLiteTransformationProvider provider; + + [SetUp] + public void SetUp() + { + connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); + provider.CommandTimeout = 12; + provider.ExecuteNonQuery("CREATE TABLE Items (Id INTEGER, Name TEXT, Deleted TEXT, Note TEXT)"); + provider.Insert("Items", new[] { "Id", "Name", "Deleted", "Note" }, new object[] { 1, "O'Brien", null, null }); + provider.Insert("Items", new[] { "Id", "Name", "Deleted", "Note" }, new object[] { 2, "other", "yes", "note" }); + provider.Insert("Items", new[] { "Id", "Name", "Deleted", "Note" }, new object[] { 3, "other", null, "note" }); + } + + [TearDown] + public void TearDown() { provider.Dispose(); connection.Dispose(); } + + [TestCase(false, true, false, 1)] + [TestCase(false, false, true, 2)] + [TestCase(true, true, false, 1)] + [TestCase(false, true, true, 3)] + [TestCase(true, true, true, 3)] + public void SelectCombinesEqualityNullAndNotNullPredicates(bool equality, bool nulls, bool notNulls, int expectedId) + { + using var command = provider.CreateCommand(); + using var reader = provider.SelectComplex(command, "Items", new[] { "Id", "Name" }, + equality ? new[] { "Id" } : null, equality ? new object[] { expectedId } : null, + nulls ? (notNulls ? new[] { "Deleted" } : new[] { "Deleted", "Note" }) : null, + notNulls ? (nulls ? new[] { "Note", "Name" } : new[] { "Deleted", "Note" }) : null); + Assert.That(reader.Read(), Is.True); + Assert.That(reader.GetInt64(0), Is.EqualTo(expectedId)); + Assert.That(reader.Read(), Is.False); + } + + [Test] + public void ParameterizedUpdateAndDeleteDistinguishNullPredicatesAndKeepOtherRows() + { + Assert.That(provider.Update("Items", new[] { "Name", "Note" }, new object[] { "updated ' value", "saved" }, + new[] { "Id", "Deleted" }, new object[] { 1, null }), Is.EqualTo(1)); + Assert.That(provider.SelectScalar("Name", "Items", new[] { "Id" }, new object[] { 1 }), Is.EqualTo("updated ' value")); + Assert.That(provider.Delete("Items", new[] { "Name", "Deleted" }, new object[] { "other", DBNull.Value }), Is.EqualTo(1)); + Assert.That(provider.ExecuteStringQuery("SELECT Name FROM Items ORDER BY Id"), Is.EqualTo(new[] { "updated ' value", "other" })); + Assert.That(provider.SelectScalar("Note", "Items", "Id = 1"), Is.EqualTo("saved")); + } + + [TestCase(false)] + [TestCase(true)] + public void DeleteBindsNonNullParametersAfterNullPredicates(bool databaseNull) + { + Assert.That(provider.Delete("Items", new[] { "Deleted", "Name", "Id" }, new object[] { databaseNull ? DBNull.Value : null, "other", 3 }), Is.EqualTo(1)); + Assert.That(provider.ExecuteStringQuery("SELECT Id FROM Items ORDER BY Id"), Is.EqualTo(new[] { "1", "2" })); + } + + [TestCase("columns-null")] + [TestCase("values-null")] + [TestCase("length-mismatch")] + [TestCase("empty")] + public void InvalidDeletePredicatesMustNotDeleteTheWholeTable(string invalid) + { + var columns = invalid == "columns-null" ? null : invalid == "empty" ? Array.Empty() : new[] { "Id" }; + var values = invalid == "values-null" ? null : invalid is "length-mismatch" or "empty" ? Array.Empty() : new object[] { 1 }; + Assert.Catch(() => provider.Delete("Items", columns, values)); + Assert.That(provider.SelectScalar("COUNT(*)", "Items"), Is.EqualTo(3L)); + } + + [Test] + public void InsertIfMissingOnlyInsertsOnceAndRetainsOriginalValues() + { + var columns = new[] { "Id", "Name" }; + Assert.That(provider.InsertIfNotExists("Items", columns, new object[] { 4, "first" }, new[] { "Id" }, new object[] { 4 }), Is.EqualTo(1)); + Assert.That(provider.InsertIfNotExists("Items", columns, new object[] { 4, "overwrite" }, new[] { "Id" }, new object[] { 4 }), Is.Zero); + Assert.That(provider.SelectScalar("Name", "Items", "Id=4"), Is.EqualTo("first")); + } + + [Test] + public void UpdateWithSqlPredicateAndWholeTableDeleteHaveExplicitRowCounts() + { + Assert.That(provider.Update("Items", new[] { "Name", "Note" }, new object[] { "all", "changed" }, "Id >= 2"), Is.EqualTo(2)); + Assert.That(provider.Update("Items", new[] { "Note" }, new object[] { "everyone" }), Is.EqualTo(3)); + Assert.That(provider.SelectScalar("COUNT(*)", "Items"), Is.EqualTo(3L)); + Assert.That(provider.Delete("Items"), Is.EqualTo(3)); + Assert.That(provider.SelectScalar("COUNT(*)", "Items"), Is.EqualTo(0L)); + } + + [Test] + public void ParameterizedNonQueryAndStringResultsPreserveNullsAndQuotes() + { + Assert.That(provider.ExecuteNonQuery("INSERT INTO Items (Id, Name) VALUES (@p0, @p1)", 9, 4, "'; DROP TABLE Items; --"), Is.EqualTo(1)); + Assert.That(provider.ExecuteStringQuery("SELECT Note FROM Items WHERE Id IN (1,2) ORDER BY Id"), Is.EqualTo(new string[] { null, "note" })); + Assert.That(provider.SelectScalar("Name", "Items", "Id=4"), Is.EqualTo("'; DROP TABLE Items; --")); + Assert.That(provider.TableExists("Items"), Is.True); + } + + [TestCase("insert", "table")] + [TestCase("insert", "columns")] + [TestCase("insert", "values")] + [TestCase("update", "table")] + [TestCase("update", "columns")] + [TestCase("update", "values")] + [TestCase("filtered-update", "table")] + [TestCase("filtered-update", "columns")] + [TestCase("filtered-update", "values")] + public void MissingWriteArgumentsAreRejectedWithoutChangingData(string operation, string missing) + { + var table = missing == "table" ? null : "Items"; + var columns = missing == "columns" ? null : new[] { "Name" }; + var values = missing == "values" ? null : new object[] { "unexpected" }; + var error = Assert.Throws(() => + { + if (operation == "insert") provider.Insert(table, columns, values); + else if (operation == "update") provider.Update(table, columns, values, "Id=1"); + else provider.Update(table, columns, values, new[] { "Id" }, new object[] { 1 }); + }); + Assert.That(error.ParamName, Is.EqualTo(missing)); + Assert.That(provider.SelectScalar("COUNT(*)", "Items"), Is.EqualTo(3L)); + Assert.That(provider.SelectScalar("Name", "Items", "Id=1"), Is.EqualTo("O'Brien")); + } + + [TestCase("insert")] + [TestCase("update")] + [TestCase("filtered-update")] + [TestCase("where")] + public void MismatchedWriteArgumentsFailBeforeExecution(string operation) + { + Assert.Catch(() => + { + if (operation == "insert") provider.Insert("Items", new[] { "Id", "Name" }, new object[] { 9 }); + else if (operation == "update") provider.Update("Items", new[] { "Id", "Name" }, new object[] { 9 }, "Id=1"); + else provider.Update("Items", new[] { "Name" }, operation == "where" ? new object[] { "wrong" } : Array.Empty(), + new[] { "Id" }, operation == "where" ? Array.Empty() : new object[] { 1 }); + }); + Assert.That(provider.SelectScalar("Name", "Items", "Id=1"), Is.EqualTo("O'Brien")); + Assert.That(provider.SelectScalar("COUNT(*)", "Items"), Is.EqualTo(3L)); + } + + [Test] + public void InvalidReadAndDeleteArgumentsLeaveTheConnectionUsable() + { + using var command = provider.CreateCommand(); + Assert.Throws(() => provider.SelectComplex(command, null, new[] { "Id" })); + Assert.Throws(() => provider.SelectComplex(command, "Items", null)); + Assert.Throws(() => provider.Delete(null)); + Assert.Throws(() => provider.ExecuteScalar("SELECT Missing FROM Items")); + Assert.That(provider.SelectScalar("COUNT(*)", "Items"), Is.EqualTo(3L)); + } + + [Test] + public void EmbeddedScriptExecutesAndMissingResourceReportsItsName() + { + provider.ExecuteNonQuery("CREATE TABLE ScriptData (Id INTEGER)"); + provider.ExecuteResourceScript(GetType().Assembly, "Migrator.Tests.ScriptResource.sql"); + Assert.That(provider.SelectScalar("Id", "ScriptData"), Is.EqualTo(2L)); + var error = Assert.Throws(() => provider.ExecuteResourceScript(GetType().Assembly, "missing.sql")); + Assert.That(error.FileName, Is.EqualTo("missing.sql")); + Assert.That(provider.SelectScalar("COUNT(*)", "ScriptData"), Is.EqualTo(1L)); + } + + [Test] + public void InspectorCallbacksDisposeReadersEvenWhenTheCallbackThrows() + { + var schema = new SchemaInspector(provider); + IDataReader captured = null; + Assert.Throws(() => schema.Query("SELECT * FROM Items", reader => + { + captured = reader; + Assert.That(reader.Read(), Is.True); + throw new InvalidOperationException("callback"); + })); + Assert.That(captured.IsClosed, Is.True); + Assert.That(schema.Table("Items").SelectScalar("COUNT(*)"), Is.EqualTo(3L)); + Assert.That(schema.Table("Items").SelectScalar("Name", "Id=1"), Is.EqualTo("O'Brien")); + Assert.That(schema.NullableScalar("SELECT MAX(Id) FROM Items WHERE Id < 0"), Is.Null); + Assert.That(schema.Strings("SELECT Name FROM Items WHERE Id={0}", 1), Is.EqualTo(new[] { "O'Brien" })); + } +} diff --git a/src/Migrator.Tests/SQLiteFallbackAlterationTests.cs b/src/Migrator.Tests/SQLiteFallbackAlterationTests.cs new file mode 100644 index 00000000..59789cfb --- /dev/null +++ b/src/Migrator.Tests/SQLiteFallbackAlterationTests.cs @@ -0,0 +1,168 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Microsoft.Data.Sqlite; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class SQLiteFallbackAlterationTests +{ + // Select the compatibility path, but execute every schema/data statement against SQLite. + private sealed class LegacyProvider(IDbConnection connection) + : SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null) + { + public override object ExecuteScalar(string sql) => sql == "SELECT sqlite_version()" ? "3.25.0" : base.ExecuteScalar(sql); + } + + private SqliteConnection connection; + private LegacyProvider provider; + + [SetUp] + public void SetUp() + { + connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False"); + connection.Open(); + provider = new LegacyProvider(connection); + } + + [TearDown] + public void TearDown() { provider.Dispose(); connection.Dispose(); } + + [Test] + public void RenamePreservesPrimaryUniqueAndForeignKeysIndexesAndRows() + { + provider.ExecuteNonQuery(""" + CREATE TABLE Lookup (Id INTEGER PRIMARY KEY); + CREATE TABLE Parent (Old INTEGER, Value TEXT, CONSTRAINT PK_Parent PRIMARY KEY(Old), + CONSTRAINT UQ_Parent UNIQUE(Old), CONSTRAINT FK_Lookup FOREIGN KEY(Old) REFERENCES Lookup(Id)); + CREATE INDEX IX_Parent ON Parent(Old); + CREATE TABLE Child (Id INTEGER, ParentId INTEGER, LookupId INTEGER, + CONSTRAINT FK_Parent FOREIGN KEY(ParentId) REFERENCES Parent(Old), + CONSTRAINT FK_Other FOREIGN KEY(LookupId) REFERENCES Lookup(Id)); + INSERT INTO Lookup VALUES (1); + INSERT INTO Parent VALUES (1, 'preserved'); + INSERT INTO Child VALUES (2, 1, 1); + """); + provider.RenameColumn("Parent", "Old", "Renamed"); + Assert.That(provider.ColumnExists("Parent", "Old"), Is.False); + Assert.That(provider.ExecuteScalar("SELECT Value FROM Parent WHERE Renamed=1"), Is.EqualTo("preserved")); + Assert.That(provider.GetTableConstraints("Parent").OfType().Single().KeyColumns, Is.EqualTo(new[] { "Renamed" })); + Assert.That(provider.GetTableConstraints("Parent").OfType().Single().KeyColumns, Is.EqualTo(new[] { "Renamed" })); + Assert.That(provider.GetIndexes("Parent").Single(x => x.Name == "IX_Parent").KeyColumns, Is.EqualTo(new[] { "Renamed" })); + Assert.That(provider.GetForeignKeyConstraints("Parent").Single().ChildColumns, Is.EqualTo(new[] { "Renamed" })); + Assert.That(provider.GetForeignKeyConstraints("Child").Single(x => x.Name == "FK_Parent").ParentColumns, Is.EqualTo(new[] { "Renamed" })); + Assert.That(provider.GetForeignKeyConstraints("Child").Single(x => x.Name == "FK_Other").ParentColumns, Is.EqualTo(new[] { "Id" })); + Assert.That(provider.CheckForeignKeyIntegrity(), Is.True); + Assert.That(provider.GetTables(), Does.Not.Contain("ParentTemp").And.Not.Contain("ChildTemp")); + } + + [TestCase("", "Old")] + [TestCase("Existing", "Old")] + [TestCase("New", "Missing")] + public void InvalidRenamePreservesOriginalSchemaAndData(string newName, string oldName) + { + provider.ExecuteNonQuery("CREATE TABLE Original (Old INTEGER, Existing TEXT); INSERT INTO Original VALUES (7, 'keep')"); + Assert.Catch(() => provider.RenameColumn("Original", oldName, newName)); + Assert.That(provider.ExecuteScalar("SELECT Existing FROM Original WHERE Old=7"), Is.EqualTo("keep")); + Assert.That(provider.GetTables(), Is.EquivalentTo(new[] { "Original" })); + } + + [Test] + public void RenameWithEnabledForeignKeysIsRejectedBeforeRebuild() + { + provider.ExecuteNonQuery("CREATE TABLE Original (Old INTEGER); INSERT INTO Original VALUES (7)"); + provider.SetPragmaForeignKeys(true); + Assert.Catch(() => provider.RenameColumn("Original", "Old", "New")); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + Assert.That(provider.ExecuteScalar("SELECT Old FROM Original"), Is.EqualTo(7L)); + } + + [Test] + public void RenameWithTriggerIsRejectedWithoutDroppingTheTrigger() + { + provider.ExecuteNonQuery("CREATE TABLE Original (Old INTEGER); CREATE TABLE Audit (Id INTEGER); CREATE TRIGGER RecordInsert AFTER INSERT ON Original BEGIN INSERT INTO Audit VALUES (NEW.Old); END"); + Assert.Throws(() => provider.RenameColumn("Original", "Old", "New")); + provider.ExecuteNonQuery("INSERT INTO Original VALUES (7)"); + Assert.That(provider.ExecuteScalar("SELECT Id FROM Audit"), Is.EqualTo(7L)); + } + + [TestCase("CONSTRAINT CK_Value CHECK (Obsolete > 0)", "check constraint")] + [TestCase("CONSTRAINT UQ_Value UNIQUE (Obsolete, Retained)", "composite unique")] + [TestCase("CONSTRAINT PK_Value PRIMARY KEY (Obsolete, Retained)", "primary-key")] + public void RemovingAConstrainedColumnRequiresExplicitConstraintRemoval(string constraint, string message) + { + provider.ExecuteNonQuery($"CREATE TABLE Original (Obsolete INTEGER, Retained INTEGER, {constraint}); INSERT INTO Original VALUES (1, 2)"); + var error = Assert.Catch(() => provider.RemoveColumn("Original", "Obsolete")); + Assert.That(error.Message, Does.Contain(message)); + Assert.That(provider.ExecuteScalar("SELECT Retained FROM Original WHERE Obsolete=1"), Is.EqualTo(2L)); + Assert.That(provider.GetTables(), Is.EquivalentTo(new[] { "Original" })); + } + + [Test] + public void RemovingColumnWithCompositeIndexLeavesIndexAndRowsUntouched() + { + provider.ExecuteNonQuery("CREATE TABLE Original (Obsolete INTEGER, Retained INTEGER); CREATE INDEX IX_Both ON Original(Obsolete, Retained); INSERT INTO Original VALUES (1, 2)"); + Assert.That(Assert.Catch(() => provider.RemoveColumn("Original", "Obsolete")).Message, Does.Contain("composite index")); + Assert.That(provider.GetIndexes("Original").Single().KeyColumns, Is.EqualTo(new[] { "Obsolete", "Retained" })); + Assert.That(provider.ExecuteScalar("SELECT Retained FROM Original WHERE Obsolete=1"), Is.EqualTo(2L)); + } + + [Test] + public void FailedRebuildRollsBackDataAndRestoresForeignKeySetting() + { + provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER, Name TEXT); INSERT INTO Original VALUES (1, NULL)"); + provider.SetPragmaForeignKeys(true); + Assert.Catch(() => provider.ChangeColumn("Original", new Column("Name", DbType.String) { IsNullable = false })); + Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); + Assert.That(provider.HasActiveTransaction, Is.False); + Assert.That(provider.GetTables(), Is.EquivalentTo(new[] { "Original" })); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Original WHERE Name IS NULL"), Is.EqualTo(1L)); + provider.Insert("Original", new[] { "Id", "Name" }, new object[] { 2, null }); + Assert.That(provider.ExecuteScalar("SELECT COUNT(*) FROM Original"), Is.EqualTo(2L)); + } + + [Test] + public void FailureRebuildingDependentTableRollsBackTheParentRename() + { + provider.ExecuteNonQuery(""" + CREATE TABLE Parent (Old INTEGER PRIMARY KEY); + CREATE TABLE Child (Id INTEGER PRIMARY KEY, ParentId INTEGER REFERENCES Parent(Old)) WITHOUT ROWID; + INSERT INTO Parent VALUES (1); INSERT INTO Child VALUES (2, 1); + """); + Assert.Throws(() => provider.RenameColumn("Parent", "Old", "New")); + Assert.That(provider.HasActiveTransaction, Is.False); + Assert.That(provider.ColumnExists("Parent", "Old"), Is.True); + Assert.That(provider.ColumnExists("Parent", "New"), Is.False); + Assert.That(provider.ExecuteScalar("SELECT Old FROM Parent"), Is.EqualTo(1L)); + Assert.That(provider.ExecuteScalar("SELECT ParentId FROM Child"), Is.EqualTo(1L)); + Assert.That(provider.CheckForeignKeyIntegrity(), Is.True); + Assert.That(provider.GetTables(), Is.EquivalentTo(new[] { "Parent", "Child" })); + } + + [Test] + public void RenameInsideCallerTransactionCanBeRolledBackByTheCaller() + { + provider.ExecuteNonQuery("CREATE TABLE Parent (Old INTEGER PRIMARY KEY); CREATE TABLE Child (Id INTEGER REFERENCES Parent(Old)); INSERT INTO Parent VALUES (1); INSERT INTO Child VALUES (1)"); + provider.BeginTransaction(); + provider.RenameColumn("Parent", "Old", "New"); + Assert.That(provider.HasActiveTransaction, Is.True); + Assert.That(provider.CheckForeignKeyIntegrity(), Is.True); + provider.Rollback(); + Assert.That(provider.ExecuteScalar("SELECT Old FROM Parent"), Is.EqualTo(1L)); + Assert.That(provider.GetForeignKeyConstraints("Child").Single().ParentColumns, Is.EqualTo(new[] { "Old" })); + } + + [Test] + public void RenameUpdatesSelfReferencingParentColumns() + { + provider.ExecuteNonQuery("CREATE TABLE Nodes (Old INTEGER PRIMARY KEY, ParentId INTEGER REFERENCES Nodes(Old)); INSERT INTO Nodes VALUES (1, NULL), (2, 1)"); + provider.RenameColumn("Nodes", "Old", "Id"); + Assert.That(provider.GetForeignKeyConstraints("Nodes").Single().ParentColumns, Is.EqualTo(new[] { "Id" })); + Assert.That(provider.ExecuteScalar("SELECT ParentId FROM Nodes WHERE Id=2"), Is.EqualTo(1L)); + Assert.That(provider.CheckForeignKeyIntegrity(), Is.True); + } +} diff --git a/src/Migrator.Tests/SQLiteViewBehaviorTests.cs b/src/Migrator.Tests/SQLiteViewBehaviorTests.cs new file mode 100644 index 00000000..6fdcac68 --- /dev/null +++ b/src/Migrator.Tests/SQLiteViewBehaviorTests.cs @@ -0,0 +1,107 @@ +using System; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using Microsoft.Data.Sqlite; +using NUnit.Framework; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class SQLiteViewBehaviorTests +{ + private SqliteConnection connection; + private SQLiteTransformationProvider provider; + + [SetUp] + public void SetUp() + { + connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); + provider.ExecuteNonQuery(""" + CREATE TABLE Customers (Id INTEGER, Name TEXT, RegionId INTEGER); + CREATE TABLE Regions (Id INTEGER, Label TEXT); + CREATE TABLE Orders (OrderId INTEGER, CustomerId INTEGER); + INSERT INTO Customers VALUES (1, 'Ada', 10); + INSERT INTO Regions VALUES (10, 'West'); + INSERT INTO Orders VALUES (101, 1), (102, NULL); + """); + } + + [TearDown] + public void TearDown() { provider.Dispose(); connection.Dispose(); } + + [TestCase(JoinType.Join, false, 1)] + [TestCase(JoinType.LeftJoin, false, 2)] + [TestCase(JoinType.Join, true, 1)] + [TestCase(JoinType.LeftJoin, true, 2)] + public void FluentViewHonorsJoinKindAndCanReplaceExistingDefinition(JoinType kind, bool alias, int count) + { + var prefix = alias ? "c" : "Customers"; + var builder = new MigrationBuilder(); + builder.Create.View("OrderDetails").FromTable("Orders").WithElements( + new ViewColumn("Orders", "OrderId"), new ViewColumn(prefix, "Name"), + alias ? new ViewJoin("Customers", "c", "Id", "Orders", "CustomerId", kind) + : new ViewJoin("Customers", "Id", "Orders", "CustomerId", kind)); + builder.Apply(provider); + builder.Apply(provider); + var schema = new SchemaInspector(provider); + Assert.That(schema.ViewExists("OrderDetails"), Is.True); + Assert.That(schema.ViewExists("Missing"), Is.False); + Assert.That(schema.Scalar("SELECT COUNT(*) FROM OrderDetails"), Is.EqualTo((long)count)); + Assert.That(schema.Strings($"SELECT {prefix}Name FROM OrderDetails ORDER BY OrdersOrderId"), + Is.EqualTo(count == 1 ? new[] { "Ada" } : new string[] { "Ada", null })); + } + + [Test] + public void ChainedViewJoinsUseTheParentAlias() + { + var builder = new MigrationBuilder(); + builder.Create.View("OrderRegions").FromTable("Orders").WithElements( + new ViewColumn("Orders", "OrderId"), new ViewColumn("Regions", "Label"), + new ViewJoin("Customers", "c", "Id", "Orders", "CustomerId", JoinType.Join), + new ViewJoin(JoinType.Join, "Regions", "Id", "Customers", "c", "RegionId")); + builder.Apply(provider); + Assert.That(provider.ExecuteStringQuery("SELECT RegionsLabel FROM OrderRegions"), Is.EqualTo(new[] { "West" })); + } + + [Test] + public void FieldViewsGroupColumnsByRelationshipAndAssignDistinctJoinAliases() + { + var name = new ViewField("Name", "Customers", "Id", "Orders", "CustomerId"); + var builder = new MigrationBuilder(); + builder.Create.View("OrderDetails").FromTable("Orders").WithFields( + new ViewField("OrderId"), name, + new ViewField("RegionId", "Customers", "Id", "Orders", "CustomerId")); + name.ColumnName = "Missing"; + builder.Apply(provider); + Assert.That(provider.ExecuteStringQuery("SELECT Name || ':' || RegionId FROM OrderDetails"), Is.EqualTo(new[] { "Ada:10" })); + + provider.ExecuteNonQuery("CREATE TABLE Assignments (Id INTEGER, CustomerId INTEGER, RegionId INTEGER); INSERT INTO Assignments VALUES (1, 1, 10)"); + provider.AddView("AssignmentDetails", "Assignments", new IViewField[] + { + new ViewField("Id", "Assignments", null, null, null), + new ViewField("Name", "Customers", "Id", "Assignments", "CustomerId"), + new ViewField("Label", "Regions", "Id", "Assignments", "RegionId") + }); + Assert.That(provider.ExecuteStringQuery("SELECT Name || ':' || Label FROM AssignmentDetails"), Is.EqualTo(new[] { "Ada:West" })); + } + + [Test] + public void FieldViewsCanJoinTheSameTableThroughDifferentKeys() + { + provider.ExecuteNonQuery("CREATE TABLE Transfers (SenderId INTEGER, RecipientId INTEGER); INSERT INTO Customers VALUES (2, 'Grace', 10); INSERT INTO Transfers VALUES (1, 2)"); + provider.AddView("TransferNames", "Transfers", new IViewField[] + { + new ViewField("Name", "Customers", "Id", "Transfers", "SenderId"), + new ViewField("Name", "Customers", "Id", "Transfers", "RecipientId") + }); + using var command = provider.CreateCommand(); + using var reader = provider.ExecuteQuery(command, "SELECT * FROM TransferNames"); + Assert.That(reader.Read(), Is.True); + Assert.That(reader.GetString(0), Is.EqualTo("Ada")); + Assert.That(reader.GetString(1), Is.EqualTo("Grace")); + Assert.That(reader.Read(), Is.False); + } +} diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index d6da6dae..283c846c 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -393,6 +393,49 @@ public override void RemoveForeignKey(string table, string name) RecreateTable(sqliteTableInfo); } + public override void RemoveAllForeignKeys(string tableName, string columnName) + { + bool Matches(string name) => string.Equals(name, tableName, StringComparison.OrdinalIgnoreCase); + bool Includes(string[] columns) => string.IsNullOrEmpty(columnName) || columns.Contains(columnName, StringComparer.OrdinalIgnoreCase); + var tables = new List(); + foreach (var table in GetTables()) + { + var info = GetSQLiteTableInfo(table); + var removed = info.ForeignKeys.RemoveAll(f => (Matches(table) && Includes(f.ChildColumns)) + || (Matches(f.ParentTable) && Includes(f.ParentColumns))); + if (removed > 0) tables.Add(info); + } + if (tables.Count == 0) return; + + var foreignKeys = IsPragmaForeignKeysOn(); + if (HasActiveTransaction && foreignKeys) + throw new MigrationException("SQLite rebuild requires foreign keys to be disabled before beginning the transaction. Use the migration runner."); + var ownsTransaction = !HasActiveTransaction; + Exception failure = null; + try + { + if (ownsTransaction) + { + if (foreignKeys) SetPragmaForeignKeys(false); + BeginTransaction(); + } + foreach (var info in tables) RecreateTable(info); + if (ownsTransaction) Commit(); + } + catch (Exception ex) + { + failure = ex; + if (ownsTransaction) + try { Rollback(); } catch (Exception rollback) { ex.Data["RollbackException"] = rollback; } + throw; + } + finally + { + try { if (ownsTransaction && foreignKeys) SetPragmaForeignKeys(true); } + catch (Exception restore) { if (failure == null) throw; failure.Data["ConnectionRestoreException"] = restore; } + } + } + public string[] GetCreateIndexSqlStrings(string table) { var sqlStrings = new List(); @@ -636,6 +679,9 @@ public override void RenameColumn(string tableName, string oldColumnName, string foreach (var foreignKey in sqliteTableInfo.ForeignKeys) { foreignKey.ChildColumns = [.. foreignKey.ChildColumns.Select(x => x.Equals(oldColumnName, StringComparison.OrdinalIgnoreCase) ? newColumnName : x)]; + if (string.Equals(foreignKey.ParentTable, tableName, StringComparison.OrdinalIgnoreCase)) + foreignKey.ParentColumns = foreignKey.ParentColumns.Select(x => + string.Equals(x, oldColumnName, StringComparison.OrdinalIgnoreCase) ? newColumnName : x).ToArray(); } foreach (var index in sqliteTableInfo.Indexes) @@ -648,31 +694,37 @@ public override void RenameColumn(string tableName, string oldColumnName, string unique.KeyColumns = [.. unique.KeyColumns.Select(x => x.Equals(oldColumnName, StringComparison.OrdinalIgnoreCase) ? newColumnName : x)]; } - RecreateTable(sqliteTableInfo); - - var allTables = GetTables(); - - // Rename in foreign keys of depending tables - foreach (var allTablesItem in allTables) + // Rebuild the parent and every dependent table atomically. Checking integrity + // between those rebuilds would see references to the parent's old column name. + var ownsTransaction = !HasActiveTransaction; + if (ownsTransaction) BeginTransaction(); + try { - if (allTablesItem == tableName) + RecreateTable(sqliteTableInfo); + foreach (var otherTable in GetTables()) { - continue; + if (string.Equals(otherTable, tableName, StringComparison.OrdinalIgnoreCase)) continue; + var otherInfo = GetSQLiteTableInfo(otherTable); + var references = otherInfo.ForeignKeys.Where(f => + string.Equals(f.ParentTable, tableName, StringComparison.OrdinalIgnoreCase) + && f.ParentColumns.Contains(oldColumnName, StringComparer.OrdinalIgnoreCase)).ToArray(); + if (references.Length == 0) continue; + foreach (var foreignKey in references) + foreignKey.ParentColumns = foreignKey.ParentColumns.Select(x => + string.Equals(x, oldColumnName, StringComparison.OrdinalIgnoreCase) ? newColumnName : x).ToArray(); + RecreateTable(otherInfo); } - - var sqliteTableInfoOther = GetSQLiteTableInfo(allTablesItem); - - foreach (var foreignKey in sqliteTableInfoOther.ForeignKeys) + if (ownsTransaction) { - if (foreignKey.ParentTable != tableName) - { - continue; - } - - foreignKey.ParentColumns = foreignKey.ParentColumns.Select(x => x == oldColumnName ? newColumnName : x).ToArray(); - - RecreateTable(sqliteTableInfoOther); + if (!CheckForeignKeyIntegrity()) throw new MigrationException("SQLite rename would leave invalid foreign keys."); + Commit(); } + } + catch (Exception ex) + { + if (ownsTransaction) + try { Rollback(); } catch (Exception rollback) { ex.Data["RollbackException"] = rollback; } + throw; } } else diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index e914ec46..2a433e81 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -246,18 +246,20 @@ public virtual void AddView(string name, string tableName, params IViewField[] f { var lst = fields.Where(x => string.IsNullOrEmpty(x.TableName) || x.TableName == tableName) - .Select(x => x.ColumnName) + .Select(x => tableName + "." + x.ColumnName) .ToList(); var nr = 0; var joins = ""; - foreach (var joinTable in fields.Where(x => !string.IsNullOrEmpty(x.TableName) && x.TableName != tableName).GroupBy(x => x.TableName)) + foreach (var joinTable in fields.Where(x => !string.IsNullOrEmpty(x.TableName) && x.TableName != tableName) + .GroupBy(x => new { x.TableName, x.KeyColumnName, x.ParentTableName, x.ParentKeyColumnName })) { + var relationship = joinTable.Key; + var alias = "T" + nr++; + joins += $"JOIN {relationship.TableName} {alias} ON {alias}.{relationship.KeyColumnName} = {relationship.ParentTableName}.{relationship.ParentKeyColumnName} "; foreach (var viewField in joinTable) { - joins += string.Format("JOIN {0} {1} ON {1}.{2} = {3}.{4} ", viewField.TableName, " T" + nr, - viewField.KeyColumnName, viewField.ParentTableName, viewField.ParentKeyColumnName); - lst.Add(" T" + nr + "." + viewField.ColumnName); + lst.Add(alias + "." + viewField.ColumnName); } } @@ -295,9 +297,10 @@ public virtual void AddView(string name, string tableName, params IViewElement[] } var tableAlias = string.IsNullOrEmpty(viewJoin.TableAlias) ? viewJoin.TableName : viewJoin.TableAlias; + var parentAlias = string.IsNullOrEmpty(viewJoin.ParentTableAlias) ? viewJoin.ParentTableName : viewJoin.ParentTableAlias; joins += string.Format("{0} {1} {2} ON {2}.{3} = {4}.{5} ", joinType, viewJoin.TableName, tableAlias, - viewJoin.ColumnName, viewJoin.ParentTableName, viewJoin.ParentColumnName); + viewJoin.ColumnName, parentAlias, viewJoin.ParentColumnName); } var select = string.Format("SELECT {0} FROM {1} {1} {2}", string.Join(",", selectedColumns), tableName, joins); @@ -1336,12 +1339,16 @@ public virtual int Delete(string table, string[] whereColumns = null, object[] w throw new ArgumentNullException("table"); } - if (null == whereColumns || null == whereValues) + if (whereColumns == null && whereValues == null) { return ExecuteNonQuery(string.Format("DELETE FROM {0}", table)); } else { + ArgumentNullException.ThrowIfNull(whereColumns); + ArgumentNullException.ThrowIfNull(whereValues); + if (whereColumns.Length == 0 || whereColumns.Length != whereValues.Length) + throw new ArgumentException("Delete predicates need matching, non-empty column and value arrays."); table = QuoteTableNameIfRequired(table); using var command = CreateCommand(); @@ -1353,7 +1360,7 @@ public virtual int Delete(string table, string[] whereColumns = null, object[] w command.Transaction = _transaction; var query = string.Format("DELETE FROM {0} WHERE ({1})", table, - GetWhereString(whereColumns, whereValues)); + GetWhereStringWithNullCheck(whereColumns, whereValues)); command.CommandText = query; command.CommandType = CommandType.Text; @@ -1362,6 +1369,7 @@ public virtual int Delete(string table, string[] whereColumns = null, object[] w foreach (var value in whereValues) { + if (value == null || value == DBNull.Value) continue; var parameter = command.CreateParameter(); ConfigureParameterWithValue(parameter, paramCount, value);