From b9750ea7cd4f0773fd172a6ec35723ca40c01b14 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 21:37:54 +0200 Subject: [PATCH 1/5] Fix remaining time bindings, quoted constraints and schema lookup gaps --- docs/issue-audit.md | 25 +++- .../IdentifierAndTimeRegressionTests.cs | 61 +++++++++ .../Generic/Generic_ConstraintExistsBase.cs | 15 +++ .../Providers/Live/LiveDatabaseTests.cs | 31 +++++ ...TransformationProvider_TableExistsTests.cs | 28 ++++ ...formationProvider_ConstraintExistsTests.cs | 17 +++ .../SQLiteForeignKeyMatchTests.cs | 77 +++++++++++ .../Framework/ForeignKeyConstraint.cs | 4 +- src/Migrator/Providers/CatalogDefaultValue.cs | 1 + .../Providers/ColumnPropertiesMapper.cs | 2 +- .../Providers/ConstraintMetadataReader.cs | 10 +- src/Migrator/Providers/Dialect.cs | 19 ++- .../Providers/ForeignKeyMetadataReader.cs | 17 +-- .../Impl/DB2/DB2TransformationProvider.cs | 2 +- .../FirebirdTransformationProvider.cs | 2 +- .../Providers/Impl/Hana/HanaDialect.cs | 2 +- .../Impl/Informix/InformixDialect.cs | 5 +- .../InformixTransformationProvider.cs | 2 +- .../Impl/Mysql/MySqlTransformationProvider.cs | 13 +- .../Oracle/Data/OracleSystemDataLoader.cs | 36 +++--- .../Providers/Impl/Oracle/OracleCatalog.cs | 15 +++ .../Providers/Impl/Oracle/OracleDialect.cs | 15 +++ .../Oracle/OracleTransformationProvider.cs | 120 ++++-------------- .../Impl/PostgreSQL/PostgreSQLDialect.cs | 2 +- .../PostgreSQLTransformationProvider.cs | 24 +--- .../Providers/Impl/SQLite/SQLiteTableSql.cs | 11 ++ .../SQLite/SQLiteTransformationProvider.cs | 57 ++++----- .../Impl/SqlServer/SqlServerDialect.cs | 9 +- .../SqlServerTransformationProvider.cs | 118 +++++++---------- src/Migrator/Providers/SqlIdentifier.cs | 67 ++++++++++ .../Providers/TransformationProvider.cs | 24 ++-- 31 files changed, 521 insertions(+), 310 deletions(-) create mode 100644 src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs create mode 100644 src/Migrator.Tests/SQLiteForeignKeyMatchTests.cs create mode 100644 src/Migrator/Providers/Impl/Oracle/OracleCatalog.cs create mode 100644 src/Migrator/Providers/SqlIdentifier.cs diff --git a/docs/issue-audit.md b/docs/issue-audit.md index 8ba45d86..305e6dd3 100644 --- a/docs/issue-audit.md +++ b/docs/issue-audit.md @@ -12,7 +12,7 @@ Evidence used so far: clean master build and SQLite run (139 passed, one unrelat | [#30](https://github.com/dotnetprojects/Migrator.NET/issues/30) Updates are not respecting command timeout | Historical report rechecked; retain closed state | Master Update explicitly assigns CommandTimeout when configured and attaches the provider transaction before execution. No fresh wall-clock timeout reproduction was run; this is source evidence. | | [#31](https://github.com/dotnetprojects/Migrator.NET/issues/31) Parameter names (and meaning) differ in ITransformationProvider and Implementation Class TransformationProvider | Historical report rechecked; retain closed state | The current interface names FK arguments childTable/childColumns and parentTable/parentColumns; PR #174 corrects the independent action path and definitions. Original report supplies screenshots only; no blanket claim for every parameter name. | | [#32](https://github.com/dotnetprojects/Migrator.NET/issues/32) Implementation of GetForeignKeyConstraints is wrong in TransformationProvider | Historically closed; relevant baseline test verified | `GetForeignKeyConstraints_MultiColumnColumn_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | -| [#33](https://github.com/dotnetprojects/Migrator.NET/issues/33) SQLite Foreign Keys: OnDelete, OnUpdate, Match is not implemented (ignored in SQLite) | Partial; keep open | SQLite independent DELETE/UPDATE actions now execute; MATCH semantics still need an explicit supported-policy decision. | +| [#33](https://github.com/dotnetprojects/Migrator.NET/issues/33) SQLite Foreign Keys: OnDelete, OnUpdate, Match is not implemented (ignored in SQLite) | Actions merged; MATCH correction in working tree | Independent DELETE/UPDATE actions execute. The follow-up rejects unsupported MATCH modes rather than silently ignoring them; see the current-open-issue review below. | | [#34](https://github.com/dotnetprojects/Migrator.NET/issues/34) SQLite Foreign Keys: FKs added by AddTable are removed when using other methods | Historically closed; relevant baseline test verified | `AddForeignKey_RenameParentColumWithForeignKeyAndData_ForeignKeyPointsToRenamedColumn` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#35](https://github.com/dotnetprojects/Migrator.NET/issues/35) SQLite: UNIQUEs are removed when using some other methods after AddTable | Historically closed; relevant baseline test verified | `ChangeColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#37](https://github.com/dotnetprojects/Migrator.NET/issues/37) Override in SQLite for AddForeignKey silently does nothing | Historically closed; relevant baseline test verified | `AddForeignKey_Cascade_DeletingParentDeletesReferencingChildren` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | @@ -74,11 +74,11 @@ Evidence used so far: clean master build and SQLite run (139 passed, one unrelat | [#125](https://github.com/dotnetprojects/Migrator.NET/issues/125) Postgre: IncludeColumns in AddIndex is not used at all | Historically closed; relevant baseline test verified | `AddIndex_IncludeColumnsMultiple_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#126](https://github.com/dotnetprojects/Migrator.NET/issues/126) Postgre does neither extract included columns nor does it retrieve the partial filters | Historically closed; relevant baseline test verified | `AddIndex_FilteredIndexSingle_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#132](https://github.com/dotnetprojects/Migrator.NET/issues/132) SQL Server does not remove the unique index on ChangeColumn() with no ColumnProperty.Unique | Resolved by v13 explicit constraints in PR #181; await merge | ColumnProperty.Unique and implicit column-owned uniqueness are removed. ChangeColumn preserves explicit constraints and indexes; use RemoveConstraint or RemoveIndex after inspecting metadata. Live SQL Server tests ChangeColumnPreservesExplicitUniqueFromTableOrColumnCreation, ExplicitUniqueRemovalAllowsDuplicates and ChangeColumn_DoesNotRemoveUserOwnedUniqueOrMutateDefinition passed in run 35766920321. The migration guide documents this intentional breaking replacement. | -| [#134](https://github.com/dotnetprojects/Migrator.NET/issues/134) Remove hacks for some SQlite features | Partial; keep open | SQLite native rename/drop selected when eligible; guarded reconstruction retained. Unsupported table properties remain explicit failures. | +| [#134](https://github.com/dotnetprojects/Migrator.NET/issues/134) Remove hacks for some SQlite features | Native rename/drop merged; commented and closed | SQLite native rename/drop selected when eligible; guarded reconstruction retained for operations without a native equivalent. | | [#135](https://github.com/dotnetprojects/Migrator.NET/issues/135) Feature CopyDataFromTableToTable | Historically closed; relevant baseline test verified | `CopyDataFromTableToTable_UsingOrderBy_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#139](https://github.com/dotnetprojects/Migrator.NET/issues/139) Default value is not reset on ChangeColumn | Fix in PR #174; await merge | Default removal regressions enabled; SQL Server default lookup and Oracle in-place reset corrected. | -| [#140](https://github.com/dotnetprojects/Migrator.NET/issues/140) Remove table in Oracle does not cleanup sequences | Verified fix in PR #174; await merge | Default Oracle RemoveTable no longer guesses sequence ownership. RemoveTableWithOwnedSequences validates explicit legacy names and propagates cleanup errors. Passed live Oracle CI in run 35737814671. | -| [#141](https://github.com/dotnetprojects/Migrator.NET/issues/141) RemoveTable in Oracle does not cleanup => TRIGGERs | Verified in PR #174; await merge | Oracle table-owned trigger cleanup is exercised by the legacy sequence/trigger regression; no guessed trigger-name cleanup. Passed live Oracle CI in run 35737814671. | +| [#140](https://github.com/dotnetprojects/Migrator.NET/issues/140) Remove table in Oracle does not cleanup sequences | Safe cleanup merged in PR #174; ownership policy remains explicit | Default Oracle RemoveTable no longer guesses sequence ownership. RemoveTableWithOwnedSequences validates explicit legacy names and propagates cleanup errors. Passed live Oracle CI in run 35737814671. | +| [#141](https://github.com/dotnetprojects/Migrator.NET/issues/141) RemoveTable in Oracle does not cleanup => TRIGGERs | Verified fix merged in PR #174; commented and closed | Oracle table-owned trigger cleanup is exercised by ExplicitLegacyCleanupDropsSequenceAndTableOwnedTrigger; no guessed trigger-name cleanup. Passed live Oracle CI in run 35737814671. | | [#143](https://github.com/dotnetprojects/Migrator.NET/issues/143) Replace Identity trigger to "GENERATED...." | Historically closed; relevant baseline test verified | `GetColumns_GetIdentity_Succeeds` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#145](https://github.com/dotnetprojects/Migrator.NET/issues/145) ExecuteScalar("SELECT MAX(Id) FROM MyTable") should return null (C#) if table is empty | Additive fix in PR #174; await merge | ExecuteNullableScalar returns null for null/DBNull and preserves typed structs; existing ExecuteScalar contract stays compatible. | | [#146](https://github.com/dotnetprojects/Migrator.NET/issues/146) Oracle: Handle default value "NULL" | Historically closed; relevant baseline test verified | `DefaultValue_Null_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | @@ -97,3 +97,20 @@ Evidence used so far: clean master build and SQLite run (139 passed, one unrelat - Keep partial ownership/scope reports open; new complete fixes close only after the referenced PR merges. - Keep issue comments and this inventory synchronized as evidence changes. +## Current open issues rechecked on 2026-09-22 + +The seven currently open reports were rechecked against merged master `c257125` and their issue comments. The local checkout was 77 commits behind that revision. PR #174 and the later v13 schema/column changes are now merged; the old "await merge" labels for #140/#141 were stale. Open issue state alone therefore does not mean the original bug is still present. Following user authorization, #134 and #141 were commented and closed through the signed-in GitHub UI (the connector lacks issue-write permission). The other five remain open. This review does not establish that the fixes have been published on NuGet. + +| Issue | Current conclusion | Remaining scope / next action | +| --- | --- | --- | +| #33 | ON DELETE/ON UPDATE fixed; reproduced and corrected silent MATCH handling locally | SQLite itself enforces only SIMPLE. Accept unspecified/NONE/SIMPLE, preserve declared SIMPLE during reconstruction, and reject FULL/PARTIAL/unknown modes during creation, preview and adding a foreign-key definition. Preserve declared metadata so reconstruction rejects unsupported legacy declarations before modifying the original table. Merge this correction before considering the issue resolved under this explicit limitation. | +| #48 | Partially fixed; keep open | PostgreSQL qualified metadata and SQL Server column lookup have regressions. This does not cover every DDL/catalog operation. For example, SQLite TableExists/ViewExists still query unqualified sqlite_master and do not resolve attached-database names. A provider-by-operation schema contract is still required. | +| #54 | Substantially fixed; cross-provider completion unverified | Generic add/remove paths escape constraint identifiers; current v13 inline primary/unique/check constraints use QuoteIdentifier. SQLite uses it for inline FKs too. Informix explicitly rejects complex inline constraint names without DELIMIDENT support. Do not claim all engine-specific create/inspect/remove paths are verified from SQLite results. | +| #134 | Requested native rename/drop implemented; closed | RenameColumn uses native SQL on SQLite >= 3.26; RemoveColumn uses native SQL on >= 3.35 when dependency checks allow it. Other changes still need reconstruction. Closed for the named examples; a demand to eliminate all reconstruction is broader and cannot be inferred from SQLite's native capabilities. | +| #140 | Safe explicit legacy-sequence cleanup implemented | Native identity sequences are engine-owned. Legacy ownership must be supplied through RemoveTableWithOwnedSequences; RemoveTable intentionally does not delete a similarly named, potentially shared sequence. Ready for closure if this ownership contract is accepted. | +| #141 | Already fixed/verified; closed with evidence | The merged Oracle live regression checks table-owned trigger removal. No new implementation change is needed. | +| #162 | Original SQL Server/PostgreSQL gaps fixed; legacy representations remain | All current dialects register DbType.Time. SQL Server/PostgreSQL native type/default/parameter regressions are merged. Oracle maps it to DATE, SQL Server 2005 to DATETIME, and Informix to INTERVAL HOUR TO SECOND. Native TIME is not a universal engine capability; keep broader representation/round-trip work separate from the missing mapping report. | + +SQLite's [documented MATCH limitation](https://www.sqlite.org/foreignkeys.html#limits_and_unsupported_features) is an engine limitation, not a missing SQL clause: emitting MATCH FULL would still enforce SIMPLE semantics. The correction raises NotSupportedException instead of promising that behavior. + +Local evidence: the unchanged master unit + SQLite selection passed **284 tests**, with no failures or skips. Seven new `SQLiteForeignKeyMatchTests` cases produced **five failures before the fix**, then all passed. The complete same unit + SQLite selection after the fix passed **291 tests**, with no failures or skips. Tests cover composite-key null semantics, independent update/delete actions after reconstruction, rejection in execution/preview/add-FK, and preservation of existing schema/data/foreign-key settings on a rejected rebuild. External database suites were not rerun locally (Docker is unavailable); Oracle/SQL Server/PostgreSQL conclusions use the merged source and prior linked live CI evidence. The new master CI run [35772295882](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35772295882) was still in progress when inspected, so it is not counted as passing evidence here. diff --git a/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs new file mode 100644 index 00000000..5067b635 --- /dev/null +++ b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs @@ -0,0 +1,61 @@ +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("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 TimeSpan(0, 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.1234560'")); + } + + [Test, Category("SQLite")] + public void TimeDefaultsAndValuesSurviveSQLiteReconstruction() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + var time = new TimeSpan(0, 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(TimeSpan.Parse(Convert.ToString(provider.ExecuteScalar("SELECT CAST(Value AS TEXT) FROM Times"))), Is.EqualTo(time)); + provider.Insert("Times", ["Id", "Value"], [2, time]); + Assert.That(TimeSpan.Parse(Convert.ToString(provider.ExecuteScalar("SELECT CAST(Value AS TEXT) FROM Times WHERE Id=2"))), Is.EqualTo(time)); + } +} diff --git a/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs index 75e0a05f..536380bb 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs @@ -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]); + } + /// /// Should return true if foreign key exists. /// diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index fe5f6db4..469d1296 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -27,6 +27,37 @@ public class LiveDatabaseTests(string database, ProviderTypes providerType) private ITransformationProvider provider; internal ITransformationProvider Provider => provider; + [Test] + public void TimeOfDayDefaultsAndParametersPersist() + { + var time = new TimeSpan(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 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)); + } + Assert.That(provider.GetColumns("clock_values").Single(c => c.Name.Equals("value", StringComparison.OrdinalIgnoreCase)).Type, Is.EqualTo(DbType.Time)); + } + + [Test] + public void QuotedConstraintNamesCanBeInspectedAndRemoved() + { + const string name = "UQ ' dotted.name"; + provider.AddTable("named_constraints", new Column("id", DbType.Int32), + 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 action) { try { SetUp(); action(this); } diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs index 2d6a4187..437035ee 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs @@ -11,6 +11,34 @@ namespace Migrator.Tests.Providers.OracleProvider; [Category("Oracle")] public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase { + [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 TimeSpan(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 Value FROM ClockValues WHERE Id=1")).TimeOfDay, Is.EqualTo(time)); + Assert.That(Convert.ToDateTime(Provider.ExecuteScalar("SELECT Value FROM ClockValues WHERE Id=2")).TimeOfDay, Is.EqualTo(time)); + } + [Test] public void LegacyForeignKeyOverloadHonorsCascadeDelete() { diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs index d694f22c..f42f0fb2 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs @@ -8,6 +8,23 @@ namespace Migrator.Tests.Providers.SQLServer; [Category("SQLServer")] public class SQLServerTransformationProvider_ConstraintExistsTests : Generic_ConstraintExistsBase { + [Test] + public void QualifiedNamesKeepColumnsIndexesAndConstraintsInTheirSchema() + { + Provider.ExecuteNonQuery("CREATE SCHEMA [audit.region]"); + const string table = "[audit.region].[O'Brien]"; + Provider.AddTable(table, new DotNetProjects.Migrator.Framework.Column("Id", System.Data.DbType.Int32), + new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ ' name", "Id")); + Assert.That(Provider.TableExists(table), Is.True); + Assert.That(Provider.GetColumns(table).Length, Is.EqualTo(1)); + Assert.That(Provider.GetIndexes(table).Length, Is.EqualTo(1)); + Assert.That(Provider.ConstraintExists(table, "UQ ' name"), Is.True); + Provider.RemoveConstraint(table, "UQ ' name"); + Assert.That(Provider.GetIndexes(table), Is.Empty); + Provider.RemoveTable(table); + Assert.That(Provider.TableExists(table), Is.False); + } + [SetUp] public async Task SetUpAsync() { diff --git a/src/Migrator.Tests/SQLiteForeignKeyMatchTests.cs b/src/Migrator.Tests/SQLiteForeignKeyMatchTests.cs new file mode 100644 index 00000000..5f62bdd4 --- /dev/null +++ b/src/Migrator.Tests/SQLiteForeignKeyMatchTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers; +using Microsoft.Data.Sqlite; +using NUnit.Framework; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class SQLiteForeignKeyMatchTests +{ + private static ForeignKeyConstraint ForeignKey(string match) => + new("FK_Child", "Parent", ["A", "B"], "Child", ["A", "B"]) + { Match = match, OnDelete = "SET NULL", OnUpdate = "CASCADE" }; + + [TestCase("FULL")] + [TestCase("PARTIAL")] + [TestCase("unknown")] + public void UnsupportedMatchIsRejectedByCreationPreviewAndAddingConstraint(string match) + { + using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Parent (A INTEGER, B INTEGER, PRIMARY KEY(A, B))"); + IDbField[] fields = [new Column("A", DbType.Int32), new Column("B", DbType.Int32), ForeignKey(match)]; + var operation = new CreateTableOperation("Child", null, fields); + Assert.Throws(() => operation.ToSql(new SqlGenerationContext(ProviderTypes.SQLite))); + Assert.Throws(() => operation.Apply(provider)); + Assert.That(provider.TableExists("Child"), Is.False); + provider.ExecuteNonQuery("CREATE TABLE Child (A INTEGER, B INTEGER); INSERT INTO Child VALUES (1, 2)"); + Assert.Throws(() => ((TransformationProvider)provider).AddForeignKey("Child", ForeignKey(match))); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child")), Is.EqualTo(1)); + Assert.That(provider.GetForeignKeyConstraints("Child"), Is.Empty); + } + + [TestCase(null)] + [TestCase("NONE")] + [TestCase("simple")] + public void SupportedMatchRetainsCompositeNullSemanticsAndIndependentActionsAfterRebuild(string match) + { + using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Parent (A INTEGER, B INTEGER, PRIMARY KEY(A, B)); INSERT INTO Parent VALUES (1, 2)"); + provider.AddTable("Child", new Column("A", DbType.Int32), new Column("B", DbType.Int32), ForeignKey(match)); + provider.ChangeColumn("Child", new Column("A", DbType.Int64)); + provider.ExecuteNonQuery("INSERT INTO Child VALUES (NULL, 99), (1, 2); UPDATE Parent SET A=3 WHERE A=1"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT A FROM Child WHERE B=2")), Is.EqualTo(3)); + Assert.Catch(() => provider.ExecuteNonQuery("INSERT INTO Child VALUES (3, 99)")); + provider.ExecuteNonQuery("DELETE FROM Parent"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE A IS NULL")), Is.EqualTo(2)); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child WHERE B IS NULL")), Is.EqualTo(1)); + if (match == "simple") + Assert.That(provider.GetTableConstraints("Child").OfType().Single().Match, Is.EqualTo("SIMPLE")); + } + + [Test] + public void LegacyUnsupportedMatchIsReportedAndRebuildLeavesOriginalSchemaAndRowsIntact() + { + using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); + provider.ExecuteNonQuery("CREATE TABLE Parent (A INTEGER, B INTEGER, PRIMARY KEY(A, B)); CREATE TABLE Child (A INTEGER, B INTEGER, FOREIGN KEY(A, B) REFERENCES Parent(A, B) MATCH FULL); INSERT INTO Child VALUES (NULL, 99)"); + Assert.That(provider.GetForeignKeyConstraints("Child").Single().Match, Is.EqualTo("FULL")); + var original = provider.ExecuteScalar("SELECT sql FROM sqlite_master WHERE name='Child'"); + Assert.Throws(() => provider.ChangeColumn("Child", new Column("A", DbType.Int64))); + Assert.That(provider.ExecuteScalar("SELECT sql FROM sqlite_master WHERE name='Child'"), Is.EqualTo(original)); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT B FROM Child")), Is.EqualTo(99)); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("PRAGMA foreign_keys")), Is.EqualTo(1)); + Assert.That(provider.TableExists("ChildTemp"), Is.False); + } +} diff --git a/src/Migrator/Framework/ForeignKeyConstraint.cs b/src/Migrator/Framework/ForeignKeyConstraint.cs index 44569fed..19481379 100644 --- a/src/Migrator/Framework/ForeignKeyConstraint.cs +++ b/src/Migrator/Framework/ForeignKeyConstraint.cs @@ -35,7 +35,9 @@ public ForeignKeyConstraint(string name, string parentTable, string[] parentcolu public string OnUpdate { get; set; } /// - /// /// Gets or sets the match text. Currently only used for SQLite. + /// Gets or sets the declared match text. Currently only used for SQLite. + /// SQLite enforces only SIMPLE. Null, empty and the PRAGMA value NONE use + /// that default; other modes are rejected when creating or rebuilding tables. /// public string Match { get; set; } } diff --git a/src/Migrator/Providers/CatalogDefaultValue.cs b/src/Migrator/Providers/CatalogDefaultValue.cs index 422ee9e8..50347359 100644 --- a/src/Migrator/Providers/CatalogDefaultValue.cs +++ b/src/Migrator/Providers/CatalogDefaultValue.cs @@ -20,6 +20,7 @@ internal static object Parse(string source, DbType type) var literal = value[1..^1].Replace("''", "'"); if (type is DbType.Date or DbType.DateTime or DbType.DateTime2 && DateTime.TryParse(literal, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return DateTime.SpecifyKind(date, DateTimeKind.Utc); + if (type == DbType.Time && TimeSpan.TryParse(literal, CultureInfo.InvariantCulture, out var time)) return time; if (type == DbType.Guid && Guid.TryParse(literal, out var guid)) return guid; return literal; } diff --git a/src/Migrator/Providers/ColumnPropertiesMapper.cs b/src/Migrator/Providers/ColumnPropertiesMapper.cs index 5b4e0a3c..59c70456 100644 --- a/src/Migrator/Providers/ColumnPropertiesMapper.cs +++ b/src/Migrator/Providers/ColumnPropertiesMapper.cs @@ -69,5 +69,5 @@ protected virtual void AddUnsigned(Column column, List values) values.Add(sql); } protected virtual void AddType(List values) => values.Add(Type); - protected virtual void AddName(List values) => values.Add(_Dialect.ColumnNameNeedsQuote || _Dialect.IsReservedWord(Name) ? QuotedName : Name); + protected virtual void AddName(List values) => values.Add(_Dialect.QuoteColumnNameIfRequired(Name)); } diff --git a/src/Migrator/Providers/ConstraintMetadataReader.cs b/src/Migrator/Providers/ConstraintMetadataReader.cs index 342facf2..59fd8feb 100644 --- a/src/Migrator/Providers/ConstraintMetadataReader.cs +++ b/src/Migrator/Providers/ConstraintMetadataReader.cs @@ -18,7 +18,7 @@ internal static class ConstraintMetadataReader public static TableConstraint[] Read(TransformationProvider provider, string table) { string sql; - var parameterTable = table; + var parameterTable = provider.QuoteTableNameIfRequired(table); string schema = null; var oracle = provider.Dialect is OracleDialect; if (provider.Dialect is SqlServerDialect) @@ -58,12 +58,8 @@ FROM SYSCAT.TABCONST c LEFT JOIN SYSCAT.KEYCOLUSE k } else if (oracle || provider.Dialect is MysqlDialect) { - // Quoted identifiers containing a dot need a structured name API rather than ambiguous splitting. - var parts = table.Split('.'); - if (parts.Length > 2 || parts.Any(p => p.Contains('"') || p.Contains('`') || p.Contains('['))) - throw new NotSupportedException("Quoted qualified constraint lookup is not implemented for this provider."); - parameterTable = oracle ? parts[^1].ToUpperInvariant() : parts[^1]; - schema = parts.Length == 2 ? (oracle ? parts[0].ToUpperInvariant() : parts[0]) : null; + var relation = SqlIdentifier.Catalog(provider.QuoteTableNameIfRequired(table), oracle); + parameterTable = relation.Name; schema = relation.Schema; sql = oracle ? @"SELECT c.CONSTRAINT_NAME,c.CONSTRAINT_TYPE,k.COLUMN_NAME,k.POSITION,c.SEARCH_CONDITION_VC FROM ALL_CONSTRAINTS c LEFT JOIN ALL_CONS_COLUMNS k ON k.OWNER=c.OWNER AND k.CONSTRAINT_NAME=c.CONSTRAINT_NAME AND c.CONSTRAINT_TYPE IN ('P','U') WHERE c.TABLE_NAME=:lookup_table AND c.OWNER=COALESCE(:lookup_schema,SYS_CONTEXT('USERENV','CURRENT_SCHEMA')) AND c.CONSTRAINT_TYPE IN ('P','U','C') diff --git a/src/Migrator/Providers/Dialect.cs b/src/Migrator/Providers/Dialect.cs index dd051307..085c40f4 100644 --- a/src/Migrator/Providers/Dialect.cs +++ b/src/Migrator/Providers/Dialect.cs @@ -51,7 +51,7 @@ protected virtual string ResolveCollation(CollationKind kind) => public virtual string GetTableConstraintSql(TableConstraint constraint) { if (constraint.Name != null && string.IsNullOrWhiteSpace(constraint.Name)) throw new MigrationException("A constraint name must not be empty."); - string Keys(string[] columns) => string.Join(", ", columns.Select(name => ColumnNameNeedsQuote || IsReservedWord(name) ? QuoteIdentifier(name) : name)); + string Keys(string[] columns) => string.Join(", ", columns.Select(QuoteColumnNameIfRequired)); var body = constraint switch { PrimaryKeyConstraint p when p.NonClustered && !SupportsNonClustered => throw new System.NotSupportedException("This dialect does not support nonclustered primary keys."), @@ -380,14 +380,14 @@ public virtual string SqlForColumnAttribute(ColumnAttribute property, Column col public virtual string Quote(string value) { - return string.Format(QuoteTemplate, value); + return SqlIdentifier.Render(this, value, true); } public virtual string QuoteColumnNameIfRequired(string columnName) { - if (ColumnNameNeedsQuote || IsReservedWord(columnName)) + if (ColumnNameNeedsQuote || IsReservedWord(columnName) || !SqlIdentifier.IsSimple(columnName)) { - return Quote(columnName); + return QuoteIdentifier(columnName); } return columnName; @@ -395,12 +395,7 @@ public virtual string QuoteColumnNameIfRequired(string columnName) public virtual string QuoteTableNameIfRequired(string tableName) { - if (TableNameNeedsQuote || IsReservedWord(tableName)) - { - return Quote(tableName); - } - - return tableName; + return SqlIdentifier.Render(this, tableName, TableNameNeedsQuote); } public virtual string Default(object defaultValue) @@ -410,6 +405,10 @@ public virtual string Default(object defaultValue) { defaultValue = "''"; } + else if (defaultValue is TimeSpan time) + { + return "DEFAULT '" + time.ToString("c", CultureInfo.InvariantCulture) + "'"; + } else if (defaultValue is Guid) { var guidValue = string.Format("DEFAULT '{0}'", defaultValue.ToString()); diff --git a/src/Migrator/Providers/ForeignKeyMetadataReader.cs b/src/Migrator/Providers/ForeignKeyMetadataReader.cs index 0f76ca6a..0a7d13e9 100644 --- a/src/Migrator/Providers/ForeignKeyMetadataReader.cs +++ b/src/Migrator/Providers/ForeignKeyMetadataReader.cs @@ -17,11 +17,11 @@ internal static class ForeignKeyMetadataReader { public static ForeignKeyConstraint[] Read(TransformationProvider provider, string table) { - var parameterTable = table; + var parameterTable = provider.QuoteTableNameIfRequired(table); string schema = null; string sql; if (provider.Dialect is SqlServerDialect) - sql = @"SELECT f.name,OBJECT_NAME(f.referenced_object_id),cc.name,pc.name,k.constraint_column_id, + sql = @"SELECT f.name,CASE WHEN OBJECT_SCHEMA_NAME(f.referenced_object_id)=OBJECT_SCHEMA_NAME(f.parent_object_id) THEN OBJECT_NAME(f.referenced_object_id) ELSE QUOTENAME(OBJECT_SCHEMA_NAME(f.referenced_object_id))+'.'+QUOTENAME(OBJECT_NAME(f.referenced_object_id)) END,cc.name,pc.name,k.constraint_column_id, REPLACE(f.delete_referential_action_desc,'_',' '),REPLACE(f.update_referential_action_desc,'_',' ') FROM sys.foreign_keys f JOIN sys.foreign_key_columns k ON k.constraint_object_id=f.object_id JOIN sys.columns cc ON cc.object_id=k.parent_object_id AND cc.column_id=k.parent_column_id @@ -30,7 +30,7 @@ public static ForeignKeyConstraint[] Read(TransformationProvider provider, strin else if (provider.Dialect is PostgreSQLDialect) { parameterTable = provider.QuoteTableNameIfRequired(table); - sql = @"SELECT c.conname,p.relname,cc.attname,pc.attname,k.ordinality,c.confdeltype::text,c.confupdtype::text + sql = @"SELECT c.conname,CASE WHEN p.relnamespace=(SELECT relnamespace FROM pg_class WHERE oid=c.conrelid) THEN p.relname ELSE quote_ident((SELECT nspname FROM pg_namespace WHERE oid=p.relnamespace))||'.'||quote_ident(p.relname) END,cc.attname,pc.attname,k.ordinality,c.confdeltype::text,c.confupdtype::text FROM pg_constraint c JOIN pg_class p ON p.oid=c.confrelid CROSS JOIN LATERAL unnest(c.conkey,c.confkey) WITH ORDINALITY k(childnum,parentnum,ordinality) JOIN pg_attribute cc ON cc.attrelid=c.conrelid AND cc.attnum=k.childnum @@ -39,9 +39,8 @@ CROSS JOIN LATERAL unnest(c.conkey,c.confkey) WITH ORDINALITY k(childnum,parentn } else if (provider.Dialect is MysqlDialect) { - var parts = table.Split('.'); - if (parts.Length > 2 || parts.Any(p => p.Contains((char)96))) throw new NotSupportedException("Use unquoted names for MySQL foreign-key catalog lookup."); - parameterTable = parts[^1]; schema = parts.Length == 2 ? parts[0] : null; + var relation = SqlIdentifier.Catalog(provider.QuoteTableNameIfRequired(table)); + parameterTable = relation.Name; schema = relation.Schema; sql = @"SELECT k.CONSTRAINT_NAME,k.REFERENCED_TABLE_NAME,k.COLUMN_NAME,k.REFERENCED_COLUMN_NAME,k.ORDINAL_POSITION,r.DELETE_RULE,r.UPDATE_RULE FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_CONSTRAINTS r ON r.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND r.TABLE_NAME=k.TABLE_NAME AND r.CONSTRAINT_NAME=k.CONSTRAINT_NAME @@ -50,10 +49,8 @@ FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_C } else if (provider.Dialect is OracleDialect) { - var parts = table.Split('.'); - if (parts.Length > 2 || parts.Any(p => p.Contains('"'))) - throw new NotSupportedException("Use unquoted schema/table names for Oracle foreign-key catalog lookup."); - parameterTable = parts[^1].ToUpperInvariant(); schema = parts.Length == 2 ? parts[0].ToUpperInvariant() : null; + var relation = SqlIdentifier.Catalog(provider.QuoteTableNameIfRequired(table), true); + parameterTable = relation.Name; schema = relation.Schema; sql = @"SELECT c.CONSTRAINT_NAME, CASE WHEN p.OWNER=c.OWNER THEN p.TABLE_NAME ELSE p.OWNER||'.'||p.TABLE_NAME END, cc.COLUMN_NAME,pc.COLUMN_NAME,cc.POSITION,c.DELETE_RULE,'NO ACTION' diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index d36a01db..07d0f9c5 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -42,7 +42,7 @@ public override string[] GetTables() => ExecuteStringQuery( public override List GetDatabases() => [Convert.ToString(ExecuteScalar("VALUES CURRENT SERVER")).Trim()]; public override string[] GetConstraints(string table) => ExecuteStringQuery( $"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}'").ToArray(); - public override bool ConstraintExists(string table, string name) => GetConstraints(table).Contains(Name(name)); + public override bool ConstraintExists(string table, string name) => GetConstraints(table).Any(n => n == name || n == Name(name).Replace("''", "'")); protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( $"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' AND TYPE='P'").FirstOrDefault(); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index dab77056..7d75446d 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -64,7 +64,7 @@ public override string[] GetConstraints(string table) => ExecuteStringQuery( $"SELECT TRIM(RDB$CONSTRAINT_NAME) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME='{CatalogName(table)}'").ToArray(); public override bool ConstraintExists(string table, string name) => - GetConstraints(table).Any(n => n == CatalogName(name).Replace("''", "'")); + GetConstraints(table).Any(n => n == name || n == CatalogName(name).Replace("''", "'")); protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery($"SELECT TRIM(RDB$CONSTRAINT_NAME) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME='{CatalogName(table)}' AND RDB$CONSTRAINT_TYPE='PRIMARY KEY'").FirstOrDefault(); diff --git a/src/Migrator/Providers/Impl/Hana/HanaDialect.cs b/src/Migrator/Providers/Impl/Hana/HanaDialect.cs index cf94b0aa..c8f32bf1 100644 --- a/src/Migrator/Providers/Impl/Hana/HanaDialect.cs +++ b/src/Migrator/Providers/Impl/Hana/HanaDialect.cs @@ -44,7 +44,7 @@ public HanaDialect() public override bool NeedsNullForNullableWhenAlteringTable => true; public override bool SupportsIndex => false; public override string QuoteTemplate => "\"{0}\""; - public override string Quote(string name) => string.Join(".", name.Split('.').Select(QuoteIdentifier)); + public override string Quote(string name) => base.Quote(name); public override string Default(object value) => value switch { bool boolean => boolean ? "DEFAULT TRUE" : "DEFAULT FALSE", diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 0b703122..19f0dbe5 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -54,9 +54,8 @@ public override string GetTableConstraintSql(TableConstraint constraint) }; var body = base.GetTableConstraintSql(copy); if (constraint.Name == null) return body; - if (!System.Text.RegularExpressions.Regex.IsMatch(constraint.Name, @"^[A-Za-z_][A-Za-z0-9_$]*$")) - throw new NotSupportedException("Informix constraint names require simple identifiers unless DELIMIDENT is configured."); - return body + " CONSTRAINT " + constraint.Name; + // DELIMIDENT must be enabled on the connection for delimited identifiers. + return body + " CONSTRAINT " + QuoteIdentifier(constraint.Name); } public override string Default(object value) => value is bool boolean ? (boolean ? "DEFAULT 't'" : "DEFAULT 'f'") : base.Default(value); diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index e9174cfb..0ea0341b 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -41,7 +41,7 @@ public override string[] GetTables() => ExecuteStringQuery( public override List GetDatabases() => ExecuteStringQuery("SELECT name FROM sysmaster:sysdatabases"); public override string[] GetConstraints(string table) => ExecuteStringQuery( $"SELECT c.constrname FROM sysconstraints c JOIN systables t ON c.tabid=t.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}'").Select(n => n.Trim()).ToArray(); - public override bool ConstraintExists(string table, string name) => GetConstraints(table).Contains(Name(name)); + public override bool ConstraintExists(string table, string name) => GetConstraints(table).Any(n => n == name || n == Name(name).Replace("''", "'")); protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( $"SELECT c.constrname FROM sysconstraints c JOIN systables t ON c.tabid=t.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND c.constrtype='P'").FirstOrDefault()?.Trim(); diff --git a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs index 7e06754c..1e792da4 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -37,7 +37,7 @@ public override void RemoveForeignKey(string table, string name) { if (ForeignKeyExists(table, name)) { - ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP FOREIGN KEY {1}", table, _dialect.Quote(name))); + ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP FOREIGN KEY {1}", table, _dialect.QuoteIdentifier(name))); } } @@ -122,9 +122,9 @@ public override void RemoveConstraint(string table, string name) var action = type switch { "PRIMARY KEY" => "DROP PRIMARY KEY", - "FOREIGN KEY" => "DROP FOREIGN KEY " + _dialect.Quote(name), - "UNIQUE" => "DROP INDEX " + _dialect.Quote(name), - "CHECK" => (_dialect is MariaDBDialect ? "DROP CONSTRAINT " : "DROP CHECK ") + _dialect.Quote(name), + "FOREIGN KEY" => "DROP FOREIGN KEY " + _dialect.QuoteIdentifier(name), + "UNIQUE" => "DROP INDEX " + _dialect.QuoteIdentifier(name), + "CHECK" => (_dialect is MariaDBDialect ? "DROP CONSTRAINT " : "DROP CHECK ") + _dialect.QuoteIdentifier(name), _ => throw new MigrationException($"Constraint '{name}' does not exist") }; ExecuteNonQuery($"ALTER TABLE {_dialect.Quote(table)} {action}"); @@ -241,6 +241,7 @@ private object ReadDefault(string value, DbType type, string extra) return new DatabaseDefault(value); return type switch { + DbType.Time => TimeSpan.Parse(value, CultureInfo.InvariantCulture), DbType.Boolean => value != "0", DbType.Byte => byte.Parse(value, CultureInfo.InvariantCulture), DbType.Int16 => short.Parse(value, CultureInfo.InvariantCulture), @@ -301,7 +302,7 @@ public override void RemoveIndex(string table, string name) { if (IndexExists(table, name)) { - ExecuteNonQuery(string.Format("DROP INDEX {1} ON {0}", table, _dialect.Quote(name))); + ExecuteNonQuery(string.Format("DROP INDEX {1} ON {0}", table, _dialect.QuoteIdentifier(name))); } } @@ -331,7 +332,7 @@ public override string AddIndex(string table, Index index) if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0 || index.Clustered) throw new NotSupportedException("MySQL and MariaDB do not support included columns, filtered indexes or explicit clustered indexes."); var name = index.Name ?? $"IX_{table}_{string.Join("_", index.KeyColumns)}"; - ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {_dialect.Quote(name)} ON {_dialect.Quote(table)} ({string.Join(", ", index.KeyColumns.Select(_dialect.Quote))})"); + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {_dialect.QuoteIdentifier(name)} ON {_dialect.Quote(table)} ({string.Join(", ", index.KeyColumns.Select(_dialect.Quote))})"); return name; } diff --git a/src/Migrator/Providers/Impl/Oracle/Data/OracleSystemDataLoader.cs b/src/Migrator/Providers/Impl/Oracle/Data/OracleSystemDataLoader.cs index 287389a9..0042423c 100644 --- a/src/Migrator/Providers/Impl/Oracle/Data/OracleSystemDataLoader.cs +++ b/src/Migrator/Providers/Impl/Oracle/Data/OracleSystemDataLoader.cs @@ -16,9 +16,9 @@ public List GetUserTabIdentityCols(string tableName) { List userTabIdentityCols = []; - var tableNameQuoted = _oracleTransformationProvider.QuoteTableNameIfRequired(tableName); + var tablePredicate = OracleCatalog.Predicate(_oracleTransformationProvider, tableName); - var sql = $"SELECT TABLE_NAME, COLUMN_NAME, GENERATION_TYPE, SEQUENCE_NAME FROM USER_TAB_IDENTITY_COLS WHERE TABLE_NAME = '{tableNameQuoted.ToUpperInvariant()}'"; + var sql = "SELECT TABLE_NAME, COLUMN_NAME, GENERATION_TYPE, SEQUENCE_NAME FROM ALL_TAB_IDENTITY_COLS WHERE " + tablePredicate; using var cmd = _oracleTransformationProvider.CreateCommand(); using var reader = _oracleTransformationProvider.ExecuteQuery(cmd, sql); @@ -46,7 +46,7 @@ public List GetUserTabIdentityCols(string tableName) public List GetForeignKeyConstraintItems(string tableName) { - var tableNameQuoted = _oracleTransformationProvider.QuoteTableNameIfRequired(tableName); + var tablePredicate = OracleCatalog.Predicate(_oracleTransformationProvider, tableName); var sb = new StringBuilder(); sb.AppendLine("SELECT"); @@ -57,14 +57,14 @@ public List GetForeignKeyConstraintItems(string tableN sb.AppendLine(" c_pk.TABLE_NAME AS PARENT_TABLE,"); sb.AppendLine(" col_pk.COLUMN_NAME AS PARENT_COLUMN"); sb.AppendLine("FROM "); - sb.AppendLine(" USER_CONS_COLUMNS a "); - sb.AppendLine("JOIN USER_CONSTRAINTS c"); + sb.AppendLine(" ALL_CONS_COLUMNS a "); + sb.AppendLine("JOIN ALL_CONSTRAINTS c"); sb.AppendLine(" ON a.owner = c.owner AND a.CONSTRAINT_NAME = c.CONSTRAINT_NAME"); - sb.AppendLine("JOIN USER_CONSTRAINTS c_pk"); + sb.AppendLine("JOIN ALL_CONSTRAINTS c_pk"); sb.AppendLine(" ON c.R_OWNER = c_pk.OWNER AND c.R_CONSTRAINT_NAME = c_pk.CONSTRAINT_NAME"); - sb.AppendLine("JOIN USER_CONS_COLUMNS col_pk"); + sb.AppendLine("JOIN ALL_CONS_COLUMNS col_pk"); sb.AppendLine(" ON c_pk.CONSTRAINT_NAME = col_pk.CONSTRAINT_NAME AND c_pk.OWNER = col_pk.OWNER AND a.POSITION = col_pk.POSITION"); - sb.AppendLine($"WHERE LOWER(a.TABLE_NAME) = LOWER('{tableNameQuoted}') AND c.CONSTRAINT_TYPE = 'R'"); + sb.AppendLine("WHERE " + OracleCatalog.Predicate(_oracleTransformationProvider, tableName, "a.TABLE_NAME", "a.OWNER") + " AND c.CONSTRAINT_TYPE='R'"); sb.AppendLine("ORDER BY a.POSITION"); var sql = sb.ToString(); @@ -93,7 +93,7 @@ public List GetForeignKeyConstraintItems(string tableN public List GetPrimaryKeyItems(string tableName) { - var tableNameQuoted = _oracleTransformationProvider.QuoteTableNameIfRequired(tableName); + var tablePredicate = OracleCatalog.Predicate(_oracleTransformationProvider, tableName); var sql = $@" SELECT @@ -103,13 +103,13 @@ public List GetPrimaryKeyItems(string tableName) uc.CONSTRAINT_NAME, uc.STATUS FROM - USER_CONSTRAINTS uc + ALL_CONSTRAINTS uc JOIN - USER_CONS_COLUMNS ucc - ON uc.CONSTRAINT_NAME = ucc.CONSTRAINT_NAME + ALL_CONS_COLUMNS ucc + ON uc.OWNER = ucc.OWNER AND uc.CONSTRAINT_NAME = ucc.CONSTRAINT_NAME WHERE uc.CONSTRAINT_TYPE = 'P' - AND ucc.TABLE_NAME = '{tableNameQuoted.ToUpperInvariant()}' + {"AND " + OracleCatalog.Predicate(_oracleTransformationProvider, tableName, "ucc.TABLE_NAME", "ucc.OWNER")} ORDER BY ucc.POSITION "; @@ -138,7 +138,7 @@ ORDER BY public List GetIndexItems(string tableName) { - var tableNameQuoted = _oracleTransformationProvider.QuoteTableNameIfRequired(tableName); + var tablePredicate = OracleCatalog.Predicate(_oracleTransformationProvider, tableName); var sql = @$" SELECT @@ -150,15 +150,15 @@ public List GetIndexItems(string tableName) CASE WHEN c.constraint_type = 'P' THEN 'YES' ELSE 'NO' END AS is_primary_key, CASE WHEN c.constraint_type = 'U' THEN 'YES' ELSE 'NO' END AS is_unique_key FROM - user_indexes i + all_indexes i JOIN - user_ind_columns ic ON i.index_name = ic.index_name AND + all_ind_columns ic ON i.owner = ic.index_owner AND i.index_name = ic.index_name AND i.table_name = ic.table_name LEFT JOIN - user_constraints c ON i.index_name = c.index_name AND + all_constraints c ON i.owner = c.index_owner AND i.index_name = c.index_name AND i.table_name = c.table_name WHERE - UPPER(i.table_name) = '{tableNameQuoted.ToUpperInvariant()}' + {OracleCatalog.Predicate(_oracleTransformationProvider, tableName, "i.table_name", "i.table_owner")} -- AND -- i.index_type = 'NORMAL' ORDER BY diff --git a/src/Migrator/Providers/Impl/Oracle/OracleCatalog.cs b/src/Migrator/Providers/Impl/Oracle/OracleCatalog.cs new file mode 100644 index 00000000..aaa93160 --- /dev/null +++ b/src/Migrator/Providers/Impl/Oracle/OracleCatalog.cs @@ -0,0 +1,15 @@ +using DotNetProjects.Migrator.Framework; + +namespace DotNetProjects.Migrator.Providers.Impl.Oracle; + +internal static class OracleCatalog +{ + internal static string Literal(string value) => "'" + value.Replace("'", "''") + "'"; + + internal static string Predicate(ITransformationProvider provider, string table, string tableColumn = "TABLE_NAME", string ownerColumn = "OWNER") + { + var name = SqlIdentifier.Catalog(provider.QuoteTableNameIfRequired(table), true); + return tableColumn + "=" + Literal(name.Name) + " AND " + ownerColumn + "=" + + (name.Schema == null ? "SYS_CONTEXT('USERENV','CURRENT_SCHEMA')" : Literal(name.Schema)); + } +} diff --git a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs index 49fef510..db4e13c6 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs @@ -10,6 +10,7 @@ public class OracleDialect : Dialect public OracleDialect() { + AddReservedWords("SELECT", "FROM", "WHERE", "ORDER", "GROUP", "TABLE", "USER"); RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); RegisterColumnType(DbType.AnsiStringFixedLength, 2000, "CHAR($l)"); RegisterColumnType(DbType.AnsiString, "VARCHAR2(255)"); @@ -110,6 +111,11 @@ public override string Default(object defaultValue) { return string.Format("DEFAULT {0}", booleanValue ? "1" : "0"); } + else if (defaultValue is TimeSpan time) + { + var date = TimeValue(time); + return "DEFAULT TO_DATE('" + date.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + "', 'YYYY-MM-DD HH24:MI:SS')"; + } else if (defaultValue is Guid guid) { var bytes = guid.ToByteArray(); @@ -156,4 +162,13 @@ public override string Default(object defaultValue) return base.Default(defaultValue); } + + internal static DateTime TimeValue(TimeSpan value) + { + if (value < TimeSpan.Zero || value >= TimeSpan.FromDays(1)) + throw new ArgumentOutOfRangeException(nameof(value), "A time of day must be within one day."); + if (value.Ticks % TimeSpan.TicksPerSecond != 0) + throw new NotSupportedException("Oracle DbType.Time uses DATE, which has whole-second precision. Use an explicit TIMESTAMP or interval for fractional seconds."); + return new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Unspecified).Add(value); + } } diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 5e7b5f71..a5b20db2 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -20,6 +20,7 @@ namespace DotNetProjects.Migrator.Providers.Impl.Oracle; public class OracleTransformationProvider : TransformationProvider, IOracleTransformationProvider { private IOracleSystemDataLoader _oracleSystemDataLoader; + public const string TemporaryColumnName = "TEMPCOL"; public OracleTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) @@ -242,7 +243,6 @@ public override void ChangeColumn(string table, string sqlColumn) } table = QuoteTableNameIfRequired(table); - sqlColumn = QuoteColumnNameIfRequired(sqlColumn); ExecuteNonQuery(string.Format("ALTER TABLE {0} MODIFY {1}", table, sqlColumn)); } @@ -251,104 +251,28 @@ public override void AddColumn(string table, string sqlColumn) { GuardAgainstMaximumIdentifierLengthForOracle(table); table = QuoteTableNameIfRequired(table); - sqlColumn = QuoteColumnNameIfRequired(sqlColumn); ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD {1}", table, sqlColumn)); } - public override string[] GetConstraints(string table) - { - var constraints = new List(); - using (var cmd = CreateCommand()) - using ( - var reader = - ExecuteQuery(cmd, - string.Format("SELECT constraint_name FROM user_constraints WHERE lower(table_name) = '{0}'", table.ToLower()))) - { - while (reader.Read()) - { - constraints.Add(reader.GetString(0)); - } - } - - return constraints.ToArray(); - } - - protected override string GetPrimaryKeyConstraintName(string table) - { - var constraints = new List(); - - using (var cmd = CreateCommand()) - using ( - var reader = - ExecuteQuery(cmd, - string.Format("SELECT constraint_name FROM user_constraints WHERE lower(table_name) = '{0}' and constraint_type = 'P'", table.ToLower()))) - { - while (reader.Read()) - { - constraints.Add(reader.GetString(0)); - } - } - - return constraints.FirstOrDefault(); - } - - public override bool ConstraintExists(string table, string name) - { - var sql = - string.Format( - "SELECT COUNT(constraint_name) FROM user_constraints WHERE lower(constraint_name) = '{0}' AND lower(table_name) = '{1}'", - name.ToLower(), table.ToLower()); - - Logger.Log(sql); - var scalar = ExecuteScalar(sql); - - return Convert.ToInt32(scalar) == 1; - } - - public override bool ColumnExists(string table, string column) - { - if (!TableExists(table)) - { - return false; - } + public override string[] GetConstraints(string table) => ExecuteStringQuery( + "SELECT CONSTRAINT_NAME FROM ALL_CONSTRAINTS WHERE " + OracleCatalog.Predicate(this, table)).ToArray(); - var sql = - string.Format( - "SELECT COUNT(column_name) FROM user_tab_columns WHERE lower(table_name) = '{0}' AND lower(column_name) = '{1}'", - table.ToLower(), column.ToLower()); - Logger.Log(sql); - var scalar = ExecuteScalar(sql); - return Convert.ToInt32(scalar) == 1; - } + protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( + "SELECT CONSTRAINT_NAME FROM ALL_CONSTRAINTS WHERE CONSTRAINT_TYPE='P' AND " + OracleCatalog.Predicate(this, table)).FirstOrDefault(); - public override bool TableExists(string table) - { - var sql = string.Format("SELECT COUNT(table_name) FROM user_tables WHERE lower(table_name) = '{0}'", table.ToLower()); + public override bool ConstraintExists(string table, string name) => + GetConstraints(table).Any(actual => actual == name || actual == name.ToUpperInvariant()); - if (_defaultSchema != null) - { - sql = string.Format("SELECT COUNT(table_name) FROM user_tables WHERE lower(owner) = '{0}' and lower(table_name) = '{1}'", _defaultSchema.ToLower(), table.ToLower()); - } - - Logger.Log(sql); - var count = ExecuteScalar(sql); - return Convert.ToInt32(count) == 1; - } + public override bool ColumnExists(string table, string column) => Convert.ToInt32(ExecuteScalar( + "SELECT COUNT(*) FROM ALL_TAB_COLUMNS WHERE " + OracleCatalog.Predicate(this, table) + + " AND COLUMN_NAME=" + OracleCatalog.Literal(SqlIdentifier.Catalog(QuoteColumnNameIfRequired(column), true).Name))) > 0; - public override bool ViewExists(string view) - { - var sql = string.Format("SELECT COUNT(view_name) FROM user_views WHERE lower(view_name) = '{0}'", view.ToLower()); - - if (_defaultSchema != null) - { - sql = string.Format("SELECT COUNT(view_name) FROM user_views WHERE lower(owner) = '{0}' and lower(view_name) = '{1}'", _defaultSchema.ToLower(), view.ToLower()); - } + public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( + "SELECT COUNT(*) FROM ALL_TABLES WHERE " + OracleCatalog.Predicate(this, table))) > 0; - Logger.Log(sql); - var count = ExecuteScalar(sql); - return Convert.ToInt32(count) == 1; - } + public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( + "SELECT COUNT(*) FROM ALL_VIEWS WHERE " + OracleCatalog.Predicate(this, view, "VIEW_NAME"))) > 0; public override List GetDatabases() { @@ -388,14 +312,15 @@ public override Column[] GetColumns(string table) stringBuilder.AppendLine(" DATA_PRECISION,"); stringBuilder.AppendLine(" DATA_SCALE,"); stringBuilder.AppendLine(" CHAR_COL_DECL_LENGTH"); - stringBuilder.AppendLine($"FROM USER_TAB_COLUMNS WHERE LOWER(TABLE_NAME) = LOWER('{table}')"); + stringBuilder.AppendLine("FROM ALL_TAB_COLUMNS WHERE " + OracleCatalog.Predicate(this, table) + " ORDER BY COLUMN_ID"); var stringBuilder2 = new StringBuilder(); stringBuilder2.AppendLine("SELECT x.column_name, x.data_default"); stringBuilder2.AppendLine("FROM XMLTABLE("); stringBuilder2.AppendLine(" '/ROWSET/ROW'"); stringBuilder2.AppendLine(" PASSING DBMS_XMLGEN.GETXMLTYPE("); - stringBuilder2.AppendLine($" 'SELECT column_name, data_default FROM user_tab_columns WHERE table_name = ''{table.ToUpperInvariant()}'''"); + var defaultQuery = "SELECT column_name, data_default FROM all_tab_columns WHERE " + OracleCatalog.Predicate(this, table); + stringBuilder2.AppendLine(" " + OracleCatalog.Literal(defaultQuery)); stringBuilder2.AppendLine(" )"); stringBuilder2.AppendLine(" COLUMNS"); stringBuilder2.AppendLine(" column_name VARCHAR2(4000) PATH 'COLUMN_NAME',"); @@ -404,7 +329,7 @@ public override Column[] GetColumns(string table) var userTabIdentityCols = _oracleSystemDataLoader.GetUserTabIdentityCols(tableName: table); var primaryKeyItems = _oracleSystemDataLoader.GetPrimaryKeyItems(tableName: table); - var uniqueColumns = ExecuteStringQuery("SELECT MIN(cc.COLUMN_NAME) FROM USER_CONSTRAINTS c JOIN USER_CONS_COLUMNS cc ON c.CONSTRAINT_NAME=cc.CONSTRAINT_NAME WHERE c.CONSTRAINT_TYPE='U' AND LOWER(c.TABLE_NAME)=LOWER('{0}') GROUP BY c.CONSTRAINT_NAME HAVING COUNT(*)=1", table.Replace("'", "''")); + List userTabColumns = []; @@ -713,7 +638,12 @@ public override string GenerateParameterName(int index) protected override void ConfigureParameterWithValue(IDbDataParameter parameter, int index, object value) { - if (value is Guid || value is Guid?) + if (value is TimeSpan time) + { + parameter.DbType = DbType.Date; + parameter.Value = OracleDialect.TimeValue(time); + } + else if (value is Guid || value is Guid?) { parameter.DbType = DbType.Binary; @@ -818,7 +748,7 @@ public override void RemoveColumnDefaultValue(string table, string column) public override void AddTable(string name, params IDbField[] fields) { - GuardAgainstMaximumIdentifierLengthForOracle(name); + foreach (var part in SqlIdentifier.Parse(name)) GuardAgainstMaximumIdentifierLengthForOracle(part.Value); var columns = fields.OfType().ToArray(); GuardAgainstMaximumColumnNameLengthForOracle(name, columns); foreach (var identity in columns.Where(c => c.IsIdentity)) diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs index 1cd7fea1..5e7b2a3d 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs @@ -128,7 +128,7 @@ public override string Default(object defaultValue) { if (defaultValue is TimeSpan timeSpan) { - var intervalPostgreNotation = $"{(int)timeSpan.TotalHours:D2}:{timeSpan.Minutes:D2}:{timeSpan.Seconds:D2}.{timeSpan.Milliseconds:D3}"; + var intervalPostgreNotation = timeSpan.ToString("c", System.Globalization.CultureInfo.InvariantCulture); return $"DEFAULT '{intervalPostgreNotation}'"; } diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index 25ab5284..254ad704 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -328,6 +328,8 @@ private IDbCommand MetadataCommand(string relation, string name = null) return command; } + public override string[] GetConstraints(string table) => GetTableConstraints(table).Select(c => c.Name).Where(n => n != null).ToArray(); + public override bool ConstraintExists(string table, string name) { using var command = MetadataCommand(table, name); @@ -897,28 +899,6 @@ public override Column[] GetColumns(string table) return columns.ToArray(); } - public override string[] GetConstraints(string table) - { - var constraints = new List(); - - using (var cmd = CreateCommand()) - using ( - var reader = - ExecuteQuery( - cmd, string.Format(@"select c.conname as constraint_name -from pg_constraint c -join pg_class t on c.conrelid = t.oid -where LOWER(t.relname) = LOWER('{0}')", table))) - { - while (reader.Read()) - { - constraints.Add(reader.GetString(0)); - } - } - - return constraints.ToArray(); - } - public override Column GetColumnByName(string table, string columnName) { // Duplicate because of the lower case issue diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs index e6022fdb..3ba2ff1e 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs @@ -77,6 +77,7 @@ public static string Generate(Dialect dialect, string quotedTable, IDbField[] fi foreach (var fk in foreignKeys) { + var match = ValidateMatch(fk.Match); var sourceColumnNamesQuotedString = string.Join(", ", fk.ChildColumns.Select(dialect.QuoteColumnNameIfRequired)); var parentColumnNamesQuotedString = string.Join(", ", fk.ParentColumns.Select(dialect.QuoteColumnNameIfRequired)); var parentTableNameQuoted = dialect.QuoteTableNameIfRequired(fk.ParentTable); @@ -84,6 +85,7 @@ public static string Generate(Dialect dialect, string quotedTable, IDbField[] fi var foreignKeySql = (fk.Name == null ? "" : $"CONSTRAINT {dialect.QuoteIdentifier(fk.Name)} ") + $"FOREIGN KEY ({sourceColumnNamesQuotedString}) REFERENCES {parentTableNameQuoted}" + (fk.ParentColumns.Length == 0 ? "" : $"({parentColumnNamesQuotedString})"); + if (match == "SIMPLE") foreignKeySql += " MATCH SIMPLE"; if (!string.IsNullOrWhiteSpace(fk.OnDelete) && !string.Equals(fk.OnDelete, "NO ACTION", StringComparison.OrdinalIgnoreCase)) { foreignKeySql += $" ON DELETE {ValidateAction(fk.OnDelete)}"; @@ -118,6 +120,15 @@ public static string Generate(Dialect dialect, string quotedTable, IDbField[] fi return stringBuilder.ToString(); } + internal static string ValidateMatch(string match) + { + var value = match?.Trim().ToUpperInvariant(); + // SQLite accepts MATCH syntax but enforces only SIMPLE. NONE is the + // value returned by PRAGMA foreign_key_list when no match is declared. + if (string.IsNullOrEmpty(value) || value is "NONE" or "SIMPLE") return value; + throw new NotSupportedException("SQLite only enforces MATCH SIMPLE; unsupported foreign-key match: " + match); + } + private static string ValidateAction(string action) { var value = action.ToUpperInvariant(); diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index a21e8646..2ae5edd3 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -95,6 +95,13 @@ public override void AddForeignKey( RecreateTable(sqliteTableInfo); } + public override void AddForeignKey(string table, ForeignKeyConstraint fk) + { + if (fk == null) throw new ArgumentNullException(nameof(fk)); + SQLiteTableSql.ValidateMatch(fk.Match); + base.AddForeignKey(table, fk); + } + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) { @@ -181,6 +188,9 @@ public override ForeignKeyConstraint[] GetForeignKeyConstraints(string tableName (candidate.ParentColumns.Length == 0 || candidate.ParentColumns.SequenceEqual(foreignKey.ParentColumns, StringComparer.OrdinalIgnoreCase))); if (definition == null) throw new MigrationException("Cannot match a SQLite foreign key to its declaration."); foreignKey.Name = definition.Name; + // PRAGMA reports NONE even for an explicitly declared MATCH FULL. + // Retain the declaration so a rebuild cannot silently discard it. + foreignKey.Match = definition.Match ?? foreignKey.Match; declared.Remove(definition); } @@ -813,6 +823,7 @@ private static string ValidateForeignKeyAction(string action) public void RecreateTable(SQLiteTableInfo sqliteTableInfo) { + foreach (var foreignKey in sqliteTableInfo.ForeignKeys) SQLiteTableSql.ValidateMatch(foreignKey.Match); var oldName = sqliteTableInfo.TableNameMapping.OldName; var script = GetSqlCreateTableScript(oldName); if (Regex.IsMatch(script, @"\b(STRICT|GENERATED|DEFERRABLE|COLLATE)\b|WITHOUT\s+ROWID|CREATE\s+VIRTUAL|ON\s+CONFLICT", RegexOptions.IgnoreCase)) @@ -1072,39 +1083,10 @@ public override bool ConstraintExists(string table, string name) public override string[] GetConstraints(string table) { - if (!TableExists(table)) - { - throw new Exception($"Table '{table}' does not exist."); - } - - var sqliteInfo = GetSQLiteTableInfo(table); - - var foreignKeyNames = sqliteInfo.ForeignKeys - .Select(x => x.Name) - .ToList(); - - var uniqueConstraints = sqliteInfo.Uniques - .Select(x => x.Name) - .ToList(); - - var checkConstraints = sqliteInfo.CheckConstraints - .Select(x => x.Name) - .ToList(); - - var names = foreignKeyNames.Concat(uniqueConstraints) - .Concat(checkConstraints) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .ToArray(); - - var distinctNames = names.Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (names.Length != distinctNames.Length) - { - throw new Exception($"There are duplicate constraint names in table {table}'"); - } - - return distinctNames; + var names = GetTableConstraints(table).Select(c => c.Name).Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); + if (names.Distinct(StringComparer.OrdinalIgnoreCase).Count() != names.Length) + throw new MigrationException("Duplicate constraint names in table: " + table); + return names; } public override string[] GetTables() @@ -1558,7 +1540,13 @@ public override void CopyDataFromTableToTable(string sourceTableName, List= 0) - { - var owner = value.Substring(0, firstDotIndex); - var table = value.Substring(firstDotIndex + 1); - return (string.Format(QuoteTemplate, owner) + "." + string.Format(QuoteTemplate, table)); - } - return string.Format(QuoteTemplate, value); + return base.Quote(value); } public override string Default(object defaultValue) diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index f62b550b..9b0d1d64 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -118,60 +118,42 @@ public override void CopyDataFromTableToTable(string sourceTableName, List(); + while (reader.Read()) names.Add(reader.GetString(0)); + return names.ToArray(); } public override void AddColumn(string table, string sqlColumn) @@ -309,8 +291,9 @@ public override bool ColumnExists(string table, string column) public override void RemoveColumnDefaultValue(string table, string column) { - var sql = string.Format("SELECT name FROM sys.default_constraints WHERE parent_object_id = OBJECT_ID('{0}') AND parent_column_id = (SELECT column_id FROM sys.columns WHERE name = '{1}' AND object_id = OBJECT_ID('{0}'))", table, column); - var constraintName = ExecuteScalar(sql); + using var command = ObjectCommand(table, column); + command.CommandText = "SELECT name FROM sys.default_constraints WHERE parent_object_id=OBJECT_ID(@table) AND parent_column_id=(SELECT column_id FROM sys.columns WHERE object_id=OBJECT_ID(@table) AND name=@name)"; + var constraintName = command.ExecuteScalar(); if (constraintName != null && constraintName != DBNull.Value && !string.IsNullOrWhiteSpace(constraintName.ToString())) { RemoveConstraint(table, constraintName.ToString()); @@ -319,8 +302,9 @@ public override void RemoveColumnDefaultValue(string table, string column) public override Index[] GetIndexes(string table) { - // This migrator does not support schemas so we fall back to dbo in SQL Server - var schemaName = "dbo"; + var relation = SqlIdentifier.Catalog(QuoteTableNameIfRequired(table)); + var schemaName = relation.Schema ?? "dbo"; + table = relation.Name; var indexes = new List(); @@ -345,8 +329,8 @@ sys.indexes i JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id JOIN sys.columns col ON ic.object_id = col.object_id AND ic.column_id = col.column_id WHERE - LOWER(t.name) = '{table.ToLowerInvariant()}' AND - LOWER(s.name) = '{schemaName.ToLowerInvariant()}' + LOWER(t.name) = '{table.ToLowerInvariant().Replace("'", "''")}' AND + LOWER(s.name) = '{schemaName.ToLowerInvariant().Replace("'", "''")}' ORDER BY s.name, t.name, i.name, ic.index_column_id"; @@ -511,21 +495,9 @@ public override int GetColumnContentSize(string table, string columnName) public override Column[] GetColumns(string table) { - string schema; - - var firstIndex = table.IndexOf("."); - if (firstIndex >= 0) - { - schema = table.Substring(0, firstIndex); - table = table.Substring(firstIndex + 1); - } - else - { - schema = _defaultSchema; - } - - schema = string.IsNullOrWhiteSpace(schema) ? "dbo" : schema.Trim('[', ']').Replace("''", "'"); - table = table.Trim('[', ']'); + var relation = SqlIdentifier.Catalog(QuoteTableNameIfRequired(table)); + var schema = relation.Schema ?? "dbo"; + table = relation.Name; var tableLiteral = table.Replace("'", "''"); var schemaLiteral = schema.Replace("'", "''"); var pkColumns = ExecuteStringQuery("SELECT cu.COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE cu JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_NAME=cu.CONSTRAINT_NAME AND tc.CONSTRAINT_SCHEMA=cu.CONSTRAINT_SCHEMA WHERE tc.TABLE_NAME='{0}' AND tc.TABLE_SCHEMA='{1}' AND tc.CONSTRAINT_TYPE='PRIMARY KEY'", tableLiteral, schemaLiteral); @@ -959,9 +931,8 @@ INNER JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC public override bool IndexExists(string table, string name) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT top 1 * FROM sys.indexes WHERE object_id = OBJECT_ID('{0}') AND name = '{1}'", table, name)); + using var cmd = ObjectCommand(table, name); + using var reader = ExecuteQuery(cmd, "SELECT 1 FROM sys.indexes WHERE object_id=OBJECT_ID(@table) AND name=@name"); return reader.Read(); } @@ -975,15 +946,20 @@ public override void RemoveIndex(string table, string name) protected override string GetPrimaryKeyConstraintName(string table) { - using var cmd = CreateCommand(); - using var reader = - ExecuteQuery(cmd, string.Format("SELECT name FROM sys.indexes WHERE object_id = OBJECT_ID('{0}') AND is_primary_key = 1", table)); + using var cmd = ObjectCommand(table); + using var reader = ExecuteQuery(cmd, "SELECT name FROM sys.indexes WHERE object_id=OBJECT_ID(@table) AND is_primary_key=1"); return reader.Read() ? reader.GetString(0) : null; } protected override void ConfigureParameterWithValue(IDbDataParameter parameter, int index, object value) { - if (value is ushort) + if (value is TimeSpan time && _dialect is SqlServer2005Dialect) + { + if (time < TimeSpan.Zero || time >= TimeSpan.FromDays(1)) throw new ArgumentOutOfRangeException(nameof(value)); + parameter.DbType = DbType.DateTime; + parameter.Value = new DateTime(1900, 1, 1).Add(time); + } + else if (value is ushort) { parameter.DbType = DbType.Int32; parameter.Value = value; diff --git a/src/Migrator/Providers/SqlIdentifier.cs b/src/Migrator/Providers/SqlIdentifier.cs new file mode 100644 index 00000000..e319f8d7 --- /dev/null +++ b/src/Migrator/Providers/SqlIdentifier.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace DotNetProjects.Migrator.Providers; + +// SQL object paths and identifier atoms are different: a constraint named +// "a.b" is one atom, whereas a table named schema.table is a two-part path. +internal static class SqlIdentifier +{ + internal readonly record struct Part(string Value, bool Quoted); + + internal static bool IsSimple(string value) => Regex.IsMatch(value, @"^[A-Za-z_][A-Za-z0-9_$#]*$"); + + internal static Part[] Parse(string name) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("An object name is required.", nameof(name)); + var parts = new List(); + for (var i = 0; i < name.Length;) + { + while (i < name.Length && char.IsWhiteSpace(name[i])) i++; + if (i == name.Length) throw new ArgumentException("Empty identifier component.", nameof(name)); + var quoted = name[i] is '"' or '[' or '`'; + var value = new StringBuilder(); + if (quoted) + { + var closing = name[i] == '[' ? ']' : name[i]; + i++; + var closed = false; + while (i < name.Length) + { + var c = name[i++]; + if (c != closing) { value.Append(c); continue; } + if (i < name.Length && name[i] == closing) { value.Append(c); i++; continue; } + closed = true; + break; + } + if (!closed) throw new ArgumentException("Unclosed identifier delimiter.", nameof(name)); + while (i < name.Length && char.IsWhiteSpace(name[i])) i++; + if (i < name.Length && name[i] != '.') throw new ArgumentException("Unexpected text after quoted identifier.", nameof(name)); + } + else + { + while (i < name.Length && name[i] != '.') value.Append(name[i++]); + } + var atom = quoted ? value.ToString() : value.ToString().Trim(); + if (atom.Length == 0) throw new ArgumentException("Empty identifier component.", nameof(name)); + parts.Add(new Part(atom, quoted)); + if (i < name.Length && ++i == name.Length) throw new ArgumentException("Empty identifier component.", nameof(name)); + } + return parts.ToArray(); + } + + internal static string Render(Dialect dialect, string name, bool alwaysQuote) => + string.Join(".", Parse(name).Select(p => alwaysQuote || p.Quoted || !IsSimple(p.Value) || dialect.IsReservedWord(p.Value) + ? dialect.QuoteIdentifier(p.Value) : p.Value)); + + internal static (string Schema, string Name) Catalog(string name, bool upperCase = false) + { + var parts = Parse(name); + if (parts.Length > 2) throw new NotSupportedException("Use a schema and object name, without a database/server prefix."); + string Value(Part p) => upperCase && !p.Quoted ? p.Value.ToUpperInvariant() : p.Value; + return (parts.Length == 2 ? Value(parts[0]) : null, Value(parts[^1])); + } +} diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index bf8a43e6..bb9f572a 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -227,7 +227,11 @@ public virtual void RemoveConstraint(string table, string name) throw new MigrationException($"Constraint '{name}' does not exist"); } - ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP CONSTRAINT {1}", QuoteTableNameIfRequired(table), QuoteConstraintNameIfRequired(name))); + var names = GetConstraints(table); + var actual = names.FirstOrDefault(n => n == name) + ?? names.SingleOrDefault(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)) + ?? throw new MigrationException("Constraint was not found in the requested table: " + name); + ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP CONSTRAINT {1}", QuoteTableNameIfRequired(table), _dialect.QuoteIdentifier(actual))); } public virtual void RemoveAllConstraints(string table) @@ -1524,22 +1528,14 @@ public void Dispose() public virtual string QuoteColumnNameIfRequired(string name) { - if (Dialect.ColumnNameNeedsQuote || Dialect.IsReservedWord(name)) - { - return Dialect.Quote(name); - } - - return name; + return _dialect.QuoteColumnNameIfRequired(name); } public virtual string QuoteTableNameIfRequired(string name) { - if (Dialect.TableNameNeedsQuote || Dialect.IsReservedWord(name)) - { - return Dialect.Quote(name); - } - - return name; + if (!string.IsNullOrWhiteSpace(_defaultSchema) && SqlIdentifier.Parse(name).Length == 1) + name = _defaultSchema + "." + name; + return _dialect.QuoteTableNameIfRequired(name); } public virtual string Encode(Guid guid) @@ -1570,7 +1566,7 @@ public virtual void RemoveAllForeignKeys(string tableName, string columnName) public virtual void AddTable(string table, string engine, string columns) { - table = _dialect.TableNameNeedsQuote ? _dialect.Quote(table) : table; + table = QuoteTableNameIfRequired(table); var sqlCreate = string.Format("CREATE TABLE {0} ({1})", table, columns); ExecuteNonQuery(sqlCreate); From e756de97790b41dffef67a42c536d25b11823594 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 21:49:58 +0200 Subject: [PATCH 2/5] Separate TimeOnly clock values from TimeSpan intervals and fix CI regressions --- .github/scripts/start-database.sh | 2 +- docs/runner-guide.md | 240 ++++++++++-------- .../IdentifierAndTimeRegressionTests.cs | 23 +- src/Migrator.Tests/IntervalRegression.cs | 22 ++ .../Providers/Live/LiveDatabaseTests.cs | 9 +- ...TransformationProvider_TableExistsTests.cs | 9 +- ...ansformationProvider_MetadataScopeTests.cs | 7 +- ...verTransformationProvider_AddIndexTests.cs | 2 +- .../SqlServerTransformationProviderTests.cs | 9 +- src/Migrator/Framework/MigratorDbType.cs | 2 + src/Migrator/Providers/CatalogDefaultValue.cs | 2 +- .../Providers/ColumnPropertiesMapper.cs | 4 + src/Migrator/Providers/Dialect.cs | 11 +- .../Providers/Impl/Hana/HanaDialect.cs | 1 - .../Impl/Informix/InformixDialect.cs | 10 +- .../InformixTransformationProvider.cs | 2 +- .../Impl/Mysql/MySqlTransformationProvider.cs | 2 +- .../Oracle/Data/OracleSystemDataLoader.cs | 12 +- .../Providers/Impl/Oracle/OracleDialect.cs | 12 +- .../Oracle/OracleTransformationProvider.cs | 33 ++- .../Impl/PostgreSQL/PostgreSQLDialect.cs | 8 +- .../PostgreSQLTransformationProvider.cs | 30 +-- .../SQLite/SQLiteTransformationProvider.cs | 10 +- .../Impl/SqlServer/SqlServerDialect.cs | 5 - .../SqlServerTransformationProvider.cs | 7 +- .../Providers/TransformationProvider.cs | 11 +- 26 files changed, 286 insertions(+), 199 deletions(-) create mode 100644 src/Migrator.Tests/IntervalRegression.cs diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh index b71442de..19e8424e 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -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 ;; diff --git a/docs/runner-guide.md b/docs/runner-guide.md index ade59538..2d5db78e 100644 --- a/docs/runner-guide.md +++ b/docs/runner-guide.md @@ -1,112 +1,128 @@ -# Runner and fluent API upgrade - -These APIs describe the source upgrade under review in PRs #173, #174, #175 and #177. They are not a statement about the currently released NuGet packages. Build the repository to try them; no package publication is part of this change. - -## Fluent quick start - -The [compiled quick-start project](../examples/FluentQuickStart/Program.cs) executes preview, migration and automatic reversal against SQLite: - -```sh -dotnet run --project examples/FluentQuickStart -``` - -```csharp -[Migration(1, Scope = "demo"), Tags("core")] -public class CreateUsers : AutoReversingMigration -{ - public override void BuildUp(MigrationBuilder migration) - { - migration.Create.Table("Users") - .WithColumn("Id").AsInt32().WithPrimaryKey("PK_Id", "Id") - .WithColumn("Name").AsString(255).NotNullable(); - } -} -``` - -Use `DotNetProjects.Migrator`, `.Framework` and `.Framework.Fluent`. A table definition is completed before execution. Existing imperative `Migration.Up/Down` classes keep working. `FluentMigration` supports authored `BuildDown`; `AutoReversingMigration` reverses supported create/rename operations in reverse order. Destructive changes, data, SQL and callbacks need explicit reverse operations. Automatic reversal never restores deleted data. - -The builder has `Create`, `Alter`, `Delete`, `Rename`, `Insert`, `Update`, `Execute` and `Administration`. Schema inspection is exposed through `FluentMigration.Schema`, and the provider through `Context`. History and transaction methods remain explicit context operations. Administrative operations, views, data copying and updates from another table have typed operations; their SQL preview is currently unsupported. See the [operation coverage inventory](fluent-operation-coverage.md) for the normal API mappings and test limits. - -## Scripts and provider-specific cleanup - -`Execute.Script(path)` and `Execute.EmbeddedScript(assembly, resourceName)` capture script text as dedicated operations. Imperative callers can use `ExecuteScript(path)`, `ExecuteResourceScript(assembly, name)` and `ExecuteSqlScript(text)`. SQL Server splits standalone `GO` lines, including an optional `--` comment, while respecting strings, quoted identifiers and nested comments. GO repetition and SQLCMD directives fail explicitly before executing batches. Ordinary `ExecuteNonQuery` and fluent `Execute.Sql` never split client separators. Other providers receive the script as one command unless they implement `IScriptBatchProvider`; this is not a complete SQL*Plus, mysql-client or isql interpreter. - -Oracle `RemoveTable` leaves unrelated sequences intact and relies on Oracle to remove table-owned triggers and native identity objects. For legacy sequences you explicitly own, use `OracleTransformationProvider.RemoveTableWithOwnedSequences(table, sequenceNames)` through an explicit provider context/callback. It accepts simple unquoted sequence names, validates existence before dropping the table, and propagates cleanup failures. Oracle DDL is not atomic. SQL Server removes only column-unique constraints carrying its ownership marker; historical unmarked objects can be adopted explicitly with `SqlServerTransformationProvider.AdoptColumnUniqueConstraint(table, column, constraint)`. Adoption verifies a single-column UNIQUE constraint before marking it and rejects composite constraints. Names alone never establish ownership. - -## Runner options - -`runner.Options` supports: - -| Option | Semantics | -| --- | --- | -| `Tags` / `TagMatch` | Ordinal names; explicit `Any` or `All`. No filter selects all versioned migrations. Filtered applied versions remain applied on downgrade. | -| `Profiles` | Explicit names of `[Profile("name")]` classes. Run after versioned migrations without recording versions; run again when selected again. | -| `TransactionMode` | `PerMigration` by default; `None` or `WholeSession` available. | -| `Activator` | Optional constructor activation delegate. | -| `Lock` / `LockTimeout` | Optional `IMigrationLock` lease; acquire before reading history and release on completion/failure. | - -Unscoped migrations inherit the provider scope; explicitly scoped migrations run only in that scope. Discovery, duplicate validation and history reads use the effective scope. Scopes separate history, not tables. Legacy custom providers can adopt the additive `IMigrationHistory` interface for read-only planning and effective-scope selection. - -Maintenance classes use `[Maintenance(MaintenanceStage.BeforeRun)]`, `BeforeMigration`, `AfterMigration` or `AfterRun`. Profiles and maintenance accept `Order` and `Scope`. Ordering uses `Order` then ordinal full type name. Hooks stop on failure; later hooks are not cleanup guarantees. Connection/transaction restoration and lock release do not depend on hooks running. Profiles and maintenance use `Up`; they do not acquire version records. - -## Transactions and locks - -`PerMigration` commits each successful migration. `None` leaves transaction behavior to the provider/operations. `WholeSession` is accepted for SQLite, PostgreSQL and SQL Server dialects; history-table initialization occurs before that transaction. Other dialects fail explicitly because transactional DDL has not been verified. Arbitrary imperative SQL can still violate transaction assumptions; database administration and implicit-commit statements require separate runs. - -`AfterUp`/`AfterDown` run after commit. In whole-session mode they are deferred until the complete session commits. Their failure reports an error after durable changes; it cannot undo a successful commit. Caller-owned connections remain caller-owned. - -`new DatabaseMigrationLock()` uses SQL Server application locks, PostgreSQL advisory locks or MySQL/MariaDB named locks. Locks are session-owned, keyed by database/history table/scope, and remain held across migration commits. Do not switch databases, replace/close the connection or manipulate the native lock inside a migration. Unsupported providers, including SQLite, reject this lock implementation. Supply a custom `IMigrationLock` where another coordination mechanism is required. MySQL named locks coordinate one server, not an entire distributed cluster. - -## Planning and SQL preview - -`runner.Plan(target)` and `DryRun` inspect history without creating/upgrading it and do not invoke migration bodies, callbacks, transactions or SQLite PRAGMA changes. Custom providers must implement `IMigrationHistory` for these paths. - -`runner.PreviewSql(target, providerType)` connects for history/schema reads. `MigrationSqlPreview.Generate(providerType, migrations)` can generate SQL offline. Earlier structured operations update a planned schema so later operations can refer to newly created/renamed tables. SQL preview currently supports a subset: basic tables/columns, supported renames, simple indexes, inserts and raw SQL. Unsupported alterations, constraints, filters, callbacks and schema dependencies fail explicitly. Output is operation SQL, not an idempotent history-managed deployment bundle. - -Imperative bodies require `allowLegacyBodies: true`. Provider calls are captured through a rejecting proxy: direct connections, commands and unsupported reads/callbacks are blocked. **Arbitrary C# cannot be sandboxed**: constructors, fluent authoring and opted-in imperative bodies can still access files, networks or external state. Use trusted migration code. Migrations overriding `InitializeOnce` are rejected before their body runs, because skipping initialization could produce misleading SQL. Post-commit callbacks do not run during preview. Raw SQL invalidates planned schema knowledge, so later structured schema dependencies fail explicitly. - -## CLI from source - -```sh -dotnet pack src/Migrator.Tool -o artifacts/packages -dotnet tool install DotNetProjects.Migrator.Tool --add-source artifacts/packages --tool-path artifacts/tools -``` - -On Windows, use a short tool installation directory (or the default global-tool directory): the bundled SQLite native library failed to load from this review workspace's deeply nested tool path, while the same package passed from a short temporary path. - -Set `MIGRATOR_CONNECTION` in your environment; the tool does not print its value. Common commands: - -```sh -migrator list --assembly MyMigrations.dll --provider SQLite -migrator status --assembly MyMigrations.dll --provider SQLite -migrator validate --assembly MyMigrations.dll --provider SQLite -migrator plan --assembly MyMigrations.dll --provider SQLite --target 10 -migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql -migrator sql --assembly MyMigrations.dll --provider SQLite --offline --output migration.sql -migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession -migrator rollback --assembly MyMigrations.dll --provider SQLite --target 0 -``` - -Use `--connection-env NAME`, `--schema`, `--tags a,b`, `--tag-match Any|All`, `--profiles a,b`, `--timeout SECONDS`, `--lock` and `--lock-timeout SECONDS` where applicable. `rollback` requires an explicit lower target and rejects any plan containing upward steps. Target validation runs after acquiring the configured lock and refreshing history. Offline SQL assumes empty history and currently rejects profiles/maintenance. `validate` validates version planning, not arbitrary migration-body behavior. The packaged drivers cover SQLite, SQL Server, PostgreSQL, MySQL/MariaDB, Oracle and Firebird. Other library providers need a custom host. - -Exit codes: `0` success, `1` execution/load failure, `2` invalid arguments, `3` unsupported operation/provider, `4` lock timeout. SQL output may contain migration data; exception and provider trace details are omitted from CLI diagnostics. - -## Optional DI and logging - -The source package `DotNetProjects.Migrator.Extensions.DependencyInjection` provides `services.AddMigrator(providerFactory, migrationAssembly, configureOptions)`. Resolve `Migrator` inside a service scope; migration constructors use that scope's services. Options are scoped snapshots. Provider disposal follows the DI scope. Microsoft logging records lifecycle events while omitting SQL text and raw exception messages; the core retains its lightweight logger API. - -## Validation - -Build before using the test scripts (they intentionally use `--no-build`): - -```sh -dotnet build Migrator.slnx -pwsh .github/scripts/test.ps1 -Database Unit -pwsh .github/scripts/test.ps1 -Database SQLite -``` - -See [live database tests](live-database-tests.md) for the full matrix. Provider-specific changes need live provider evidence. Check PR CI and review threads after every push; reply with implementation/test evidence and resolve fixed findings. Keep commits descriptive and merge the PR stack in dependency order only after review. - -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`. +# Runner and fluent API upgrade + +These APIs describe the source upgrade under review in PRs #173, #174, #175 and #177. They are not a statement about the currently released NuGet packages. Build the repository to try them; no package publication is part of this change. + +## Fluent quick start + +The [compiled quick-start project](../examples/FluentQuickStart/Program.cs) executes preview, migration and automatic reversal against SQLite: + +```sh +dotnet run --project examples/FluentQuickStart +``` + +```csharp +[Migration(1, Scope = "demo"), Tags("core")] +public class CreateUsers : AutoReversingMigration +{ + public override void BuildUp(MigrationBuilder migration) + { + migration.Create.Table("Users") + .WithColumn("Id").AsInt32().WithPrimaryKey("PK_Id", "Id") + .WithColumn("Name").AsString(255).NotNullable(); + } +} +``` + +Use `DotNetProjects.Migrator`, `.Framework` and `.Framework.Fluent`. A table definition is completed before execution. Existing imperative `Migration.Up/Down` classes keep working. `FluentMigration` supports authored `BuildDown`; `AutoReversingMigration` reverses supported create/rename operations in reverse order. Destructive changes, data, SQL and callbacks need explicit reverse operations. Automatic reversal never restores deleted data. + +The builder has `Create`, `Alter`, `Delete`, `Rename`, `Insert`, `Update`, `Execute` and `Administration`. Schema inspection is exposed through `FluentMigration.Schema`, and the provider through `Context`. History and transaction methods remain explicit context operations. Administrative operations, views, data copying and updates from another table have typed operations; their SQL preview is currently unsupported. See the [operation coverage inventory](fluent-operation-coverage.md) for the normal API mappings and test limits. + +## Scripts and provider-specific cleanup + +`Execute.Script(path)` and `Execute.EmbeddedScript(assembly, resourceName)` capture script text as dedicated operations. Imperative callers can use `ExecuteScript(path)`, `ExecuteResourceScript(assembly, name)` and `ExecuteSqlScript(text)`. SQL Server splits standalone `GO` lines, including an optional `--` comment, while respecting strings, quoted identifiers and nested comments. GO repetition and SQLCMD directives fail explicitly before executing batches. Ordinary `ExecuteNonQuery` and fluent `Execute.Sql` never split client separators. Other providers receive the script as one command unless they implement `IScriptBatchProvider`; this is not a complete SQL*Plus, mysql-client or isql interpreter. + +Oracle `RemoveTable` leaves unrelated sequences intact and relies on Oracle to remove table-owned triggers and native identity objects. For legacy sequences you explicitly own, use `OracleTransformationProvider.RemoveTableWithOwnedSequences(table, sequenceNames)` through an explicit provider context/callback. It accepts simple unquoted sequence names, validates existence before dropping the table, and propagates cleanup failures. Oracle DDL is not atomic. SQL Server removes only column-unique constraints carrying its ownership marker; historical unmarked objects can be adopted explicitly with `SqlServerTransformationProvider.AdoptColumnUniqueConstraint(table, column, constraint)`. Adoption verifies a single-column UNIQUE constraint before marking it and rejects composite constraints. Names alone never establish ownership. + +## Runner options + +`runner.Options` supports: + +| Option | Semantics | +| --- | --- | +| `Tags` / `TagMatch` | Ordinal names; explicit `Any` or `All`. No filter selects all versioned migrations. Filtered applied versions remain applied on downgrade. | +| `Profiles` | Explicit names of `[Profile("name")]` classes. Run after versioned migrations without recording versions; run again when selected again. | +| `TransactionMode` | `PerMigration` by default; `None` or `WholeSession` available. | +| `Activator` | Optional constructor activation delegate. | +| `Lock` / `LockTimeout` | Optional `IMigrationLock` lease; acquire before reading history and release on completion/failure. | + +Unscoped migrations inherit the provider scope; explicitly scoped migrations run only in that scope. Discovery, duplicate validation and history reads use the effective scope. Scopes separate history, not tables. Legacy custom providers can adopt the additive `IMigrationHistory` interface for read-only planning and effective-scope selection. + +Maintenance classes use `[Maintenance(MaintenanceStage.BeforeRun)]`, `BeforeMigration`, `AfterMigration` or `AfterRun`. Profiles and maintenance accept `Order` and `Scope`. Ordering uses `Order` then ordinal full type name. Hooks stop on failure; later hooks are not cleanup guarantees. Connection/transaction restoration and lock release do not depend on hooks running. Profiles and maintenance use `Up`; they do not acquire version records. + +## Transactions and locks + +`PerMigration` commits each successful migration. `None` leaves transaction behavior to the provider/operations. `WholeSession` is accepted for SQLite, PostgreSQL and SQL Server dialects; history-table initialization occurs before that transaction. Other dialects fail explicitly because transactional DDL has not been verified. Arbitrary imperative SQL can still violate transaction assumptions; database administration and implicit-commit statements require separate runs. + +`AfterUp`/`AfterDown` run after commit. In whole-session mode they are deferred until the complete session commits. Their failure reports an error after durable changes; it cannot undo a successful commit. Caller-owned connections remain caller-owned. + +`new DatabaseMigrationLock()` uses SQL Server application locks, PostgreSQL advisory locks or MySQL/MariaDB named locks. Locks are session-owned, keyed by database/history table/scope, and remain held across migration commits. Do not switch databases, replace/close the connection or manipulate the native lock inside a migration. Unsupported providers, including SQLite, reject this lock implementation. Supply a custom `IMigrationLock` where another coordination mechanism is required. MySQL named locks coordinate one server, not an entire distributed cluster. + +## Planning and SQL preview + +`runner.Plan(target)` and `DryRun` inspect history without creating/upgrading it and do not invoke migration bodies, callbacks, transactions or SQLite PRAGMA changes. Custom providers must implement `IMigrationHistory` for these paths. + +`runner.PreviewSql(target, providerType)` connects for history/schema reads. `MigrationSqlPreview.Generate(providerType, migrations)` can generate SQL offline. Earlier structured operations update a planned schema so later operations can refer to newly created/renamed tables. SQL preview currently supports a subset: basic tables/columns, supported renames, simple indexes, inserts and raw SQL. Unsupported alterations, constraints, filters, callbacks and schema dependencies fail explicitly. Output is operation SQL, not an idempotent history-managed deployment bundle. + +Imperative bodies require `allowLegacyBodies: true`. Provider calls are captured through a rejecting proxy: direct connections, commands and unsupported reads/callbacks are blocked. **Arbitrary C# cannot be sandboxed**: constructors, fluent authoring and opted-in imperative bodies can still access files, networks or external state. Use trusted migration code. Migrations overriding `InitializeOnce` are rejected before their body runs, because skipping initialization could produce misleading SQL. Post-commit callbacks do not run during preview. Raw SQL invalidates planned schema knowledge, so later structured schema dependencies fail explicitly. + +## CLI from source + +```sh +dotnet pack src/Migrator.Tool -o artifacts/packages +dotnet tool install DotNetProjects.Migrator.Tool --add-source artifacts/packages --tool-path artifacts/tools +``` + +On Windows, use a short tool installation directory (or the default global-tool directory): the bundled SQLite native library failed to load from this review workspace's deeply nested tool path, while the same package passed from a short temporary path. + +Set `MIGRATOR_CONNECTION` in your environment; the tool does not print its value. Common commands: + +```sh +migrator list --assembly MyMigrations.dll --provider SQLite +migrator status --assembly MyMigrations.dll --provider SQLite +migrator validate --assembly MyMigrations.dll --provider SQLite +migrator plan --assembly MyMigrations.dll --provider SQLite --target 10 +migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql +migrator sql --assembly MyMigrations.dll --provider SQLite --offline --output migration.sql +migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession +migrator rollback --assembly MyMigrations.dll --provider SQLite --target 0 +``` + +Use `--connection-env NAME`, `--schema`, `--tags a,b`, `--tag-match Any|All`, `--profiles a,b`, `--timeout SECONDS`, `--lock` and `--lock-timeout SECONDS` where applicable. `rollback` requires an explicit lower target and rejects any plan containing upward steps. Target validation runs after acquiring the configured lock and refreshing history. Offline SQL assumes empty history and currently rejects profiles/maintenance. `validate` validates version planning, not arbitrary migration-body behavior. The packaged drivers cover SQLite, SQL Server, PostgreSQL, MySQL/MariaDB, Oracle and Firebird. Other library providers need a custom host. + +Exit codes: `0` success, `1` execution/load failure, `2` invalid arguments, `3` unsupported operation/provider, `4` lock timeout. SQL output may contain migration data; exception and provider trace details are omitted from CLI diagnostics. + +## Optional DI and logging + +The source package `DotNetProjects.Migrator.Extensions.DependencyInjection` provides `services.AddMigrator(providerFactory, migrationAssembly, configureOptions)`. Resolve `Migrator` inside a service scope; migration constructors use that scope's services. Options are scoped snapshots. Provider disposal follows the DI scope. Microsoft logging records lifecycle events while omitting SQL text and raw exception messages; the core retains its lightweight logger API. + +## Validation + +Build before using the test scripts (they intentionally use `--no-build`): + +```sh +dotnet build Migrator.slnx +pwsh .github/scripts/test.ps1 -Database Unit +pwsh .github/scripts/test.ps1 -Database SQLite +``` + +See [live database tests](live-database-tests.md) for the full matrix. Provider-specific changes need live provider evidence. Check PR CI and review threads after every push; reply with implementation/test evidence and resolve fixed findings. Keep commits descriptive and merge the PR stack in dependency order only after review. + +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 `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. diff --git a/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs index 5067b635..ccf0386c 100644 --- a/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs +++ b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs @@ -15,6 +15,21 @@ 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(() => 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]")] @@ -38,7 +53,7 @@ public void OracleAndPostgreSqlQualifyReservedComponentsIndependently() [Test] public void TimeDefaultsAreQuotedAndPreserveSubMillisecondPrecision() { - var time = new TimeSpan(0, 12, 34, 56).Add(TimeSpan.FromTicks(1234560)); + 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.1234560'")); } @@ -47,15 +62,15 @@ public void TimeDefaultsAreQuotedAndPreserveSubMillisecondPrecision() public void TimeDefaultsAndValuesSurviveSQLiteReconstruction() { using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); - var time = new TimeSpan(0, 12, 34, 56).Add(TimeSpan.FromTicks(1234560)); + 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(TimeSpan.Parse(Convert.ToString(provider.ExecuteScalar("SELECT CAST(Value AS TEXT) FROM Times"))), Is.EqualTo(time)); + 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(TimeSpan.Parse(Convert.ToString(provider.ExecuteScalar("SELECT CAST(Value AS TEXT) FROM Times WHERE Id=2"))), Is.EqualTo(time)); + Assert.That(TimeOnly.Parse(Convert.ToString(provider.ExecuteScalar("SELECT CAST(Value AS TEXT) FROM Times WHERE Id=2"))), Is.EqualTo(time)); } } diff --git a/src/Migrator.Tests/IntervalRegression.cs b/src/Migrator.Tests/IntervalRegression.cs new file mode 100644 index 00000000..52df6302 --- /dev/null +++ b/src/Migrator.Tests/IntervalRegression.cs @@ -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)); + } + } +} diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index 469d1296..10e0beb8 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -30,17 +30,18 @@ public class LiveDatabaseTests(string database, ProviderTypes providerType) [Test] public void TimeOfDayDefaultsAndParametersPersist() { - var time = new TimeSpan(12, 34, 56); + 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 value FROM clock_values WHERE id=" + id); + 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)); + 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)); + 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] diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs index 437035ee..3ed399ba 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs @@ -11,6 +11,9 @@ namespace Migrator.Tests.Providers.OracleProvider; [Category("Oracle")] public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase { + [Test] + public void NegativeMultiDayIntervalDefaultsAndParametersPersist() => IntervalRegression.Verify(Provider, true); + [Test] public void QualifiedQuotedNamesRoundTripThroughColumnsIndexesAndConstraints() { @@ -31,12 +34,12 @@ public void QualifiedQuotedNamesRoundTripThroughColumnsIndexesAndConstraints() [Test] public void TimeOfDayUsesTheDocumentedDateRepresentationForDefaultsAndParameters() { - var time = new TimeSpan(12, 34, 56); + 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 Value FROM ClockValues WHERE Id=1")).TimeOfDay, Is.EqualTo(time)); - Assert.That(Convert.ToDateTime(Provider.ExecuteScalar("SELECT Value FROM ClockValues WHERE Id=2")).TimeOfDay, Is.EqualTo(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] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs index 4ed76b0e..b58eaca8 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs @@ -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() { @@ -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())); } } diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs index 38b1f87d..e319a2a3 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs @@ -293,7 +293,7 @@ public void AddIndex_FilteredIndexMiscellaneousFilterTypesAndDataTypes_Success() Is.EquivalentTo(filterItems.Select(x => x.ColumnName.ToLowerInvariant())) ); - var expectedSql = @"CREATE UNIQUE NONCLUSTERED INDEX [TestIndexName] ON [TestTable] ([TestColumn1], [TestColumn2], [TestColumn3], [TestColumn4], [TestColumn5], [TestColumn6], [TestColumn7], [TestColumn8], [TestColumn9], [TestColumn10], [TestColumn11], [TestColumn12], [TestColumn13]) WHERE [TestColumn1] = 1 AND [TestColumn2] > 2 AND [TestColumn3] >= 2323 AND [TestColumn4] <> 3434 AND [TestColumn5] <> -3434 AND [TestColumn6] < 3434345345 AND [TestColumn7] <> 'asdf' AND [TestColumn8] = 11 AND [TestColumn9] > 22 AND [TestColumn10] >= 33 AND [TestColumn11] <> 44 AND [TestColumn12] < 55 AND [TestColumn13] <= 66"; + var expectedSql = @"CREATE UNIQUE NONCLUSTERED INDEX [TestIndexName] ON [dbo].[TestTable] ([TestColumn1], [TestColumn2], [TestColumn3], [TestColumn4], [TestColumn5], [TestColumn6], [TestColumn7], [TestColumn8], [TestColumn9], [TestColumn10], [TestColumn11], [TestColumn12], [TestColumn13]) WHERE [TestColumn1] = 1 AND [TestColumn2] > 2 AND [TestColumn3] >= 2323 AND [TestColumn4] <> 3434 AND [TestColumn5] <> -3434 AND [TestColumn6] < 3434345345 AND [TestColumn7] <> 'asdf' AND [TestColumn8] = 11 AND [TestColumn9] > 22 AND [TestColumn10] >= 33 AND [TestColumn11] <> 44 AND [TestColumn12] < 55 AND [TestColumn13] <= 66"; Assert.That(addIndexSql, Is.EqualTo(expectedSql)); } diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index 27d4843e..b9696843 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -13,19 +13,22 @@ namespace Migrator.Tests.Providers.SQLServer; [Category("SQLServer")] public class SqlServerTransformationProviderTests : SQLServerTransformationProviderTestBase { + [Test] + public void NegativeMultiDayIntervalDefaultsAndParametersPersist() => IntervalRegression.Verify(Provider, false); + [Test] public void TimeTypeDefaultAndValueRoundTripThroughMetadata() { - var time = new TimeSpan(0, 12, 34, 56, 789); + var time = new TimeOnly(12, 34, 56, 789); Provider.AddTable("ClockValues", new Column("Moment",DbType.Time,time)); var column = Provider.GetColumns("ClockValues").Single(); Assert.That(column.Type, Is.EqualTo(DbType.Time)); Assert.That(column.DefaultValue, Is.EqualTo(time)); Provider.AddTable("CopiedClock", column); Provider.ExecuteNonQuery("INSERT INTO CopiedClock DEFAULT VALUES"); - Assert.That(Provider.ExecuteScalar("SELECT Moment FROM CopiedClock"), Is.EqualTo(time)); + Assert.That(Provider.ExecuteScalar("SELECT Moment FROM CopiedClock"), Is.EqualTo(time.ToTimeSpan())); Provider.Insert("ClockValues", new[] { "Moment" }, new object[] { time }); - Assert.That(Provider.ExecuteScalar("SELECT Moment FROM ClockValues"), Is.EqualTo(time)); + Assert.That(Provider.ExecuteScalar("SELECT Moment FROM ClockValues"), Is.EqualTo(time.ToTimeSpan())); } [Test] diff --git a/src/Migrator/Framework/MigratorDbType.cs b/src/Migrator/Framework/MigratorDbType.cs index 6e5e09f2..f7a06ed4 100644 --- a/src/Migrator/Framework/MigratorDbType.cs +++ b/src/Migrator/Framework/MigratorDbType.cs @@ -19,6 +19,7 @@ public enum MigratorDbType SByte = 14, Single = 15, String = 16, + /// A time of day represented by System.TimeOnly. Time = 17, UInt16 = 18, UInt32 = 19, @@ -31,5 +32,6 @@ public enum MigratorDbType DateTimeOffset = 27, Json = 9000, + /// A duration represented by System.TimeSpan; native interval or signed .NET ticks. Interval = 9001 } diff --git a/src/Migrator/Providers/CatalogDefaultValue.cs b/src/Migrator/Providers/CatalogDefaultValue.cs index 50347359..1231e9b7 100644 --- a/src/Migrator/Providers/CatalogDefaultValue.cs +++ b/src/Migrator/Providers/CatalogDefaultValue.cs @@ -20,7 +20,7 @@ internal static object Parse(string source, DbType type) var literal = value[1..^1].Replace("''", "'"); if (type is DbType.Date or DbType.DateTime or DbType.DateTime2 && DateTime.TryParse(literal, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return DateTime.SpecifyKind(date, DateTimeKind.Utc); - if (type == DbType.Time && TimeSpan.TryParse(literal, CultureInfo.InvariantCulture, out var time)) return time; + if (type == DbType.Time && TimeOnly.TryParse(literal, CultureInfo.InvariantCulture, out var time)) return time; if (type == DbType.Guid && Guid.TryParse(literal, out var guid)) return guid; return literal; } diff --git a/src/Migrator/Providers/ColumnPropertiesMapper.cs b/src/Migrator/Providers/ColumnPropertiesMapper.cs index 59c70456..a736fb51 100644 --- a/src/Migrator/Providers/ColumnPropertiesMapper.cs +++ b/src/Migrator/Providers/ColumnPropertiesMapper.cs @@ -41,6 +41,10 @@ protected virtual void AddCollation(Column column, List values) } protected virtual void AddDefaultValue(Column column, List values) { + if (column.Type == System.Data.DbType.Time && column.DefaultValue is TimeSpan) + throw new ArgumentException("Use TimeOnly for a Time default; TimeSpan represents MigratorDbType.Interval."); + if (column.MigratorDbType == MigratorDbType.Interval && column.DefaultValue is TimeOnly) + throw new ArgumentException("Use TimeSpan for an Interval default; TimeOnly represents a time of day."); if (column.DefaultValue != null) values.Add(_Dialect.Default(column.DefaultValue)); } protected virtual void AddIdentity(Column column, List values) diff --git a/src/Migrator/Providers/Dialect.cs b/src/Migrator/Providers/Dialect.cs index 085c40f4..bff3ba9a 100644 --- a/src/Migrator/Providers/Dialect.cs +++ b/src/Migrator/Providers/Dialect.cs @@ -405,9 +405,16 @@ public virtual string Default(object defaultValue) { defaultValue = "''"; } - else if (defaultValue is TimeSpan time) + else if (defaultValue is TimeOnly time) { - return "DEFAULT '" + time.ToString("c", CultureInfo.InvariantCulture) + "'"; + return "DEFAULT '" + time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture) + "'"; + } + else if (defaultValue is TimeSpan interval) + { + // The portable interval representation on these dialects is signed .NET ticks. + var type = GetTypeName((DbType)MigratorDbType.Interval); + if (type is not ("BIGINT" or "INTEGER")) throw new NotSupportedException("This dialect requires native interval default handling."); + return "DEFAULT " + interval.Ticks.ToString(CultureInfo.InvariantCulture); } else if (defaultValue is Guid) { diff --git a/src/Migrator/Providers/Impl/Hana/HanaDialect.cs b/src/Migrator/Providers/Impl/Hana/HanaDialect.cs index c8f32bf1..fb326d59 100644 --- a/src/Migrator/Providers/Impl/Hana/HanaDialect.cs +++ b/src/Migrator/Providers/Impl/Hana/HanaDialect.cs @@ -48,7 +48,6 @@ public HanaDialect() public override string Default(object value) => value switch { bool boolean => boolean ? "DEFAULT TRUE" : "DEFAULT FALSE", - TimeSpan time when time >= TimeSpan.Zero && time < TimeSpan.FromDays(1) => "DEFAULT '" + time.ToString("c", System.Globalization.CultureInfo.InvariantCulture) + "'", byte[] bytes => "DEFAULT X'" + Convert.ToHexString(bytes) + "'", _ => base.Default(value) }; diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 19f0dbe5..3a52f920 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -31,7 +31,7 @@ public InformixDialect() RegisterColumnType(DbType.Int32, "INTEGER"); RegisterColumnType(DbType.Int64, "BIGINT"); RegisterColumnType(DbType.Single, "SMALLFLOAT"); - RegisterColumnType(DbType.Time, "INTERVAL HOUR TO SECOND"); + RegisterColumnType(DbType.Time, "DATETIME HOUR TO SECOND"); RegisterColumnType(DbType.String, 255, "VARCHAR($l)"); RegisterColumnType(DbType.String, 32739, "LVARCHAR($l)"); RegisterColumnType(DbType.AnsiString, 255, "VARCHAR($l)"); @@ -58,7 +58,13 @@ public override string GetTableConstraintSql(TableConstraint constraint) return body + " CONSTRAINT " + QuoteIdentifier(constraint.Name); } - public override string Default(object value) => value is bool boolean ? (boolean ? "DEFAULT 't'" : "DEFAULT 'f'") : base.Default(value); + public override string Default(object value) => value switch + { + bool boolean => boolean ? "DEFAULT 't'" : "DEFAULT 'f'", + TimeOnly time when time.Ticks % TimeSpan.TicksPerSecond == 0 => "DEFAULT DATETIME(" + time.ToString("HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + ") HOUR TO SECOND", + TimeOnly => throw new NotSupportedException("Informix Time has whole-second precision."), + _ => base.Default(value) + }; public override ColumnPropertiesMapper GetColumnMapper(Column column) { diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 0ea0341b..c09e7844 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -65,7 +65,7 @@ public override Column[] GetColumns(string table) { 1 => DbType.Int16, 2 or 6 => DbType.Int32, 17 or 18 or 52 or 53 => DbType.Int64, 3 => DbType.Double, 4 => DbType.Single, 5 or 8 => DbType.Decimal, - 7 => DbType.Date, 10 => DbType.DateTime, 11 => DbType.Binary, 14 => DbType.Time, + 7 => DbType.Date, 10 => ((Convert.ToInt32(reader.GetValue(2)) >> 4) & 15) == 6 ? DbType.Time : DbType.DateTime, 11 => DbType.Binary, 14 => (DbType)MigratorDbType.Interval, 0 or 15 => DbType.StringFixedLength, 45 => DbType.Boolean, _ => DbType.String }; diff --git a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs index 1e792da4..25d6da10 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -241,7 +241,7 @@ private object ReadDefault(string value, DbType type, string extra) return new DatabaseDefault(value); return type switch { - DbType.Time => TimeSpan.Parse(value, CultureInfo.InvariantCulture), + DbType.Time => TimeOnly.Parse(value, CultureInfo.InvariantCulture), DbType.Boolean => value != "0", DbType.Byte => byte.Parse(value, CultureInfo.InvariantCulture), DbType.Int16 => short.Parse(value, CultureInfo.InvariantCulture), diff --git a/src/Migrator/Providers/Impl/Oracle/Data/OracleSystemDataLoader.cs b/src/Migrator/Providers/Impl/Oracle/Data/OracleSystemDataLoader.cs index 0042423c..fb4d2f68 100644 --- a/src/Migrator/Providers/Impl/Oracle/Data/OracleSystemDataLoader.cs +++ b/src/Migrator/Providers/Impl/Oracle/Data/OracleSystemDataLoader.cs @@ -46,8 +46,6 @@ public List GetUserTabIdentityCols(string tableName) public List GetForeignKeyConstraintItems(string tableName) { - var tablePredicate = OracleCatalog.Predicate(_oracleTransformationProvider, tableName); - var sb = new StringBuilder(); sb.AppendLine("SELECT"); sb.AppendLine(" a.OWNER AS TABLE_SCHEMA,"); @@ -93,8 +91,6 @@ public List GetForeignKeyConstraintItems(string tableN public List GetPrimaryKeyItems(string tableName) { - var tablePredicate = OracleCatalog.Predicate(_oracleTransformationProvider, tableName); - var sql = $@" SELECT ucc.TABLE_NAME, @@ -138,8 +134,6 @@ ORDER BY public List GetIndexItems(string tableName) { - var tablePredicate = OracleCatalog.Predicate(_oracleTransformationProvider, tableName); - var sql = @$" SELECT i.table_name, @@ -151,14 +145,14 @@ public List GetIndexItems(string tableName) CASE WHEN c.constraint_type = 'U' THEN 'YES' ELSE 'NO' END AS is_unique_key FROM all_indexes i - JOIN - all_ind_columns ic ON i.owner = ic.index_owner AND i.index_name = ic.index_name AND + JOIN + all_ind_columns ic ON i.owner = ic.index_owner AND i.index_name = ic.index_name AND i.table_name = ic.table_name LEFT JOIN all_constraints c ON i.owner = c.index_owner AND i.index_name = c.index_name AND i.table_name = c.table_name WHERE - {OracleCatalog.Predicate(_oracleTransformationProvider, tableName, "i.table_name", "i.table_owner")} + {OracleCatalog.Predicate(_oracleTransformationProvider, tableName, "i.table_name", "i.table_owner")} -- AND -- i.index_type = 'NORMAL' ORDER BY diff --git a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs index db4e13c6..be459f91 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs @@ -111,11 +111,15 @@ public override string Default(object defaultValue) { return string.Format("DEFAULT {0}", booleanValue ? "1" : "0"); } - else if (defaultValue is TimeSpan time) + else if (defaultValue is TimeOnly time) { var date = TimeValue(time); return "DEFAULT TO_DATE('" + date.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + "', 'YYYY-MM-DD HH24:MI:SS')"; } + else if (defaultValue is TimeSpan interval) + { + return "DEFAULT NUMTODSINTERVAL(" + ((decimal)interval.Ticks / TimeSpan.TicksPerSecond).ToString(System.Globalization.CultureInfo.InvariantCulture) + ", 'SECOND')"; + } else if (defaultValue is Guid guid) { var bytes = guid.ToByteArray(); @@ -163,12 +167,10 @@ public override string Default(object defaultValue) return base.Default(defaultValue); } - internal static DateTime TimeValue(TimeSpan value) + internal static DateTime TimeValue(TimeOnly value) { - if (value < TimeSpan.Zero || value >= TimeSpan.FromDays(1)) - throw new ArgumentOutOfRangeException(nameof(value), "A time of day must be within one day."); if (value.Ticks % TimeSpan.TicksPerSecond != 0) throw new NotSupportedException("Oracle DbType.Time uses DATE, which has whole-second precision. Use an explicit TIMESTAMP or interval for fractional seconds."); - return new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Unspecified).Add(value); + return new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Unspecified).Add(value.ToTimeSpan()); } } diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index a5b20db2..3e87b596 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -193,11 +193,15 @@ private void CopyDataFromOneColumnToAnother(string table, string fromColumn, str public override void RenameTable(string oldName, string newName) { - GuardAgainstMaximumIdentifierLengthForOracle(newName); - GuardAgainstExistingTableWithSameName(newName, oldName); - + var oldRelation = SqlIdentifier.Catalog(QuoteTableNameIfRequired(oldName), true); + var newRelation = SqlIdentifier.Catalog(newName, true); + if (newRelation.Schema != null && newRelation.Schema != oldRelation.Schema) + throw new NotSupportedException("Oracle RENAME does not move a table between schemas."); + GuardAgainstMaximumIdentifierLengthForOracle(newRelation.Name); + var target = (oldRelation.Schema == null ? "" : _dialect.QuoteIdentifier(oldRelation.Schema) + ".") + _dialect.QuoteIdentifier(newRelation.Name); + GuardAgainstExistingTableWithSameName(target, oldName); oldName = QuoteTableNameIfRequired(oldName); - newName = QuoteTableNameIfRequired(newName); + newName = _dialect.QuoteIdentifier(newRelation.Name); ExecuteNonQuery(string.Format("ALTER TABLE {0} RENAME TO {1}", oldName, newName)); } @@ -249,7 +253,7 @@ public override void ChangeColumn(string table, string sqlColumn) public override void AddColumn(string table, string sqlColumn) { - GuardAgainstMaximumIdentifierLengthForOracle(table); + foreach (var part in SqlIdentifier.Parse(table)) GuardAgainstMaximumIdentifierLengthForOracle(part.Value); table = QuoteTableNameIfRequired(table); ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD {1}", table, sqlColumn)); @@ -638,11 +642,16 @@ public override string GenerateParameterName(int index) protected override void ConfigureParameterWithValue(IDbDataParameter parameter, int index, object value) { - if (value is TimeSpan time) + if (value is TimeOnly time) { parameter.DbType = DbType.Date; parameter.Value = OracleDialect.TimeValue(time); } + else if (value is TimeSpan interval) + { + // ODP.NET infers IntervalDS from a TimeSpan value. + parameter.Value = interval; + } else if (value is Guid || value is Guid?) { parameter.DbType = DbType.Binary; @@ -742,7 +751,7 @@ public override void CopyDataFromTableToTable(string sourceTableName, List= new Version(3, 26, 0)) { if (string.IsNullOrWhiteSpace(newColumnName)) throw new ArgumentException("A column name is required."); - ExecuteNonQuery($"ALTER TABLE {Dialect.Quote(tableName)} RENAME COLUMN {Dialect.Quote(oldColumnName)} TO {Dialect.Quote(newColumnName)}"); + ExecuteNonQuery($"ALTER TABLE {Dialect.Quote(tableName)} RENAME COLUMN {Dialect.QuoteIdentifier(oldColumnName)} TO {Dialect.QuoteIdentifier(newColumnName)}"); return; } @@ -1161,7 +1161,7 @@ public bool IsNullable(string columnDef) public bool ColumnMatch(string column, string columnDef) { - return columnDef.StartsWith(column + " ") || columnDef.StartsWith(_dialect.Quote(column)); + return columnDef.StartsWith(column + " ") || columnDef.StartsWith(_dialect.QuoteIdentifier(column)); } public override bool IndexExists(string table, string name) @@ -1540,11 +1540,11 @@ public override void CopyDataFromTableToTable(string sourceTableName, List= TimeSpan.FromDays(1)) throw new ArgumentOutOfRangeException(nameof(defaultValue), "SQL Server TIME must be within one day."); - return "DEFAULT '" + time.ToString("c", System.Globalization.CultureInfo.InvariantCulture) + "'"; - } if (defaultValue.GetType().Equals(typeof(bool))) { return string.Format("DEFAULT {0}", (bool)defaultValue ? "1" : "0"); diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index 9b0d1d64..cc9a27ce 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -641,7 +641,7 @@ public override Column[] GetColumns(string table) } else if (column.Type == DbType.Time) { - column.DefaultValue = TimeSpan.Parse(bracesAndSingleQuoteStrippedString, CultureInfo.InvariantCulture); + column.DefaultValue = TimeOnly.Parse(bracesAndSingleQuoteStrippedString, CultureInfo.InvariantCulture); } else if (column.Type == DbType.Boolean) { @@ -953,11 +953,10 @@ protected override string GetPrimaryKeyConstraintName(string table) protected override void ConfigureParameterWithValue(IDbDataParameter parameter, int index, object value) { - if (value is TimeSpan time && _dialect is SqlServer2005Dialect) + if (value is TimeOnly time && _dialect is SqlServer2005Dialect) { - if (time < TimeSpan.Zero || time >= TimeSpan.FromDays(1)) throw new ArgumentOutOfRangeException(nameof(value)); parameter.DbType = DbType.DateTime; - parameter.Value = new DateTime(1900, 1, 1).Add(time); + parameter.Value = new DateTime(1900, 1, 1).Add(time.ToTimeSpan()); } else if (value is ushort) { diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index bb9f572a..9f423616 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -1802,10 +1802,17 @@ protected virtual void ConfigureParameterWithValue(IDbDataParameter parameter, i parameter.DbType = DbType.DateTime; parameter.Value = value; } - else if (value is TimeSpan timeSpan) + else if (value is TimeOnly time) { parameter.DbType = DbType.Time; - parameter.Value = timeSpan; + parameter.Value = time.ToTimeSpan(); // ADO.NET drivers commonly carry SQL TIME as TimeSpan. + } + else if (value is TimeSpan interval) + { + var type = _dialect.GetTypeName((DbType)MigratorDbType.Interval); + if (type is not ("BIGINT" or "INTEGER")) throw new NotSupportedException("This provider requires native interval parameter handling."); + parameter.DbType = DbType.Int64; + parameter.Value = interval.Ticks; } else if (value is DateTimeOffset dateTimeOffset) { From dd9a512b4908741e15467e69c714f9079b2a4c66 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 21:56:17 +0200 Subject: [PATCH 3/5] Validate native clock types and legacy time precision across providers --- docs/issue-audit.md | 11 ++++++---- .../IdentifierAndTimeRegressionTests.cs | 2 +- .../Providers/Hana/HanaProviderTests.cs | 19 +++++++++++++++++ .../Providers/Live/LiveDatabaseTests.cs | 5 +++-- .../Live/LiveMetadataRegressionTests.cs | 4 ++-- .../SqlServerTransformationProviderTests.cs | 21 +++++++++++++++++++ src/Migrator/Providers/Dialect.cs | 2 +- .../Providers/Impl/Oracle/OracleDialect.cs | 2 +- .../Impl/SqlServer/SqlServer2005Dialect.cs | 4 ++++ 9 files changed, 59 insertions(+), 11 deletions(-) diff --git a/docs/issue-audit.md b/docs/issue-audit.md index 305e6dd3..ab965a82 100644 --- a/docs/issue-audit.md +++ b/docs/issue-audit.md @@ -104,13 +104,16 @@ The seven currently open reports were rechecked against merged master `c257125` | Issue | Current conclusion | Remaining scope / next action | | --- | --- | --- | | #33 | ON DELETE/ON UPDATE fixed; reproduced and corrected silent MATCH handling locally | SQLite itself enforces only SIMPLE. Accept unspecified/NONE/SIMPLE, preserve declared SIMPLE during reconstruction, and reject FULL/PARTIAL/unknown modes during creation, preview and adding a foreign-key definition. Preserve declared metadata so reconstruction rejects unsupported legacy declarations before modifying the original table. Merge this correction before considering the issue resolved under this explicit limitation. | -| #48 | Partially fixed; keep open | PostgreSQL qualified metadata and SQL Server column lookup have regressions. This does not cover every DDL/catalog operation. For example, SQLite TableExists/ViewExists still query unqualified sqlite_master and do not resolve attached-database names. A provider-by-operation schema contract is still required. | -| #54 | Substantially fixed; cross-provider completion unverified | Generic add/remove paths escape constraint identifiers; current v13 inline primary/unique/check constraints use QuoteIdentifier. SQLite uses it for inline FKs too. Informix explicitly rejects complex inline constraint names without DELIMIDENT support. Do not claim all engine-specific create/inspect/remove paths are verified from SQLite results. | +| #48 | Partially fixed; keep open | PR #185 adds escaped multipart identifiers, default-schema DDL, table-scoped constraint lookups, and qualified SQL Server/Oracle columns/indexes. This does not cover every DDL/catalog operation. For example, SQLite TableExists/ViewExists still query unqualified sqlite_master and do not resolve attached-database names. A provider-by-operation schema contract is still required. | +| #54 | Additional catalog/drop bugs fixed in PR #185; CI validation in progress | Regression tests now create, inspect and remove a unique constraint containing spaces, an apostrophe and a dot. Lookups must be table-scoped. Informix uses delimited names with DELIMIDENT enabled; ASE requires its quoted identifier enhancement setting. | | #134 | Requested native rename/drop implemented; closed | RenameColumn uses native SQL on SQLite >= 3.26; RemoveColumn uses native SQL on >= 3.35 when dependency checks allow it. Other changes still need reconstruction. Closed for the named examples; a demand to eliminate all reconstruction is broader and cannot be inferred from SQLite's native capabilities. | | #140 | Safe explicit legacy-sequence cleanup implemented | Native identity sequences are engine-owned. Legacy ownership must be supplied through RemoveTableWithOwnedSequences; RemoveTable intentionally does not delete a similarly named, potentially shared sequence. Ready for closure if this ownership contract is accepted. | | #141 | Already fixed/verified; closed with evidence | The merged Oracle live regression checks table-owned trigger removal. No new implementation change is needed. | -| #162 | Original SQL Server/PostgreSQL gaps fixed; legacy representations remain | All current dialects register DbType.Time. SQL Server/PostgreSQL native type/default/parameter regressions are merged. Oracle maps it to DATE, SQL Server 2005 to DATETIME, and Informix to INTERVAL HOUR TO SECOND. Native TIME is not a universal engine capability; keep broader representation/round-trip work separate from the missing mapping report. | +| #162 | Time/Interval conflation fixed in PR #185; CI validation in progress | TimeOnly now means time of day; TimeSpan means MigratorDbType.Interval. Time metadata defaults use TimeOnly. Informix Time changes from INTERVAL to DATETIME HOUR TO SECOND. Oracle DATE and SQL Server 2005 DATETIME remain explicit legacy representations. Native intervals and signed tick representations have negative multi-day default/parameter regressions. | SQLite's [documented MATCH limitation](https://www.sqlite.org/foreignkeys.html#limits_and_unsupported_features) is an engine limitation, not a missing SQL clause: emitting MATCH FULL would still enforce SIMPLE semantics. The correction raises NotSupportedException instead of promising that behavior. -Local evidence: the unchanged master unit + SQLite selection passed **284 tests**, with no failures or skips. Seven new `SQLiteForeignKeyMatchTests` cases produced **five failures before the fix**, then all passed. The complete same unit + SQLite selection after the fix passed **291 tests**, with no failures or skips. Tests cover composite-key null semantics, independent update/delete actions after reconstruction, rejection in execution/preview/add-FK, and preservation of existing schema/data/foreign-key settings on a rejected rebuild. External database suites were not rerun locally (Docker is unavailable); Oracle/SQL Server/PostgreSQL conclusions use the merged source and prior linked live CI evidence. The new master CI run [35772295882](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35772295882) was still in progress when inspected, so it is not counted as passing evidence here. +Local evidence: the unchanged master unit + SQLite selection passed **284 tests**, with no failures or skips. Seven new `SQLiteForeignKeyMatchTests` cases produced **five failures before the fix**, then all passed. The complete same unit + SQLite selection after the fix passed **299 tests**, with no failures or skips. Tests cover composite-key null semantics, independent update/delete actions after reconstruction, rejection in execution/preview/add-FK, and preservation of existing schema/data/foreign-key settings on a rejected rebuild. External database suites were not rerun locally (Docker is unavailable); Oracle/SQL Server/PostgreSQL conclusions use the merged source and prior linked live CI evidence. The new master CI run [35772295882](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35772295882) was still in progress when inspected, so it is not counted as passing evidence here. + + +PR [#185](https://github.com/dotnetprojects/Migrator.NET/pull/185) contains the new implementation work. The first database run [35775458663](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35775458663) exposed a PostgreSQL interval-format regression, reserved column names in two new test queries, and Informix/Sybase failures. Db2 could not download its container image. These results are not counted as a successful matrix. The corrected branch has a new full matrix run; final evidence will be recorded after completion. diff --git a/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs index ccf0386c..61668424 100644 --- a/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs +++ b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs @@ -55,7 +55,7 @@ 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.1234560'")); + Assert.That(dialect.Default(time), Is.EqualTo("DEFAULT '12:34:56.123456'")); } [Test, Category("SQLite")] diff --git a/src/Migrator.Tests/Providers/Hana/HanaProviderTests.cs b/src/Migrator.Tests/Providers/Hana/HanaProviderTests.cs index fe5aab94..24aae395 100644 --- a/src/Migrator.Tests/Providers/Hana/HanaProviderTests.cs +++ b/src/Migrator.Tests/Providers/Hana/HanaProviderTests.cs @@ -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() { diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index 10e0beb8..2cdfecb0 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -40,15 +40,16 @@ public void TimeOfDayDefaultsAndParametersPersist() 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)); + 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"); const string name = "UQ ' dotted.name"; - provider.AddTable("named_constraints", new Column("id", DbType.Int32), + 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); diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 640e43e9..3b132829 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -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")] diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index b9696843..3bfd5384 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -13,6 +13,27 @@ namespace Migrator.Tests.Providers.SQLServer; [Category("SQLServer")] public class SqlServerTransformationProviderTests : SQLServerTransformationProviderTestBase { + [Test] + public void LegacyDialectKeepsTimeOfDayAndDurationRepresentationsSeparate() + { + using var legacy = DotNetProjects.Migrator.ProviderFactory.Create(ProviderTypes.SqlServer2005, Provider.ConnectionString, null); + var time = new TimeOnly(12, 34, 56, 120); + legacy.AddTable("LegacyClock", new Column("Id", DbType.Int32), new Column("Moment", DbType.Time, time)); + try + { + legacy.Insert("LegacyClock", ["Id"], [1]); + legacy.Insert("LegacyClock", ["Id", "Moment"], [2, time]); + foreach (var id in new[] { 1, 2 }) + Assert.That(Convert.ToDateTime(legacy.ExecuteScalar("SELECT Moment FROM LegacyClock WHERE Id=" + id)).TimeOfDay, Is.EqualTo(time.ToTimeSpan())); + IntervalRegression.Verify(legacy, false); + } + finally + { + legacy.RemoveTable("LegacyClock"); + if (legacy.TableExists("DurationValues")) legacy.RemoveTable("DurationValues"); + } + } + [Test] public void NegativeMultiDayIntervalDefaultsAndParametersPersist() => IntervalRegression.Verify(Provider, false); diff --git a/src/Migrator/Providers/Dialect.cs b/src/Migrator/Providers/Dialect.cs index bff3ba9a..98374287 100644 --- a/src/Migrator/Providers/Dialect.cs +++ b/src/Migrator/Providers/Dialect.cs @@ -407,7 +407,7 @@ public virtual string Default(object defaultValue) } else if (defaultValue is TimeOnly time) { - return "DEFAULT '" + time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture) + "'"; + return "DEFAULT '" + time.ToString("HH:mm:ss.FFFFFFF", CultureInfo.InvariantCulture) + "'"; } else if (defaultValue is TimeSpan interval) { diff --git a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs index be459f91..d355a80a 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs @@ -170,7 +170,7 @@ public override string Default(object defaultValue) internal static DateTime TimeValue(TimeOnly value) { if (value.Ticks % TimeSpan.TicksPerSecond != 0) - throw new NotSupportedException("Oracle DbType.Time uses DATE, which has whole-second precision. Use an explicit TIMESTAMP or interval for fractional seconds."); + throw new NotSupportedException("Oracle DbType.Time uses DATE, which has whole-second precision. Use an explicit TIMESTAMP representation for fractional time-of-day precision."); return new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Unspecified).Add(value.ToTimeSpan()); } } diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs index d104a167..3a3445d4 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServer2005Dialect.cs @@ -15,6 +15,10 @@ public SqlServer2005Dialect() RegisterColumnType(DbType.Xml, "XML"); } + public override string Default(object value) => value is System.TimeOnly time + ? "DEFAULT '1900-01-01T" + time.ToString("HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture) + "'" + : base.Default(value); + public override ITransformationProvider GetTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) { return new SqlServerTransformationProvider(dialect, connectionString, defaultSchema ?? DboSchemaName, scope, providerName); From 226c106557272137cc1f68806c48d1aca490af4a Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 22:05:39 +0200 Subject: [PATCH 4/5] Reject unsupported ASE key names before creating constraints --- docs/issue-audit.md | 6 ++++-- .../Providers/Live/LiveDatabaseTests.cs | 9 ++++++++- src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs | 13 +++++++++++++ .../Impl/Sybase/SybaseTransformationProvider.cs | 10 ++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/issue-audit.md b/docs/issue-audit.md index ab965a82..887122d9 100644 --- a/docs/issue-audit.md +++ b/docs/issue-audit.md @@ -84,7 +84,7 @@ Evidence used so far: clean master build and SQLite run (139 passed, one unrelat | [#146](https://github.com/dotnetprojects/Migrator.NET/issues/146) Oracle: Handle default value "NULL" | Historically closed; relevant baseline test verified | `DefaultValue_Null_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#152](https://github.com/dotnetprojects/Migrator.NET/issues/152) Exception "This is currently not supported by the migrator see issue #44. You need to use NOT NULL for a PK column." occurs | Verified on master; closed | The old issue-44 exception is absent; SQLite supplies single-PK NOT NULL and supports nullable composite members in existing regressions. | | [#161](https://github.com/dotnetprojects/Migrator.NET/issues/161) Microsoft SQLite: FK integrity issue when using AddTable (by e.g. using AddColumn) | Fix in PRs #173/#174; await merge | SQLite FK settings restored after success/failure; integrity checked before commit; rebuild dependencies guarded. Regression coverage uses both driver paths. | -| [#162](https://github.com/dotnetprojects/Migrator.NET/issues/162) DbType.Time is not implemented | Partial; keep open | SQL Server native TIME metadata/defaults and TimeSpan binding verified live. PostgreSQL native TIME metadata/default parsing now has a regression in PR #174; passed live PostgreSQL CI in run 35742976746. Oracle and SqlServer2005 retain documented historical representations; no universal native time claim. | +| [#162](https://github.com/dotnetprojects/Migrator.NET/issues/162) DbType.Time is not implemented | Partial; keep open | PR #185 separates TimeOnly clock values from TimeSpan intervals; the earlier use of TimeSpan for time-of-day values conflated the two concepts. PostgreSQL native TIME metadata/default parsing now has a regression in PR #174; passed live PostgreSQL CI in run 35742976746. Oracle and SqlServer2005 retain documented historical representations; no universal native time claim. | | [#164](https://github.com/dotnetprojects/Migrator.NET/issues/164) PostgreTransform Provider does not quote IncludeColumns for reserved names | Historically closed; relevant baseline test verified | `AddIndex_IncludeColumnsWithReservedWord_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#165](https://github.com/dotnetprojects/Migrator.NET/issues/165) Included columns not quoted in Postgre | Duplicate verified; closed | Duplicates #164; PostgreSQL included-column quoting is covered by the live metadata regression. | | [#167](https://github.com/dotnetprojects/Migrator.NET/issues/167) Get columns in postgre should not quote table name | Historically closed; relevant baseline test verified | `AddIndex_TableNameIsReservedWord_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | @@ -105,7 +105,7 @@ The seven currently open reports were rechecked against merged master `c257125` | --- | --- | --- | | #33 | ON DELETE/ON UPDATE fixed; reproduced and corrected silent MATCH handling locally | SQLite itself enforces only SIMPLE. Accept unspecified/NONE/SIMPLE, preserve declared SIMPLE during reconstruction, and reject FULL/PARTIAL/unknown modes during creation, preview and adding a foreign-key definition. Preserve declared metadata so reconstruction rejects unsupported legacy declarations before modifying the original table. Merge this correction before considering the issue resolved under this explicit limitation. | | #48 | Partially fixed; keep open | PR #185 adds escaped multipart identifiers, default-schema DDL, table-scoped constraint lookups, and qualified SQL Server/Oracle columns/indexes. This does not cover every DDL/catalog operation. For example, SQLite TableExists/ViewExists still query unqualified sqlite_master and do not resolve attached-database names. A provider-by-operation schema contract is still required. | -| #54 | Additional catalog/drop bugs fixed in PR #185; CI validation in progress | Regression tests now create, inspect and remove a unique constraint containing spaces, an apostrophe and a dot. Lookups must be table-scoped. Informix uses delimited names with DELIMIDENT enabled; ASE requires its quoted identifier enhancement setting. | +| #54 | Additional catalog/drop bugs fixed in PR #185; CI validation in progress | Regression tests now create, inspect and remove a unique constraint containing spaces, an apostrophe and a dot. Lookups must be table-scoped. Informix uses delimited names with DELIMIDENT enabled. ASE 16.0 still failed to remove the punctuated key name with quoted identifier enhancement enabled; PR #185 now conservatively rejects key names containing dots or apostrophes before DDL, and tests a spaced name separately. This is an explicit unsupported case, not full identifier support. | | #134 | Requested native rename/drop implemented; closed | RenameColumn uses native SQL on SQLite >= 3.26; RemoveColumn uses native SQL on >= 3.35 when dependency checks allow it. Other changes still need reconstruction. Closed for the named examples; a demand to eliminate all reconstruction is broader and cannot be inferred from SQLite's native capabilities. | | #140 | Safe explicit legacy-sequence cleanup implemented | Native identity sequences are engine-owned. Legacy ownership must be supplied through RemoveTableWithOwnedSequences; RemoveTable intentionally does not delete a similarly named, potentially shared sequence. Ready for closure if this ownership contract is accepted. | | #141 | Already fixed/verified; closed with evidence | The merged Oracle live regression checks table-owned trigger removal. No new implementation change is needed. | @@ -117,3 +117,5 @@ Local evidence: the unchanged master unit + SQLite selection passed **284 tests* PR [#185](https://github.com/dotnetprojects/Migrator.NET/pull/185) contains the new implementation work. The first database run [35775458663](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35775458663) exposed a PostgreSQL interval-format regression, reserved column names in two new test queries, and Informix/Sybase failures. Db2 could not download its container image. These results are not counted as a successful matrix. The corrected branch has a new full matrix run; final evidence will be recorded after completion. + +Run [35777089076](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35777089076) at `dd9a512` passed Unit, SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2 and HANA. Informix failed while downloading its container, before tests. Sybase reproduced the punctuated key-removal limitation described above. The follow-up guard requires a fresh run. diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index 2cdfecb0..8e417b51 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -48,7 +48,14 @@ public void TimeOfDayDefaultsAndParametersPersist() public void QuotedConstraintNamesCanBeInspectedAndRemoved() { if (providerType == ProviderTypes.Sybase) provider.ExecuteNonQuery("SET QUOTED_IDENTIFIER ON"); - const string name = "UQ ' dotted.name"; + var name = "UQ ' dotted.name"; + if (providerType == ProviderTypes.Sybase) + { + Assert.Throws(() => 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)); diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs index 54a64516..0ad86d6a 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs @@ -43,6 +43,19 @@ public SybaseDialect() public override string Default(object value) => value is bool boolean ? (boolean ? "DEFAULT 1" : "DEFAULT 0") : base.Default(value); public override string QuoteTemplate => "[{0}]"; + public override string GetTableConstraintSql(TableConstraint constraint) + { + // ASE 16.0 can create these key names but subsequently fails to resolve + // the backing index in ALTER TABLE DROP CONSTRAINT, even when delimited. + if (constraint is PrimaryKeyConstraint or DotNetProjects.Migrator.Framework.UniqueConstraint) + ValidateKeyConstraintName(constraint.Name); + return base.GetTableConstraintSql(constraint); + } + internal static void ValidateKeyConstraintName(string name) + { + if (name?.IndexOfAny(['.', '\'']) >= 0) + throw new System.NotSupportedException("ASE key constraint names containing a dot or apostrophe are unsupported. Use a name without those characters."); + } public override bool NeedsNullForNullableWhenAlteringTable => true; public override ColumnPropertiesMapper GetColumnMapper(Column column) diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 30f94098..3c71d4f3 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -22,6 +22,16 @@ public SybaseTransformationProvider(Dialect dialect, IDbConnection connection, s : base(dialect, connection, null, scope) { } private static string Literal(string name) => name.Replace("'", "''"); + public override void AddPrimaryKey(string name, string table, params string[] columns) + { + SybaseDialect.ValidateKeyConstraintName(name); + base.AddPrimaryKey(name, table, columns); + } + public override void AddUniqueConstraint(string name, string table, params string[] columns) + { + SybaseDialect.ValidateKeyConstraintName(name); + base.AddUniqueConstraint(name, table, columns); + } public override void AddColumn(string table, Column column) => AddColumn(table, _dialect.GetAndMapColumnProperties(column).ColumnSql); From f4329b3bfc66b7b66ad23a3191223e1f59d6d340 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 22:12:13 +0200 Subject: [PATCH 5/5] Record passing provider matrix and remaining issue scope --- docs/issue-audit.md | 30 ++--- docs/runner-guide.md | 258 ++++++++++++++++++++++--------------------- 2 files changed, 146 insertions(+), 142 deletions(-) diff --git a/docs/issue-audit.md b/docs/issue-audit.md index 887122d9..76f5c42c 100644 --- a/docs/issue-audit.md +++ b/docs/issue-audit.md @@ -12,7 +12,7 @@ Evidence used so far: clean master build and SQLite run (139 passed, one unrelat | [#30](https://github.com/dotnetprojects/Migrator.NET/issues/30) Updates are not respecting command timeout | Historical report rechecked; retain closed state | Master Update explicitly assigns CommandTimeout when configured and attaches the provider transaction before execution. No fresh wall-clock timeout reproduction was run; this is source evidence. | | [#31](https://github.com/dotnetprojects/Migrator.NET/issues/31) Parameter names (and meaning) differ in ITransformationProvider and Implementation Class TransformationProvider | Historical report rechecked; retain closed state | The current interface names FK arguments childTable/childColumns and parentTable/parentColumns; PR #174 corrects the independent action path and definitions. Original report supplies screenshots only; no blanket claim for every parameter name. | | [#32](https://github.com/dotnetprojects/Migrator.NET/issues/32) Implementation of GetForeignKeyConstraints is wrong in TransformationProvider | Historically closed; relevant baseline test verified | `GetForeignKeyConstraints_MultiColumnColumn_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | -| [#33](https://github.com/dotnetprojects/Migrator.NET/issues/33) SQLite Foreign Keys: OnDelete, OnUpdate, Match is not implemented (ignored in SQLite) | Actions merged; MATCH correction in working tree | Independent DELETE/UPDATE actions execute. The follow-up rejects unsupported MATCH modes rather than silently ignoring them; see the current-open-issue review below. | +| [#33](https://github.com/dotnetprojects/Migrator.NET/issues/33) SQLite Foreign Keys: OnDelete, OnUpdate, Match is not implemented (ignored in SQLite) | Actions merged; MATCH correction in PR #185 | Independent DELETE/UPDATE actions execute. The follow-up rejects unsupported MATCH modes rather than silently ignoring them; see the current-open-issue review below. | | [#34](https://github.com/dotnetprojects/Migrator.NET/issues/34) SQLite Foreign Keys: FKs added by AddTable are removed when using other methods | Historically closed; relevant baseline test verified | `AddForeignKey_RenameParentColumWithForeignKeyAndData_ForeignKeyPointsToRenamedColumn` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#35](https://github.com/dotnetprojects/Migrator.NET/issues/35) SQLite: UNIQUEs are removed when using some other methods after AddTable | Historically closed; relevant baseline test verified | `ChangeColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#37](https://github.com/dotnetprojects/Migrator.NET/issues/37) Override in SQLite for AddForeignKey silently does nothing | Historically closed; relevant baseline test verified | `AddForeignKey_Cascade_DeletingParentDeletesReferencingChildren` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | @@ -27,8 +27,8 @@ Evidence used so far: clean master build and SQLite run (139 passed, one unrelat | [#46](https://github.com/dotnetprojects/Migrator.NET/issues/46) SQLite: ConstraintExists returns false in any case (hard-coded). | Verified on master; closed | ConstraintExists reads SQLite metadata; clean master SQLite suite passed. | | [#47](https://github.com/dotnetprojects/Migrator.NET/issues/47) SQLite: GetConstraints returns empty array in any case (hard-coded). | Verified on master; closed | GetConstraints no longer returns an unconditional empty array; generic constraint tests cover metadata. | | [#48](https://github.com/dotnetprojects/Migrator.NET/issues/48) Schema is not supported in almost any case e.g. in AddTable | Partial; keep open | SQL Server schema-qualified column metadata corrected. PostgreSQL relation-based, parameterized column/constraint/existence lookup now has cross-schema and quoted-name regressions in PR #174; passed live PostgreSQL CI in run 35742976746. Cross-provider schema qualification is not complete. | -| [#52](https://github.com/dotnetprojects/Migrator.NET/issues/52) AddForeignKey in TransformationProvider uses the same for OnUpdate and OnDelete which is wrong | Fixed in PR #174; await merge | Independent-action overload and provider guards; SQL Server update cascade/delete set-null regression passed live CI at b8b075e. | -| [#53](https://github.com/dotnetprojects/Migrator.NET/issues/53) QuoteColumnNames should return a new list instead of changing the given list | Fixed in PR #174; await merge | QuoteColumnNamesIfRequired returns a fresh array; FK inputs are copied. | +| [#52](https://github.com/dotnetprojects/Migrator.NET/issues/52) AddForeignKey in TransformationProvider uses the same for OnUpdate and OnDelete which is wrong | Fixed in PR #174; merged | Independent-action overload and provider guards; SQL Server update cascade/delete set-null regression passed live CI at b8b075e. | +| [#53](https://github.com/dotnetprojects/Migrator.NET/issues/53) QuoteColumnNames should return a new list instead of changing the given list | Fixed in PR #174; merged | QuoteColumnNamesIfRequired returns a fresh array; FK inputs are copied. | | [#54](https://github.com/dotnetprojects/Migrator.NET/issues/54) Constraint names are not quoted in many cases. Probably in all cases? | Partial; keep open | Generic removal and FK paths quote constraints. All provider-specific inline constraint paths still need review. | | [#56](https://github.com/dotnetprojects/Migrator.NET/issues/56) public virtual bool ViewExists(string view) implementation is wrong | Historical report rechecked; retain closed state | Master live Oracle ViewExists_ViewExists_Returns and ViewExists_ViewDoesNotExist_ReturnsFalse both passed. Provider-specific overrides remain important; this does not certify arbitrary custom-provider implementations. | | [#57](https://github.com/dotnetprojects/Migrator.NET/issues/57) public virtual bool TableExists(string view) implementation is wrong | Historical report rechecked; retain closed state | Master live SQL Server TableExists_WithSchemaNameTableExists_Returns and TableExists_TableDoesNotExist_ReturnsFalse passed. Qualified lookup gaps in other providers remain tracked in #48. | @@ -51,9 +51,9 @@ Evidence used so far: clean master build and SQLite run (139 passed, one unrelat | [#92](https://github.com/dotnetprojects/Migrator.NET/issues/92) Add boolean default value tests for Postgre | Historically closed; relevant baseline test verified | `GetColumns_DefaultValueBooleanValues_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (20 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#95](https://github.com/dotnetprojects/Migrator.NET/issues/95) Postgre SQL interval default value is not implemented | Historically closed; relevant baseline test verified | `GetColumns_Postgres_DefaultValues_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#97](https://github.com/dotnetprojects/Migrator.NET/issues/97) Postgres: GetColumnContentSize throws No function matches the given name and argument types. You might need to add explicit type casts. | Historically closed; relevant baseline test verified | `GetColumnContentSize_UseOnNonStringColumn_ThrowsSpeakingException` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | -| [#98](https://github.com/dotnetprojects/Migrator.NET/issues/98) GetColumnContentSize should return int? instead of int for empty tables or NULL columns | Additive fix in PR #174; await merge | GetNullableColumnContentSize distinguishes empty/all-NULL input while keeping the existing int contract. | -| [#101](https://github.com/dotnetprojects/Migrator.NET/issues/101) GetColumns in SqlServerTransformationProvider swallows exceptions | Fixed in PR #174; await merge | SQL Server GetColumns propagates metadata errors rather than returning an empty schema. | -| [#102](https://github.com/dotnetprojects/Migrator.NET/issues/102) GetColumns_UniqueButNotPrimaryKey_ReturnsFalse should be moved to generic GetColumns tests | Reproduced and verified in PR #174; await merge | Moving the uniqueness test to generic fixtures reproduced missing UNIQUE flags on SQL Server, Oracle and PostgreSQL (run 35737057890). Added catalog queries and a composite-constraint counterexample; all provider jobs passed run 35737814671. | +| [#98](https://github.com/dotnetprojects/Migrator.NET/issues/98) GetColumnContentSize should return int? instead of int for empty tables or NULL columns | Additive fix in PR #174; merged | GetNullableColumnContentSize distinguishes empty/all-NULL input while keeping the existing int contract. | +| [#101](https://github.com/dotnetprojects/Migrator.NET/issues/101) GetColumns in SqlServerTransformationProvider swallows exceptions | Fixed in PR #174; merged | SQL Server GetColumns propagates metadata errors rather than returning an empty schema. | +| [#102](https://github.com/dotnetprojects/Migrator.NET/issues/102) GetColumns_UniqueButNotPrimaryKey_ReturnsFalse should be moved to generic GetColumns tests | Reproduced and verified in PR #174; merged | Moving the uniqueness test to generic fixtures reproduced missing UNIQUE flags on SQL Server, Oracle and PostgreSQL (run 35737057890). Added catalog queries and a composite-constraint counterexample; all provider jobs passed run 35737814671. | | [#103](https://github.com/dotnetprojects/Migrator.NET/issues/103) SQL Server: GetColumns parses datetime as DbType.Date instead of DbType.DateTime/DateTime2 - Major bug | Historically closed; relevant baseline test verified | `AddTableDateTime2` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#104](https://github.com/dotnetprojects/Migrator.NET/issues/104) SQL Server: Default value of type DateTime/DateTime2 is not parsed | Historically closed; relevant baseline test verified | `GetColumns_DefaultValues_Succeeds` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#105](https://github.com/dotnetprojects/Migrator.NET/issues/105) Oracle: Only bool, Guid and DateTime are implemented in Default in OracleDialect | Historically closed; relevant baseline test verified | `GetColumns_Oracle_DefaultValues_Succeeds` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | @@ -73,17 +73,17 @@ Evidence used so far: clean master build and SQLite run (139 passed, one unrelat | [#124](https://github.com/dotnetprojects/Migrator.NET/issues/124) PostgreSQL: AddIndex UNIQUE is not supported silently although available via Index class which is misleading | Historically closed; relevant baseline test verified | `AddIndex_Unique_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#125](https://github.com/dotnetprojects/Migrator.NET/issues/125) Postgre: IncludeColumns in AddIndex is not used at all | Historically closed; relevant baseline test verified | `AddIndex_IncludeColumnsMultiple_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#126](https://github.com/dotnetprojects/Migrator.NET/issues/126) Postgre does neither extract included columns nor does it retrieve the partial filters | Historically closed; relevant baseline test verified | `AddIndex_FilteredIndexSingle_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | -| [#132](https://github.com/dotnetprojects/Migrator.NET/issues/132) SQL Server does not remove the unique index on ChangeColumn() with no ColumnProperty.Unique | Resolved by v13 explicit constraints in PR #181; await merge | ColumnProperty.Unique and implicit column-owned uniqueness are removed. ChangeColumn preserves explicit constraints and indexes; use RemoveConstraint or RemoveIndex after inspecting metadata. Live SQL Server tests ChangeColumnPreservesExplicitUniqueFromTableOrColumnCreation, ExplicitUniqueRemovalAllowsDuplicates and ChangeColumn_DoesNotRemoveUserOwnedUniqueOrMutateDefinition passed in run 35766920321. The migration guide documents this intentional breaking replacement. | +| [#132](https://github.com/dotnetprojects/Migrator.NET/issues/132) SQL Server does not remove the unique index on ChangeColumn() with no ColumnProperty.Unique | Resolved by v13 explicit constraints in PR #181; merged | ColumnProperty.Unique and implicit column-owned uniqueness are removed. ChangeColumn preserves explicit constraints and indexes; use RemoveConstraint or RemoveIndex after inspecting metadata. Live SQL Server tests ChangeColumnPreservesExplicitUniqueFromTableOrColumnCreation, ExplicitUniqueRemovalAllowsDuplicates and ChangeColumn_DoesNotRemoveUserOwnedUniqueOrMutateDefinition passed in run 35766920321. The migration guide documents this intentional breaking replacement. | | [#134](https://github.com/dotnetprojects/Migrator.NET/issues/134) Remove hacks for some SQlite features | Native rename/drop merged; commented and closed | SQLite native rename/drop selected when eligible; guarded reconstruction retained for operations without a native equivalent. | | [#135](https://github.com/dotnetprojects/Migrator.NET/issues/135) Feature CopyDataFromTableToTable | Historically closed; relevant baseline test verified | `CopyDataFromTableToTable_UsingOrderBy_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | -| [#139](https://github.com/dotnetprojects/Migrator.NET/issues/139) Default value is not reset on ChangeColumn | Fix in PR #174; await merge | Default removal regressions enabled; SQL Server default lookup and Oracle in-place reset corrected. | +| [#139](https://github.com/dotnetprojects/Migrator.NET/issues/139) Default value is not reset on ChangeColumn | Fix in PR #174; merged | Default removal regressions enabled; SQL Server default lookup and Oracle in-place reset corrected. | | [#140](https://github.com/dotnetprojects/Migrator.NET/issues/140) Remove table in Oracle does not cleanup sequences | Safe cleanup merged in PR #174; ownership policy remains explicit | Default Oracle RemoveTable no longer guesses sequence ownership. RemoveTableWithOwnedSequences validates explicit legacy names and propagates cleanup errors. Passed live Oracle CI in run 35737814671. | | [#141](https://github.com/dotnetprojects/Migrator.NET/issues/141) RemoveTable in Oracle does not cleanup => TRIGGERs | Verified fix merged in PR #174; commented and closed | Oracle table-owned trigger cleanup is exercised by ExplicitLegacyCleanupDropsSequenceAndTableOwnedTrigger; no guessed trigger-name cleanup. Passed live Oracle CI in run 35737814671. | | [#143](https://github.com/dotnetprojects/Migrator.NET/issues/143) Replace Identity trigger to "GENERATED...." | Historically closed; relevant baseline test verified | `GetColumns_GetIdentity_Succeeds` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | -| [#145](https://github.com/dotnetprojects/Migrator.NET/issues/145) ExecuteScalar("SELECT MAX(Id) FROM MyTable") should return null (C#) if table is empty | Additive fix in PR #174; await merge | ExecuteNullableScalar returns null for null/DBNull and preserves typed structs; existing ExecuteScalar contract stays compatible. | +| [#145](https://github.com/dotnetprojects/Migrator.NET/issues/145) ExecuteScalar("SELECT MAX(Id) FROM MyTable") should return null (C#) if table is empty | Additive fix in PR #174; merged | ExecuteNullableScalar returns null for null/DBNull and preserves typed structs; existing ExecuteScalar contract stays compatible. | | [#146](https://github.com/dotnetprojects/Migrator.NET/issues/146) Oracle: Handle default value "NULL" | Historically closed; relevant baseline test verified | `DefaultValue_Null_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#152](https://github.com/dotnetprojects/Migrator.NET/issues/152) Exception "This is currently not supported by the migrator see issue #44. You need to use NOT NULL for a PK column." occurs | Verified on master; closed | The old issue-44 exception is absent; SQLite supplies single-PK NOT NULL and supports nullable composite members in existing regressions. | -| [#161](https://github.com/dotnetprojects/Migrator.NET/issues/161) Microsoft SQLite: FK integrity issue when using AddTable (by e.g. using AddColumn) | Fix in PRs #173/#174; await merge | SQLite FK settings restored after success/failure; integrity checked before commit; rebuild dependencies guarded. Regression coverage uses both driver paths. | +| [#161](https://github.com/dotnetprojects/Migrator.NET/issues/161) Microsoft SQLite: FK integrity issue when using AddTable (by e.g. using AddColumn) | Fix in PRs #173/#174; merged | SQLite FK settings restored after success/failure; integrity checked before commit; rebuild dependencies guarded. Regression coverage uses both driver paths. | | [#162](https://github.com/dotnetprojects/Migrator.NET/issues/162) DbType.Time is not implemented | Partial; keep open | PR #185 separates TimeOnly clock values from TimeSpan intervals; the earlier use of TimeSpan for time-of-day values conflated the two concepts. PostgreSQL native TIME metadata/default parsing now has a regression in PR #174; passed live PostgreSQL CI in run 35742976746. Oracle and SqlServer2005 retain documented historical representations; no universal native time claim. | | [#164](https://github.com/dotnetprojects/Migrator.NET/issues/164) PostgreTransform Provider does not quote IncludeColumns for reserved names | Historically closed; relevant baseline test verified | `AddIndex_IncludeColumnsWithReservedWord_Succeeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | | [#165](https://github.com/dotnetprojects/Migrator.NET/issues/165) Included columns not quoted in Postgre | Duplicate verified; closed | Duplicates #164; PostgreSQL included-column quoting is covered by the live metadata regression. | @@ -99,17 +99,17 @@ Evidence used so far: clean master build and SQLite run (139 passed, one unrelat ## Current open issues rechecked on 2026-09-22 -The seven currently open reports were rechecked against merged master `c257125` and their issue comments. The local checkout was 77 commits behind that revision. PR #174 and the later v13 schema/column changes are now merged; the old "await merge" labels for #140/#141 were stale. Open issue state alone therefore does not mean the original bug is still present. Following user authorization, #134 and #141 were commented and closed through the signed-in GitHub UI (the connector lacks issue-write permission). The other five remain open. This review does not establish that the fixes have been published on NuGet. +The seven currently open reports were rechecked against merged master `c257125` and their issue comments. The local checkout was 77 commits behind that revision. PR #174 and the later v13 schema/column changes are now merged; the old "await merge" labels were stale and have been updated. Open issue state alone therefore does not mean the original bug is still present. Following user authorization, #134 and #141 were commented and closed through the signed-in GitHub UI (the connector lacks issue-write permission). The other five remain open. This review does not establish that the fixes have been published on NuGet. | Issue | Current conclusion | Remaining scope / next action | | --- | --- | --- | | #33 | ON DELETE/ON UPDATE fixed; reproduced and corrected silent MATCH handling locally | SQLite itself enforces only SIMPLE. Accept unspecified/NONE/SIMPLE, preserve declared SIMPLE during reconstruction, and reject FULL/PARTIAL/unknown modes during creation, preview and adding a foreign-key definition. Preserve declared metadata so reconstruction rejects unsupported legacy declarations before modifying the original table. Merge this correction before considering the issue resolved under this explicit limitation. | | #48 | Partially fixed; keep open | PR #185 adds escaped multipart identifiers, default-schema DDL, table-scoped constraint lookups, and qualified SQL Server/Oracle columns/indexes. This does not cover every DDL/catalog operation. For example, SQLite TableExists/ViewExists still query unqualified sqlite_master and do not resolve attached-database names. A provider-by-operation schema contract is still required. | -| #54 | Additional catalog/drop bugs fixed in PR #185; CI validation in progress | Regression tests now create, inspect and remove a unique constraint containing spaces, an apostrophe and a dot. Lookups must be table-scoped. Informix uses delimited names with DELIMIDENT enabled. ASE 16.0 still failed to remove the punctuated key name with quoted identifier enhancement enabled; PR #185 now conservatively rejects key names containing dots or apostrophes before DDL, and tests a spaced name separately. This is an explicit unsupported case, not full identifier support. | +| #54 | Additional catalog/drop bugs fixed in PR #185; validated at 226c106; awaiting merge | Regression tests now create, inspect and remove a unique constraint containing spaces, an apostrophe and a dot. Lookups must be table-scoped. Informix uses delimited names with DELIMIDENT enabled. ASE 16.0 still failed to remove the punctuated key name with quoted identifier enhancement enabled; PR #185 now conservatively rejects key names containing dots or apostrophes before DDL, and tests a spaced name separately. This is an explicit unsupported case, not full identifier support. | | #134 | Requested native rename/drop implemented; closed | RenameColumn uses native SQL on SQLite >= 3.26; RemoveColumn uses native SQL on >= 3.35 when dependency checks allow it. Other changes still need reconstruction. Closed for the named examples; a demand to eliminate all reconstruction is broader and cannot be inferred from SQLite's native capabilities. | | #140 | Safe explicit legacy-sequence cleanup implemented | Native identity sequences are engine-owned. Legacy ownership must be supplied through RemoveTableWithOwnedSequences; RemoveTable intentionally does not delete a similarly named, potentially shared sequence. Ready for closure if this ownership contract is accepted. | | #141 | Already fixed/verified; closed with evidence | The merged Oracle live regression checks table-owned trigger removal. No new implementation change is needed. | -| #162 | Time/Interval conflation fixed in PR #185; CI validation in progress | TimeOnly now means time of day; TimeSpan means MigratorDbType.Interval. Time metadata defaults use TimeOnly. Informix Time changes from INTERVAL to DATETIME HOUR TO SECOND. Oracle DATE and SQL Server 2005 DATETIME remain explicit legacy representations. Native intervals and signed tick representations have negative multi-day default/parameter regressions. | +| #162 | Time/Interval conflation fixed in PR #185; validated at 226c106; awaiting merge | TimeOnly now means time of day; TimeSpan means MigratorDbType.Interval. Time metadata defaults use TimeOnly. Informix Time changes from INTERVAL to DATETIME HOUR TO SECOND. Oracle DATE and SQL Server 2005 DATETIME remain explicit legacy representations. Native intervals and signed tick representations have negative multi-day default/parameter regressions. | SQLite's [documented MATCH limitation](https://www.sqlite.org/foreignkeys.html#limits_and_unsupported_features) is an engine limitation, not a missing SQL clause: emitting MATCH FULL would still enforce SIMPLE semantics. The correction raises NotSupportedException instead of promising that behavior. @@ -118,4 +118,6 @@ Local evidence: the unchanged master unit + SQLite selection passed **284 tests* PR [#185](https://github.com/dotnetprojects/Migrator.NET/pull/185) contains the new implementation work. The first database run [35775458663](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35775458663) exposed a PostgreSQL interval-format regression, reserved column names in two new test queries, and Informix/Sybase failures. Db2 could not download its container image. These results are not counted as a successful matrix. The corrected branch has a new full matrix run; final evidence will be recorded after completion. -Run [35777089076](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35777089076) at `dd9a512` passed Unit, SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2 and HANA. Informix failed while downloading its container, before tests. Sybase reproduced the punctuated key-removal limitation described above. The follow-up guard requires a fresh run. +Run [35777089076](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35777089076) at `dd9a512` passed Unit, SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2 and HANA. Informix failed while downloading its container, before tests. Sybase reproduced the punctuated key-removal limitation described above. The follow-up guard is verified by the final run below. + +Final verification: [run 35778092728](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35778092728) passed all 12 test jobs and the complete-coverage gate at code commit `226c106`: **809 passed, zero failed, zero skipped**. Counts: Unit 94, SQLite 205, SQL Server 130, PostgreSQL 145, Oracle 105, MySQL 19, MariaDB 18, Firebird 22, Db2 19, Informix 22, Sybase 21, HANA 9. Subsequent changes only update this audit and the runner guide. The passing matrix verifies the named regression scope; it does not resolve every schema operation in #48 or infer ownership of legacy Oracle sequences in #140. diff --git a/docs/runner-guide.md b/docs/runner-guide.md index 2d5db78e..dda607b4 100644 --- a/docs/runner-guide.md +++ b/docs/runner-guide.md @@ -1,128 +1,130 @@ -# Runner and fluent API upgrade - -These APIs describe the source upgrade under review in PRs #173, #174, #175 and #177. They are not a statement about the currently released NuGet packages. Build the repository to try them; no package publication is part of this change. - -## Fluent quick start - -The [compiled quick-start project](../examples/FluentQuickStart/Program.cs) executes preview, migration and automatic reversal against SQLite: - -```sh -dotnet run --project examples/FluentQuickStart -``` - -```csharp -[Migration(1, Scope = "demo"), Tags("core")] -public class CreateUsers : AutoReversingMigration -{ - public override void BuildUp(MigrationBuilder migration) - { - migration.Create.Table("Users") - .WithColumn("Id").AsInt32().WithPrimaryKey("PK_Id", "Id") - .WithColumn("Name").AsString(255).NotNullable(); - } -} -``` - -Use `DotNetProjects.Migrator`, `.Framework` and `.Framework.Fluent`. A table definition is completed before execution. Existing imperative `Migration.Up/Down` classes keep working. `FluentMigration` supports authored `BuildDown`; `AutoReversingMigration` reverses supported create/rename operations in reverse order. Destructive changes, data, SQL and callbacks need explicit reverse operations. Automatic reversal never restores deleted data. - -The builder has `Create`, `Alter`, `Delete`, `Rename`, `Insert`, `Update`, `Execute` and `Administration`. Schema inspection is exposed through `FluentMigration.Schema`, and the provider through `Context`. History and transaction methods remain explicit context operations. Administrative operations, views, data copying and updates from another table have typed operations; their SQL preview is currently unsupported. See the [operation coverage inventory](fluent-operation-coverage.md) for the normal API mappings and test limits. - -## Scripts and provider-specific cleanup - -`Execute.Script(path)` and `Execute.EmbeddedScript(assembly, resourceName)` capture script text as dedicated operations. Imperative callers can use `ExecuteScript(path)`, `ExecuteResourceScript(assembly, name)` and `ExecuteSqlScript(text)`. SQL Server splits standalone `GO` lines, including an optional `--` comment, while respecting strings, quoted identifiers and nested comments. GO repetition and SQLCMD directives fail explicitly before executing batches. Ordinary `ExecuteNonQuery` and fluent `Execute.Sql` never split client separators. Other providers receive the script as one command unless they implement `IScriptBatchProvider`; this is not a complete SQL*Plus, mysql-client or isql interpreter. - -Oracle `RemoveTable` leaves unrelated sequences intact and relies on Oracle to remove table-owned triggers and native identity objects. For legacy sequences you explicitly own, use `OracleTransformationProvider.RemoveTableWithOwnedSequences(table, sequenceNames)` through an explicit provider context/callback. It accepts simple unquoted sequence names, validates existence before dropping the table, and propagates cleanup failures. Oracle DDL is not atomic. SQL Server removes only column-unique constraints carrying its ownership marker; historical unmarked objects can be adopted explicitly with `SqlServerTransformationProvider.AdoptColumnUniqueConstraint(table, column, constraint)`. Adoption verifies a single-column UNIQUE constraint before marking it and rejects composite constraints. Names alone never establish ownership. - -## Runner options - -`runner.Options` supports: - -| Option | Semantics | -| --- | --- | -| `Tags` / `TagMatch` | Ordinal names; explicit `Any` or `All`. No filter selects all versioned migrations. Filtered applied versions remain applied on downgrade. | -| `Profiles` | Explicit names of `[Profile("name")]` classes. Run after versioned migrations without recording versions; run again when selected again. | -| `TransactionMode` | `PerMigration` by default; `None` or `WholeSession` available. | -| `Activator` | Optional constructor activation delegate. | -| `Lock` / `LockTimeout` | Optional `IMigrationLock` lease; acquire before reading history and release on completion/failure. | - -Unscoped migrations inherit the provider scope; explicitly scoped migrations run only in that scope. Discovery, duplicate validation and history reads use the effective scope. Scopes separate history, not tables. Legacy custom providers can adopt the additive `IMigrationHistory` interface for read-only planning and effective-scope selection. - -Maintenance classes use `[Maintenance(MaintenanceStage.BeforeRun)]`, `BeforeMigration`, `AfterMigration` or `AfterRun`. Profiles and maintenance accept `Order` and `Scope`. Ordering uses `Order` then ordinal full type name. Hooks stop on failure; later hooks are not cleanup guarantees. Connection/transaction restoration and lock release do not depend on hooks running. Profiles and maintenance use `Up`; they do not acquire version records. - -## Transactions and locks - -`PerMigration` commits each successful migration. `None` leaves transaction behavior to the provider/operations. `WholeSession` is accepted for SQLite, PostgreSQL and SQL Server dialects; history-table initialization occurs before that transaction. Other dialects fail explicitly because transactional DDL has not been verified. Arbitrary imperative SQL can still violate transaction assumptions; database administration and implicit-commit statements require separate runs. - -`AfterUp`/`AfterDown` run after commit. In whole-session mode they are deferred until the complete session commits. Their failure reports an error after durable changes; it cannot undo a successful commit. Caller-owned connections remain caller-owned. - -`new DatabaseMigrationLock()` uses SQL Server application locks, PostgreSQL advisory locks or MySQL/MariaDB named locks. Locks are session-owned, keyed by database/history table/scope, and remain held across migration commits. Do not switch databases, replace/close the connection or manipulate the native lock inside a migration. Unsupported providers, including SQLite, reject this lock implementation. Supply a custom `IMigrationLock` where another coordination mechanism is required. MySQL named locks coordinate one server, not an entire distributed cluster. - -## Planning and SQL preview - -`runner.Plan(target)` and `DryRun` inspect history without creating/upgrading it and do not invoke migration bodies, callbacks, transactions or SQLite PRAGMA changes. Custom providers must implement `IMigrationHistory` for these paths. - -`runner.PreviewSql(target, providerType)` connects for history/schema reads. `MigrationSqlPreview.Generate(providerType, migrations)` can generate SQL offline. Earlier structured operations update a planned schema so later operations can refer to newly created/renamed tables. SQL preview currently supports a subset: basic tables/columns, supported renames, simple indexes, inserts and raw SQL. Unsupported alterations, constraints, filters, callbacks and schema dependencies fail explicitly. Output is operation SQL, not an idempotent history-managed deployment bundle. - -Imperative bodies require `allowLegacyBodies: true`. Provider calls are captured through a rejecting proxy: direct connections, commands and unsupported reads/callbacks are blocked. **Arbitrary C# cannot be sandboxed**: constructors, fluent authoring and opted-in imperative bodies can still access files, networks or external state. Use trusted migration code. Migrations overriding `InitializeOnce` are rejected before their body runs, because skipping initialization could produce misleading SQL. Post-commit callbacks do not run during preview. Raw SQL invalidates planned schema knowledge, so later structured schema dependencies fail explicitly. - -## CLI from source - -```sh -dotnet pack src/Migrator.Tool -o artifacts/packages -dotnet tool install DotNetProjects.Migrator.Tool --add-source artifacts/packages --tool-path artifacts/tools -``` - -On Windows, use a short tool installation directory (or the default global-tool directory): the bundled SQLite native library failed to load from this review workspace's deeply nested tool path, while the same package passed from a short temporary path. - -Set `MIGRATOR_CONNECTION` in your environment; the tool does not print its value. Common commands: - -```sh -migrator list --assembly MyMigrations.dll --provider SQLite -migrator status --assembly MyMigrations.dll --provider SQLite -migrator validate --assembly MyMigrations.dll --provider SQLite -migrator plan --assembly MyMigrations.dll --provider SQLite --target 10 -migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql -migrator sql --assembly MyMigrations.dll --provider SQLite --offline --output migration.sql -migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession -migrator rollback --assembly MyMigrations.dll --provider SQLite --target 0 -``` - -Use `--connection-env NAME`, `--schema`, `--tags a,b`, `--tag-match Any|All`, `--profiles a,b`, `--timeout SECONDS`, `--lock` and `--lock-timeout SECONDS` where applicable. `rollback` requires an explicit lower target and rejects any plan containing upward steps. Target validation runs after acquiring the configured lock and refreshing history. Offline SQL assumes empty history and currently rejects profiles/maintenance. `validate` validates version planning, not arbitrary migration-body behavior. The packaged drivers cover SQLite, SQL Server, PostgreSQL, MySQL/MariaDB, Oracle and Firebird. Other library providers need a custom host. - -Exit codes: `0` success, `1` execution/load failure, `2` invalid arguments, `3` unsupported operation/provider, `4` lock timeout. SQL output may contain migration data; exception and provider trace details are omitted from CLI diagnostics. - -## Optional DI and logging - -The source package `DotNetProjects.Migrator.Extensions.DependencyInjection` provides `services.AddMigrator(providerFactory, migrationAssembly, configureOptions)`. Resolve `Migrator` inside a service scope; migration constructors use that scope's services. Options are scoped snapshots. Provider disposal follows the DI scope. Microsoft logging records lifecycle events while omitting SQL text and raw exception messages; the core retains its lightweight logger API. - -## Validation - -Build before using the test scripts (they intentionally use `--no-build`): - -```sh -dotnet build Migrator.slnx -pwsh .github/scripts/test.ps1 -Database Unit -pwsh .github/scripts/test.ps1 -Database SQLite -``` - -See [live database tests](live-database-tests.md) for the full matrix. Provider-specific changes need live provider evidence. Check PR CI and review threads after every push; reply with implementation/test evidence and resolve fixed findings. Keep commits descriptive and merge the PR stack in dependency order only after review. - -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 `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. +# Runner and fluent API upgrade + +These APIs describe the source upgrade under review in PRs #173, #174, #175 and #177. They are not a statement about the currently released NuGet packages. Build the repository to try them; no package publication is part of this change. + +## Fluent quick start + +The [compiled quick-start project](../examples/FluentQuickStart/Program.cs) executes preview, migration and automatic reversal against SQLite: + +```sh +dotnet run --project examples/FluentQuickStart +``` + +```csharp +[Migration(1, Scope = "demo"), Tags("core")] +public class CreateUsers : AutoReversingMigration +{ + public override void BuildUp(MigrationBuilder migration) + { + migration.Create.Table("Users") + .WithColumn("Id").AsInt32().WithPrimaryKey("PK_Id", "Id") + .WithColumn("Name").AsString(255).NotNullable(); + } +} +``` + +Use `DotNetProjects.Migrator`, `.Framework` and `.Framework.Fluent`. A table definition is completed before execution. Existing imperative `Migration.Up/Down` classes keep working. `FluentMigration` supports authored `BuildDown`; `AutoReversingMigration` reverses supported create/rename operations in reverse order. Destructive changes, data, SQL and callbacks need explicit reverse operations. Automatic reversal never restores deleted data. + +The builder has `Create`, `Alter`, `Delete`, `Rename`, `Insert`, `Update`, `Execute` and `Administration`. Schema inspection is exposed through `FluentMigration.Schema`, and the provider through `Context`. History and transaction methods remain explicit context operations. Administrative operations, views, data copying and updates from another table have typed operations; their SQL preview is currently unsupported. See the [operation coverage inventory](fluent-operation-coverage.md) for the normal API mappings and test limits. + +## Scripts and provider-specific cleanup + +`Execute.Script(path)` and `Execute.EmbeddedScript(assembly, resourceName)` capture script text as dedicated operations. Imperative callers can use `ExecuteScript(path)`, `ExecuteResourceScript(assembly, name)` and `ExecuteSqlScript(text)`. SQL Server splits standalone `GO` lines, including an optional `--` comment, while respecting strings, quoted identifiers and nested comments. GO repetition and SQLCMD directives fail explicitly before executing batches. Ordinary `ExecuteNonQuery` and fluent `Execute.Sql` never split client separators. Other providers receive the script as one command unless they implement `IScriptBatchProvider`; this is not a complete SQL*Plus, mysql-client or isql interpreter. + +Oracle `RemoveTable` leaves unrelated sequences intact and relies on Oracle to remove table-owned triggers and native identity objects. For legacy sequences you explicitly own, use `OracleTransformationProvider.RemoveTableWithOwnedSequences(table, sequenceNames)` through an explicit provider context/callback. It accepts simple unquoted sequence names, validates existence before dropping the table, and propagates cleanup failures. Oracle DDL is not atomic. SQL Server removes only column-unique constraints carrying its ownership marker; historical unmarked objects can be adopted explicitly with `SqlServerTransformationProvider.AdoptColumnUniqueConstraint(table, column, constraint)`. Adoption verifies a single-column UNIQUE constraint before marking it and rejects composite constraints. Names alone never establish ownership. + +## Runner options + +`runner.Options` supports: + +| Option | Semantics | +| --- | --- | +| `Tags` / `TagMatch` | Ordinal names; explicit `Any` or `All`. No filter selects all versioned migrations. Filtered applied versions remain applied on downgrade. | +| `Profiles` | Explicit names of `[Profile("name")]` classes. Run after versioned migrations without recording versions; run again when selected again. | +| `TransactionMode` | `PerMigration` by default; `None` or `WholeSession` available. | +| `Activator` | Optional constructor activation delegate. | +| `Lock` / `LockTimeout` | Optional `IMigrationLock` lease; acquire before reading history and release on completion/failure. | + +Unscoped migrations inherit the provider scope; explicitly scoped migrations run only in that scope. Discovery, duplicate validation and history reads use the effective scope. Scopes separate history, not tables. Legacy custom providers can adopt the additive `IMigrationHistory` interface for read-only planning and effective-scope selection. + +Maintenance classes use `[Maintenance(MaintenanceStage.BeforeRun)]`, `BeforeMigration`, `AfterMigration` or `AfterRun`. Profiles and maintenance accept `Order` and `Scope`. Ordering uses `Order` then ordinal full type name. Hooks stop on failure; later hooks are not cleanup guarantees. Connection/transaction restoration and lock release do not depend on hooks running. Profiles and maintenance use `Up`; they do not acquire version records. + +## Transactions and locks + +`PerMigration` commits each successful migration. `None` leaves transaction behavior to the provider/operations. `WholeSession` is accepted for SQLite, PostgreSQL and SQL Server dialects; history-table initialization occurs before that transaction. Other dialects fail explicitly because transactional DDL has not been verified. Arbitrary imperative SQL can still violate transaction assumptions; database administration and implicit-commit statements require separate runs. + +`AfterUp`/`AfterDown` run after commit. In whole-session mode they are deferred until the complete session commits. Their failure reports an error after durable changes; it cannot undo a successful commit. Caller-owned connections remain caller-owned. + +`new DatabaseMigrationLock()` uses SQL Server application locks, PostgreSQL advisory locks or MySQL/MariaDB named locks. Locks are session-owned, keyed by database/history table/scope, and remain held across migration commits. Do not switch databases, replace/close the connection or manipulate the native lock inside a migration. Unsupported providers, including SQLite, reject this lock implementation. Supply a custom `IMigrationLock` where another coordination mechanism is required. MySQL named locks coordinate one server, not an entire distributed cluster. + +## Planning and SQL preview + +`runner.Plan(target)` and `DryRun` inspect history without creating/upgrading it and do not invoke migration bodies, callbacks, transactions or SQLite PRAGMA changes. Custom providers must implement `IMigrationHistory` for these paths. + +`runner.PreviewSql(target, providerType)` connects for history/schema reads. `MigrationSqlPreview.Generate(providerType, migrations)` can generate SQL offline. Earlier structured operations update a planned schema so later operations can refer to newly created/renamed tables. SQL preview currently supports a subset: basic tables/columns, supported renames, simple indexes, inserts and raw SQL. Unsupported alterations, constraints, filters, callbacks and schema dependencies fail explicitly. Output is operation SQL, not an idempotent history-managed deployment bundle. + +Imperative bodies require `allowLegacyBodies: true`. Provider calls are captured through a rejecting proxy: direct connections, commands and unsupported reads/callbacks are blocked. **Arbitrary C# cannot be sandboxed**: constructors, fluent authoring and opted-in imperative bodies can still access files, networks or external state. Use trusted migration code. Migrations overriding `InitializeOnce` are rejected before their body runs, because skipping initialization could produce misleading SQL. Post-commit callbacks do not run during preview. Raw SQL invalidates planned schema knowledge, so later structured schema dependencies fail explicitly. + +## CLI from source + +```sh +dotnet pack src/Migrator.Tool -o artifacts/packages +dotnet tool install DotNetProjects.Migrator.Tool --add-source artifacts/packages --tool-path artifacts/tools +``` + +On Windows, use a short tool installation directory (or the default global-tool directory): the bundled SQLite native library failed to load from this review workspace's deeply nested tool path, while the same package passed from a short temporary path. + +Set `MIGRATOR_CONNECTION` in your environment; the tool does not print its value. Common commands: + +```sh +migrator list --assembly MyMigrations.dll --provider SQLite +migrator status --assembly MyMigrations.dll --provider SQLite +migrator validate --assembly MyMigrations.dll --provider SQLite +migrator plan --assembly MyMigrations.dll --provider SQLite --target 10 +migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql +migrator sql --assembly MyMigrations.dll --provider SQLite --offline --output migration.sql +migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession +migrator rollback --assembly MyMigrations.dll --provider SQLite --target 0 +``` + +Use `--connection-env NAME`, `--schema`, `--tags a,b`, `--tag-match Any|All`, `--profiles a,b`, `--timeout SECONDS`, `--lock` and `--lock-timeout SECONDS` where applicable. `rollback` requires an explicit lower target and rejects any plan containing upward steps. Target validation runs after acquiring the configured lock and refreshing history. Offline SQL assumes empty history and currently rejects profiles/maintenance. `validate` validates version planning, not arbitrary migration-body behavior. The packaged drivers cover SQLite, SQL Server, PostgreSQL, MySQL/MariaDB, Oracle and Firebird. Other library providers need a custom host. + +Exit codes: `0` success, `1` execution/load failure, `2` invalid arguments, `3` unsupported operation/provider, `4` lock timeout. SQL output may contain migration data; exception and provider trace details are omitted from CLI diagnostics. + +## Optional DI and logging + +The source package `DotNetProjects.Migrator.Extensions.DependencyInjection` provides `services.AddMigrator(providerFactory, migrationAssembly, configureOptions)`. Resolve `Migrator` inside a service scope; migration constructors use that scope's services. Options are scoped snapshots. Provider disposal follows the DI scope. Microsoft logging records lifecycle events while omitting SQL text and raw exception messages; the core retains its lightweight logger API. + +## Validation + +Build before using the test scripts (they intentionally use `--no-build`): + +```sh +dotnet build Migrator.slnx +pwsh .github/scripts/test.ps1 -Database Unit +pwsh .github/scripts/test.ps1 -Database SQLite +``` + +See [live database tests](live-database-tests.md) for the full matrix. Provider-specific changes need live provider evidence. Check PR CI and review threads after every push; reply with implementation/test evidence and resolve fixed findings. Keep commits descriptive and merge the PR stack in dependency order only after review. + +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 `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.