Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/Migrator.Tests/ConstraintCatalogParsingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System;
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;
using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint;
using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint;

namespace Migrator.Tests;

public class ConstraintCatalogParsingTests
{
private MySqlTransformationProvider provider;
private IDbCommand command;
private DataTable rows;
private DataTableReader reader;

[SetUp]
public void SetUp()
{
var connection = Substitute.For<IDbConnection>();
connection.State.Returns(ConnectionState.Open);
command = Substitute.For<IDbCommand>();
connection.CreateCommand().Returns(command);
command.CreateParameter().Returns(_ => Substitute.For<IDbDataParameter>());
command.Parameters.Returns(Substitute.For<IDataParameterCollection>());
provider = Substitute.ForPartsOf<MySqlTransformationProvider>(new MysqlDialect(), connection, "default", null);
rows = new DataTable();
foreach (var name in new[] { "name", "type", "column", "ordinal", "expression" }) rows.Columns.Add(name, typeof(object));
provider.Configure().GetForeignKeyConstraints(Arg.Any<string>()).Returns(_ =>
{
Assert.That(reader.IsClosed, Is.True, "Close the catalog reader before querying foreign keys.");
return new[] { new ForeignKeyConstraint("FK_Parent", "Parent", new[] { "Id" }, "Items", new[] { "ParentId" }) };
});
}

[TearDown]
public void TearDown() { reader?.Dispose(); rows.Dispose(); provider.Dispose(); }

private TableConstraint[] Read()
{
reader = rows.CreateDataReader();
provider.Configure().ExecuteQuery(Arg.Any<IDbCommand>(), Arg.Any<string>()).Returns(reader);
return ConstraintMetadataReader.Read(provider, "Items");
}

[TestCase("P")][TestCase("PK")][TestCase(" primary key ")][TestCase("PN")]
[TestCase("U")][TestCase("UQ")][TestCase(" unique ")]
public void CompositeKeysKeepCatalogOrderAndConstraintBoundaries(string type)
{
rows.Rows.Add("First", type, "Z", 1, DBNull.Value);
rows.Rows.Add("First", type, "A", 2, DBNull.Value);
rows.Rows.Add("Second", "U", "Other", 1, DBNull.Value);
var result = Read();
var first = result[0];
var columns = first is PrimaryKeyConstraint pk ? pk.KeyColumns : ((UniqueConstraint)first).KeyColumns;
Assert.That(columns, Is.EqualTo(new[] { "Z", "A" }));
Assert.That(first.Name, Is.EqualTo("First"));
if (type.Trim().StartsWith("P", StringComparison.OrdinalIgnoreCase))
Assert.That(((PrimaryKeyConstraint)first).NonClustered, Is.EqualTo(type == "PN"));
else Assert.That(first, Is.TypeOf<UniqueConstraint>());
Assert.That(((UniqueConstraint)result[1]).KeyColumns, Is.EqualTo(new[] { "Other" }));
Assert.That(result[2].Name, Is.EqualTo("FK_Parent"));
Assert.That(result, Has.Length.EqualTo(3));
command.Received(1).Dispose();
}

[TestCase("C", "CHECK (Amount > 0)", "Amount > 0")]
[TestCase("K", "Amount > 0", "Amount > 0")]
[TestCase(" check ", null, null)]
public void ChecksPreserveExpressionsIncludingMissingCatalogText(string type, string expression, string expected)
{
rows.Rows.Add("Positive", type, DBNull.Value, 0, (object)expression ?? DBNull.Value);
Assert.That(((CheckConstraint)Read()[0]).CheckConstraintString, Is.EqualTo(expected));
}

[Test]
public void EmptyCatalogStillIncludesForeignKeys()
=> Assert.That(Read().Select(c => c.Name), Is.EqualTo(new[] { "FK_Parent" }));

[Test]
public void UnknownCatalogTypeFailsAndDisposesResources()
{
rows.Rows.Add("Unknown", "unexpected", "Id", 1, DBNull.Value);
Assert.Throws<MigrationException>(() => Read());
Assert.That(reader.IsClosed, Is.True);
command.Received(1).Dispose();
provider.DidNotReceive().GetForeignKeyConstraints(Arg.Any<string>());
}
}
60 changes: 60 additions & 0 deletions src/Migrator.Tests/SQLiteFallbackAlterationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ public class SQLiteFallbackAlterationTests
private sealed class LegacyProvider(IDbConnection connection)
: SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null)
{
public bool FailParentRebuild { get; set; }
public override object ExecuteScalar(string sql) => sql == "SELECT sqlite_version()" ? "3.25.0" : base.ExecuteScalar(sql);
public override int ExecuteNonQuery(string sql)
{
if (FailParentRebuild && sql.StartsWith("CREATE TABLE", StringComparison.OrdinalIgnoreCase) && sql.Contains("OriginalTemp"))
throw new InvalidOperationException("Injected rebuild failure.");
return base.ExecuteNonQuery(sql);
}
}

private SqliteConnection connection;
Expand Down Expand Up @@ -165,4 +172,57 @@ public void RenameUpdatesSelfReferencingParentColumns()
Assert.That(provider.ExecuteScalar("SELECT ParentId FROM Nodes WHERE Id=2"), Is.EqualTo(1L));
Assert.That(provider.CheckForeignKeyIntegrity(), Is.True);
}

[TestCase("Obsolete")]
[TestCase("obsolete")]
public void RemoveColumnUpdatesIncomingKeysCaseInsensitively(string name)
{
provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER PRIMARY KEY, Obsolete INTEGER UNIQUE); CREATE TABLE Child (Value INTEGER REFERENCES Original(Obsolete)); INSERT INTO Original VALUES (1, 7); INSERT INTO Child VALUES (7)");
provider.RemoveColumn("Original", name);
Assert.That(provider.ColumnExists("Original", "Obsolete"), Is.False);
Assert.That(provider.GetForeignKeyConstraints("Child"), Is.Empty);
Assert.That(provider.ExecuteScalar("SELECT Value FROM Child"), Is.EqualTo(7L));
Assert.That(provider.ExecuteScalar("SELECT Id FROM Original"), Is.EqualTo(1L));
Assert.That(provider.CheckForeignKeyIntegrity(), Is.True);
}

[TestCase(false)]
[TestCase(true)]
public void FailureRemovingColumnPreservesEarlierDependentTables(bool callerTransaction)
{
provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER PRIMARY KEY, Obsolete INTEGER UNIQUE); CREATE TABLE Child (Value INTEGER REFERENCES Original(Obsolete)); INSERT INTO Original VALUES (1, 7); INSERT INTO Child VALUES (7)");
if (callerTransaction) provider.BeginTransaction();
provider.FailParentRebuild = true;
Assert.That(Assert.Throws<InvalidOperationException>(() => provider.RemoveColumn("Original", "Obsolete")).Message, Is.EqualTo("Injected rebuild failure."));
Assert.That(provider.HasActiveTransaction, Is.EqualTo(callerTransaction));
if (callerTransaction) provider.Rollback();
Assert.That(provider.GetForeignKeyConstraints("Child"), Has.Length.EqualTo(1));
Assert.That(provider.ExecuteScalar("SELECT Obsolete FROM Original"), Is.EqualTo(7L));
Assert.That(provider.ExecuteScalar("SELECT Value FROM Child"), Is.EqualTo(7L));
Assert.That(provider.GetTables(), Is.EquivalentTo(new[] { "Original", "Child" }));
Assert.That(provider.CheckForeignKeyIntegrity(), Is.True);
}

[Test]
public void RemovingSelfReferencedColumnPreservesOtherValues()
{
provider.ExecuteNonQuery("CREATE TABLE Nodes (Id INTEGER PRIMARY KEY, Obsolete INTEGER UNIQUE, ParentValue INTEGER REFERENCES Nodes(Obsolete)); INSERT INTO Nodes VALUES (1, 7, NULL), (2, 8, 7)");
provider.RemoveColumn("Nodes", "obsolete");
Assert.That(provider.GetForeignKeyConstraints("Nodes"), Is.Empty);
Assert.That(provider.ExecuteScalar("SELECT ParentValue FROM Nodes WHERE Id=2"), Is.EqualTo(7L));
Assert.That(provider.CheckForeignKeyIntegrity(), Is.True);
}

[Test]
public void SuccessfulRemovalRemainsInTheCallerTransaction()
{
provider.ExecuteNonQuery("CREATE TABLE Original (Id INTEGER PRIMARY KEY, Obsolete INTEGER UNIQUE); CREATE TABLE Child (Value INTEGER REFERENCES Original(Obsolete)); INSERT INTO Original VALUES (1, 7); INSERT INTO Child VALUES (7)");
provider.BeginTransaction();
provider.RemoveColumn("Original", "Obsolete");
Assert.That(provider.HasActiveTransaction, Is.True);
Assert.That(provider.GetForeignKeyConstraints("Child"), Is.Empty);
provider.Rollback();
Assert.That(provider.GetForeignKeyConstraints("Child"), Has.Length.EqualTo(1));
Assert.That(provider.ExecuteScalar("SELECT Obsolete FROM Original"), Is.EqualTo(7L));
}
}
36 changes: 36 additions & 0 deletions src/Migrator.Tests/SchemaCatalogContractTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Data;
using System.Data.Common;
using System.Linq;
using DotNetProjects.Migrator.Providers.Impl.SQLite;
using NSubstitute;
using NUnit.Framework;

namespace Migrator.Tests;

public class SchemaCatalogContractTests
{
[Test]
public void SchemaColumnEnumerationReturnsColumnNamesAndScopesTheRequest()
{
using var table = new DataTable();
table.Columns.Add("TABLE_NAME"); table.Columns.Add("COLUMN_NAME");
table.Rows.Add("Orders", "Id"); table.Rows.Add("Orders", "Total");
var connection = Substitute.For<DbConnection>();
connection.GetSchema("Columns", Arg.Any<string[]>()).Returns(table);
using var provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null);
Assert.That(provider.GetColumns("sales", "Orders").ToArray(), Is.EqualTo(new[] { "Id", "Total" }));
connection.Received(1).GetSchema("Columns", Arg.Is<string[]>(x => x.Length == 4 && x[0] == null && x[1] == "sales" && x[2] == "Orders" && x[3] == null));
}

[Test]
public void SchemaTableEnumerationPreservesReturnedNamesAndSchemaRestriction()
{
using var table = new DataTable(); table.Columns.Add("TABLE_NAME");
table.Rows.Add("Orders"); table.Rows.Add("Order Details");
var connection = Substitute.For<DbConnection>();
connection.GetSchema("Tables", Arg.Any<string[]>()).Returns(table);
using var provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null);
Assert.That(provider.GetTables("sales").ToArray(), Is.EqualTo(new[] { "Orders", "Order Details" }));
connection.Received(1).GetSchema("Tables", Arg.Is<string[]>(x => x.Length == 4 && x[1] == "sales" && x[2] == null));
}
}
86 changes: 86 additions & 0 deletions src/Migrator.Tests/SqlPreviewBoundaryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Data;
using DotNetProjects.Migrator;
using DotNetProjects.Migrator.Framework;
using DotNetProjects.Migrator.Framework.Fluent;
using DotNetProjects.Migrator.Providers;
using NUnit.Framework;

namespace Migrator.Tests;

public class SqlPreviewBoundaryTests
{
private static IEnumerable<TestCaseData> Literals()
{
yield return new TestCaseData(null, "NULL");
yield return new TestCaseData(DBNull.Value, "NULL");
yield return new TestCaseData("O'Brien", "'O''Brien'");
yield return new TestCaseData("", "''");
yield return new TestCaseData((byte)255, "255");
yield return new TestCaseData((sbyte)-128, "-128");
yield return new TestCaseData(short.MinValue, "-32768");
yield return new TestCaseData(ushort.MaxValue, "65535");
yield return new TestCaseData(int.MinValue, "-2147483648");
yield return new TestCaseData(uint.MaxValue, "4294967295");
yield return new TestCaseData(long.MinValue, "-9223372036854775808");
yield return new TestCaseData(ulong.MaxValue, "18446744073709551615");
yield return new TestCaseData(12.5m, "12.5");
yield return new TestCaseData(12.5f, "12.5");
yield return new TestCaseData(12.5d, "12.5");
yield return new TestCaseData(new DateTime(2024, 2, 29, 12, 34, 56).AddTicks(1234567), "'2024-02-29 12:34:56.1234567'");
yield return new TestCaseData(Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), "'00112233-4455-6677-8899-aabbccddeeff'");
}

[TestCaseSource(nameof(Literals)), SetCulture("de-DE")]
public void LiteralsPreserveValuesWithoutUsingCurrentCulture(object value, string expected)
=> Assert.That(new SqlGenerationContext(ProviderTypes.SQLite).Literal(value), Is.EqualTo(expected));

[TestCase(ProviderTypes.SQLite, "1", "0")]
[TestCase(ProviderTypes.SqlServer, "1", "0")]
[TestCase(ProviderTypes.PostgreSQL, "TRUE", "FALSE")]
[TestCase(ProviderTypes.PostgreSQL82, "TRUE", "FALSE")]
public void BooleanLiteralsUseTheTargetDialect(ProviderTypes provider, string yes, string no)
{
var context = new SqlGenerationContext(provider);
Assert.That(context.Literal(true), Is.EqualTo(yes));
Assert.That(context.Literal(false), Is.EqualTo(no));
}

[Test]
public void UnsupportedValuesCannotMasqueradeAsSqlLiterals()
{
var context = new SqlGenerationContext(ProviderTypes.SQLite);
foreach (var value in new object[] { DayOfWeek.Monday, RawSql.Insert("DROP TABLE Items"), new Version(1, 2), new byte[] { 1 }, TimeSpan.FromDays(1) })
Assert.Throws<NotSupportedException>(() => context.Literal(value));
}

[TestCase(ProviderTypes.SQLite, "\"two words\"", "\"a.b\"")]
[TestCase(ProviderTypes.PostgreSQL, "\"two words\"", "\"a.b\"")]
[TestCase(ProviderTypes.SqlServer, "[two words]", "[a.b]")]
public void IdentifierAtomsAreQuotedWithoutSplittingDots(ProviderTypes type, string spaced, string dotted)
{
var context = new SqlGenerationContext(type);
Assert.That(context.Quote("two words"), Is.EqualTo(spaced));
Assert.That(context.Quote("a.b"), Is.EqualTo(dotted));
}

[TestCase(ProviderTypes.SQLite, "\"sales.region\".\"Order Lines\"", "\"sales.region\".\"Order Lines\"")]
[TestCase(ProviderTypes.PostgreSQL, "\"sales.region\".\"Order Lines\"", "\"sales.region\".\"Order Lines\"")]
[TestCase(ProviderTypes.SqlServer, "[sales.region].[Order]]Lines]", "[sales.region].[Order]]Lines]")]
public void QualifiedTableNamesPreserveQuotedComponents(ProviderTypes type, string name, string expected)
=> Assert.That(new SqlGenerationContext(type).Table(name), Is.EqualTo(expected));

[Test, Category("SQLite")]
public void PreviewWithSpacesAndDottedColumnNamesExecutesLikeTheMigration()
{
var builder = new MigrationBuilder();
builder.Create.Table("Order Details").WithColumn("Id").AsInt32().WithColumn("Line.Item").AsString();
builder.Insert.IntoTable("Order Details").Row(new[] { "Id", "Line.Item" }, new object[] { 1, "O'Brien" });
builder.Create.Index("Index with spaces").OnTable("Order Details").WithColumns("Line.Item");
using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null);
foreach (var sql in builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite))) provider.ExecuteNonQuery(sql);
Assert.That(provider.ExecuteScalar("SELECT \"Line.Item\" FROM \"Order Details\" WHERE Id=1"), Is.EqualTo("O'Brien"));
Assert.That(provider.IndexExists("Order Details", "Index with spaces"), Is.True);
}
}
16 changes: 7 additions & 9 deletions src/Migrator/Framework/Fluent/SqlGenerationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ private void RequireKnownSchema()
public Dialect Dialect { get; }
public SqlGenerationContext(ProviderTypes provider, Func<string, Column[]> existingTable = null)
{ Provider = provider; Dialect = ProviderFactory.DialectForProvider(provider) ?? throw new ArgumentException("Unknown provider."); this.existingTable = existingTable; }
private string AlwaysQuote(string name)
public string Quote(string name) => Dialect.QuoteColumnNameIfRequired(name);
public string Table(string name) => Dialect.QuoteTableNameIfRequired(name);
private static readonly HashSet<Type> NumericTypes = new()
{
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Identifier cannot be empty.");
var template = Dialect.QuoteTemplate;
var closing = template[^1].ToString();
return string.Format(CultureInfo.InvariantCulture, template, name.Replace(closing, closing + closing));
}
public string Quote(string name) => Dialect.ColumnNameNeedsQuote || Dialect.IsReservedWord(name) ? AlwaysQuote(name) : name;
public string Table(string name) => string.Join(".", name.Split('.').Select(part => Dialect.TableNameNeedsQuote || Dialect.IsReservedWord(part) ? AlwaysQuote(part) : part));
typeof(byte), typeof(sbyte), typeof(short), typeof(ushort), typeof(int), typeof(uint),
typeof(long), typeof(ulong), typeof(decimal), typeof(float), typeof(double)
};
public string Column(Column column) => Dialect.GetAndMapColumnProperties(Definitions.CopyColumn(column)).ColumnSql;
public string Literal(object value) => value switch
{
Expand All @@ -37,7 +35,7 @@ private string AlwaysQuote(string name)
DateTime d => "'" + d.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture) + "'",
Guid g when Dialect is DotNetProjects.Migrator.Providers.Impl.Oracle.OracleDialect => Dialect.Default(g)[8..],
Guid g => "'" + g + "'",
byte or sbyte or short or ushort or int or uint or long or ulong or decimal or float or double => Convert.ToString(value, CultureInfo.InvariantCulture),
object number when NumericTypes.Contains(number.GetType()) => Convert.ToString(value, CultureInfo.InvariantCulture),
_ => throw new NotSupportedException("No portable SQL literal for " + value.GetType().Name)
};
public void RequireTable(string table)
Expand Down
Loading
Loading