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/issue-audit.md b/docs/issue-audit.md index 8ba45d86..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) | 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 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,18 +73,18 @@ 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. | -| [#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. | +| [#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. | -| [#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. | +| [#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. | -| [#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. | +| [#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. | | [#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. | @@ -97,3 +97,27 @@ 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 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; 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; 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. + +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. + +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 ade59538..dda607b4 100644 --- a/docs/runner-guide.md +++ b/docs/runner-guide.md @@ -109,4 +109,22 @@ See [live database tests](live-database-tests.md) for the full matrix. Provider- An auxiliary-only `MigrateToLastVersion()` run preserves existing version history while executing selected profiles and maintenance. A completely empty run does not create a history table. Post-commit callbacks receive their migration context in both per-migration and whole-session modes; callback failure cannot undo a committed migration. -PostgreSQL column and constraint metadata resolves the requested relation through the database, including schema-qualified or explicitly quoted names and the connection search path. The lookup is parameterized and distinguishes same-named tables in different schemas. This does not imply complete schema qualification for every provider operation. Native `time without time zone` metadata and literal defaults map to `TimeSpan`. +PostgreSQL column and constraint metadata resolves the requested relation through the database, including schema-qualified or explicitly quoted names and the connection search path. The lookup is parameterized and distinguishes same-named tables in different schemas. This does not imply complete schema qualification for every provider operation. Native `time without time zone` metadata and literal defaults map to `TimeOnly`. + + +### Time of day and intervals + +`DbType.Time` / `MigratorDbType.Time` is a time of day. Use `TimeOnly` for defaults and values passed to `Insert`/`Update`. `MigratorDbType.Interval` is a duration; use `TimeSpan`, including negative and multi-day values. A `TimeSpan` default on a Time column is rejected instead of silently treating a duration as a clock time. + +```csharp +new Column("job_time", DbType.Time, new TimeOnly(12, 34, 56)); +new Column("elapsed", MigratorDbType.Interval, TimeSpan.FromDays(2)); +``` + +PostgreSQL and Oracle use native intervals. SQL Server, SQLite, MySQL and MariaDB represent intervals as signed .NET ticks (100 ns units). Integer catalog metadata cannot distinguish an interval from an ordinary integer column, so retain the migration definition when that semantic distinction matters. Other dialects without an Interval mapping reject it. + +Oracle's Time representation remains DATE with a fixed 1970-01-01 date and whole-second precision; fractional defaults/parameters are rejected. SQL Server 2005 uses DATETIME with its native precision. Informix Time now uses DATETIME HOUR TO SECOND (whole seconds), not INTERVAL. Existing Informix columns created with the old mapping require an explicit migration. SQLite stores clock times as invariant text. Raw ADO.NET scalar results retain driver-specific CLR types; a driver may return SQL TIME as TimeSpan or DateTime even though the public input is TimeOnly. + +This changes the old shared parameter inference: TimeSpan now means Interval. Migrate time-of-day inputs with `TimeOnly.FromTimeSpan(value)`; it rejects negative or multi-day durations. Do not convert genuine intervals this way. + +ASE 16.0 key constraints with dots or apostrophes in their names are rejected before DDL. The tested server can create a punctuated name but cannot reliably resolve its backing index when removing the constraint. Use a key name without those characters; this restriction applies to primary and unique keys. diff --git a/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs new file mode 100644 index 00000000..61668424 --- /dev/null +++ b/src/Migrator.Tests/IdentifierAndTimeRegressionTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Providers.Impl.Mysql; +using DotNetProjects.Migrator.Providers.Impl.SQLite; +using NUnit.Framework; + +namespace Migrator.Tests; + +public class IdentifierAndTimeRegressionTests +{ + [TestCase(-51)] + [TestCase(51)] + public void SQLiteIntervalsKeepTheirSignAndDaysSeparateFromTimeOfDay(int hours) + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + var duration = TimeSpan.FromHours(hours).Add(TimeSpan.FromTicks(1234567)); + provider.AddTable("Durations", new Column("Id", DbType.Int32), new Column("Elapsed", MigratorDbType.Interval, duration)); + provider.Insert("Durations", ["Id"], [1]); + provider.Insert("Durations", ["Id", "Elapsed"], [2, duration]); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Elapsed FROM Durations WHERE Id=1")), Is.EqualTo(duration.Ticks)); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Elapsed FROM Durations WHERE Id=2")), Is.EqualTo(duration.Ticks)); + Assert.Throws(() => provider.AddTable("WrongTime", new Column("Moment", DbType.Time, duration))); + Assert.That(provider.TableExists("WrongTime"), Is.False); + } + + [TestCase("sales.Orders", "[sales].[Orders]")] + [TestCase("[sales.region].[Order]]Lines]", "[sales.region].[Order]]Lines]")] + [TestCase("[sales].[O'Brien]", "[sales].[O'Brien]")] + public void QualifiedSqlServerNamesEscapeEachComponentExactlyOnce(string input, string expected) + { + var dialect = new SqlServerDialect(); + Assert.That(dialect.Quote(input), Is.EqualTo(expected)); + Assert.That(dialect.QuoteColumnNameIfRequired("value.part]"), Is.EqualTo("[value.part]]]")); + } + + [Test] + public void OracleAndPostgreSqlQualifyReservedComponentsIndependently() + { + foreach (var dialect in new Dialect[] { new OracleDialect(), new PostgreSQLDialect() }) + { + Assert.That(dialect.QuoteTableNameIfRequired("sales.select"), Is.EqualTo("sales.\"select\"")); + Assert.That(dialect.QuoteTableNameIfRequired("\"sales.region\".\"O'Brien\""), Is.EqualTo("\"sales.region\".\"O'Brien\"")); + } + } + + [Test] + public void TimeDefaultsAreQuotedAndPreserveSubMillisecondPrecision() + { + var time = new TimeOnly(12, 34, 56).Add(TimeSpan.FromTicks(1234560)); + foreach (var dialect in new Dialect[] { new SQLiteDialect(), new MysqlDialect(), new PostgreSQLDialect(), new SqlServerDialect() }) + Assert.That(dialect.Default(time), Is.EqualTo("DEFAULT '12:34:56.123456'")); + } + + [Test, Category("SQLite")] + public void TimeDefaultsAndValuesSurviveSQLiteReconstruction() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + var time = new TimeOnly(12, 34, 56).Add(TimeSpan.FromTicks(1234560)); + provider.AddTable("Times", new Column("Id", DbType.Int32), new Column("Value", DbType.Time) { DefaultValue = time }); + var column = provider.GetColumns("Times").Single(c => c.Name == "Value"); + Assert.That(column.Type, Is.EqualTo(DbType.Time)); + Assert.That(column.DefaultValue, Is.EqualTo(time)); + provider.ChangeColumn("Times", new Column("Id", DbType.Int64)); + provider.Insert("Times", ["Id"], [1]); + Assert.That(TimeOnly.Parse(Convert.ToString(provider.ExecuteScalar("SELECT CAST(Value AS TEXT) FROM Times"))), Is.EqualTo(time)); + provider.Insert("Times", ["Id", "Value"], [2, time]); + Assert.That(TimeOnly.Parse(Convert.ToString(provider.ExecuteScalar("SELECT CAST(Value AS TEXT) FROM Times WHERE Id=2"))), Is.EqualTo(time)); + } +} 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/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/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 fe5f6db4..8e417b51 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -27,6 +27,46 @@ public class LiveDatabaseTests(string database, ProviderTypes providerType) private ITransformationProvider provider; internal ITransformationProvider Provider => provider; + [Test] + public void TimeOfDayDefaultsAndParametersPersist() + { + var time = new TimeOnly(12, 34, 56); + provider.AddTable("clock_values", new Column("id", DbType.Int32), new Column("value", DbType.Time) { DefaultValue = time }); + provider.Insert("clock_values", ["id"], [1]); + provider.Insert("clock_values", ["id", "value"], [2, time]); + foreach (var id in new[] { 1, 2 }) + { + var stored = provider.ExecuteScalar("SELECT " + provider.QuoteColumnNameIfRequired("value") + " FROM clock_values WHERE id=" + id); + var actual = stored is DateTime date ? date.TimeOfDay : stored is TimeSpan span ? span : TimeSpan.Parse(Convert.ToString(stored), System.Globalization.CultureInfo.InvariantCulture); + Assert.That(actual, Is.EqualTo(time.ToTimeSpan())); + } + Assert.That(provider.GetColumns("clock_values").Single(c => c.Name.Equals("value", StringComparison.OrdinalIgnoreCase)).Type, Is.EqualTo(DbType.Time)); + if (providerType is ProviderTypes.Mysql or ProviderTypes.MariaDB) IntervalRegression.Verify(provider, false); + } + + [Test] + public void QuotedConstraintNamesCanBeInspectedAndRemoved() + { + if (providerType == ProviderTypes.Sybase) provider.ExecuteNonQuery("SET QUOTED_IDENTIFIER ON"); + var name = "UQ ' dotted.name"; + if (providerType == ProviderTypes.Sybase) + { + Assert.Throws(() => provider.AddTable("unsupported_key_name", + new Column("id", DbType.Int32) { IsNullable = false }, new DotNetProjects.Migrator.Framework.UniqueConstraint(name, "id"))); + Assert.That(provider.TableExists("unsupported_key_name"), Is.False); + name = "UQ quoted name"; + } + provider.AddTable("named_constraints", new Column("id", DbType.Int32) { IsNullable = false }, + new DotNetProjects.Migrator.Framework.UniqueConstraint(name, "id")); + provider.AddTable("other_constraints", new Column("id", DbType.Int32)); + Assert.That(provider.ConstraintExists("named_constraints", name), Is.True); + Assert.That(provider.ConstraintExists("other_constraints", name), Is.False); + provider.RemoveConstraint("named_constraints", name); + Assert.That(provider.ConstraintExists("named_constraints", name), Is.False); + provider.Insert("named_constraints", ["id"], [1]); + provider.Insert("named_constraints", ["id"], [1]); + } + internal void RunRegression(Action action) { try { SetUp(); action(this); } 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/OracleProvider/OracleTransformationProvider_TableExistsTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs index 2d6a4187..3ed399ba 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs @@ -11,6 +11,37 @@ namespace Migrator.Tests.Providers.OracleProvider; [Category("Oracle")] public class OracleTransformationProvider_TableExistsTests : OracleTransformationProviderTestBase { + [Test] + public void NegativeMultiDayIntervalDefaultsAndParametersPersist() => IntervalRegression.Verify(Provider, true); + + [Test] + public void QualifiedQuotedNamesRoundTripThroughColumnsIndexesAndConstraints() + { + var schema = Convert.ToString(Provider.ExecuteScalar("SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA') FROM dual")); + var table = "\"" + schema.Replace("\"", "\"\"") + "\".\"O'Brien\""; + Provider.AddTable(table, new Column("Id", DbType.Int32), + new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ ' name", "Id")); + Assert.That(Provider.TableExists(table), Is.True); + Assert.That(Provider.ColumnExists(table, "Id"), Is.True); + Assert.That(Provider.GetColumns(table).Length, Is.EqualTo(1)); + Assert.That(Provider.GetIndexes(table).Length, Is.EqualTo(1)); + Assert.That(Provider.GetTableConstraints(table).Length, Is.EqualTo(1)); + Provider.RemoveConstraint(table, "UQ ' name"); + Provider.RemoveTable(table); + Assert.That(Provider.TableExists(table), Is.False); + } + + [Test] + public void TimeOfDayUsesTheDocumentedDateRepresentationForDefaultsAndParameters() + { + var time = new TimeOnly(12, 34, 56); + Provider.AddTable("ClockValues", new Column("Id", DbType.Int32), new Column("Value", DbType.Time) { DefaultValue = time }); + Provider.Insert("ClockValues", ["Id"], [1]); + Provider.Insert("ClockValues", ["Id", "Value"], [2, time]); + Assert.That(Convert.ToDateTime(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("Value") + " FROM ClockValues WHERE Id=1")).TimeOfDay, Is.EqualTo(time.ToTimeSpan())); + Assert.That(Convert.ToDateTime(Provider.ExecuteScalar("SELECT " + Provider.QuoteColumnNameIfRequired("Value") + " FROM ClockValues WHERE Id=2")).TimeOfDay, Is.EqualTo(time.ToTimeSpan())); + } + [Test] public void LegacyForeignKeyOverloadHonorsCascadeDelete() { 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/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/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index 27d4843e..3bfd5384 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -13,19 +13,43 @@ 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); + [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.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/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 422ee9e8..1231e9b7 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 && 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 5b4e0a3c..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) @@ -69,5 +73,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..98374287 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,17 @@ public virtual string Default(object defaultValue) { defaultValue = "''"; } + else if (defaultValue is TimeOnly time) + { + 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) { 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..fb326d59 100644 --- a/src/Migrator/Providers/Impl/Hana/HanaDialect.cs +++ b/src/Migrator/Providers/Impl/Hana/HanaDialect.cs @@ -44,11 +44,10 @@ 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", - 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 0b703122..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)"); @@ -54,12 +54,17 @@ 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); + 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 e9174cfb..c09e7844 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(); @@ -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 7e06754c..25d6da10 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 => TimeOnly.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..fb4d2f68 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,8 +46,6 @@ public List GetUserTabIdentityCols(string tableName) public List GetForeignKeyConstraintItems(string tableName) { - var tableNameQuoted = _oracleTransformationProvider.QuoteTableNameIfRequired(tableName); - var sb = new StringBuilder(); sb.AppendLine("SELECT"); sb.AppendLine(" a.OWNER AS TABLE_SCHEMA,"); @@ -57,14 +55,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,8 +91,6 @@ public List GetForeignKeyConstraintItems(string tableN public List GetPrimaryKeyItems(string tableName) { - var tableNameQuoted = _oracleTransformationProvider.QuoteTableNameIfRequired(tableName); - var sql = $@" SELECT ucc.TABLE_NAME, @@ -103,13 +99,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,8 +134,6 @@ ORDER BY public List GetIndexItems(string tableName) { - var tableNameQuoted = _oracleTransformationProvider.QuoteTableNameIfRequired(tableName); - var sql = @$" SELECT i.table_name, @@ -150,15 +144,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 - JOIN - user_ind_columns ic ON i.index_name = ic.index_name AND + all_indexes i + 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 - 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..d355a80a 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,15 @@ public override string Default(object defaultValue) { return string.Format("DEFAULT {0}", booleanValue ? "1" : "0"); } + 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(); @@ -156,4 +166,11 @@ public override string Default(object defaultValue) return base.Default(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 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/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 5e7b5f71..3e87b596 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) @@ -192,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)); } @@ -242,113 +247,36 @@ public override void ChangeColumn(string table, string sqlColumn) } table = QuoteTableNameIfRequired(table); - sqlColumn = QuoteColumnNameIfRequired(sqlColumn); ExecuteNonQuery(string.Format("ALTER TABLE {0} MODIFY {1}", table, sqlColumn)); } public override void AddColumn(string table, string sqlColumn) { - GuardAgainstMaximumIdentifierLengthForOracle(table); + foreach (var part in SqlIdentifier.Parse(table)) GuardAgainstMaximumIdentifierLengthForOracle(part.Value); 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; - } - - 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; - } - - 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 string[] GetConstraints(string table) => ExecuteStringQuery( + "SELECT CONSTRAINT_NAME FROM ALL_CONSTRAINTS WHERE " + OracleCatalog.Predicate(this, table)).ToArray(); - 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()); - } + protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( + "SELECT CONSTRAINT_NAME FROM ALL_CONSTRAINTS WHERE CONSTRAINT_TYPE='P' AND " + OracleCatalog.Predicate(this, table)).FirstOrDefault(); - Logger.Log(sql); - var count = ExecuteScalar(sql); - return Convert.ToInt32(count) == 1; - } + public override bool ConstraintExists(string table, string name) => + GetConstraints(table).Any(actual => actual == name || actual == name.ToUpperInvariant()); - public override bool ViewExists(string view) - { - var sql = string.Format("SELECT COUNT(view_name) FROM user_views WHERE lower(view_name) = '{0}'", view.ToLower()); + 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; - 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 +316,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 +333,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 +642,17 @@ 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 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; @@ -812,13 +751,13 @@ public override void CopyDataFromTableToTable(string sourceTableName, List().ToArray(); GuardAgainstMaximumColumnNameLengthForOracle(name, columns); foreach (var identity in columns.Where(c => c.IsIdentity)) @@ -878,13 +817,9 @@ public override string Encode(Guid guid) public override bool IndexExists(string table, string name) { - var sql = - string.Format( - "SELECT COUNT(index_name) FROM user_indexes WHERE lower(index_name) = '{0}' AND lower(table_name) = '{1}'", - name.ToLower(), table.ToLower()); - Logger.Log(sql); - var scalar = ExecuteScalar(sql); - return Convert.ToInt32(scalar) == 1; + var sql = "SELECT COUNT(*) FROM ALL_INDEXES WHERE " + OracleCatalog.Predicate(this, table, "TABLE_NAME", "TABLE_OWNER") + + " AND INDEX_NAME=" + OracleCatalog.Literal(SqlIdentifier.Catalog(QuoteConstraintNameIfRequired(name), true).Name); + return Convert.ToInt32(ExecuteScalar(sql)) == 1; } public override void UpdateTargetFromSource(string tableSourceNotQuoted, string tableTargetNotQuoted, ColumnPair[] fromSourceToTargetColumnPairs, ColumnPair[] conditionColumnPairs) diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs index 1cd7fea1..5e50b31f 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs @@ -128,7 +128,13 @@ 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 ticks = Math.Abs((decimal)timeSpan.Ticks); + var hours = decimal.Truncate(ticks / TimeSpan.TicksPerHour); + var minutes = decimal.Truncate(ticks % TimeSpan.TicksPerHour / TimeSpan.TicksPerMinute); + var seconds = ticks % TimeSpan.TicksPerMinute / TimeSpan.TicksPerSecond; + var intervalPostgreNotation = (timeSpan.Ticks < 0 ? "-" : "") + hours.ToString(System.Globalization.CultureInfo.InvariantCulture) + + ":" + minutes.ToString("00", System.Globalization.CultureInfo.InvariantCulture) + + ":" + seconds.ToString("00.#######", 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..68b54dde 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); @@ -669,7 +671,7 @@ public override Column[] GetColumns(string table) else if (column.MigratorDbType == MigratorDbType.Time) { var match = stripSingleQuoteRegEx.Match(columnInfo.ColumnDefault); - if (!match.Success || !TimeSpan.TryParse(match.Value, CultureInfo.InvariantCulture, out var time)) + if (!match.Success || !TimeOnly.TryParse(match.Value, CultureInfo.InvariantCulture, out var time)) throw new NotSupportedException("Cannot parse PostgreSQL time default: " + columnInfo.ColumnDefault); column.DefaultValue = time; } @@ -684,20 +686,13 @@ public override Column[] GetColumns(string table) throw new Exception("Postgre default value for interval: Single quotes around the interval string are expected."); } - column.DefaultValue = match.Value; - var splitted = match.Value.Split(':'); - if (splitted.Length != 3) - { - throw new NotImplementedException($"Cannot interpret {columnInfo.ColumnDefault} in column '{column.Name}' unexpected pattern."); - } - - var hours = int.Parse(splitted[0], CultureInfo.InvariantCulture); - var minutes = int.Parse(splitted[1], CultureInfo.InvariantCulture); - var splitted2 = splitted[2].Split('.'); - var seconds = int.Parse(splitted2[0], CultureInfo.InvariantCulture); - var milliseconds = int.Parse(splitted2[1], CultureInfo.InvariantCulture); - - column.DefaultValue = new TimeSpan(0, hours, minutes, seconds, milliseconds); + var interval = Regex.Match(match.Value, @"^([+-]?)(\d+):(\d{2}):(\d{2}(?:\.\d{1,7})?)$"); + if (!interval.Success) throw new NotSupportedException("Cannot parse interval default: " + columnInfo.ColumnDefault); + var ticks = decimal.Parse(interval.Groups[2].Value, CultureInfo.InvariantCulture) * TimeSpan.TicksPerHour + + decimal.Parse(interval.Groups[3].Value, CultureInfo.InvariantCulture) * TimeSpan.TicksPerMinute + + decimal.Parse(interval.Groups[4].Value, CultureInfo.InvariantCulture) * TimeSpan.TicksPerSecond; + if (interval.Groups[1].Value == "-") ticks = -ticks; + column.DefaultValue = TimeSpan.FromTicks(checked((long)ticks)); } else { @@ -897,28 +892,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 @@ -1042,7 +1015,12 @@ 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; } @@ -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() @@ -1179,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) @@ -1558,7 +1540,13 @@ public override void CopyDataFromTableToTable(string sourceTableName, List 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); diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs index cdb578b7..f7b9a8c1 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs @@ -116,23 +116,11 @@ public override ITransformationProvider GetTransformationProvider(Dialect dialec public override string Quote(string value) { - var firstDotIndex = value.IndexOf('.'); - if (firstDotIndex >= 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) { - if (defaultValue is TimeSpan time) - { - if (time < TimeSpan.Zero || time >= 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 f62b550b..cc9a27ce 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); @@ -669,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) { @@ -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,19 @@ 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 TimeOnly time && _dialect is SqlServer2005Dialect) + { + parameter.DbType = DbType.DateTime; + parameter.Value = new DateTime(1900, 1, 1).Add(time.ToTimeSpan()); + } + else if (value is ushort) { parameter.DbType = DbType.Int32; parameter.Value = value; 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); 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..9f423616 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); @@ -1806,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) {