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
2 changes: 1 addition & 1 deletion .github/scripts/start-database.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ for attempt in $(seq 1 120); do
done
case "$database" in
Sybase)
printf "disk init name='migrator_data', physname='/opt/sybase/migrator_data.dat', size='128M'\ngo\ndisk init name='migrator_log', physname='/opt/sybase/migrator_log.dat', size='64M'\ngo\n" | docker exec -i migrator-db bash -c 'source /opt/sybase/SYBASE.sh; isql -b -Usa -PmyPassword -Slocalhost:5000'
printf "sp_configure 'quoted identifier enhancement', 1\ngo\ndisk init name='migrator_data', physname='/opt/sybase/migrator_data.dat', size='128M'\ngo\ndisk init name='migrator_log', physname='/opt/sybase/migrator_log.dat', size='64M'\ngo\n" | docker exec -i migrator-db bash -c 'source /opt/sybase/SYBASE.sh; isql -b -Usa -PmyPassword -Slocalhost:5000'
;;
SQLServer) docker exec migrator-db /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'YourStrong@Passw0rd' -b -Q 'CREATE DATABASE [Whatever];' ;;
Oracle) docker exec -i migrator-db sqlplus -s / as sysdba < .github/workflows/sql/oracle.sql ;;
Expand Down
52 changes: 38 additions & 14 deletions docs/issue-audit.md

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion docs/runner-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,22 @@ See [live database tests](live-database-tests.md) for the full matrix. Provider-

An auxiliary-only `MigrateToLastVersion()` run preserves existing version history while executing selected profiles and maintenance. A completely empty run does not create a history table. Post-commit callbacks receive their migration context in both per-migration and whole-session modes; callback failure cannot undo a committed migration.

PostgreSQL column and constraint metadata resolves the requested relation through the database, including schema-qualified or explicitly quoted names and the connection search path. The lookup is parameterized and distinguishes same-named tables in different schemas. This does not imply complete schema qualification for every provider operation. Native `time without time zone` metadata and literal defaults map to `TimeSpan`.
PostgreSQL column and constraint metadata resolves the requested relation through the database, including schema-qualified or explicitly quoted names and the connection search path. The lookup is parameterized and distinguishes same-named tables in different schemas. This does not imply complete schema qualification for every provider operation. Native `time without time zone` metadata and literal defaults map to `TimeOnly`.


### Time of day and intervals

`DbType.Time` / `MigratorDbType.Time` is a time of day. Use `TimeOnly` for defaults and values passed to `Insert`/`Update`. `MigratorDbType.Interval` is a duration; use `TimeSpan`, including negative and multi-day values. A `TimeSpan` default on a Time column is rejected instead of silently treating a duration as a clock time.

```csharp
new Column("job_time", DbType.Time, new TimeOnly(12, 34, 56));
new Column("elapsed", MigratorDbType.Interval, TimeSpan.FromDays(2));
```

PostgreSQL and Oracle use native intervals. SQL Server, SQLite, MySQL and MariaDB represent intervals as signed .NET ticks (100 ns units). Integer catalog metadata cannot distinguish an interval from an ordinary integer column, so retain the migration definition when that semantic distinction matters. Other dialects without an Interval mapping reject it.

Oracle's Time representation remains DATE with a fixed 1970-01-01 date and whole-second precision; fractional defaults/parameters are rejected. SQL Server 2005 uses DATETIME with its native precision. Informix Time now uses DATETIME HOUR TO SECOND (whole seconds), not INTERVAL. Existing Informix columns created with the old mapping require an explicit migration. SQLite stores clock times as invariant text. Raw ADO.NET scalar results retain driver-specific CLR types; a driver may return SQL TIME as TimeSpan or DateTime even though the public input is TimeOnly.

This changes the old shared parameter inference: TimeSpan now means Interval. Migrate time-of-day inputs with `TimeOnly.FromTimeSpan(value)`; it rejects negative or multi-day durations. Do not convert genuine intervals this way.

ASE 16.0 key constraints with dots or apostrophes in their names are rejected before DDL. The tested server can create a punctuated name but cannot reliably resolve its backing index when removing the constraint. Use a key name without those characters; this restriction applies to primary and unique keys.
76 changes: 76 additions & 0 deletions src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System;
using System.Data;
using System.Linq;
using DotNetProjects.Migrator;
using DotNetProjects.Migrator.Framework;
using DotNetProjects.Migrator.Providers;
using DotNetProjects.Migrator.Providers.Impl.SqlServer;
using DotNetProjects.Migrator.Providers.Impl.PostgreSQL;
using DotNetProjects.Migrator.Providers.Impl.Oracle;
using DotNetProjects.Migrator.Providers.Impl.Mysql;
using DotNetProjects.Migrator.Providers.Impl.SQLite;
using NUnit.Framework;

namespace Migrator.Tests;

public class IdentifierAndTimeRegressionTests
{
[TestCase(-51)]
[TestCase(51)]
public void SQLiteIntervalsKeepTheirSignAndDaysSeparateFromTimeOfDay(int hours)
{
using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null);
var duration = TimeSpan.FromHours(hours).Add(TimeSpan.FromTicks(1234567));
provider.AddTable("Durations", new Column("Id", DbType.Int32), new Column("Elapsed", MigratorDbType.Interval, duration));
provider.Insert("Durations", ["Id"], [1]);
provider.Insert("Durations", ["Id", "Elapsed"], [2, duration]);
Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Elapsed FROM Durations WHERE Id=1")), Is.EqualTo(duration.Ticks));
Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Elapsed FROM Durations WHERE Id=2")), Is.EqualTo(duration.Ticks));
Assert.Throws<ArgumentException>(() => provider.AddTable("WrongTime", new Column("Moment", DbType.Time, duration)));
Assert.That(provider.TableExists("WrongTime"), Is.False);
}

[TestCase("sales.Orders", "[sales].[Orders]")]
[TestCase("[sales.region].[Order]]Lines]", "[sales.region].[Order]]Lines]")]
[TestCase("[sales].[O'Brien]", "[sales].[O'Brien]")]
public void QualifiedSqlServerNamesEscapeEachComponentExactlyOnce(string input, string expected)
{
var dialect = new SqlServerDialect();
Assert.That(dialect.Quote(input), Is.EqualTo(expected));
Assert.That(dialect.QuoteColumnNameIfRequired("value.part]"), Is.EqualTo("[value.part]]]"));
}

[Test]
public void OracleAndPostgreSqlQualifyReservedComponentsIndependently()
{
foreach (var dialect in new Dialect[] { new OracleDialect(), new PostgreSQLDialect() })
{
Assert.That(dialect.QuoteTableNameIfRequired("sales.select"), Is.EqualTo("sales.\"select\""));
Assert.That(dialect.QuoteTableNameIfRequired("\"sales.region\".\"O'Brien\""), Is.EqualTo("\"sales.region\".\"O'Brien\""));
}
}

[Test]
public void TimeDefaultsAreQuotedAndPreserveSubMillisecondPrecision()
{
var time = new TimeOnly(12, 34, 56).Add(TimeSpan.FromTicks(1234560));
foreach (var dialect in new Dialect[] { new SQLiteDialect(), new MysqlDialect(), new PostgreSQLDialect(), new SqlServerDialect() })
Assert.That(dialect.Default(time), Is.EqualTo("DEFAULT '12:34:56.123456'"));
}

[Test, Category("SQLite")]
public void TimeDefaultsAndValuesSurviveSQLiteReconstruction()
{
using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null);
var time = new TimeOnly(12, 34, 56).Add(TimeSpan.FromTicks(1234560));
provider.AddTable("Times", new Column("Id", DbType.Int32), new Column("Value", DbType.Time) { DefaultValue = time });
var column = provider.GetColumns("Times").Single(c => c.Name == "Value");
Assert.That(column.Type, Is.EqualTo(DbType.Time));
Assert.That(column.DefaultValue, Is.EqualTo(time));
provider.ChangeColumn("Times", new Column("Id", DbType.Int64));
provider.Insert("Times", ["Id"], [1]);
Assert.That(TimeOnly.Parse(Convert.ToString(provider.ExecuteScalar("SELECT CAST(Value AS TEXT) FROM Times"))), Is.EqualTo(time));
provider.Insert("Times", ["Id", "Value"], [2, time]);
Assert.That(TimeOnly.Parse(Convert.ToString(provider.ExecuteScalar("SELECT CAST(Value AS TEXT) FROM Times WHERE Id=2"))), Is.EqualTo(time));
}
}
22 changes: 22 additions & 0 deletions src/Migrator.Tests/IntervalRegression.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;
using System.Data;
using DotNetProjects.Migrator.Framework;
using NUnit.Framework;

namespace Migrator.Tests;

internal static class IntervalRegression
{
internal static void Verify(ITransformationProvider provider, bool native)
{
var duration = -TimeSpan.FromDays(2) - new TimeSpan(3, 4, 5) - TimeSpan.FromTicks(1234560);
provider.AddTable("DurationValues", new Column("Id", DbType.Int32), new Column("Elapsed", MigratorDbType.Interval, duration));
provider.Insert("DurationValues", ["Id"], [1]);
provider.Insert("DurationValues", ["Id", "Elapsed"], [2, duration]);
foreach (var id in new[] { 1, 2 })
{
var stored = provider.ExecuteScalar("SELECT Elapsed FROM DurationValues WHERE Id=" + id);
Assert.That(native ? (TimeSpan)stored : TimeSpan.FromTicks(Convert.ToInt64(stored)), Is.EqualTo(duration));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ namespace Migrator.Tests.Providers.Generic;
[TestFixture]
public abstract class Generic_ConstraintExistsBase : TransformationProviderBase
{
[Test]
public void QuotedConstraintNamesCanBeInspectedAndRemovedFromOnlyTheirTable()
{
const string name = "UQ ' dotted.name";
Provider.AddTable("NamedConstraints", new Column("Id", DbType.Int32),
new DotNetProjects.Migrator.Framework.UniqueConstraint(name, "Id"));
Provider.AddTable("OtherConstraints", new Column("Id", DbType.Int32));
Assert.That(Provider.ConstraintExists("NamedConstraints", name), Is.True);
Assert.That(Provider.ConstraintExists("OtherConstraints", name), Is.False);
Provider.RemoveConstraint("NamedConstraints", name);
Assert.That(Provider.ConstraintExists("NamedConstraints", name), Is.False);
Provider.Insert("NamedConstraints", ["Id"], [1]);
Provider.Insert("NamedConstraints", ["Id"], [1]);
}

/// <summary>
/// Should return true if foreign key exists.
/// </summary>
Expand Down
19 changes: 19 additions & 0 deletions src/Migrator.Tests/Providers/Hana/HanaProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ public void TearDown()
}
connection?.Dispose();
}
[Test]
public void TimeOnlyDefaultsAndParametersAndQuotedConstraintsRoundTrip()
{
var time = new TimeOnly(12, 34, 56);
provider.AddTable("ClockValues", new Column("Id", DbType.Int32), new Column("Moment", DbType.Time, time),
new UniqueConstraint("UQ ' dotted.name", "Id"));
provider.Insert("ClockValues", ["Id"], [1]);
provider.Insert("ClockValues", ["Id", "Moment"], [2, time]);
foreach (var id in new[] { 1, 2 })
{
var stored = provider.ExecuteScalar("SELECT \"Moment\" FROM \"ClockValues\" WHERE \"Id\"=" + id);
var actual = stored is DateTime date ? TimeOnly.FromDateTime(date) : stored is TimeSpan span ? TimeOnly.FromTimeSpan(span) : TimeOnly.Parse(Convert.ToString(stored));
Assert.That(actual, Is.EqualTo(time));
}
Assert.That(provider.GetColumns("ClockValues").Single(c => c.Name == "Moment").Type, Is.EqualTo(DbType.Time));
provider.RemoveConstraint("ClockValues", "UQ ' dotted.name");
provider.Insert("ClockValues", ["Id"], [1]);
}

[Test]
public void ConnectionStringFactoryOpensAndDisposesOwnedConnection()
{
Expand Down
40 changes: 40 additions & 0 deletions src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,46 @@ public class LiveDatabaseTests(string database, ProviderTypes providerType)
private ITransformationProvider provider;
internal ITransformationProvider Provider => provider;

[Test]
public void TimeOfDayDefaultsAndParametersPersist()
{
var time = new TimeOnly(12, 34, 56);
provider.AddTable("clock_values", new Column("id", DbType.Int32), new Column("value", DbType.Time) { DefaultValue = time });
provider.Insert("clock_values", ["id"], [1]);
provider.Insert("clock_values", ["id", "value"], [2, time]);
foreach (var id in new[] { 1, 2 })
{
var stored = provider.ExecuteScalar("SELECT " + provider.QuoteColumnNameIfRequired("value") + " FROM clock_values WHERE id=" + id);
var actual = stored is DateTime date ? date.TimeOfDay : stored is TimeSpan span ? span : TimeSpan.Parse(Convert.ToString(stored), System.Globalization.CultureInfo.InvariantCulture);
Assert.That(actual, Is.EqualTo(time.ToTimeSpan()));
}
Assert.That(provider.GetColumns("clock_values").Single(c => c.Name.Equals("value", StringComparison.OrdinalIgnoreCase)).Type, Is.EqualTo(DbType.Time));
if (providerType is ProviderTypes.Mysql or ProviderTypes.MariaDB) IntervalRegression.Verify(provider, false);
}

[Test]
public void QuotedConstraintNamesCanBeInspectedAndRemoved()
{
if (providerType == ProviderTypes.Sybase) provider.ExecuteNonQuery("SET QUOTED_IDENTIFIER ON");
var name = "UQ ' dotted.name";
if (providerType == ProviderTypes.Sybase)
{
Assert.Throws<NotSupportedException>(() => provider.AddTable("unsupported_key_name",
new Column("id", DbType.Int32) { IsNullable = false }, new DotNetProjects.Migrator.Framework.UniqueConstraint(name, "id")));
Assert.That(provider.TableExists("unsupported_key_name"), Is.False);
name = "UQ quoted name";
}
provider.AddTable("named_constraints", new Column("id", DbType.Int32) { IsNullable = false },
new DotNetProjects.Migrator.Framework.UniqueConstraint(name, "id"));
provider.AddTable("other_constraints", new Column("id", DbType.Int32));
Assert.That(provider.ConstraintExists("named_constraints", name), Is.True);
Assert.That(provider.ConstraintExists("other_constraints", name), Is.False);
provider.RemoveConstraint("named_constraints", name);
Assert.That(provider.ConstraintExists("named_constraints", name), Is.False);
provider.Insert("named_constraints", ["id"], [1]);
provider.Insert("named_constraints", ["id"], [1]);
}

internal void RunRegression(Action<LiveDatabaseTests> action)
{
try { SetUp(); action(this); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,8 @@ public class LiveMetadataRegressionTests
Assert.That(column.Type, Is.EqualTo(DbType.Time));
f.Provider.AddTable("copied_times", column);
Assert.That(f.Provider.GetColumns("copied_times").Single().Type, Is.EqualTo(DbType.Time));
f.Provider.ExecuteNonQuery("INSERT INTO copied_times VALUES (INTERVAL(12:34:56) HOUR TO SECOND)");
Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT COUNT(*) FROM copied_times WHERE time_value=INTERVAL(12:34:56) HOUR TO SECOND")), Is.EqualTo(1));
f.Provider.ExecuteNonQuery("INSERT INTO copied_times VALUES (DATETIME(12:34:56) HOUR TO SECOND)");
Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT COUNT(*) FROM copied_times WHERE time_value=DATETIME(12:34:56) HOUR TO SECOND")), Is.EqualTo(1));
});

[Test, Category("Sybase")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,37 @@ namespace Migrator.Tests.Providers.OracleProvider;
[Category("Oracle")]
public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase
{
[Test]
public void NegativeMultiDayIntervalDefaultsAndParametersPersist() => IntervalRegression.Verify(Provider, true);

[Test]
public void QualifiedQuotedNamesRoundTripThroughColumnsIndexesAndConstraints()
{
var schema = Convert.ToString(Provider.ExecuteScalar("SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA') FROM dual"));
var table = "\"" + schema.Replace("\"", "\"\"") + "\".\"O'Brien\"";
Provider.AddTable(table, new Column("Id", DbType.Int32),
new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ ' name", "Id"));
Assert.That(Provider.TableExists(table), Is.True);
Assert.That(Provider.ColumnExists(table, "Id"), Is.True);
Assert.That(Provider.GetColumns(table).Length, Is.EqualTo(1));
Assert.That(Provider.GetIndexes(table).Length, Is.EqualTo(1));
Assert.That(Provider.GetTableConstraints(table).Length, Is.EqualTo(1));
Provider.RemoveConstraint(table, "UQ ' name");
Provider.RemoveTable(table);
Assert.That(Provider.TableExists(table), Is.False);
}

[Test]
public void TimeOfDayUsesTheDocumentedDateRepresentationForDefaultsAndParameters()
{
var time = new TimeOnly(12, 34, 56);
Provider.AddTable("ClockValues", new Column("Id", DbType.Int32), new Column("Value", DbType.Time) { DefaultValue = time });
Provider.Insert("ClockValues", ["Id"], [1]);
Provider.Insert("ClockValues", ["Id", "Value"], [2, time]);
Assert.That(Convert.ToDateTime(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("Value") + " FROM ClockValues WHERE Id=1")).TimeOfDay, Is.EqualTo(time.ToTimeSpan()));
Assert.That(Convert.ToDateTime(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("Value") + " FROM ClockValues WHERE Id=2")).TimeOfDay, Is.EqualTo(time.ToTimeSpan()));
}

[Test]
public void LegacyForeignKeyOverloadHonorsCascadeDelete()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ namespace Migrator.Tests.Providers.PostgreSQL;

public class PostgreSQLTransformationProvider_MetadataScopeTests : PostgreSQLTransformationProviderTestBase
{
[Test]
public void NegativeMultiDayIntervalDefaultsAndParametersPersist() => IntervalRegression.Verify(Provider, true);

[Test]
public void QualifiedMetadataDoesNotMixSameNamedTablesOrConstraints()
{
Expand Down Expand Up @@ -41,12 +44,12 @@ public void QuotedCatalogNamesRemainExactAndAreParameterized()
[Test]
public void NativeTimeRoundTripsThroughMetadataDefaultsAndParameters()
{
var value = new TimeSpan(0, 12, 34, 56, 789);
var value = new TimeOnly(12, 34, 56, 789);
Provider.AddTable("NativeTimeRoundTrip", new Column("Value", DbType.Time, value));
var column = Provider.GetColumns("NativeTimeRoundTrip").Single();
Assert.That(column.MigratorDbType, Is.EqualTo(MigratorDbType.Time));
Assert.That(column.DefaultValue, Is.EqualTo(value));
Provider.Insert("NativeTimeRoundTrip", new[] { "Value" }, new object[] { value });
Assert.That(Provider.ExecuteScalar("SELECT * FROM NativeTimeRoundTrip"), Is.EqualTo(value));
Assert.That(Provider.ExecuteScalar("SELECT * FROM NativeTimeRoundTrip"), Is.EqualTo(value.ToTimeSpan()));
}
}
Loading
Loading