diff --git a/.github/qualification/Hana/Hana.csproj b/.github/qualification/Hana/Hana.csproj new file mode 100644 index 00000000..6beacf72 --- /dev/null +++ b/.github/qualification/Hana/Hana.csproj @@ -0,0 +1,4 @@ + + Exenet9.0enable + + diff --git a/.github/qualification/Hana/Program.cs b/.github/qualification/Hana/Program.cs new file mode 100644 index 00000000..3050b6cd --- /dev/null +++ b/.github/qualification/Hana/Program.cs @@ -0,0 +1,39 @@ +using Sap.Data.Hana; + +using var connection = new HanaConnection(Environment.GetEnvironmentVariable("MIGRATOR_HANA") + ?? throw new InvalidOperationException("MIGRATOR_HANA must identify the disposable CI database.")); +connection.Open(); +void Execute(string sql) +{ + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); +} +object Scalar(string sql) +{ + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); +} +Execute("CREATE SCHEMA MIGRATOR_QUALIFICATION"); +try +{ + Execute("CREATE ROW TABLE MIGRATOR_QUALIFICATION.ITEMS (ID INTEGER GENERATED BY DEFAULT AS IDENTITY, LABEL NVARCHAR(40) DEFAULT 'initial', CONSTRAINT PK_ITEMS PRIMARY KEY (ID), CONSTRAINT UQ_LABEL UNIQUE (LABEL))"); + Execute("INSERT INTO MIGRATOR_QUALIFICATION.ITEMS (LABEL) VALUES ('kept')"); + if (Convert.ToInt32(Scalar("SELECT COUNT(*) FROM MIGRATOR_QUALIFICATION.ITEMS")) != 1) + throw new Exception("Inserted row missing."); + using (var transaction = connection.BeginTransaction()) + { + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "INSERT INTO MIGRATOR_QUALIFICATION.ITEMS (LABEL) VALUES ('rolled back')"; + command.ExecuteNonQuery(); + transaction.Rollback(); + } + if (Convert.ToInt32(Scalar("SELECT COUNT(*) FROM MIGRATOR_QUALIFICATION.ITEMS")) != 1) + throw new Exception("Rollback did not preserve the original row count."); + if (Convert.ToInt32(Scalar("SELECT COUNT(*) FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME='MIGRATOR_QUALIFICATION' AND TABLE_NAME='ITEMS'")) != 2) + throw new Exception("Column catalog could not be read."); + Console.WriteLine("HANA qualification passed: native .NET connection, DDL, identity, constraints, data, rollback and catalog access."); +} +finally { Execute("DROP SCHEMA MIGRATOR_QUALIFICATION CASCADE"); } diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh index 7bb5d15a..b71442de 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -11,6 +11,7 @@ pull() { } case "$database" in Unit|SQLite) exit 0 ;; + Hana) bash .github/scripts/start-hana.sh; exit 0 ;; MySQL) docker run -d --name migrator-db -p 3306:3306 -e MYSQL_ROOT_PASSWORD=rootpass -e MYSQL_DATABASE=testdb -e MYSQL_USER=testuser -e MYSQL_PASSWORD=testpass mysql:8.0.44 ready() { docker exec migrator-db mysql -uroot -prootpass -e 'SELECT 1' >/dev/null 2>&1; } diff --git a/.github/scripts/start-hana.sh b/.github/scripts/start-hana.sh new file mode 100644 index 00000000..859c0217 --- /dev/null +++ b/.github/scripts/start-hana.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail +sudo sysctl -w fs.file-max=20000000 fs.aio-max-nr=262144 vm.memory_failure_early_kill=1 vm.max_map_count=135217728 +mkdir -p "$RUNNER_TEMP/hana" +printf '{"master_password":"MgT9ci7Q4xZ2"}' > "$RUNNER_TEMP/hana/password.json" +sudo chown -R 12000:79 "$RUNNER_TEMP/hana" +sudo chmod 600 "$RUNNER_TEMP/hana/password.json" +docker pull saplabs/hanaexpress:2.00.088.00.20251110.1 +docker run -d --name migrator-db --hostname hxe -p 39041:39041 \ + --ulimit nofile=1048576:1048576 \ + --sysctl kernel.shmmax=1073741824 --sysctl kernel.shmmni=32768 --sysctl kernel.shmall=8388608 \ + --sysctl net.ipv4.ip_local_port_range="40000 60999" \ + -v "$RUNNER_TEMP/hana:/hana/mounts" \ + saplabs/hanaexpress:2.00.088.00.20251110.1 \ + --passwords-url file:///hana/mounts/password.json --agree-to-sap-license +for attempt in $(seq 1 180); do + if docker exec migrator-db /usr/sap/HXE/HDB90/exe/hdbsql -i 90 -d HXE -u SYSTEM -p MgT9ci7Q4xZ2 'SELECT 1 FROM DUMMY' >/dev/null 2>&1; then + echo "MIGRATOR_HANA=Server=localhost:39041;UserID=SYSTEM;Password=MgT9ci7Q4xZ2" >> "$GITHUB_ENV" + exit 0 + fi + if [ "$(docker inspect -f '{{.State.Running}}' migrator-db)" != true ]; then + docker logs migrator-db + exit 1 + fi + sleep 5 +done +docker logs migrator-db +exit 1 diff --git a/.github/scripts/test.ps1 b/.github/scripts/test.ps1 index 01f97f7b..66a36a4b 100644 --- a/.github/scripts/test.ps1 +++ b/.github/scripts/test.ps1 @@ -1,9 +1,9 @@ param( - [ValidateSet('Unit','SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase')] + [ValidateSet('Unit','SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase','Hana')] [string]$Database = 'Unit' ) $ErrorActionPreference = 'Stop' -$databases = @('SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase') +$databases = @('SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase','Hana') $filter = if ($Database -eq 'Unit') { ($databases | ForEach-Object { "TestCategory!=$_" }) -join '&' } else { "TestCategory=$Database" } $xmlDirectory = Join-Path (Get-Location) "TestResults/$Database" dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --results-directory TestResults -- NUnit.NumberOfTestWorkers=0 "NUnit.TestOutputXml=$xmlDirectory" @@ -15,6 +15,6 @@ if ([int]$counters.failed -gt 0) { throw "Failures in $Database results" } $skipped = @($results.TestRun.Results.UnitTestResult | Where-Object outcome -eq NotExecuted) Write-Host "$Database : $($counters.passed) passed, $($skipped.Count) skipped" foreach ($test in $skipped) { Write-Host "Skipped: $($test.testName) $($test.Output.ErrorInfo.Message)" } -if ($Database -in @('MySQL','MariaDB','Firebird','Db2','Informix','Sybase') -and $skipped.Count -gt 0) { +if ($Database -in @('MySQL','MariaDB','Firebird','Db2','Informix','Sybase','Hana') -and $skipped.Count -gt 0) { throw "New database suites must not skip tests." } diff --git a/.github/scripts/verify-test-coverage.py b/.github/scripts/verify-test-coverage.py index 8ef02980..002446e0 100644 --- a/.github/scripts/verify-test-coverage.py +++ b/.github/scripts/verify-test-coverage.py @@ -4,7 +4,7 @@ import xml.etree.ElementTree as ET expected = {"Unit", "SQLite", "SQLServer", "PostgreSQL", "Oracle", "MySQL", - "MariaDB", "Firebird", "Db2", "Informix", "Sybase"} + "MariaDB", "Firebird", "Db2", "Informix", "Sybase", "Hana"} seen = {} executed = 0 counts = set() diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index 7dba5873..75969306 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] + database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase, Hana] steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 @@ -62,6 +62,7 @@ jobs: if: always() with: name: test-results-${{ matrix.database }} + overwrite: true path: TestResults/ if-no-files-found: error - name: Remove test container diff --git a/.gitignore b/.gitignore index c92f3f9b..921f9fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ packages/ **/appsettings.Development.json TestResults/ +/artifacts/ diff --git a/Migrator.slnx b/Migrator.slnx index 4cddff02..0fad1ce6 100644 --- a/Migrator.slnx +++ b/Migrator.slnx @@ -2,6 +2,9 @@ + + + @@ -13,6 +16,14 @@ + + + + + + + + diff --git a/README.md b/README.md index e8f8b420..9e43a63b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ DotNetProjects.Migrator is a fork of [Migrator.NET](https://github.com/migratord - **Bring your database driver.** The library does not directly reference database-driver packages; supply an ADO.NET connection or configure the driver factory. - **SQLite schema handling.** This fork includes schema inspection and table-recreation logic for operations SQLite cannot perform directly. -Migrator is a library you embed in a migration host. It does not provide EF-style model-difference scaffolding, a packaged command-line runner, or built-in migration-content checksum validation. +The source upgrade adds a structured fluent API, runner filtering/lifecycle options, SQL-preview subset, native locking, a CLI project and optional Microsoft DI/logging integration. These changes are under review and **are not a released NuGet feature claim**. See the [runner and fluent guide](docs/runner-guide.md) and [detailed framework comparison](docs/migration-framework-comparison.md). EF-style model scaffolding and migration-content checksums remain outside the implementation. ## Installation and requirements @@ -58,12 +58,14 @@ Building the `.slnx` solution requires an SDK that understands that format, such ## Quick start +This example targets **unreleased v13 source**. Clone/check out the upgrade branch before running these commands from the repository root. For published 12.1, follow its version-specific API; see the [migration guide](docs/migration-guide-12.1-to-13.md). + ### 1. Create a migration host ```sh dotnet new console -n MigrationDemo -f net9.0 cd MigrationDemo -dotnet add package DotNetProjects.Migrator +dotnet add reference ../src/Migrator/DotNetProjects.Migrator.csproj dotnet add package Microsoft.Data.Sqlite --version 9.0.7 ``` @@ -81,9 +83,9 @@ public class CreateUsers : Migration public override void Up() { Database.AddTable("Users", - new Column("Id", DbType.Int32, ColumnProperty.NotNull), - new Column("Name", DbType.String, 255)); - Database.AddPrimaryKey("PK_Users", "Users", "Id"); + new Column("Id", DbType.Int32) { IsNullable = false }, + new Column("Name", DbType.String, 255), + new PrimaryKeyConstraint("PK_Users", "Id")); } public override void Down() @@ -187,6 +189,16 @@ Important details: See [ProviderFactory](src/Migrator/ProviderFactory.cs), [MigrationLoader](src/Migrator/MigrationLoader.cs) and [history implementation](src/Migrator/Providers/TransformationProvider.cs). +## Fluent API and deployment tooling + +Run the [compiled fluent example](examples/FluentQuickStart/Program.cs): + +```sh +dotnet run --project examples/FluentQuickStart +``` + +The example creates a complete table definition, previews it without changing history, runs a whole-session migration, then verifies automatic reversal. The [runner guide](docs/runner-guide.md) covers CLI commands, tags/profiles, maintenance, transactions, optional DI/logging, locks and preview limitations. Build the source packages locally to try the new tooling; no NuGet publication accompanies these PRs. + ## Schema and data operations Inside a migration, `Database` implements [`ITransformationProvider`](src/Migrator/Framework/ITransformationProvider.cs). It includes: @@ -212,7 +224,7 @@ public override void Down() } ``` -Provider implementations determine which operations are available and how they map to SQL. Use `Database.ExecuteNonQuery(...)` for custom SQL and keep dialect-specific statements explicit. The source also includes a [schema builder API](src/Migrator/Framework/SchemaBuilder/SchemaBuilder.cs). +Provider implementations determine which operations are available and how they map to SQL. Use `Database.ExecuteNonQuery(...)` for custom SQL and keep dialect-specific statements explicit. The source also includes the [MigrationBuilder fluent API](src/Migrator/Framework/Fluent/MigrationBuilder.cs). ## Database providers @@ -230,6 +242,7 @@ The [provider factory](src/Migrator/ProviderFactory.cs) contains these database | IBM Informix | `IBM_Informix` | | Firebird | `Firebird` | | Ingres | `Ingres` | +| SAP HANA (v13 source) | `Hana` | | Sybase | `Sybase` | This is an inventory of dialects present in source, **not a guarantee that every server version, driver or operation is supported**. Some entries are legacy variants. Verify the combination you deploy against the [provider implementations](src/Migrator/Providers/Impl) and [provider tests](src/Migrator.Tests/Providers). @@ -305,3 +318,30 @@ This project continues the original [Migrator.NET](https://github.com/migratordo ## License The package declares **Mozilla Public License 1.1 (MPL-1.1)** in its [project metadata](src/Migrator/DotNetProjects.Migrator.csproj). See the [license text](https://www.mozilla.org/en-US/MPL/1.1/) and source-file notices. + +### Version 13 source changes + +The unreleased v13 stack separates columns from named table constraints and removes the old column flags and duplicate fluent builder. See the [12.1-to-13 migration guide](docs/migration-guide-12.1-to-13.md) before recompiling migrations. These source features are not claims about the published 12.1 NuGet package. + + +### SQL expressions and collations in v13 + +```csharp +new Column("Id", DbType.String, 27) { DefaultValue = RawSql.Insert("ksuid_new()") }; +builder.Create.Table("Events").WithColumn("Id").AsString(27) + .WithDefaultValue(RawSql.Insert("ksuid_new()")); + +new Column("Name", DbType.String, 100) { Collation = Collation.CaseInsensitive }; +builder.Create.Table("Names").WithColumn("Name").AsString(100) + .WithCollation(Collation.CaseInsensitive); +``` + +SQL expressions are trusted migration code and must exist on the target database. +Ordinary string defaults remain quoted literals. Semantic collations have explicit +provider limits; SQLite's `AsciiIgnoreCase` never substitutes for Unicode folding. +Use `Collation.Named("provider_name")` for a specific language or installed collation. +See the [mapping and migration guide](docs/migration-guide-12.1-to-13.md#explicit-sql-defaults-and-semantic-collations). + +Additional engines are admitted only with passing real-database CI. +[SAP HANA provider scope, CI evidence and deferred engine requirements](docs/additional-database-qualification.md) +cover the current FluentMigrator gaps without claiming untested support. diff --git a/docs/README.md b/docs/README.md index 99d7775c..4c809518 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ Open http://localhost:8766. Content and navigation work without JavaScript. Copy ## Publish on GitHub Pages -1. In the repository's **Settings → Pages → Build and deployment**, set **Source** to **GitHub Actions**. +1. In the repository's **Settings → Pages → Build and deployment**, set **Source** to **GitHub Actions**. 2. Merge the site and `.github/workflows/pages.yml` into `master`. 3. The workflow publishes only `docs/`. After enabling Pages, you can also run **Deploy homepage to GitHub Pages** manually on `master`. @@ -24,6 +24,6 @@ All site assets use relative URLs, so the repository subpath works without a cus ## Keep the comparison accurate -The comparison distinguishes source capabilities from guarantees about released packages or database compatibility. Update the review date and source links together when reviewing it. Avoid equating transaction rollback with reversing completed migrations, treating a provider enum as a support guarantee, or treating scoped history as a migration discovery filter. +The comparison distinguishes source capabilities from guarantees about released packages or database compatibility. Update the review date and source links together when reviewing it. Avoid equating transaction rollback with reversing completed migrations, treating a provider enum as a support guarantee, or assuming scopes isolate physical tables. The v13 runner filters explicitly scoped migrations and lets unscoped migrations inherit its effective scope. -The quick start targets the current source's .NET 9 API. Check the selected NuGet release's target frameworks. The SQLite driver version matches the repository test dependency. Validate authoring and runner snippets together when changing them. +The quick start targets the unreleased v13 source's .NET 9 API and references the source project. Check the selected NuGet release's target frameworks. The SQLite driver version matches the repository test dependency. Validate authoring and runner snippets together when changing them. diff --git a/docs/additional-database-qualification.md b/docs/additional-database-qualification.md new file mode 100644 index 00000000..403d02b1 --- /dev/null +++ b/docs/additional-database-qualification.md @@ -0,0 +1,72 @@ +# Additional database qualification + +Reviewed 22 September 2026 against FluentMigrator's current +[runner projects](https://github.com/fluentmigrator/fluentmigrator/tree/main/src) +and [provider configuration](https://fluentmigrator.github.io/intro/configuration.html). +This is an implementation gate, not a claim of released provider support. + +| Additional engine | Real-engine GitHub Actions route | Current disposition | +| --- | --- | --- | +| SAP HANA | Official HANA Express Linux container and SAP's .NET driver, disposable schema | Implemented in the v13 source stack with a mandatory actual-engine matrix job; require green PR checks before merge | +| Amazon Redshift | AWS test warehouse/serverless endpoint with CI credentials, network access and resource cleanup | No configured test infrastructure; defer provider | +| Snowflake | Snowflake test account, warehouse, credentials and disposable database/schema | No configured test infrastructure; defer provider | +| Db2 for IBM i | IBM i endpoint on Power infrastructure and compatible .NET/ODBC driver | No configured test infrastructure; defer provider | + +The existing Db2 job runs Db2 LUW, not Db2 for IBM i. PostgreSQL compatibility does +not prove Redshift behavior. Snowpark's local test framework does not test Snowflake +DDL and catalog behavior through the production .NET driver. + +Older provider lists also mention SQL Server Compact and SAP SQL Anywhere. +FluentMigrator's current FAQ marks these as dropped; they are not current additions +to pursue. Different SQL Server/PostgreSQL dialect versions and Oracle drivers are +not additional database engines. + +## HANA admission criteria + +The HANA matrix startup pins HANA Express 2.00.088.00.20251110.1 and +Sap.Data.Hana.Net.v8.0 2.30.27. It uses a disposable public CI credential, bounded +startup, native .NET connection, identity/constraint DDL, persisted data, rollback +and catalog checks. Any startup or behavioral failure fails the job. Logs and +runner resource evidence are retained; cleanup runs independently of test success. +The prerequisite probe passed in [run 35766200488](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35766200488). The standalone workflow is replaced by the mandatory Hana job in the complete database matrix; the probe project remains reproducible evidence. + +The provider matrix at source `eabec55` is recorded in [run 35770116342](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35770116342). +Provider admission requires this matrix to pass, covering +imperative/fluent schema creation, constraint metadata, data, migration history, +restart/rollback, preview parity and explicit unsupported operations. Do not mark +a provider supported on the basis of SQL string tests or a skipped secret-gated job. + +## Primary sources + +- [SAP's official HANA Express image and installation requirements](https://hub.docker.com/r/saplabs/hanaexpress) +- [SAP Docker installation guide](https://developers.sap.com/tutorials/hxe-ua-install-using-docker) +- [Redshift Serverless setup](https://docs.aws.amazon.com/redshift/latest/gsg/) +- [Snowflake local testing framework scope](https://docs.snowflake.com/en/developer-guide/snowpark/python/testing-locally) +- [Db2 for IBM i platform](https://www.ibm.com/support/pages/db2-ibm-i) +- [FluentMigrator's current provider FAQ](https://fluentmigrator.github.io/intro/faq.html) + +## HANA provider scope + +The source provider is selected by `ProviderTypes.Hana` and accepts a caller-owned +`HanaConnection` or the SAP factory. The core remains free of SAP driver references. +The tests use Sap.Data.Hana.Net.v8.0 2.30.27 and HANA Express 2.00.088.00.20251110.1. + +Supported operations include row/column table creation, explicit keys/checks/FKs, +column add/change/remove/rename, table rename/remove, ordinary and unique indexes, +parameterized data operations, schema/constraint/index metadata, versioned history, +and structured create-table/add-column preview. Names are case-preserving and accept +unquoted table or schema.table input; embedded identifier dots require explicit SQL. + +Whole-session transactions and native locking are not advertised. The provider +retains HANA's default DDL autocommit behavior; data rollback does not prove DDL rollback. +Use explicit SQL for tenant administration, computed columns, specialized indexes, +collation configuration, and provider-specific data types without a mapped CLR type. +The provider rejects unsupported included/filtered/clustered indexes and semantic +collation requests instead of ignoring them. The packaged CLI does not bundle the SAP driver; +use the library runner in a host that references the SAP package. Raw defaults retain HANA's +engine restrictions: CURRENT_TIMESTAMP is valid, whereas arbitrary LOWER(...) defaults are not. + +[HANA constraints](https://help.sap.com/docs/SAP_HANA_PLATFORM/4fe29514fd584807ac9f2a04f6754767/209f7cf5751910149d9ce6b033d8ddce.html), +[referential constraints](https://help.sap.com/docs/SAP_HANA_PLATFORM/4fe29514fd584807ac9f2a04f6754767/20ccc0a175191014901b88e6bc175c44.html), +and [DDL autocommit](https://help.sap.com/docs/SAP_HANA_PLATFORM/4fe29514fd584807ac9f2a04f6754767/d538d11053bd4f3f847ec5ce817a3d4c.html) +are documented by SAP. diff --git a/docs/assets/site.css b/docs/assets/site.css index 3af8f11d..1c2286ab 100644 --- a/docs/assets/site.css +++ b/docs/assets/site.css @@ -797,3 +797,6 @@ footer .brand { scroll-behavior: auto; } } + +#version-13 .snippet { margin: 1rem 0; } +#version-13 p + p { margin-top: 1rem; } diff --git a/docs/fluent-operation-coverage.json b/docs/fluent-operation-coverage.json index b57cf8c9..af329b52 100644 --- a/docs/fluent-operation-coverage.json +++ b/docs/fluent-operation-coverage.json @@ -69,5 +69,5 @@ "MigrationApplied": "Explicit Context history/transaction APIs; not schema expressions", "MigrationUnApplied": "Explicit Context history/transaction APIs; not schema expressions", "IsMigrationApplied": "Explicit Context history/transaction APIs; not schema expressions", - "ExecuteSchemaBuilder": "Execute.WithProvider(p => p.ExecuteSchemaBuilder(legacyBuilder))" + "GetTableConstraints": "Schema.Table(table).ConstraintDefinitions()" } diff --git a/docs/index.html b/docs/index.html index 768b513a..516efd0e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -65,10 +65,9 @@

Database changes.
Part of your code.

public override void Up() { Database.AddTable("Users", - new Column("Id", DbType.Int32, - ColumnProperty.NotNull), - new Column("Name", DbType.String, 255)); - Database.AddPrimaryKey("PK_Users", "Users", "Id"); + new Column("Id", DbType.Int32) { IsNullable = false }, + new Column("Name", DbType.String, 255), + new PrimaryKeyConstraint("PK_Users", "Id")); } public override void Down() @@ -130,6 +129,30 @@

Separate histories by scope

+
+
+
+

VERSION 13 · IN REVIEW

Explicit definitions, shared authoring.

+

Breaking source changes. These features are not yet a released NuGet version.

+
+

Define primary, unique, foreign-key and check constraints as table objects. + The imperative and fluent APIs share column definitions, trusted SQL defaults and typed collation requests.

+
new Column("Id", DbType.String, 27)
+    { DefaultValue = RawSql.Insert("ksuid_new()") };
+
+builder.Create.Table("Events")
+    .WithColumn("Id").AsString(27)
+    .WithDefaultValue(RawSql.Insert("ksuid_new()"))
+    .WithColumn("Name").AsString(100)
+    .WithCollation(Collation.CaseInsensitive);
+

The target database must supply the SQL function. Collation mappings have explicit provider limits: + SQLite ASCII folding does not satisfy a Unicode case-insensitive request. + Read the 12.1-to-13 migration guide ↗.

+

Additional databases require passing real-engine CI. + SAP HANA provider in v13 review ↗; + Redshift, Snowflake and Db2 for IBM i remain unsupported.

+
+
Separate histories by scope

From code to schema.

- A minimal SQLite example.
Use a .NET 9 console project for - the current source. + A minimal SQLite example for unreleased v13 source.
+ Use .NET 9 and a checkout of the upgrade branch.

1 -

Install the packages

+

Reference the v13 source

- Add Migrator and an ADO.NET driver. This example passes an open - connection directly to the provider. + Run these commands from your Migrator.NET checkout. This example references + the source project and passes an open SQLite connection to the provider.

View package versions on NuGet ↗Install the packages
dotnet new console -n MigrationDemo -f net9.0
 cd MigrationDemo
-dotnet add package DotNetProjects.Migrator
+dotnet add reference ../src/Migrator/DotNetProjects.Migrator.csproj
 dotnet add package Microsoft.Data.Sqlite --version 9.0.7
@@ -200,10 +223,9 @@

Describe the change

public override void Up() { Database.AddTable("Users", - new Column("Id", DbType.Int32, - ColumnProperty.NotNull), - new Column("Name", DbType.String, 255)); - Database.AddPrimaryKey("PK_Users", "Users", "Id"); + new Column("Id", DbType.Int32) { IsNullable = false }, + new Column("Name", DbType.String, 255), + new PrimaryKeyConstraint("PK_Users", "Id")); } public override void Down() @@ -261,9 +283,8 @@

Run pending migrations

@@ -304,6 +325,7 @@

Additional dialects in source

  • Firebird
  • Ingres
  • Sybase
  • +
  • SAP HANA (v13 source)
  • @@ -312,12 +334,12 @@

    Additional dialects in source

    server or driver version. Schema operations and transactional DDL vary by provider. Check the provider factory and provider tests for your database. @@ -341,6 +363,11 @@

    Choose by how you work.

    >

    +

    Source upgrade under review, not a NuGet release: + fluent operations, SQL-preview subset, runner options, native locks and source CLI. + Read the runner guide and limitations. + Follow the PR stack. +

    Migrator fits applications that want explicit C# migrations and scoped history without coupling schema changes to an ORM. Other @@ -348,11 +375,11 @@

    Choose by how you work.

    Read the detailed feature comparison (Markdown) →
    Explore SQLite emulation, preservation limits and framework differences → @@ -397,7 +424,7 @@

    Choose by how you work.

    Authoring style - Handwritten C#
    Transformation API + Imperative C# + structured fluent API Handwritten C#
    Fluent DSL C# generated from model changes; editable SQL scripts; C# scripts also supported @@ -432,7 +459,7 @@

    Choose by how you work.

    Downgrade an applied version - Authored Down()
    MigrateTo + Authored Down() or supported automatic reversal Down(); auto-reverse for supported expressions @@ -444,7 +471,7 @@

    Choose by how you work.

    History / module separation - Scope in history table + selected assembly/types + Scope-filtered discovery + history Custom version tables + migration filtering @@ -455,7 +482,7 @@

    Choose by how you work.

    Transactions - Per migration + Per migration; none or verified whole-session modes Per migration by default; configurable Most migrations wrapped automatically Opt-in per script or whole run; none by default @@ -463,7 +490,7 @@

    Choose by how you work.

    Execution / deployment - Library; write your own host + Library + source CLI (unreleased) In-process runner + CLI CLI, SQL scripts, bundles, runtime API Library; host in a console app or application @@ -481,7 +508,7 @@

    Choose by how you work.

    Repeatable / recurring work - Custom application code + Ordered maintenance + named profiles; no checksum repeatables Maintenance migrations / profiles Seeding APIs (EF 9+); custom code RunAlways scripts diff --git a/docs/issue-audit.md b/docs/issue-audit.md new file mode 100644 index 00000000..8ba45d86 --- /dev/null +++ b/docs/issue-audit.md @@ -0,0 +1,99 @@ +# GitHub issue audit inventory + +Reviewed issue set: 81 issues (23 open and 58 closed at the start). Baseline: master `b7ae95c`; upgrade work is in the stack #173, #174, #175, #177, #178, #180, #181, #182 and #183. Updated 2026-09-22. + +This inventory separates verified closures, fixes awaiting merge, partial fixes, and historical reports. A historical closed state is not proof of a fresh reproduction. The historical rows below identify named passing baseline tests or explicit source evidence. They have **not all been independently reproduced from their original reports**; related coverage is labeled and must not be treated as complete behavioral proof. No newly implemented fix is closed before its PR merges. + +Evidence used so far: clean master build and SQLite run (139 passed, one unrelated skipped default-removal test); master live matrix [35715528132](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35715528132); independent FK actions [35735648261](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35735648261); reproduced metadata/time failures [35737057890](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35737057890). The integrated source `bc35e0e` passed all eleven database/unit jobs and the coverage gate in [run 35743265022](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35743265022). V13 source 746bd3c passed the complete existing-provider matrix and coverage gate in [run 35766920321](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35766920321), including 87 unit and 197 SQLite cases. HANA admission has separate actual-engine evidence. Later fixes require their own green checks. + +| Issue | Disposition | Reproduction / relevant evidence / remaining work | +| --- | --- | --- | +| [#15](https://github.com/dotnetprojects/Migrator.NET/issues/15) Feature to use update method for copying columns | Historically closed; relevant baseline test verified | `UpdateFromTableToTable_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#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. | +| [#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. | +| [#38](https://github.com/dotnetprojects/Migrator.NET/issues/38) SQLite: Using AddTable with ColumnProperty.Unique silently does nothing | Historically closed; relevant baseline test verified | `AddUniqueColumn` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#39](https://github.com/dotnetprojects/Migrator.NET/issues/39) SQLite: Indexes are dropped if certain methods are called which internally call changeColumnInternal | Historically closed; relevant baseline test verified | `AddColumn_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. | +| [#40](https://github.com/dotnetprojects/Migrator.NET/issues/40) Replace changeColumnInternal and implement different approach | Historically closed; relevant baseline test verified | `RecreateTable_HavingACompoundPrimaryKey_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#41](https://github.com/dotnetprojects/Migrator.NET/issues/41) GetIndexes should distinguish between unique constraints and unique indexes | Historically closed; relevant baseline test verified | `GetSQLiteTableInfo_GetIndexesAndColumnsWithIndex_NoUniqueOnTheColumnsAndIndexExists` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#42](https://github.com/dotnetprojects/Migrator.NET/issues/42) Add GetUniques method for SQLiteTableInfo. This is utterly missing. | Historically closed; relevant baseline test verified | `GetUniques_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#43](https://github.com/dotnetprojects/Migrator.NET/issues/43) T | Historically closed; retain state | Report title is only “T”; no reproducible requirement in the retrieved issue body. No new fix or closure claimed. | +| [#44](https://github.com/dotnetprojects/Migrator.NET/issues/44) If ColumnProperty.PrimaryKey is removed, NotNull is removed as well | Historically closed; relevant baseline test verified | `AddPrimaryKey_AddPrimaryKey_ShouldStillBeNotNull` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#45](https://github.com/dotnetprojects/Migrator.NET/issues/45) ColumnProperty.ForeignKey has no own value but is combined using Unsigned and Null which is wrong | Historical report rechecked; retain closed state | Master ColumnProperty has no active ForeignKey enum member; the obsolete commented declaration is not a combined flag. The reported bit-mask implementation is absent. | +| [#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. | +| [#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. | +| [#59](https://github.com/dotnetprojects/Migrator.NET/issues/59) If NOT NULL or NULL is not explicitly given in the create script, notnull in PRAGMA table_info is wrong | Historical report rechecked; retain closed state | Master SQLite AddTable_NoNotNullColumn_NotNullIsFalse and AddTable_NotNullColumn_NotNullIsTrue passed, distinguishing implicit nullable columns from explicit NOT NULL. | +| [#60](https://github.com/dotnetprojects/Migrator.NET/issues/60) We cannot use NULL in columns of a composite PK | Verified on master; closed | SQLite composite Guid PK regression inserts NULL members and rejects non-null duplicates; single-column PK regression rejects NULL. | +| [#62](https://github.com/dotnetprojects/Migrator.NET/issues/62) PostgreSQL: '42883: function length(integer) does not exist | Historical report rechecked; retain closed state | Master PostgreSQL GetColumnContentSize_UseOnNonStringColumn_ThrowsSpeakingException passed. Non-string input is explicitly rejected rather than sent to length(integer). | +| [#63](https://github.com/dotnetprojects/Migrator.NET/issues/63) T | Historically closed; retain state | Report title is only “T”; no reproducible requirement in the retrieved issue body. No new fix or closure claimed. | +| [#64](https://github.com/dotnetprojects/Migrator.NET/issues/64) CHECK Constraints are not implemented in SQLiteTransformationProvider | Verified on master; closed | SQLite CHECK support and valid/invalid data regressions exist. | +| [#65](https://github.com/dotnetprojects/Migrator.NET/issues/65) public override string[] GetConstraints(string table) returns an empty array in SQLite | Historically closed; relevant baseline test verified | `ConstraintExist` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#66](https://github.com/dotnetprojects/Migrator.NET/issues/66) RemoveAllConstraints should be implemented in SQLite | Historical report rechecked; retain closed state | Master RemoveAllConstraints cleared PK/UNIQUE but retained a CHECK TODO and foreign keys. PR #174 adds FK/CHECK removal; historical closure alone did not establish completeness. | +| [#68](https://github.com/dotnetprojects/Migrator.NET/issues/68) Match child properties and parent properties with data of PRAGMA foreign_key_list | Historically closed; relevant baseline test verified | `GetForeignKeyConstraints_SingleColumn_Success` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#72](https://github.com/dotnetprojects/Migrator.NET/issues/72) TableExistsShouldWorkWithBracketsAndSchemaNameAndTableName Test fails | Historically closed; relevant baseline test verified | `TableExistsShouldWorkWithBracketsAndSchemaNameAndTableName` passed in the SQLServer artifact of master run 35715528132 (2 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#73](https://github.com/dotnetprojects/Migrator.NET/issues/73) SqlServerDialect has incorrect boundaries defined for NVARCHAR(n). Should be 4000 | Historically closed; relevant baseline test verified | `AddTableWithFixedLengthEqualTo4000Characters_ShouldCreateNVARCHAR4000` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#74](https://github.com/dotnetprojects/Migrator.NET/issues/74) Fix RemoveUnexistingColumn test for SQL Server | Historically closed; relevant baseline test verified | `RemoveUnexistingColumn` passed in the SQLServer artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#75](https://github.com/dotnetprojects/Migrator.NET/issues/75) Reactivate SQL Server Tests | Historical report rechecked; retain closed state | Master workflow run 35715528132 contains a successful live SQL Server job and its NUnit artifact; the provider is enabled in the required CI matrix. | +| [#82](https://github.com/dotnetprojects/Migrator.NET/issues/82) AddTable/AddForeignKey does not quote names - important for Postgre SQL | Historical report rechecked; retain closed state | Master quotes reserved identifiers on several paths, with AddIndex_TableNameIsReservedWord_Succeeds passing. PR #174 expands constraint-name quoting. Full table/FK identifier coverage remains a limitation shared with #54. | +| [#85](https://github.com/dotnetprojects/Migrator.NET/issues/85) Reactiveate MySQL tests | Verified on master; closed | PR #171 restored live MySQL/MariaDB tests; master CI run 35715528132 passed. | +| [#89](https://github.com/dotnetprojects/Migrator.NET/issues/89) GetColumns() in Postgre does not even read the type nor does it convert it to DBType! | Historically closed; relevant baseline test verified | `GetColumns_DataTypeResolveSucceeds` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#90](https://github.com/dotnetprojects/Migrator.NET/issues/90) Default Values are not read correctly in Postgre using GetColumns() | 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. | +| [#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. | +| [#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. | +| [#106](https://github.com/dotnetprojects/Migrator.NET/issues/106) SQL Server type detection should be completely overhauled - does not work correctly | 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. | +| [#107](https://github.com/dotnetprojects/Migrator.NET/issues/107) SQL Server parser of default values does not work correctly and implements only a few data types. Should be fixed and extended. | 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. | +| [#108](https://github.com/dotnetprojects/Migrator.NET/issues/108) Oracle: Dialect for byte array byte[] fails => OracleException (0x80004005): ORA-03062: Ein Komma oder eine rechte Klammer fehlen | 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. | +| [#109](https://github.com/dotnetprojects/Migrator.NET/issues/109) SQLite: RemoveForeignKey does nothing - silently! It is overridden but just returns - nothing else. | Historically closed; relevant baseline test verified | `RemoveForeignKey` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#110](https://github.com/dotnetprojects/Migrator.NET/issues/110) Implement GetCheckConstraints() - at least for generic tests | Historically closed; relevant baseline test verified | `GetCheckConstraints_AddCheckConstraintsViaAddTable_CreatesTableCorrectly` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#112](https://github.com/dotnetprojects/Migrator.NET/issues/112) No feedback if table or constraint does not exist in RemoveConstraint in TransformationProvider | Historically closed; relevant baseline test verified | `RemoveUnexistingForeignKey` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#113](https://github.com/dotnetprojects/Migrator.NET/issues/113) PrimaryKeyExists should be overridden and should throw in SQLite since it does not support named primary keys. | Historical report rechecked; retain closed state | Master overrides PrimaryKeyExists and reports whether any primary key exists, deliberately ignoring the supplied name. This differs from the issue suggestion to throw; preserve compatibility and document the actual semantics. | +| [#114](https://github.com/dotnetprojects/Migrator.NET/issues/114) AddCheckConstraint is not overridden in SQLiteTransformationProvider | Historically closed; relevant baseline test verified | `CanAddCheckConstraint` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#115](https://github.com/dotnetprojects/Migrator.NET/issues/115) Some AddColumn virtual methods are not overridden in SQLite resulting in cascading failure. | Historically closed; relevant baseline test verified | `AddColumnWithDefaultButNoSize` passed in the SQLite artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#118](https://github.com/dotnetprojects/Migrator.NET/issues/118) ColumnExists returns false in a catch! | Historical report rechecked; retain closed state | Master ColumnExists(table, column, ignoreCase) directly queries GetColumns without a catch. The exception-swallowing code in the report is absent. | +| [#120](https://github.com/dotnetprojects/Migrator.NET/issues/120) Extend Oracle restrictions from 30bytes to 128bytes supporting Oracle versions greater than 12.1 | Historical report rechecked; retain closed state | Master Oracle validation uses Encoding.UTF8.GetBytes(name).Length with a 128-byte limit. PR #174 repairs column-name validation to validate each actual column. Older Oracle versions have different limits. | +| [#122](https://github.com/dotnetprojects/Migrator.NET/issues/122) Oracle: AddIndex does not add a unique index if used in Index instance | Historically closed; relevant baseline test verified | `AddIndex_Unique_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#123](https://github.com/dotnetprojects/Migrator.NET/issues/123) Indexes should be filterable | Historically closed; relevant baseline test verified | `AddIndex_FilteredIndexMiscellaneousFilterTypesAndDataTypes_Success` passed in the PostgreSQL artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#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. | +| [#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. | +| [#143](https://github.com/dotnetprojects/Migrator.NET/issues/143) Replace Identity trigger to "GENERATED...." | Historically closed; relevant baseline test verified | `GetColumns_GetIdentity_Succeeds` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#145](https://github.com/dotnetprojects/Migrator.NET/issues/145) ExecuteScalar("SELECT MAX(Id) FROM MyTable") should return null (C#) if table is empty | Additive fix in PR #174; await merge | ExecuteNullableScalar returns null for null/DBNull and preserves typed structs; existing ExecuteScalar contract stays compatible. | +| [#146](https://github.com/dotnetprojects/Migrator.NET/issues/146) Oracle: Handle default value "NULL" | Historically closed; relevant baseline test verified | `DefaultValue_Null_Success` passed in the Oracle artifact of master run 35715528132 (1 case(s)). This verifies the named scenario; broader edge cases are not inferred. | +| [#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. | +| [#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. | +| [#169](https://github.com/dotnetprojects/Migrator.NET/issues/169) Support ON DELETE CASCADE (and other FK actions) in SQLite migrator | 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. | + +## Remaining audit work + +- Finish individual source/test evidence for historical closed reports; do not interpret a broad green suite as proof of every original report. +- Recheck partial schema qualification, inline identifier quoting, historical uniqueness ownership and provider-specific time representations. +- 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. + diff --git a/docs/live-database-tests.md b/docs/live-database-tests.md index 8ae1d72c..a1e7f9db 100644 --- a/docs/live-database-tests.md +++ b/docs/live-database-tests.md @@ -15,9 +15,10 @@ The pull-request workflow runs independent jobs on GitHub-hosted Ubuntu 22.04 wi | Firebird | `firebirdsql/firebird:5.0.3` | FirebirdSql.Data.FirebirdClient 10.3.4 | | Db2 | `icr.io/db2_community/db2:11.5.9.0` | Net.IBM.Data.Db2-lnx 9.0.0.400 | | Informix | `icr.io/informix/informix-developer-database:15.0.1.0.3` | Informix.Net.Core-lnx 4.1501.2.2026 | +| Hana | `saplabs/hanaexpress:2.00.088.00.20251110.1` | Sap.Data.Hana.Net.v8.0 2.30.27 | | Sybase | `datagrip/sybase:16.0` (ASE developer image) | AdoNetCore.AseClient 0.19.2 | -The IBM Linux packages and ASE client are conditional test-project dependencies selected by `-p:LiveDatabase=Db2`, `Informix`, or `Sybase`. They do not become library dependencies. The library's provider identifiers and public API remain unchanged. Db2 and Informix containers need privileged mode. Image tags are fixed versions; container inspection artifacts record the actual downloaded image IDs. +The IBM Linux packages and ASE client are conditional test-project dependencies selected by `-p:LiveDatabase=Db2`, `Informix`, or `Sybase`. They do not become library dependencies. The SAP client is also a test-only dependency; the core loads its factory dynamically. Db2 and Informix containers need privileged mode. Image tags are fixed versions; container inspection artifacts record the actual downloaded image IDs. ## Coverage and isolation @@ -25,7 +26,9 @@ The IBM Linux packages and ASE client are conditional test-project dependencies Every test creates a uniquely named database (a schema for Db2, an independent server-side file for Firebird). Connections disable pooling. Teardown disposes the provider and drops that database/schema; Db2 removes tables in dependency order first. Migration cycles reuse the same isolated store to exercise repeatability even on engines whose DDL commits automatically. ASE test databases enable full logging for ALTER TABLE and allow DDL in transactions and allocate 32 MB of data plus a separate 16 MB log allocation to accommodate the image's model database. -Existing SQL Server, PostgreSQL, Oracle and SQLite suites continue to run in full. The Unit job uses the complement of all database categories. An audit compares NUnit's discovery count against the union of all job results and rejects missing or duplicate test assignments. Each job rejects zero executed tests; new suites also reject skips. Existing ignored tests retain their documented reasons: generic default removal (issue #139) and a SQL Server column-change regression (issue #132). TRX and NUnit XML expose each reason for review. +The Hana suite creates a disposable schema per test and covers imperative/fluent/generated schema creation, timestamp expression defaults, constraint/index metadata, data and nullability/default changes, caller-owned connections, DML rollback, and migration history restart/downgrade. Its unsupported-operation tests reject unavailable capabilities. + +Existing SQL Server, PostgreSQL, Oracle and SQLite suites continue to run in full. The Unit job uses the complement of all database categories. An audit compares NUnit's discovery count against the union of all job results and rejects missing or duplicate test assignments. Each job rejects zero executed tests; new suites also reject skips. Previously ignored default-removal and SQL Server uniqueness cases have behavioral replacements. TRX and NUnit XML expose any remaining ignored case and its reason; a skipped case is never evidence of support. Readiness and startup are bounded; database jobs time out after 35 minutes. Startup logs, container logs/inspection, TRX and NUnit XML are uploaded on success or failure. Registry downloads may retry; test failures never do. A new commit cancels an obsolete run. @@ -43,7 +46,11 @@ For a server-backed suite, use Linux with Docker, .NET 9 and PowerShell (`pwsh`) ```bash database=MySQL # or a server job name from the table +export RUNNER_TEMP="$(mktemp -d)" +export GITHUB_ENV="$RUNNER_TEMP/database.env" +touch "$GITHUB_ENV" bash .github/scripts/start-database.sh "$database" +while IFS= read -r setting; do export "$setting"; done < "$GITHUB_ENV" dotnet build Migrator.slnx -p:LiveDatabase="$database" pwsh -File .github/scripts/test.ps1 -Database "$database" docker logs migrator-db @@ -65,7 +72,7 @@ export INFORMIXDIR="$output/native" export LD_LIBRARY_PATH="$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" ``` -New suites accept `MIGRATOR_MYSQL`, `MIGRATOR_MARIADB`, `MIGRATOR_FIREBIRD`, `MIGRATOR_DB2`, `MIGRATOR_INFORMIX`, or `MIGRATOR_SYBASE` connection-string overrides. Use disposable servers with administrative database/schema creation permissions. Defaults match the startup script. ASE additionally expects the disposable `migrator_data` and `migrator_log` devices initialized by that script. Existing suites read `appsettings.json` through ConfigurationReader, with a `MIGRATOR_` plus uppercased configuration-key override. +New suites accept `MIGRATOR_MYSQL`, `MIGRATOR_MARIADB`, `MIGRATOR_FIREBIRD`, `MIGRATOR_DB2`, `MIGRATOR_INFORMIX`, `MIGRATOR_SYBASE`, or `MIGRATOR_HANA` connection-string overrides. Use disposable servers with administrative database/schema creation permissions. Defaults match the startup script. ASE additionally expects the disposable `migrator_data` and `migrator_log` devices initialized by that script. Existing suites read `appsettings.json` through ConfigurationReader, with a `MIGRATOR_` plus uppercased configuration-key override. To reproduce the assignment audit, download all `test-results-*` artifacts from a single completed workflow into `TestResults`, preserving their per-database directories, then run: @@ -75,6 +82,7 @@ python3 .github/scripts/verify-test-coverage.py TestResults ## Engine and provider limits +- HANA Express startup needs Docker and the kernel settings in `start-hana.sh`; it takes several minutes. The test script creates and removes schemas, so its account needs those permissions. HANA defaults retain engine restrictions: `CURRENT_TIMESTAMP` is supported, arbitrary function calls such as `LOWER(...)` are not valid default clauses. DDL may autocommit; the provider rejects whole-session transactional DDL, native locking and tenant administration. See [additional database qualification](additional-database-qualification.md) for tested scope and deferred engines. - MySQL/MariaDB DDL may commit automatically; the suite uses independent databases instead of relying on rollback. Modern pinned versions enforce CHECK constraints. - Firebird identity columns require Firebird 3 or newer; this suite tests version 5. SQL cannot enumerate all server database files, so `GetDatabases` returns the attached database. Firebird has no general table rename operation. - Db2 primary-key and unique-constraint columns must be NOT NULL. Column changes can require REORG, which the provider performs. Foreign-key updates are restrictive; supported delete actions are translated separately. `GetDatabases` returns the current server database, not a client catalog. diff --git a/docs/migration-framework-comparison.md b/docs/migration-framework-comparison.md index 1894c4ae..8aead7e4 100644 --- a/docs/migration-framework-comparison.md +++ b/docs/migration-framework-comparison.md @@ -4,7 +4,7 @@ The main matrices cover **DotNetProjects.Migrator, FluentMigrator, EF Core migrations, DbUp and Evolve**—all five frameworks on the homepage. Additional sections cover **EF6, grate and RoundhousE**, with a short boundary comparison for **Flyway and Liquibase**. This is a defined shortlist, not a claim to catalogue every migration package ever published. -Migrator findings are pinned to repository commit [`ab3aa9f`][m-revision], before the parallel refactoring. FluentMigrator's SQLite implementation is pinned to [`2e0acdb`][f-sqlite-generator]. Other findings describe the linked official documentation as reviewed, not guaranteed behavior of every historical release. EF Core features introduced in version 9 are labeled. Check provider and release compatibility separately. +Migrator findings are pinned to v13 upgrade-stack commit [`eabec55`][m-revision]. These are source capabilities under review in PRs [#173](https://github.com/dotnetprojects/Migrator.NET/pull/173), [#174](https://github.com/dotnetprojects/Migrator.NET/pull/174), [#175](https://github.com/dotnetprojects/Migrator.NET/pull/175) [#177](https://github.com/dotnetprojects/Migrator.NET/pull/177), [#180](https://github.com/dotnetprojects/Migrator.NET/pull/180) [#181](https://github.com/dotnetprojects/Migrator.NET/pull/181) and [#182](https://github.com/dotnetprojects/Migrator.NET/pull/182), **not a claim that these features have shipped on NuGet**. FluentMigrator's SQLite implementation is pinned to [`2e0acdb`][f-sqlite-generator]. Other findings describe the linked official documentation as reviewed, not guaranteed behavior of every historical release. EF Core features introduced in version 9 are labeled. Check provider and release compatibility separately. [Homepage](https://dotnetprojects.github.io/Migrator.NET/) · [Project README](../README.md) · [SQLite emulation comparison](#sqlite-emulation-comparison) · [Source index](#source-index) @@ -47,13 +47,13 @@ Evidence: [Migrator runner][m-runner], [loader][m-loader], [migration contract][ | Requires an ORM model | No | No | Yes, for normal scaffolding | No | No | | Generates changes from model differences | No built-in | No built-in model differ in core workflow | Yes | No; author scripts | No; author scripts | | Migration without a model change | Yes | Yes | Empty migration, then custom operations | Yes | Yes | -| Schema DSL / transformation API | `Database` operations; optional `SchemaBuilder` | Fluent create/alter/delete expressions | `MigrationBuilder` operations | No schema DSL; SQL / commands | No schema DSL; SQL | +| Schema DSL / transformation API | Imperative API and structured `MigrationBuilder`; provider limits apply | Fluent create/alter/delete expressions | `MigrationBuilder` operations | No schema DSL; SQL / commands | No schema DSL; SQL | | Custom C# logic | `Up` / `Down`; open provider | Migration code / connection operations | SQL/custom operations for database work | `IScript` and command factory | Surrounding host logic; migrations are SQL | | Raw SQL | Command, query and scalar APIs | Inline, file and embedded SQL | `migrationBuilder.Sql` | Primary workflow | Primary workflow | | Migration discovery | Assembly scan or explicit `Type[]` | Assembly scanning / filters | Context's migration assembly | Configurable script providers | Locations or embedded resources | -| Constructor dependency injection | Default loader uses `Activator.CreateInstance`; customize loader | Runner/DI integration | Context services; migration customization is separate | Custom script provider/host if needed | No C# migration constructors | +| Constructor dependency injection | Optional Microsoft DI/options package; custom activator supported | Runner/DI integration | Context services; migration customization is separate | Custom script provider/host if needed | No C# migration constructors | | Embedded execution | Yes | Yes | Yes | Yes | Library mode | -| Dedicated execution host | Write your own | Library or packaged runner | Tooling, bundles or custom host | Write your own | CLI, .NET tool or library | +| Dedicated execution host | Library or source-built packaged .NET tool (unreleased) | Library or packaged runner | Tooling, bundles or custom host | Write your own | CLI, .NET tool or library | EF Core's model snapshot comparison is not a live-database schema comparison. DbUp's C# support is more than static SQL file loading, but it does not supply a cross-database schema-operation layer. @@ -90,16 +90,16 @@ Evidence: [Migrator loader][m-loader], [execution][m-execution] and [history sto | Default history | `SchemaInfo` | Version table | `__EFMigrationsHistory` | E.g. `SchemaVersions` | `changelog` | | History customization | Table name; scope column | Version-table metadata | Table/schema; custom services | Custom journal / table | Metadata table/schema | | Independent modules | Scope + selected migrations | Separate history + filters | Contexts/assemblies + separate history | Filters + separate journals | Locations + separate metadata | -| Environment selection | Host selection; ignore attribute for assembly discovery | Tags / profiles / configuration | Context/deployment configuration | Filters / host | Locations / placeholders / host | +| Environment selection | Tags with explicit Any/All matching; scopes and named profiles | Tags / profiles / configuration | Context/deployment configuration | Filters / host | Locations / placeholders / host | | Skip applied work | Version history | Version history | Migration history | Journal | Metadata | | Applied-source checksum | No built-in | Not a core version-table guarantee | No script checksum journal | Standard journal tracks names; custom validation | Script checksums | | Late lower-numbered change | Revisits missing versions up to target | Check runner policy | Do not assume IDs make diverging branches safe | Unrecorded scripts eligible; ordering matters | `OutOfOrder` | | Repeat on content change | Custom | Not equivalent to maintenance/profiles | Not equivalent to seeding | Custom checksum-aware runner | Repeatable SQL | -| Always-run work | Host code; hooks are per executed migration | Maintenance / selected profiles | Seeding APIs, EF 9+ | `RunAlways` / `NullJournal` | Not identical to RunAlways | +| Always-run work | Ordered before/after-run and before/after-migration stages; selected profiles | Maintenance / selected profiles | Seeding APIs, EF 9+ | `RunAlways` / `NullJournal` | Not identical to RunAlways | | Existing-schema baseline | Custom verified history initialization | Custom baseline/runner strategy | Existing-schema workflow | `MarkAsExecuted` | `StartVersion` / skip options | | Repair checksums | Not applicable | Not established by version history | Not applicable | Custom journal concern | `repair` | -**Migrator scope detail:** `MigrationAttribute.Scope` changes where a history record is written; it does not filter assembly discovery. A runner reads its provider scope and checks duplicate versions across its entire loaded set. Use separate assemblies or explicit types, normally leaving the attribute scope unset. History isolation is not table isolation. [Loader][m-loader], [execution][m-execution], [provider][m-provider]. +**Migrator scope detail:** unscoped migrations inherit the runner scope. Explicitly scoped migrations are selected only for that scope; duplicate validation and history access use the same effective scope. Custom legacy providers without `IMigrationHistory` retain their prior behavior. History isolation is not table isolation. [Loader][m-loader], [execution][m-execution], [provider][m-provider]. ## Transactions, rollback and coordination @@ -108,14 +108,14 @@ Evidence: [Migrator execution][m-execution] and [runner][m-runner]; [FluentMigra | Capability | Migrator | FluentMigrator | EF Core | DbUp | Evolve | | ------------------------------ | -------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------ | | Default transaction unit | Per migration | Per migration; configurable | Version-sensitive: EF 9 grouped pending migrations, reverted in EF 10 | None | Per migration | -| Whole-run transaction | Not a runner option | Configure/orchestrate; check runner | Depends on version/operations | `WithTransaction()` | `CommitAll` | -| Per-change transaction opt-out | No migration attribute | Transaction behavior | Raw SQL suppression | Choose strategy / separate runs | Script opt-out | +| Whole-run transaction | `WholeSession` for verified SQLite, PostgreSQL and SQL Server dialects | Configure/orchestrate; check runner | Depends on version/operations | `WithTransaction()` | `CommitAll` | +| Per-change transaction opt-out | Run-level `None`; no per-migration transaction attribute | Transaction behavior | Raw SQL suppression | Choose strategy / separate runs | Script opt-out | | Failed DDL rollback | Engine-dependent | Engine-dependent | Engine-dependent | When enabled and supported | Engine-dependent | | Reverse committed migration | Authored `Down()` | `Down()` | Generated/editable `Down()` | Custom undo / forward fix | Forward fix; no Down command | -| Generate reverse operations | No | Supported auto-reverse expressions | Scaffolding; review output | No schema reverse generator | No | +| Generate reverse operations | Supported create/rename operations; explicit reverse required for destructive/data/SQL operations | Supported auto-reverse expressions | Scaffolding; review output | No schema reverse generator | No | | Target earlier version | `MigrateTo` | Down/rollback APIs | Earlier target / reverse script | Custom | Target limits forward work, not undo | | Restore deleted data | Backup / reconstruction | Same | Same | Same | Same | -| Cross-process coordination | No built-in migration lock found | Serialize deployment / application-lock pattern | Migration locking, EF 9+; execution-path dependent | Host/provider concern; journal is not a lock | Cluster setting; provider-dependent | +| Cross-process coordination | Opt-in native session locks for SQL Server, PostgreSQL and MySQL/MariaDB; custom abstraction | Serialize deployment / application-lock pattern | Migration locking, EF 9+; execution-path dependent | Host/provider concern; journal is not a lock | Cluster setting; provider-dependent | | Post-commit hooks | `AfterUp` / `AfterDown` | Maintenance stages | Host/seeding lifecycle; not direct equivalent | Host / ordered scripts | Host / ordered scripts | A scope, checksum, history primary key or ordinary database write lock does not prove that two deployments can safely run the entire sequence concurrently. Evolve's cluster setting must be checked for the selected provider; it is not a blanket SQLite session-lock guarantee. @@ -126,14 +126,14 @@ Evidence: [Migrator runner][m-runner] and [execution][m-execution]; [FluentMigra | Capability | Migrator | FluentMigrator | EF Core | DbUp | Evolve | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------ | --------------------------------- | -------------------------------- | ------------------------------------------------ | -| Packaged CLI | No | Yes | `dotnet ef` | Core library; custom host | Yes | +| Packaged CLI | Source project `DotNetProjects.Migrator.Tool`; not published by this upgrade | Yes | `dotnet ef` | Core library; custom host | Yes | | Dedicated migration bundle generator | No; publish host | Package runner/migrations | Yes | Publish host | CLI distribution, not EF-style bundle generation | -| Review SQL without applying | No equivalent runner SQL generator | Preview/output | Scripts | Authored SQL / pending scripts | Authored SQL | -| Dry-run qualification | Skips bodies; still touches provider/transactions | Processor preview; user code needs care | Not a full side-effect simulation | Pending list / custom simulation | `RollbackAll` actually executes | +| Review SQL without applying | Connected/offline structured subset; unsupported operations fail explicitly | Preview/output | Scripts | Authored SQL / pending scripts | Authored SQL | +| Dry-run qualification | `DryRun` plans versions without migration bodies, callbacks, transactions or history creation | Processor preview; user code needs care | Not a full side-effect simulation | Pending list / custom simulation | `RollbackAll` actually executes | | Idempotent deployment SQL | Custom | Preview is not idempotent history guarding | Provider-dependent; not SQLite | Author SQL / use journal | Author SQL / use metadata | | Status | Versions / loaded types | Runner/tool info | CLI / history APIs | Pending/executed APIs | `info` | | Command timeout | Provider setting | Processor setting | Database/provider setting | Runner/provider setting | `CommandTimeout` | -| Logging | `ILogger` / writers | Logging integration | EF logging | `IUpgradeLog` / integrations | Host/CLI | +| Logging | Legacy logger plus optional Microsoft logging adapter (SQL/exception details omitted) | Logging integration | EF logging | `IUpgradeLog` / integrations | Host/CLI | | SQL substitution | Custom | Script tokens | Custom logic | `$variable$` | `${placeholder}` | | Deployment identity | Host connection | Runner connection | Migration connection | Host connection | Tool connection | @@ -143,7 +143,7 @@ Evidence: [Migrator runner][m-runner] and [execution][m-execution]; [FluentMigra | Framework | How support is supplied | What it does not guarantee | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | -| Migrator | Source dialects + separate ADO.NET drivers. Live CI covers SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix and Sybase; Ingres is another source dialect. [CI guide][m-live]. | Every server/driver release, operation or arbitrary SQL construct. | +| Migrator | Source dialects + separate ADO.NET drivers. Live CI covers SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase and the new HANA job; Ingres is another source dialect. [CI guide][m-live]. | Every server/driver release, operation or arbitrary SQL construct. | | FluentMigrator | Provider generators/processors. [Configuration][f-config]. | The same expression working on every engine. | | EF Core | Relational provider packages. [Multiple providers][ef-providers]. | One provider's generated migrations working unchanged elsewhere. | | DbUp | Database integrations. [Provider list][d-databases]. | SQL dialect translation. | @@ -151,6 +151,32 @@ Evidence: [Migrator runner][m-runner] and [execution][m-execution]; [FluentMigra A migration can compile yet require a table copy, lose an unsupported schema detail or fail on existing data. Compare the exact operation and data shape, not just database names. +## Version 13 authoring and database additions + +The v13 source uses `PrimaryKeyConstraint`, `UniqueConstraint`, `CheckConstraint` +and `ForeignKeyConstraint` objects instead of key/uniqueness flags on columns. +`IsNullable`, `IsIdentity` and `IsUnsigned` describe column attributes. The duplicate +legacy schema builder is removed; see the [migration guide](migration-guide-12.1-to-13.md). + +Both APIs accept `RawSql.Insert("ksuid_new()")` as a default expression; ordinary +strings remain values. Typed `Collation` presets resolve supported case/accent +comparison intent or fail explicitly; installed provider names remain available. +This does not make arbitrary SQL functions or linguistic ordering portable. +See [FluentMigrator's raw SQL helper](https://fluentmigrator.github.io/basics/raw-sql.html) +for the corresponding upstream default-expression feature. + +The new **SAP HANA** source provider in [PR #182](https://github.com/dotnetprojects/Migrator.NET/pull/182) +has a mandatory GitHub Actions job using SAP's actual HANA Express engine and native +.NET driver. It covers schema/data operations, metadata, constraints, migration +history, restart, DML rollback and imperative/fluent/preview parity. DDL may +autocommit; native migration locking, whole-session transactional DDL and the +packaged CLI's online HANA host are not provided. + +The remaining additional FluentMigrator engines are **Redshift, Snowflake and +Db2 for IBM i**. They require external test infrastructure and remain deferred. +PostgreSQL tests do not qualify Redshift, and Db2 LUW tests do not qualify IBM i. +[Qualification requirements, CI evidence and primary sources](additional-database-qualification.md). + ## SQLite emulation comparison ### What emulation means @@ -170,8 +196,8 @@ Evidence: [EF Core SQLite operation table][ef-sqlite], [FluentMigrator SQLite ge | Existing-table operation | Migrator | FluentMigrator | EF Core | DbUp / Evolve | | ---------------------------- | --------------------------------- | ------------------------------- | ----------- | ---------------------------------------------- | | Add ordinary column | R | N | N | Manual SQL | -| Remove column | R | N; engine restrictions | R | Manual SQL/rebuild | -| Rename column | R | N; engine restrictions | N | Manual SQL/rebuild | +| Remove column | N on SQLite 3.35+ when eligible; R fallback | N; engine restrictions | R | Manual SQL/rebuild | +| Rename column | N on SQLite 3.26+; R fallback | N; engine restrictions | N | Manual SQL/rebuild | | Change declared type | R | Manual | R | Manual rebuild | | Change nullability | R | Manual | R | Manual SQL on 3.53+ / rebuild on older engines | | Change default | R via full `Column` | Manual | R via alter | Manual rebuild | @@ -201,17 +227,17 @@ Methods refer to the pinned [SQLite provider][m-sqlite]. Tests illustrate eviden | ------------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AddColumn` | Adds a column and mapping without an old source column; rebuilds. | Existing rows receive SQLite default/NULL behavior; incompatible NOT NULL requirements can fail. [Tests][t-add-column]. | | `ChangeColumn` | Replaces the entire matching `Column` definition; rebuilds. | Specify properties to retain. Type affinity during copying is not arbitrary data conversion. [Tests][t-change-column]. | -| `RemoveColumnDefaultValue` | Clears parsed default; rebuilds. | Dedicated API is exercised, but generic `ChangeColumn_RemoveDefaultValue_Success` is skipped under issue #139. Not every default-removal path is verified. [Tests][t-sqlite-general]. | -| `RemoveColumn` | Removes column/mapping and matching single-column indexes/uniques/FKs; rebuilds affected tables. | Rejects detected CHECK references and composite dependencies until adjusted. Can remove inbound single-column FKs from other tables. [Tests][t-remove-column]. | -| `RenameColumn` | Changes copy mapping, column and represented key/index references; adjusts referencing tables. | Requires FK enforcement off; not an arbitrary SQL-expression rewriter. [Tests][t-rename-column]. | -| `AddPrimaryKey` | Sets membership, orders selected columns, rebuilds. | Composite keys supported; `PrimaryKeyExists` checks for any PK rather than matching its name. [Tests][t-pk]. | -| `RemovePrimaryKey` | Clears PK/PK-identity flags; rebuilds. | Changes identity-related semantics; review referencing tables. [Source][m-sqlite]. | +| `RemoveColumnDefaultValue` | Clears parsed default; rebuilds. Generic default-removal regression is enabled and passes. | Dedicated and generic default-removal regressions run; provider CI is required for changes. [Tests][t-sqlite-general]. | +| `RemoveColumn` | Uses native DROP COLUMN on SQLite 3.35+ for eligible columns; otherwise removes represented dependencies and rebuilds. | Rejects detected CHECK references and composite dependencies until adjusted. Can remove inbound single-column FKs from other tables. [Tests][t-remove-column]. | +| `RenameColumn` | Native on SQLite 3.26+; reconstruction fallback for older engines. | Native rename delegates dependency rewriting to SQLite; reconstruction is not an arbitrary SQL-expression rewriter. [Tests][t-rename-column]. | +| `AddPrimaryKey` | Adds an explicit primary-key constraint and rebuilds without reordering physical columns. | Composite key order and actual constraint names are preserved; `PrimaryKeyExists` matches the name. [Tests][t-pk]. | +| `RemovePrimaryKey` | Removes the primary-key definition and identity attribute; rebuilds. | Changes identity-related semantics; review referencing tables. [Source][m-sqlite]. | | `AddForeignKey` / `RemoveForeignKey` | Adds/removes represented FK; rebuilds child table. | Validate existing rows and enforcement. [FK tests][t-fk], [integrity tests][t-integrity]. | | `AddUniqueConstraint` | Adds named unique definition; rebuilds. | Duplicate data can reject the copy. [Metadata tests][t-uniques]. | | `AddCheckConstraint` | Adds named CHECK SQL; rebuilds. | Predicate must accept existing rows and be understood by the reader. [Tests][t-check]. | | `RemoveConstraint` | Removes matching unique and check definitions; rebuilds. | Does not remove FKs/PKs; use dedicated APIs. [Source][m-sqlite]. | -| `RemoveAllConstraints` | Removes PK/unique definitions via rebuilds. | Retains FKs and leaves CHECK handling incomplete; not an all-constraint eraser. [Tests][t-remove-constraints], [source][m-sqlite]. | -| `RemoveAllIndexes` | Clears indexes **and unique constraints**; rebuilds. | Broader than dropping non-unique indexes. [Source][m-sqlite]. | +| `RemoveAllConstraints` | Clears PK, unique, FK and CHECK definitions before rebuilding. | Constraint removal can fail when dependent schemas/data require a coordinated migration. [Tests][t-remove-constraints], [source][m-sqlite]. | +| `RemoveAllIndexes` | Drops ordinary indexes; preserves declared unique constraints. | Unique indexes are indexes; constraint-backed indexes remain owned by their constraints. [Source][m-sqlite]. | | `RecreateTable` | Public low-level schema/mapping reconstruction. | Requires a consistent supported representation. [Composite-key round-trip test][t-recreate]. | | `TruncateTable` | Emits `DELETE FROM`. | Not native TRUNCATE and not an identity-sequence reset. [Source][m-sqlite]. | @@ -225,15 +251,15 @@ Methods refer to the pinned [SQLite provider][m-sqlite]. Tests illustrate eviden | FKs and delete actions | Read from schema/PRAGMA; emitted into replacement DDL. | Not a promise about every clause, e.g. arbitrary deferrability. | | Unique / CHECK definitions | Included in `SQLiteTableInfo`. | Reader restrictions apply; rename does not rewrite arbitrary CHECK expressions. | | Indexes / represented filters | Recreated after replacement. | Complex predicates, expressions, collations and sort details require separate verification. | -| Triggers | No trigger collection/replay in schema model or rebuild. | Do not assume preservation; a table drop removes its triggers. Recreate as needed. | +| Triggers | Collected and replayed for supported rebuilds without renames; unsafe rename fallback rejected. | Trigger SQL is replayed only where the rebuild does not require rewriting its identifiers. | | Views / dependent SQL | No general dependency-SQL rewrite. | Validate/recreate dependencies after renames/drops. | -| `WITHOUT ROWID`, `STRICT`, generated columns | Not modeled as a complete round-trip contract. | No blanket preservation claim for external schemas. | -| Hidden `rowid` / AUTOINCREMENT high-water mark | Only mapped columns copied; no explicit sequence-state restoration. | Historical rowid/sequence metadata may change. | +| `WITHOUT ROWID`, `STRICT`, generated columns | Unsupported reconstruction is rejected before dropping the original. | No preservation claim for unsupported external table properties. | +| Hidden `rowid` / AUTOINCREMENT high-water mark | Mapped columns and retained AUTOINCREMENT high-water state are preserved; hidden rowid is not mapped. | Deleted historical identity values are not reused after a rebuild; hidden rowid values may change. | | Type / length enforcement | Changes declarations, not SQLite typing rules. | Declared size is not SQL Server-like length enforcement. | -| FK enforcement state | Runner disables before migration and restores after successful execution. | Direct provider calls differ; exception restoration is not proven by success-path tests. | -| Whole-database FK validation | Integrity helper exists; runner does not automatically invoke it. | Enabling enforcement alone does not validate existing rows. | +| FK enforcement state | Runner and owned rebuild transactions restore the prior setting after success/failure. | Caller-owned active SQLite transactions require FK settings to be configured before beginning the transaction. | +| Whole-database FK validation | Runner and owned rebuild transactions validate integrity before commit. | Enabling enforcement alone does not validate existing rows. | -Evidence: [SQLite provider][m-sqlite], [schema model][m-sqlite-model], [execution][m-execution], [SQLite reconstruction procedure][sqlite-alter]. Re-evaluate these limitations after the parallel refactoring. +Evidence: [SQLite provider][m-sqlite], [schema model][m-sqlite-model], [execution][m-execution], [SQLite reconstruction procedure][sqlite-alter]. Native drop-column selection and AUTOINCREMENT high-water preservation have regressions. Arbitrary dependency rewriting remains unsupported. ### How the other frameworks compare on preservation @@ -306,25 +332,19 @@ These interpretations are grounded in the preceding evidence, rather than univer Potential Migrator improvements, **not implemented-feature claims**: -1. A packaged CLI and dedicated SQL-preview/export workflow. +1. Broader structured SQL-preview coverage, more client-script dialects and CLI deployment validation. SQL Server GO scripts now use an explicit batch path. The source CLI and preview subset already exist. 2. Validation of edits to already applied migration content. -3. Cross-process migration locking and explicit failure recovery. +3. More native lock backends and recovery/concurrency validation; three database families now have opt-in locks. 4. Repeatable migrations distinct from execution hooks. -5. Stronger SQLite preservation of triggers, generated columns, table options and complex indexes. -6. Clearer bulk-removal semantics and FK-state restoration after exceptions. +5. SQLite generated columns, table options, hidden rowid and complex-index preservation beyond the currently guarded subset. +6. Broader behavioral parity tests beyond the [fluent method-family inventory](fluent-operation-coverage.md), and provider coverage for explicit adoption of historical uniqueness objects without ownership markers. 7. Continued operation-level provider documentation and live test coverage. ## Validation and maintenance -Reviewed in a separate Git worktree based on `ab3aa9f`. No migration implementation files or parallel-refactoring checkout were changed. - -The existing SQLite category was executed on Windows: +The master baseline (`b7ae95c`) passed 139 SQLite tests with one skipped default-removal test. The earlier runner upgrade revision passed **93 unit tests and 184 SQLite tests, with no skips**, after rebuilding the solution. Test counts reflect replacement of assertion-free tests with behavioral checks. The packed/installed tool previously passed offline SQL, migration, status and rollback smoke checks. The provider fixes at `bdc8ac3` passed all eleven database/unit jobs and the coverage gate in [run 35737814671](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35737814671). Concurrent-runner tests passed on SQL Server, PostgreSQL, MySQL and MariaDB at `bb88165` in [run 35741656276](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35741656276). The earlier revision `bc35e0e`, including PostgreSQL metadata/time changes, passed all eleven database/unit jobs and the coverage gate in [run 35743265022](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35743265022). A green earlier revision is not evidence for a later revision. -```sh -dotnet test src/Migrator.Tests/Migrator.Tests.csproj --configuration Release --filter "TestCategory=SQLite" -``` - -**139 passed, 1 skipped, 0 failed.** The skipped test is `ChangeColumn_RemoveDefaultValue_Success`, documented by [issue #139](https://github.com/dotnetprojects/Migrator.NET/issues/139). Existing compiler warnings were present. This validates existing scenarios, not the complete preservation matrix. Competitors were reviewed through documentation/source, **not executed in a comparative test harness**. +This is not a complete implementation of the upgrade plan: SQL preview supports a structured subset; offline CLI rejects profiles/maintenance; full client-script dialects, remaining metadata/legacy ownership cases and broader deployment regressions remain work in progress. SQL Server GO splitting and explicit Oracle legacy sequence cleanup are implemented. See the [81-issue inventory](issue-audit.md) for verified closures and incomplete audit items. The operation inventory maps normal API method families to fluent/context entry points, but does not establish every overload/provider combination through execution. Competitors were reviewed through documentation/source, **not executed in a comparative harness**. When updating: @@ -345,29 +365,29 @@ When updating: - **grate / RoundhousE:** [grate][g-home], [options][g-config], [script types][g-types], [migration guide][g-migrate], [RoundhousE][r-home]. - **SQLite engine:** [ALTER TABLE and reconstruction procedure][sqlite-alter]. -[m-runner]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Migrator.cs -[m-loader]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/MigrationLoader.cs -[m-execution]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/MigrateAnywhere.cs -[m-migration]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Framework/Migration.cs -[m-api]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Framework/ITransformationProvider.cs -[m-provider]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Providers/TransformationProvider.cs -[m-factory]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/ProviderFactory.cs -[m-live]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/docs/live-database-tests.md -[m-sqlite]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs -[m-sqlite-model]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator/Providers/Impl/SQLite/Models/SQLiteTableInfo.cs -[t-add-column]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddColumnTests.cs -[t-change-column]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_ChangeColumnTests.cs -[t-remove-column]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveColumnTests.cs -[t-rename-column]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RenameColumnTests.cs -[t-pk]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddPrimaryKeyTests.cs -[t-fk]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs -[t-integrity]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_CheckForeignKeyIntegrityTests.cs -[t-uniques]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs -[t-check]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetCheckConstraintsTests.cs -[t-remove-constraints]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveAllConstraintsTests.cs -[t-recreate]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RecreateTable.cs -[t-sqlite-general]: https://github.com/dotnetprojects/Migrator.NET/blob/ab3aa9f488196139334ae4b2ea335e803a280533/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderTests.cs -[m-revision]: https://github.com/dotnetprojects/Migrator.NET/tree/ab3aa9f488196139334ae4b2ea335e803a280533/ +[m-runner]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator/Migrator.cs +[m-loader]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator/MigrationLoader.cs +[m-execution]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator/MigrationExecution.cs +[m-migration]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator/Framework/Migration.cs +[m-api]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator/Framework/ITransformationProvider.cs +[m-provider]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator/Providers/TransformationProvider.cs +[m-factory]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator/ProviderFactory.cs +[m-live]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/docs/live-database-tests.md +[m-sqlite]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +[m-sqlite-model]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator/Providers/Impl/SQLite/Models/SQLiteTableInfo.cs +[t-add-column]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddColumnTests.cs +[t-change-column]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_ChangeColumnTests.cs +[t-remove-column]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveColumnTests.cs +[t-rename-column]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RenameColumnTests.cs +[t-pk]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddPrimaryKeyTests.cs +[t-fk]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs +[t-integrity]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_CheckForeignKeyIntegrityTests.cs +[t-uniques]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs +[t-check]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetCheckConstraintsTests.cs +[t-remove-constraints]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveAllConstraintsTests.cs +[t-recreate]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RecreateTable.cs +[t-sqlite-general]: https://github.com/dotnetprojects/Migrator.NET/blob/eabec5561c2c9c77847b9adbb7b67474e3c70a50/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderTests.cs +[m-revision]: https://github.com/dotnetprojects/Migrator.NET/tree/eabec5561c2c9c77847b9adbb7b67474e3c70a50/ [f-start]: https://fluentmigrator.github.io/intro/quick-start.html [f-config]: https://fluentmigrator.github.io/intro/configuration.html [f-sql]: https://fluentmigrator.github.io/operations/execute-sql.html @@ -414,3 +434,10 @@ When updating: [liquibase-rollback]: https://support.liquibase.com/hc/en-us/articles/29383086010523-How-to-Define-Rollbacks [liquibase-preconditions]: https://docs.liquibase.com/community/user-guide-5-0-4/what-are-preconditions [f-generic-generator]: https://github.com/fluentmigrator/fluentmigrator/blob/2e0acdb7c375b03e50e65f34ddf50e44ee45df30/src/FluentMigrator.Runner.Core/Generators/Generic/GenericGenerator.cs + +The v13 column-model revision `11d6083` passed the complete existing-provider +matrix in [run 35769034542](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35769034542), +including 87 unit and 197 SQLite tests. The additional HANA source is pinned at +`eabec55` and its complete matrix is [run 35770116342](https://github.com/dotnetprojects/Migrator.NET/actions/runs/35770116342). +Require successful actual-engine checks on the final PR head before merging; +SQL-string assertions and skipped jobs do not qualify a new provider. diff --git a/docs/migration-guide-12.1-to-13.md b/docs/migration-guide-12.1-to-13.md new file mode 100644 index 00000000..c19567c5 --- /dev/null +++ b/docs/migration-guide-12.1-to-13.md @@ -0,0 +1,213 @@ +# Migrating from 12.1 to 13 + +Version 13 is a breaking release. This guide is maintained alongside the implementation; items explicitly marked planned are not available yet. Do not run a changed migration history against production without validating the upgrade on a restored database. + +## Schema model + +Columns describe data type, length, precision/scale, nullability, identity generation and defaults. Primary keys, unique constraints, foreign keys and checks belong to the table. Indexes are separate schema objects: a unique index is not automatically a unique constraint. + +The v13 API accepts complete named constraint definitions when creating a table, and returns the same kinds of definitions from metadata inspection. Key column order is significant. A composite UNIQUE constraint must never mark each member column as individually unique. Altering a column must not infer that its table constraints should be removed. + +The old column flags and duplicate fluent builder are removed. These are source-breaking changes: update historical migration source before recompiling for v13; the runner preserves the existing history table format. + +## Implemented breaking changes + +### Column flags, constructors and inspection + +`ColumnProperty`, its extensions, `Column.ColumnProperty`, +`IColumn.ColumnProperty`, `IsPrimaryKey` and `IsPrimaryKeyNonClustered` are +removed. Constructors and `AddColumn` overloads taking flags are removed. + +| 12.1 | 13 | +| --- | --- | +| `ColumnProperty.Null` / `None` | `IsNullable = true` (the default) | +| `ColumnProperty.NotNull` | `IsNullable = false` | +| `ColumnProperty.Identity` | `IsIdentity = true` | +| `ColumnProperty.Unsigned` | `IsUnsigned = true` | +| `ColumnProperty.PrimaryKey` | `new PrimaryKeyConstraint(name, columns)` in the table definition | +| `PrimaryKeyWithIdentity` | Identity on the column plus a separate primary key | +| `PrimaryKeyNonClustered` | `new PrimaryKeyConstraint(name, columns) { NonClustered = true }` | +| `ColumnProperty.Unique` | `new UniqueConstraint(name, columns)` | +| `ColumnProperty.Indexed` | An explicit `Index` definition | +| `ColumnProperty.CaseSensitive` | `Collation = "provider_collation_name"` | + +For example, replace a flagged `AddColumn` call with: + +```csharp +Database.AddColumn("Users", new Column("Email", DbType.String, 200) +{ + IsNullable = false, + DefaultValue = "unknown" +}); +Database.AddUniqueConstraint("UQ_Users_Email", "Users", "Email"); +``` + +Use `DefaultValue = 10` for numeric defaults: a positional integer after the +type is the column **size**, not its default. Unsupported unsigned/collation +combinations produce diagnostics. Collation names are explicit; the SQL Server +provider no longer queries or guesses a case-sensitive database collation. +SQLite's former `CaseSensitive` flag emitted `NOCASE`; choose `BINARY` or +`NOCASE` explicitly for the desired behavior. + +Read primary/unique membership through `GetTableConstraints(table)`, retaining +the constraint's member order. `GetColumns` returns column attributes only. +SQLite column metadata now follows physical column order, not primary-key order. +A plain SQLite INTEGER primary key remains a rowid alias; `IsIdentity` indicates +explicit `AUTOINCREMENT`, which is preserved on rebuild. + +### One fluent authoring API + +The obsolete `Framework.SchemaBuilder` namespace and `ExecuteSchemaBuilder` +method are removed. Use `Framework.Fluent.MigrationBuilder` and +`builder.Apply(provider)`, or derive from `FluentMigration`. +Replace `AddTable/AddColumn` chains with `Create.Table(...).WithColumn(...)`. +Replace `WithProperty`, unnamed `PrimaryKey()` and `Unique()` with +`NotNullable()`, `Identity()`, `Unsigned()`, `WithCollation(name)`, +`WithPrimaryKey(name, columns)` and `WithUniqueConstraint(name, columns)`. +Use `Create.ForeignKey` with separate delete/update actions. + +### Column changes do not own constraints + +`ChangeColumn` changes attributes without inferring creation or removal of +unique constraints. SQL Server's `AdoptColumnUniqueConstraint` and the implicit +ownership marker mechanism are removed. Use explicit `AddUniqueConstraint` and +`RemoveConstraint` calls. Existing extended-property markers are harmless; +v13 does not use them to delete constraints. + +Oracle changes columns in place so native constraints remain attached; a type +conversion that Oracle cannot perform must be expressed as an explicit data +migration. Identity validation runs before table creation, and identity no longer +requires primary-key membership. + +SQLite `RemoveAllIndexes` now preserves table UNIQUE constraints. To remove +constraints too, call `RemoveAllConstraints` explicitly. SQLite +`PrimaryKeyExists(table, name)` checks the actual name (use null for an unnamed +legacy key), rather than returning true for any primary key. + +### `Unique` is renamed to `UniqueConstraint` + +Replace `new Unique { Name = "UQ_Users_Email", KeyColumns = ["Email"] }` with `new UniqueConstraint("UQ_Users_Email", "Email")`. When importing both `System.Data` and `DotNetProjects.Migrator.Framework`, use an alias for the latter's `UniqueConstraint` (ADO.NET also defines that name). + +### Explicit table keys and complete constraint definitions + +```csharp +Database.AddTable("Users", + new Column("TenantId", DbType.Int32), + new Column("Id", DbType.Int32), + new Column("Email", DbType.String, 200), + new PrimaryKeyConstraint("PK_Users", "TenantId", "Id"), + new UniqueConstraint("UQ_Users_Email", "TenantId", "Email"), + new CheckConstraint("CK_Users_Id", "Id > 0")); +``` + +The supplied key order is preserved. Explicit primary-key definitions make their columns non-nullable without mutating the caller's column objects. This also rejects NULL in a composite SQLite primary key; old flag-based composite SQLite keys allowed NULL. SQLite identity requires a single INTEGER primary key and rejects incompatible combinations instead of silently removing identity. + +Fluent equivalent: append `.WithPrimaryKey("PK_Users", "TenantId", "Id")`, `.WithUniqueConstraint("UQ_Users_Email", "TenantId", "Email")`, or `.WithCheckConstraint("CK_Users_Id", "Id > 0")` to the table builder. Each is part of the complete table definition. + +### Structured constraint inspection + +Use `Database.GetTableConstraints("Users")`, or `Schema.Table("Users").ConstraintDefinitions()`, then select `PrimaryKeyConstraint`, `UniqueConstraint`, `ForeignKeyConstraint` or `CheckConstraint`. Key column order belongs to the constraint. A unique index stays in index metadata. SQLite returns `Name == null` for unnamed legacy constraints; a backing autoindex name is not an invented constraint name. + +Structured readers cover SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB, Db2, Firebird, Informix and Sybase. Each reader is exercised in its live CI job; unsupported engines throw `NotSupportedException`. MySQL identifies the primary key as `PRIMARY` regardless of a supplied symbolic name. Quoted qualified Oracle/MySQL lookups are currently rejected explicitly. These limitations must not be interpreted as empty metadata. + +### Custom provider and dialect implementations + +`ITransformationProvider` now requires `GetTableConstraints(string)`. Return accurate typed definitions, including ordered key members, or throw `NotSupportedException`; do not return an empty array for an unsupported reader. `IDialect` replaces `RegisterProperty/SqlForProperty` with `RegisterColumnAttribute/SqlForColumnAttribute`, using the non-flag `ColumnAttribute` enum (Null, NotNull, Identity, Unsigned). Custom column mappers use explicit column attributes; removed helpers include `IndexSql`, `PropertySelected`, `AddPrimaryKey`, `AddUnique`, and `AddForeignKey`. `GetPrimaryKeys(IEnumerable)` and column/index joining helpers are removed: inspect table constraints and create indexes explicitly. `GetCollationSql` generates a supported collation clause or throws before DDL. `IDialect` also adds `QuoteIdentifier(string)` for one identifier atom and `GetTableConstraintSql(TableConstraint)` for pure SQL rendering. Implementations derived from `Dialect` inherit defaults. Constraint names containing quote delimiters are escaped; a dot within a constraint name is not a schema separator. + +### SQLite alterations preserve named primary keys + +Rebuilding a table now retains an explicitly named primary key and its declared +column order. Changing a column definition does not implicitly remove that key. +To drop a column belonging to a named primary key, first call +`RemovePrimaryKey(table)`, then remove the column, and explicitly create any +replacement key. A failed attempt leaves the original table intact. + +Rebuilds also retain physical column order for tables with named primary keys, +including when a column's type or size changes. Identity rebuilds retain the +constraint name and the sequence high-water mark. + +## Provider authors and dialects (design) + +Keep SQL generation independent of a live connection. A dialect defines identifier quoting, type/literal rendering and SQL capabilities. Metadata readers inspect existing schema; execution manages commands, transactions and history. Neither preview nor a SQL generator may query or mutate the database. + +The current provider surface mixes these concerns. The v13 implementation is staged to preserve testable provider behavior while replacing authoring APIs. Unsupported combinations must fail explicitly before DDL, not disappear from generated SQL. + +## Design references + +Reviewed 2026-09-22: + +- [EF Core CreateTableOperation](https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.migrations.operations.createtableoperation?view=efcore-10.0) separates columns, primary key, unique constraints, checks and foreign keys. +- [FluentMigrator ColumnDefinition](https://github.com/fluentmigrator/fluentmigrator/blob/main/src/FluentMigrator.Abstractions/Model/ColumnDefinition.cs) still carries constraint flags; its expression/generator separation is useful, but its column model is not the target here. +- [Alembic operations](https://alembic.sqlalchemy.org/en/latest/ops.html) distinguish named table constraints from column alteration and use explicit batch reconstruction for SQLite. + +## Additional v13 candidates + +Typed constraints with ordered metadata, the SQLite constraint tokenizer, explicit SQL defaults, semantic collations and removal of the duplicate authoring API are implemented in this source stack. Typed schema-qualified identifiers, deterministic naming conventions and a broader provider-capability model remain candidates; they are not implemented features. + +## Explicit SQL defaults and semantic collations + +`RawSql.Insert("ksuid_new()")` marks trusted SQL as an expression in either API: + +```csharp +new Column("Id", DbType.String, 27) { DefaultValue = RawSql.Insert("ksuid_new()") }; +// Fluent: +builder.Create.Table("Events").WithColumn("Id").AsString(27) + .WithDefaultValue(RawSql.Insert("ksuid_new()")); +``` + +The database must provide that function. Strings remain quoted values, so +`WithDefaultValue("ksuid_new()")` stores that text instead of calling a function. +SQLite wraps expressions in parentheses as required for expression defaults. +Metadata exposes unparsed SQL defaults as `RawSql`, replacing the previous private +expression object; inspect `RawSql.Sql` instead of assuming every default is a string. +Expression text is trusted migration code, not a parameter or a cross-database function abstraction. + +`Column.Collation` is now a typed `Collation` value. String assignment still selects +a provider name through an implicit conversion; `Collation.Named("name")` is explicit. +Fluent `.WithCollation(...)` takes the same type. + +```csharp +new Column("Name", DbType.String, 100) { Collation = Collation.CaseInsensitive }; +builder.Create.Table("Names").WithColumn("Name").AsString(100) + .WithCollation(Collation.CaseInsensitive); +``` + +| Preset | SQL Server | MySQL 8 | MariaDB 10.10+ | PostgreSQL | SQLite | +| --- | --- | --- | --- | --- | --- | +| `CaseInsensitive` (accent-sensitive) | Latin1 General 100 CI AS SC | utf8mb4 0900 as ci | utf8mb4 UCA1400 nopad as ci | Explicit installed name required | Unsupported | +| `CaseSensitive` (accent-sensitive) | Latin1 General 100 CS AS SC | utf8mb4 0900 as cs | utf8mb4 UCA1400 nopad as cs | Explicit installed name required | Explicit installed name required | +| `Binary` | Latin1 General 100 BIN2 | utf8mb4 0900 bin | utf8mb4 nopad bin | C | BINARY | +| `AsciiIgnoreCase` | Unsupported | Unsupported | Unsupported | Unsupported | NOCASE | + +These presets describe comparison intent, not identical sorting, normalization, +language tailoring, or trailing-space behavior across engines. Use a named collation +for a specific language or exact provider semantics. MySQL/MariaDB presets require +utf8mb4-compatible text columns and the listed engine versions. Other dialects reject +unmapped presets; custom dialects can override `ResolveCollation(CollationKind)`. +Unsupported requests fail during SQL generation, before executing the table operation. +SQLite never downgrades Unicode case-insensitivity to its ASCII-only NOCASE behavior. +SQLite rebuilds involving collated columns still fail before replacing the table. + +For PostgreSQL, create an ICU nondeterministic collation explicitly (for example +`CREATE COLLATION app_ci (provider=icu, locale='und-u-ks-level2', deterministic=false)`) +and use `Collation.Named("app_ci")`. The framework does not silently create shared +database objects while rendering a column or preview. + +MySQL/MariaDB expose unique indexes as unique constraints in their catalogs, so +metadata cannot recover whether the original author used CREATE UNIQUE INDEX or +a UNIQUE table clause. No ownership decision may be inferred from that syntax. + +## Oracle index options and constraint metadata + +Oracle now rejects nonempty `Index.IncludeColumns` and `Index.Clustered = true` before +DDL. Version 12.1 silently ignored them. Remove these options for an ordinary Oracle +index or author an explicit Oracle-specific design; a SQL Server clustered-index +request is not translated to an Oracle index-organized table. + +Structured metadata preserves SQL Server nonclustered primary keys and Oracle +ordered foreign-key pairs/delete actions. Foreign-key constructor arrays are copied, +matching primary/unique definitions, so later caller-array edits cannot change the key. + +## Build and package identity + +Source builds now identify the core, optional DI package and CLI as 13.0.0-preview.1. The core assembly and file versions are 13.0.0.0; generated assembly metadata is enabled while preserving its existing title and description. Recompile consumers of the breaking API and update assembly/version binding assumptions. These metadata changes do not publish a package. diff --git a/docs/runner-guide.md b/docs/runner-guide.md new file mode 100644 index 00000000..ade59538 --- /dev/null +++ b/docs/runner-guide.md @@ -0,0 +1,112 @@ +# Runner and fluent API upgrade + +These APIs describe the source upgrade under review in PRs #173, #174, #175 and #177. They are not a statement about the currently released NuGet packages. Build the repository to try them; no package publication is part of this change. + +## Fluent quick start + +The [compiled quick-start project](../examples/FluentQuickStart/Program.cs) executes preview, migration and automatic reversal against SQLite: + +```sh +dotnet run --project examples/FluentQuickStart +``` + +```csharp +[Migration(1, Scope = "demo"), Tags("core")] +public class CreateUsers : AutoReversingMigration +{ + public override void BuildUp(MigrationBuilder migration) + { + migration.Create.Table("Users") + .WithColumn("Id").AsInt32().WithPrimaryKey("PK_Id", "Id") + .WithColumn("Name").AsString(255).NotNullable(); + } +} +``` + +Use `DotNetProjects.Migrator`, `.Framework` and `.Framework.Fluent`. A table definition is completed before execution. Existing imperative `Migration.Up/Down` classes keep working. `FluentMigration` supports authored `BuildDown`; `AutoReversingMigration` reverses supported create/rename operations in reverse order. Destructive changes, data, SQL and callbacks need explicit reverse operations. Automatic reversal never restores deleted data. + +The builder has `Create`, `Alter`, `Delete`, `Rename`, `Insert`, `Update`, `Execute` and `Administration`. Schema inspection is exposed through `FluentMigration.Schema`, and the provider through `Context`. History and transaction methods remain explicit context operations. Administrative operations, views, data copying and updates from another table have typed operations; their SQL preview is currently unsupported. See the [operation coverage inventory](fluent-operation-coverage.md) for the normal API mappings and test limits. + +## Scripts and provider-specific cleanup + +`Execute.Script(path)` and `Execute.EmbeddedScript(assembly, resourceName)` capture script text as dedicated operations. Imperative callers can use `ExecuteScript(path)`, `ExecuteResourceScript(assembly, name)` and `ExecuteSqlScript(text)`. SQL Server splits standalone `GO` lines, including an optional `--` comment, while respecting strings, quoted identifiers and nested comments. GO repetition and SQLCMD directives fail explicitly before executing batches. Ordinary `ExecuteNonQuery` and fluent `Execute.Sql` never split client separators. Other providers receive the script as one command unless they implement `IScriptBatchProvider`; this is not a complete SQL*Plus, mysql-client or isql interpreter. + +Oracle `RemoveTable` leaves unrelated sequences intact and relies on Oracle to remove table-owned triggers and native identity objects. For legacy sequences you explicitly own, use `OracleTransformationProvider.RemoveTableWithOwnedSequences(table, sequenceNames)` through an explicit provider context/callback. It accepts simple unquoted sequence names, validates existence before dropping the table, and propagates cleanup failures. Oracle DDL is not atomic. SQL Server removes only column-unique constraints carrying its ownership marker; historical unmarked objects can be adopted explicitly with `SqlServerTransformationProvider.AdoptColumnUniqueConstraint(table, column, constraint)`. Adoption verifies a single-column UNIQUE constraint before marking it and rejects composite constraints. Names alone never establish ownership. + +## Runner options + +`runner.Options` supports: + +| Option | Semantics | +| --- | --- | +| `Tags` / `TagMatch` | Ordinal names; explicit `Any` or `All`. No filter selects all versioned migrations. Filtered applied versions remain applied on downgrade. | +| `Profiles` | Explicit names of `[Profile("name")]` classes. Run after versioned migrations without recording versions; run again when selected again. | +| `TransactionMode` | `PerMigration` by default; `None` or `WholeSession` available. | +| `Activator` | Optional constructor activation delegate. | +| `Lock` / `LockTimeout` | Optional `IMigrationLock` lease; acquire before reading history and release on completion/failure. | + +Unscoped migrations inherit the provider scope; explicitly scoped migrations run only in that scope. Discovery, duplicate validation and history reads use the effective scope. Scopes separate history, not tables. Legacy custom providers can adopt the additive `IMigrationHistory` interface for read-only planning and effective-scope selection. + +Maintenance classes use `[Maintenance(MaintenanceStage.BeforeRun)]`, `BeforeMigration`, `AfterMigration` or `AfterRun`. Profiles and maintenance accept `Order` and `Scope`. Ordering uses `Order` then ordinal full type name. Hooks stop on failure; later hooks are not cleanup guarantees. Connection/transaction restoration and lock release do not depend on hooks running. Profiles and maintenance use `Up`; they do not acquire version records. + +## Transactions and locks + +`PerMigration` commits each successful migration. `None` leaves transaction behavior to the provider/operations. `WholeSession` is accepted for SQLite, PostgreSQL and SQL Server dialects; history-table initialization occurs before that transaction. Other dialects fail explicitly because transactional DDL has not been verified. Arbitrary imperative SQL can still violate transaction assumptions; database administration and implicit-commit statements require separate runs. + +`AfterUp`/`AfterDown` run after commit. In whole-session mode they are deferred until the complete session commits. Their failure reports an error after durable changes; it cannot undo a successful commit. Caller-owned connections remain caller-owned. + +`new DatabaseMigrationLock()` uses SQL Server application locks, PostgreSQL advisory locks or MySQL/MariaDB named locks. Locks are session-owned, keyed by database/history table/scope, and remain held across migration commits. Do not switch databases, replace/close the connection or manipulate the native lock inside a migration. Unsupported providers, including SQLite, reject this lock implementation. Supply a custom `IMigrationLock` where another coordination mechanism is required. MySQL named locks coordinate one server, not an entire distributed cluster. + +## Planning and SQL preview + +`runner.Plan(target)` and `DryRun` inspect history without creating/upgrading it and do not invoke migration bodies, callbacks, transactions or SQLite PRAGMA changes. Custom providers must implement `IMigrationHistory` for these paths. + +`runner.PreviewSql(target, providerType)` connects for history/schema reads. `MigrationSqlPreview.Generate(providerType, migrations)` can generate SQL offline. Earlier structured operations update a planned schema so later operations can refer to newly created/renamed tables. SQL preview currently supports a subset: basic tables/columns, supported renames, simple indexes, inserts and raw SQL. Unsupported alterations, constraints, filters, callbacks and schema dependencies fail explicitly. Output is operation SQL, not an idempotent history-managed deployment bundle. + +Imperative bodies require `allowLegacyBodies: true`. Provider calls are captured through a rejecting proxy: direct connections, commands and unsupported reads/callbacks are blocked. **Arbitrary C# cannot be sandboxed**: constructors, fluent authoring and opted-in imperative bodies can still access files, networks or external state. Use trusted migration code. Migrations overriding `InitializeOnce` are rejected before their body runs, because skipping initialization could produce misleading SQL. Post-commit callbacks do not run during preview. Raw SQL invalidates planned schema knowledge, so later structured schema dependencies fail explicitly. + +## CLI from source + +```sh +dotnet pack src/Migrator.Tool -o artifacts/packages +dotnet tool install DotNetProjects.Migrator.Tool --add-source artifacts/packages --tool-path artifacts/tools +``` + +On Windows, use a short tool installation directory (or the default global-tool directory): the bundled SQLite native library failed to load from this review workspace's deeply nested tool path, while the same package passed from a short temporary path. + +Set `MIGRATOR_CONNECTION` in your environment; the tool does not print its value. Common commands: + +```sh +migrator list --assembly MyMigrations.dll --provider SQLite +migrator status --assembly MyMigrations.dll --provider SQLite +migrator validate --assembly MyMigrations.dll --provider SQLite +migrator plan --assembly MyMigrations.dll --provider SQLite --target 10 +migrator sql --assembly MyMigrations.dll --provider SQLite --output migration.sql +migrator sql --assembly MyMigrations.dll --provider SQLite --offline --output migration.sql +migrator migrate --assembly MyMigrations.dll --provider SQLite --scope billing --transaction WholeSession +migrator rollback --assembly MyMigrations.dll --provider SQLite --target 0 +``` + +Use `--connection-env NAME`, `--schema`, `--tags a,b`, `--tag-match Any|All`, `--profiles a,b`, `--timeout SECONDS`, `--lock` and `--lock-timeout SECONDS` where applicable. `rollback` requires an explicit lower target and rejects any plan containing upward steps. Target validation runs after acquiring the configured lock and refreshing history. Offline SQL assumes empty history and currently rejects profiles/maintenance. `validate` validates version planning, not arbitrary migration-body behavior. The packaged drivers cover SQLite, SQL Server, PostgreSQL, MySQL/MariaDB, Oracle and Firebird. Other library providers need a custom host. + +Exit codes: `0` success, `1` execution/load failure, `2` invalid arguments, `3` unsupported operation/provider, `4` lock timeout. SQL output may contain migration data; exception and provider trace details are omitted from CLI diagnostics. + +## Optional DI and logging + +The source package `DotNetProjects.Migrator.Extensions.DependencyInjection` provides `services.AddMigrator(providerFactory, migrationAssembly, configureOptions)`. Resolve `Migrator` inside a service scope; migration constructors use that scope's services. Options are scoped snapshots. Provider disposal follows the DI scope. Microsoft logging records lifecycle events while omitting SQL text and raw exception messages; the core retains its lightweight logger API. + +## Validation + +Build before using the test scripts (they intentionally use `--no-build`): + +```sh +dotnet build Migrator.slnx +pwsh .github/scripts/test.ps1 -Database Unit +pwsh .github/scripts/test.ps1 -Database SQLite +``` + +See [live database tests](live-database-tests.md) for the full matrix. Provider-specific changes need live provider evidence. Check PR CI and review threads after every push; reply with implementation/test evidence and resolve fixed findings. Keep commits descriptive and merge the PR stack in dependency order only after review. + +An auxiliary-only `MigrateToLastVersion()` run preserves existing version history while executing selected profiles and maintenance. A completely empty run does not create a history table. Post-commit callbacks receive their migration context in both per-migration and whole-session modes; callback failure cannot undo a committed migration. + +PostgreSQL column and constraint metadata resolves the requested relation through the database, including schema-qualified or explicitly quoted names and the connection search path. The lookup is parameterized and distinguishes same-named tables in different schemas. This does not imply complete schema qualification for every provider operation. Native `time without time zone` metadata and literal defaults map to `TimeSpan`. diff --git a/examples/FluentQuickStart/FluentQuickStart.csproj b/examples/FluentQuickStart/FluentQuickStart.csproj new file mode 100644 index 00000000..d0d7285c --- /dev/null +++ b/examples/FluentQuickStart/FluentQuickStart.csproj @@ -0,0 +1,4 @@ + + Exenet9.0enable + + diff --git a/examples/FluentQuickStart/Program.cs b/examples/FluentQuickStart/Program.cs new file mode 100644 index 00000000..16301a5e --- /dev/null +++ b/examples/FluentQuickStart/Program.cs @@ -0,0 +1,30 @@ +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers; +using Microsoft.Data.Sqlite; + +using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); +connection.Open(); +using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null, "demo"); +var runner = new Migrator(provider, false, typeof(CreateUsers)); +runner.Options.Tags.Add("core"); +runner.Options.TransactionMode = MigrationTransactionMode.WholeSession; +Console.WriteLine(runner.PreviewSql(1, ProviderTypes.SQLite)); +if (provider.TableExists("Users") || provider.TableExists(provider.SchemaInfoTable)) throw new Exception("Preview wrote to the database."); +runner.MigrateToLastVersion(); +if (!provider.ColumnExists("Users", "Name")) throw new Exception("Migration failed."); +runner.MigrateTo(0); +if (provider.TableExists("Users")) throw new Exception("Automatic reversal failed."); +Console.WriteLine("Quick-start migration, preview and reversal passed."); + +[Migration(1, Scope = "demo"), Tags("core")] +public class CreateUsers : AutoReversingMigration +{ + public override void BuildUp(MigrationBuilder migration) + { + migration.Create.Table("Users") + .WithColumn("Id").AsInt32().WithPrimaryKey("PK_Id", "Id") + .WithColumn("Name").AsString(255).NotNullable(); + } +} diff --git a/src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj b/src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj new file mode 100644 index 00000000..91cb4ae0 --- /dev/null +++ b/src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj @@ -0,0 +1,9 @@ + + net9.013.0.0-preview.1MPL-1.1Optional dependency injection, options and logging integration for Migrator.NET. + + + + + + + diff --git a/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs b/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs new file mode 100644 index 00000000..cdc63c2d --- /dev/null +++ b/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.Extensions.Logging; +namespace DotNetProjects.Migrator.Extensions.DependencyInjection; + +/// Logs lifecycle events. SQL and exception messages may contain secrets and are omitted. +public sealed class MigrationLogger(Microsoft.Extensions.Logging.ILogger logger) : Framework.ILogger +{ + public void Started(List currentVersion, long finalVersion) => logger.LogInformation("Migration run started; target {Version}", finalVersion); + public void Finished(List currentVersion, long finalVersion) => logger.LogInformation("Migration run completed; target {Version}", finalVersion); + public void MigrateUp(long version, string migrationName) => logger.LogInformation("Applying migration {Version} ({Name})", version, migrationName); + public void MigrateDown(long version, string migrationName) => logger.LogInformation("Reverting migration {Version} ({Name})", version, migrationName); + public void Skipping(long version) => logger.LogWarning("Skipping migration {Version}", version); + public void RollingBack(long originalVersion) => logger.LogWarning("Rolling back migration {Version}", originalVersion); + public void ApplyingDBChange(string sql) => logger.LogDebug("Executing a database change"); + public void Exception(long version, string migrationName, Exception ex) => logger.LogError("Migration {Version} failed: {ExceptionType}", version, ex.GetType().Name); + public void Exception(string message, Exception ex) => logger.LogError("Migration operation failed: {ExceptionType}", ex.GetType().Name); + public void Log(string format, params object[] args) => logger.LogInformation("Provider informational event"); + public void Warn(string format, params object[] args) => logger.LogWarning("Provider warning event"); + public void Trace(string format, params object[] args) { } // Provider traces commonly contain SQL values. +} diff --git a/src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..0ae41213 --- /dev/null +++ b/src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using System.Reflection; +using DotNetProjects.Migrator.Framework; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +namespace DotNetProjects.Migrator.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddMigrator(this IServiceCollection services, + Func providerFactory, Assembly migrations, Action configure = null) + { + services.AddOptions(); + if (configure != null) services.Configure(configure); + services.AddScoped(providerFactory); + foreach (var type in MigrationLoader.GetMigrationTypes(migrations)) services.TryAddTransient(type); + services.AddScoped(sp => + { + var provider = sp.GetRequiredService(); + var loader = new MigrationLoader(provider, migrations, false); + var options = sp.GetRequiredService>().Value; + options.Activator ??= type => (IMigration)sp.GetRequiredService(type); + return new Migrator(provider, new MigrationLogger(sp.GetService()?.CreateLogger("Migrator.NET") ?? NullLogger.Instance), loader) { Options = options }; + }); + return services; + } +} diff --git a/src/Migrator.Tests/ColumnPropertyMapperTest.cs b/src/Migrator.Tests/ColumnPropertyMapperTest.cs index 3add18d0..e9794567 100644 --- a/src/Migrator.Tests/ColumnPropertyMapperTest.cs +++ b/src/Migrator.Tests/ColumnPropertyMapperTest.cs @@ -16,7 +16,7 @@ public class ColumnPropertyMapperTest public void OracleCreatesNotNullSql() { var mapper = new ColumnPropertiesMapper(new OracleDialect(), "varchar(30)"); - mapper.MapColumnProperties(new Column("foo", DbType.String, ColumnProperty.NotNull)); + mapper.MapColumnProperties(new Column("foo",DbType.String){IsNullable = false}); Assert.That("foo varchar(30) NOT NULL", Is.EqualTo(mapper.ColumnSql)); } @@ -25,46 +25,18 @@ public void OracleCreatesSql() { var mapper = new ColumnPropertiesMapper(new OracleDialect(), "varchar(30)"); mapper.MapColumnProperties(new Column("foo", DbType.String, 0)); - Assert.That("foo varchar(30)", Is.EqualTo(mapper.ColumnSql)); + Assert.That("foo varchar(30) NULL", Is.EqualTo(mapper.ColumnSql)); } - [Test] - public void OracleIndexSqlIsNoNullWhenIndexed() - { - var mapper = new ColumnPropertiesMapper(new OracleDialect(), "char(1)"); - mapper.MapColumnProperties(new Column("foo", DbType.StringFixedLength, 1, ColumnProperty.Indexed)); - Assert.That(mapper.IndexSql, Is.Not.Null); - } - [Test] - public void OracleIndexSqlIsNullWhenIndexedFalse() - { - var mapper = new ColumnPropertiesMapper(new OracleDialect(), "char(1)"); - mapper.MapColumnProperties(new Column("foo", DbType.StringFixedLength, 1, 0)); - Assert.That(mapper.IndexSql, Is.Null); - } - [Test] - public void PostgresIndexSqlIsNoNullWhenIndexed() - { - var mapper = new ColumnPropertiesMapper(new PostgreSQLDialect(), "char(1)"); - mapper.MapColumnProperties(new Column("foo", DbType.StringFixedLength, 1, ColumnProperty.Indexed)); - Assert.That(mapper.IndexSql, Is.Not.Null); - } - [Test] - public void PostgresIndexSqlIsNullWhenIndexedFalse() - { - var mapper = new ColumnPropertiesMapper(new PostgreSQLDialect(), "char(1)"); - mapper.MapColumnProperties(new Column("foo", DbType.StringFixedLength, 1, 0)); - Assert.That(mapper.IndexSql, Is.Null); - } [Test] public void SqlServerCreatesNotNullSql() { var mapper = new ColumnPropertiesMapper(new SqlServerDialect(), "varchar(30)"); - mapper.MapColumnProperties(new Column("foo", DbType.String, ColumnProperty.NotNull)); + mapper.MapColumnProperties(new Column("foo",DbType.String){IsNullable = false}); Assert.That("[foo] varchar(30) NOT NULL", Is.EqualTo(mapper.ColumnSql)); } @@ -73,10 +45,10 @@ public void SqlServerCreatesSqWithBooleanDefault() { var mapper = new ColumnPropertiesMapper(new SqlServerDialect(), "bit"); mapper.MapColumnProperties(new Column("foo", DbType.Boolean, 0, false)); - Assert.That("[foo] bit DEFAULT 0", Is.EqualTo(mapper.ColumnSql)); + Assert.That("[foo] bit NULL DEFAULT 0", Is.EqualTo(mapper.ColumnSql)); mapper.MapColumnProperties(new Column("bar", DbType.Boolean, 0, true)); - Assert.That("[bar] bit DEFAULT 1", Is.EqualTo(mapper.ColumnSql)); + Assert.That("[bar] bit NULL DEFAULT 1", Is.EqualTo(mapper.ColumnSql)); } [Test] @@ -84,7 +56,7 @@ public void SqlServerCreatesSqWithDefault() { var mapper = new ColumnPropertiesMapper(new SqlServerDialect(), "varchar(30)"); mapper.MapColumnProperties(new Column("foo", DbType.String, 0, "'NEW'")); - Assert.That("[foo] varchar(30) DEFAULT '''NEW'''", Is.EqualTo(mapper.ColumnSql)); + Assert.That("[foo] varchar(30) NULL DEFAULT '''NEW'''", Is.EqualTo(mapper.ColumnSql)); } [Test] @@ -92,7 +64,7 @@ public void SqlServerCreatesSqWithNullDefault() { var mapper = new ColumnPropertiesMapper(new SqlServerDialect(), "varchar(30)"); mapper.MapColumnProperties(new Column("foo", DbType.String, 0, "NULL")); - Assert.That("[foo] varchar(30) DEFAULT 'NULL'", Is.EqualTo(mapper.ColumnSql)); + Assert.That("[foo] varchar(30) NULL DEFAULT 'NULL'", Is.EqualTo(mapper.ColumnSql)); } [Test] @@ -100,22 +72,15 @@ public void SqlServerCreatesSql() { var mapper = new ColumnPropertiesMapper(new SqlServerDialect(), "varchar(30)"); mapper.MapColumnProperties(new Column("foo", DbType.String, 0)); - Assert.That("[foo] varchar(30)", Is.EqualTo(mapper.ColumnSql)); + Assert.That("[foo] varchar(30) NULL", Is.EqualTo(mapper.ColumnSql)); } - [Test] - public void SqlServerIndexSqlIsNoNullWhenIndexed() - { - var mapper = new ColumnPropertiesMapper(new SqlServerDialect(), "char(1)"); - mapper.MapColumnProperties(new Column("foo", DbType.StringFixedLength, 1, ColumnProperty.Indexed)); - Assert.That(mapper.IndexSql, Is.Null); - } [Test] public void SQLiteIndexSqlWithEmptyStringDefault() { var mapper = new ColumnPropertiesMapper(new SQLiteDialect(), "varchar(30)"); - mapper.MapColumnProperties(new Column("foo", DbType.String, 1, ColumnProperty.NotNull, string.Empty)); + mapper.MapColumnProperties(new Column("foo",DbType.String,1,string.Empty){IsNullable = false}); Assert.That("foo varchar(30) NOT NULL DEFAULT ''", Is.EqualTo(mapper.ColumnSql)); } } \ No newline at end of file diff --git a/src/Migrator.Tests/DatabaseLockTests.cs b/src/Migrator.Tests/DatabaseLockTests.cs new file mode 100644 index 00000000..5a72fc55 --- /dev/null +++ b/src/Migrator.Tests/DatabaseLockTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Providers; +using Migrator.Tests.Settings; +using NUnit.Framework; +namespace Migrator.Tests; + +[TestFixture(ProviderTypes.SqlServer, Category = "SQLServer")] +[TestFixture(ProviderTypes.PostgreSQL, Category = "PostgreSQL")] +[TestFixture(ProviderTypes.Mysql, Category = "MySQL")] +[TestFixture(ProviderTypes.MariaDB, Category = "MariaDB")] +public class DatabaseLockTests(ProviderTypes type) +{ + private DbConnection Open() + { + DbConnection connection; + if (type == ProviderTypes.SqlServer) + { + var config = new ConfigurationReader().GetDatabaseConnectionConfigById("SQLServer"); + var builder = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(config.ConnectionString) { InitialCatalog = "master" }; + connection = new Microsoft.Data.SqlClient.SqlConnection(builder.ConnectionString); + } + else if (type == ProviderTypes.PostgreSQL) + connection = new Npgsql.NpgsqlConnection(new ConfigurationReader().GetDatabaseConnectionConfigById("PostgreSQL").ConnectionString); + else + connection = new MySql.Data.MySqlClient.MySqlConnection(Environment.GetEnvironmentVariable(type == ProviderTypes.Mysql ? "MIGRATOR_MYSQL" : "MIGRATOR_MARIADB") + ?? "Server=127.0.0.1;Database=testdb;User ID=root;Password=rootpass;Pooling=false"); + connection.Open(); return connection; + } + private sealed class RunState : IDisposable + { + public int Calls; + public readonly ManualResetEventSlim Entered = new(); + public readonly ManualResetEventSlim Release = new(); + public void Dispose() { Entered.Dispose(); Release.Dispose(); } + } + [Migration(1)] + private sealed class CountMigration(RunState state) : Migration + { + public override void Up() + { + Interlocked.Increment(ref state.Calls); + state.Entered.Set(); + if (!state.Release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("Test migration gate timed out."); + } + public override void Down() { } + } + private sealed class SignallingLock(ManualResetEventSlim attempted) : IMigrationLock + { + public IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout) + { attempted.Set(); return new DatabaseMigrationLock().Acquire(provider, scope, timeout); } + } + [Test] + public async Task ConcurrentRunnersReloadStaleHistoryAfterAcquiringNativeLock() + { + using var connection1 = Open(); using var connection2 = Open(); + using var p1 = ProviderFactory.Create(type, connection1, null); + using var p2 = ProviderFactory.Create(type, connection2, null); + p1.SchemaInfoTable = p2.SchemaInfoTable = "lockhistory_" + Guid.NewGuid().ToString("N")[..12]; + using var state = new RunState(); using var attempted = new ManualResetEventSlim(); + Assert.That(p2.AppliedMigrations, Is.Empty); // Deliberately seed a stale empty cache. + var first = new DotNetProjects.Migrator.Migrator(p1, false, typeof(CountMigration)); + var second = new DotNetProjects.Migrator.Migrator(p2, false, typeof(CountMigration)); + first.Options.Activator = second.Options.Activator = _ => new CountMigration(state); + first.Options.Lock = new DatabaseMigrationLock(); second.Options.Lock = new SignallingLock(attempted); + first.Options.LockTimeout = second.Options.LockTimeout = TimeSpan.FromSeconds(15); + Task one = null, two = null; + try + { + one = Task.Run(first.MigrateToLastVersion); + Assert.That(state.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True); + two = Task.Run(second.MigrateToLastVersion); + Assert.That(attempted.Wait(TimeSpan.FromSeconds(10)), Is.True); + state.Release.Set(); + await Task.WhenAll(one, two); + Assert.That(state.Calls, Is.EqualTo(1)); + Assert.That(((IMigrationHistory)p2).ReadAppliedMigrations(), Is.EqualTo(new long[] { 1 })); + using var released = new DatabaseMigrationLock().Acquire(p2, ((IMigrationHistory)p2).Scope, TimeSpan.Zero); + } + finally + { + state.Release.Set(); + try { await Task.WhenAll(new[] { one, two }.Where(task => task != null)); } + finally { p1.RemoveTable(p1.SchemaInfoTable); } + } + } + + [Test] public void IndependentSessionsContendAndCanAcquireAfterRelease() + { + using var connection1 = Open(); using var connection2 = Open(); + using var p1 = ProviderFactory.Create(type, connection1, null); + using var p2 = ProviderFactory.Create(type, connection2, null); + var migrationLock = new DatabaseMigrationLock(); var scope = Guid.NewGuid().ToString("N"); + using (migrationLock.Acquire(p1, scope, TimeSpan.FromSeconds(1))) + { + Assert.Throws(() => migrationLock.Acquire(p2, scope, TimeSpan.FromMilliseconds(100))); + using var independentScope = migrationLock.Acquire(p2, scope + "other", TimeSpan.Zero); + } + using var acquiredAfterRelease = migrationLock.Acquire(p2, scope, TimeSpan.FromSeconds(1)); + } +} diff --git a/src/Migrator.Tests/FluentOperationsTests.cs b/src/Migrator.Tests/FluentOperationsTests.cs index 11350894..94e2f626 100644 --- a/src/Migrator.Tests/FluentOperationsTests.cs +++ b/src/Migrator.Tests/FluentOperationsTests.cs @@ -48,7 +48,7 @@ public void TransactionIncompatibilityIsRejectedBeforeEarlierOperationsRun() } [Test] public void TableIsOneCompleteOperationAndDoesNotMutateInput() { - var column = new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey); + var column = new Column("Id",DbType.Int32){IsNullable = false}; var builder = new MigrationBuilder(); builder.Create.Table("Example").WithFields(column); column.Name = "Changed"; var operation = (CreateTableOperation)builder.Build().Single(); @@ -154,7 +154,7 @@ [Test] public void CallbacksCannotBePreviewedOrAutomaticallyReversed() } [Test, Category("SQLite")] public void FluentAndPreviewProduceEquivalentDataAndAutomaticDownRemovesTable() { - var builder = new MigrationBuilder(); builder.Create.Table("Example").WithColumn("Id").AsInt32().PrimaryKey().WithColumn("Name").AsString(); + var builder = new MigrationBuilder(); builder.Create.Table("Example").WithColumn("Id").AsInt32().WithPrimaryKey("PK_Id", "Id").WithColumn("Name").AsString(); builder.Insert.IntoTable("Example").Row(new[] { "Id", "Name" }, new object[] { 1, "O'Brien" }); using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); @@ -166,24 +166,25 @@ [Test] public void CallbacksCannotBePreviewedOrAutomaticallyReversed() builder.Build()[0].Reverse().Apply(provider); Assert.That(provider.TableExists("Example"), Is.False); } - [Test, Category("SQLite")] public void LegacyBuilderRetainsForeignKeyAction() + [Test, Category("SQLite")] public void BuilderRetainsForeignKeyAction() { using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); connection.Open(); using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); - provider.AddTable("Parent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); - var builder = new DotNetProjects.Migrator.Framework.SchemaBuilder.SchemaBuilder(); - builder.AddTable("Child").AddColumn("ParentId").OfType(DbType.Int32).AsForeignKey().ReferencedTo("Parent", "Id").WithConstraint(ForeignKeyConstraintType.Cascade); - provider.ExecuteSchemaBuilder(builder); + provider.AddTable("Parent", new Column("Id",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "Parent", "Id")); + var builder = new MigrationBuilder(); + builder.Create.Table("Child").WithColumn("ParentId").AsInt32(); + builder.Create.ForeignKey("FK_Child", "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, ForeignKeyConstraintType.Cascade); + builder.Apply(provider); provider.ExecuteNonQuery("INSERT INTO Parent VALUES (1); INSERT INTO Child VALUES (1); DELETE FROM Parent WHERE Id=1"); Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Child")), Is.Zero); } - [Test, Category("SQLite")] public void LegacyBuilderCreatesCompleteTable() + [Test, Category("SQLite")] public void BuilderCreatesCompleteTable() { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); - var builder = new DotNetProjects.Migrator.Framework.SchemaBuilder.SchemaBuilder(); - builder.AddTable("Example").AddColumn("Id").OfType(DbType.Int32); - provider.ExecuteSchemaBuilder(builder); + var builder = new MigrationBuilder(); + builder.Create.Table("Example").WithColumn("Id").AsInt32(); + builder.Apply(provider); Assert.That(provider.ColumnExists("Example", "Id"), Is.True); } } diff --git a/src/Migrator.Tests/Framework/ColumnProperties/ColumnPropertyExtensionTests.cs b/src/Migrator.Tests/Framework/ColumnProperties/ColumnPropertyExtensionTests.cs index d2f822d6..d93fe09f 100644 --- a/src/Migrator.Tests/Framework/ColumnProperties/ColumnPropertyExtensionTests.cs +++ b/src/Migrator.Tests/Framework/ColumnProperties/ColumnPropertyExtensionTests.cs @@ -1,90 +1,34 @@ -using NUnit.Framework; -using DotNetProjects.Migrator.Framework; using System; using System.Linq; - +using DotNetProjects.Migrator.Framework; +using NUnit.Framework; namespace Migrator.Tests.Framework.ColumnProperties; - -public class ColumnPropertyExtensionsTests +public class ColumnModelTests { [Test] - public void Clear() + public void ColumnsDoNotExposeConstraintFlags() { - // Arrange - var columnProperty = ColumnProperty.PrimaryKey | ColumnProperty.NotNull; - - // Act - columnProperty = columnProperty.Clear(ColumnProperty.PrimaryKey); - - // Assert - Assert.That(columnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); + Assert.That(typeof(Column).Assembly.GetType("DotNetProjects.Migrator.Framework.ColumnProperty"), Is.Null); + Assert.That(typeof(Column).GetProperties().Select(p => p.Name), Does.Not.Contain("IsPrimaryKey")); + Assert.That(typeof(Column).GetProperties().Select(p => p.Name), Does.Not.Contain("ColumnProperty")); + Assert.That(Enum.GetNames(), Is.EquivalentTo(new[] { "Null", "NotNull", "Identity", "Unsigned" })); } - [Test] - public void IsSet() + public void ColumnAttributesAreIndependent() { - // Arrange - var columnProperty = ColumnProperty.PrimaryKeyWithIdentity | ColumnProperty.NotNull; - - // Act - var actualData = GetAllSingleColumnProperties().Select(x => new - { - ColumnPropertyString = x.ToString(), - IsSet = columnProperty.IsSet(x), - IsNotSet = columnProperty.IsNotSet(x) - }) - .ToList(); - - // Assert - string[] expectedSet = [nameof(ColumnProperty.PrimaryKey), nameof(ColumnProperty.NotNull), nameof(ColumnProperty.Identity)]; - var actualDataShouldBeTrue = actualData.Where(x => expectedSet.Any(y => y == x.ColumnPropertyString)).ToList(); - var actualDataShouldBeFalse = actualData.Where(x => !expectedSet.Any(y => y == x.ColumnPropertyString)).ToList(); - - Assert.That(actualDataShouldBeTrue.Select(x => x.IsSet), Has.All.True); - Assert.That(actualDataShouldBeFalse.Select(x => x.IsSet), Has.All.False); + var column = new Column("Id") { IsIdentity = true, IsNullable = true, IsUnsigned = true }; + Assert.That(column.IsNullable, Is.True); + Assert.That(column.IsIdentity, Is.True); + column.IsNullable = false; + Assert.That(column.IsIdentity, Is.True); + Assert.That(column.IsUnsigned, Is.True); } - [Test] - public void IsNotSet() - { - // Arrange - var columnProperty = ColumnProperty.PrimaryKeyWithIdentity | ColumnProperty.NotNull; - - // Act - var actualData = GetAllSingleColumnProperties().Select(x => new - { - ColumnPropertyString = x.ToString(), - IsSet = columnProperty.IsNotSet(x), - IsNotSet = columnProperty.IsNotSet(x) - }) - .ToList(); - - // Assert - string[] expectedSet = [nameof(ColumnProperty.PrimaryKey), nameof(ColumnProperty.NotNull), nameof(ColumnProperty.Identity)]; - var actualDataShouldBeFalse = actualData.Where(x => expectedSet.Any(y => y == x.ColumnPropertyString)).ToList(); - var actualDataShouldBeTrue = actualData.Where(x => !expectedSet.Any(y => y == x.ColumnPropertyString)).ToList(); - - Assert.That(actualDataShouldBeTrue.Select(x => x.IsNotSet), Has.All.True); - Assert.That(actualDataShouldBeFalse.Select(x => x.IsNotSet), Has.All.False); - } - - [Test] - public void Set_Success() - { - // Arrange - var columnProperty = ColumnProperty.NotNull; - - // Act - var result = columnProperty.Set(ColumnProperty.PrimaryKeyWithIdentity); - - // Assert - var expected = ColumnProperty.NotNull | ColumnProperty.PrimaryKeyWithIdentity; - - Assert.That(result, Is.EqualTo(expected)); - } - - private ColumnProperty[] GetAllSingleColumnProperties() + public void KeyDefinitionsCopyOrderedCallerArrays() { - return [.. Enum.GetValues().Where(x => x == 0 || (x & (x - 1)) == 0)]; + var columns = new[] { "Second", "First" }; + var key = new PrimaryKeyConstraint("PK", columns); + columns[0] = "Changed"; + Assert.That(key.KeyColumns, Is.EqualTo(new[] { "Second", "First" })); } -} \ No newline at end of file +} diff --git a/src/Migrator.Tests/JoiningTableTransformationProviderExtensionsTests.cs b/src/Migrator.Tests/JoiningTableTransformationProviderExtensionsTests.cs index dfdaabff..5eee5d79 100644 --- a/src/Migrator.Tests/JoiningTableTransformationProviderExtensionsTests.cs +++ b/src/Migrator.Tests/JoiningTableTransformationProviderExtensionsTests.cs @@ -51,7 +51,7 @@ public void AddManyToManyJoiningTable_CreatesLeftHandSideColumn_WithCorrectName( Assert.That(lhsColumn.Name, Is.EqualTo("TestScenarioId")); Assert.That(lhsColumn.Type, Is.EqualTo(DbType.Guid)); - Assert.That(ColumnProperty.NotNull, Is.EqualTo(lhsColumn.ColumnProperty)); + Assert.That(lhsColumn.IsNullable, Is.False); }); _provider.AddManyToManyJoiningTable("dbo", "TestScenarios", "Id", "Versions", "Id"); @@ -102,7 +102,7 @@ public void AddManyToManyJoiningTable_CreatesRightHandSideColumn_WithCorrectName Assert.That(rhsColumn.Name, Is.EqualTo("VersionId")); Assert.That(DbType.Guid, Is.EqualTo(rhsColumn.Type)); - Assert.That(ColumnProperty.NotNull, Is.EqualTo(rhsColumn.ColumnProperty)); + Assert.That(rhsColumn.IsNullable, Is.False); }); _provider.AddManyToManyJoiningTable("dbo", "TestScenarios", "Id", "Versions", "Id"); @@ -119,7 +119,7 @@ public void AddManyToManyJoiningTable_CreatesRightHandSideForeignKey_WithCorrect Assert.That(rhsColumn.Name, Is.EqualTo("VersionId")); Assert.That(DbType.Guid, Is.EqualTo(rhsColumn.Type)); - Assert.That(ColumnProperty.NotNull, Is.EqualTo(rhsColumn.ColumnProperty)); + Assert.That(rhsColumn.IsNullable, Is.False); Assert.That(callInfo[1] as string, Is.EqualTo("dbo.TestScenarioVersions")); Assert.That(callInfo[2] as string, Is.EqualTo("VersionId")); diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index b1cc09e6..912afe26 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -9,6 +9,7 @@ + @@ -45,11 +46,13 @@ + + - + diff --git a/src/Migrator.Tests/ProviderCorrectionTests.cs b/src/Migrator.Tests/ProviderCorrectionTests.cs index 0e845eaf..bd4e105c 100644 --- a/src/Migrator.Tests/ProviderCorrectionTests.cs +++ b/src/Migrator.Tests/ProviderCorrectionTests.cs @@ -1,5 +1,6 @@ using System; using System.Data; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; using System.Linq; using DotNetProjects.Migrator; using DotNetProjects.Migrator.Framework; @@ -16,7 +17,7 @@ [Test] public void InlineConstraintNamesAndReservedUniqueColumnsAreQuoted() using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); provider.AddTable("QuotedConstraints", new Column("select", DbType.Int32), - new Unique { Name = "unique name", KeyColumns = new[] { "select" } }, + new UniqueConstraint { Name = "unique name", KeyColumns = new[] { "select" } }, new CheckConstraint("check name", "\"select\" > 0")); provider.ExecuteNonQuery("INSERT INTO QuotedConstraints VALUES (1)"); Assert.That(Assert.Throws(() => provider.ExecuteNonQuery("INSERT INTO QuotedConstraints VALUES (1)")).InnerException, Is.TypeOf()); @@ -39,15 +40,16 @@ [Test] public void TableCreationRetainsCallerPrimaryKeyDefinitions() { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null); - var first = new Column("First", DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.Null); - var second = new Column("Second", DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.Null); - provider.AddTable("Composite", first, second); - Assert.That(first.ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.Null)); - Assert.That(second.ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.Null)); - provider.Insert("Composite", new[] { "First", "Second" }, new object[] { 1, null }); + var first = new Column("First",DbType.Int32); + var second = new Column("Second",DbType.Int32); + provider.AddTable("Composite", first, second, new PrimaryKeyConstraint("PK_Composite", "Second", "First")); + Assert.That(first.IsNullable, Is.True); + Assert.That(second.IsNullable, Is.True); + Assert.Catch(() => provider.Insert("Composite", new[] { "First", "Second" }, new object[] { 1, null })); + provider.Insert("Composite", new[] { "First", "Second" }, new object[] { 1, 2 }); Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT COUNT(*) FROM Composite")), Is.EqualTo(1)); - provider.AddTable("Reused", first, second); - Assert.That(provider.GetColumns("Reused").Count(c => c.IsPrimaryKey), Is.EqualTo(2)); + provider.AddTable("Reused", first, second, new PrimaryKeyConstraint("PK_Reused", "Second", "First")); + Assert.That(provider.GetTableConstraints("Reused").OfType().Single().KeyColumns.Length, Is.EqualTo(2)); } [Test] public void RebuildPreservesTriggerAndUpdateAction() { diff --git a/src/Migrator.Tests/ProviderDefinitionTests.cs b/src/Migrator.Tests/ProviderDefinitionTests.cs index fe580d23..f29e771a 100644 --- a/src/Migrator.Tests/ProviderDefinitionTests.cs +++ b/src/Migrator.Tests/ProviderDefinitionTests.cs @@ -37,9 +37,9 @@ [Test] public void NullableScalarRetainsTypedValuesAndHandlesNulls() [Test] public void ChangeColumnDoesNotClearCallerUniqueFlag() { using var provider = new RecordingProvider(); - var column = new Column("Value", DbType.Int32, ColumnProperty.Unique | ColumnProperty.NotNull); + var column = new Column("Value",DbType.Int32){IsNullable = false}; provider.ChangeColumn("Example", column); - Assert.That(column.ColumnProperty, Is.EqualTo(ColumnProperty.Unique | ColumnProperty.NotNull)); + Assert.That(column.IsNullable, Is.False); } [Test] public void QuotingReturnsNewArrayWithoutChangingCallerNames() { diff --git a/src/Migrator.Tests/Providers/Base/TransformationProviderSimpleBase.cs b/src/Migrator.Tests/Providers/Base/TransformationProviderSimpleBase.cs index 94050506..83216fc1 100644 --- a/src/Migrator.Tests/Providers/Base/TransformationProviderSimpleBase.cs +++ b/src/Migrator.Tests/Providers/Base/TransformationProviderSimpleBase.cs @@ -8,33 +8,32 @@ public abstract class TransformationProviderSimpleBase : TransformationProviderB public void AddDefaultTable() { Provider.AddTable("TestTwo", - new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey), + new Column("Id",DbType.Int32){IsNullable = false}, new Column("TestId", DbType.Int32) - ); +,new PrimaryKeyConstraint("PK_" + "TestTwo", "Id") ); } public void AddTable() { Provider.AddTable("Test", - new Column("Id", DbType.Int32, ColumnProperty.NotNull), - new Column("Title", DbType.String, 100, ColumnProperty.Null), - new Column("name", DbType.String, 50, ColumnProperty.Null), - new Column("blobVal", DbType.Binary, ColumnProperty.Null), - new Column("boolVal", DbType.Boolean, ColumnProperty.Null), - new Column("bigstring", DbType.String, 50000, ColumnProperty.Null) - ); + new Column("Id",DbType.Int32){IsNullable = false}, + new Column("Title",DbType.String,100), + new Column("name",DbType.String,50), + new Column("blobVal",DbType.Binary), + new Column("boolVal",DbType.Boolean), + new Column("bigstring",DbType.String,50000) ); } public void AddTableWithPrimaryKey() { Provider.AddTable("Test", - new Column("Id", DbType.Int32, ColumnProperty.PrimaryKeyWithIdentity), - new Column("Title", DbType.String, 100, ColumnProperty.Null), - new Column("name", DbType.String, 50, ColumnProperty.NotNull), + new Column("Id",DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column("Title",DbType.String,100), + new Column("name",DbType.String,50){IsNullable = false}, new Column("blobVal", DbType.Binary), new Column("boolVal", DbType.Boolean), new Column("bigstring", DbType.String, 50000) - ); +,new PrimaryKeyConstraint("PK_" + "Test", "Id") ); } public void AddPrimaryKey() diff --git a/src/Migrator.Tests/Providers/Generic/Generic_AddPrimaryKey.cs b/src/Migrator.Tests/Providers/Generic/Generic_AddPrimaryKey.cs index b203a11c..256ecb7e 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_AddPrimaryKey.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_AddPrimaryKey.cs @@ -19,9 +19,9 @@ public void AddPrimaryKey_IdentityColumnWithData_Success() const string columnName2 = "TestColumn2"; Provider.AddTable(tableName, - new Column(columnName1, DbType.Int32, property: ColumnProperty.Identity | ColumnProperty.PrimaryKey), + new Column(columnName1,DbType.Int32){IsNullable = false,IsIdentity = true}, new Column(columnName2, DbType.String) - ); +,new PrimaryKeyConstraint("PK_" + tableName, columnName1) ); // Act Provider.Insert(tableName, [columnName2], ["Hello"]); @@ -54,9 +54,8 @@ public void AddPrimaryKey_AddPrimaryKey_ShouldStillBeNotNull() const string columnName2 = "TestColumn2"; Provider.AddTable(tableName, - new Column(columnName1, DbType.Int32, property: ColumnProperty.NotNull), - new Column(columnName2, DbType.DateTime, property: ColumnProperty.NotNull) - ); + new Column(columnName1,DbType.Int32){IsNullable = false}, + new Column(columnName2,DbType.DateTime){IsNullable = false} ); // Act Provider.AddPrimaryKey(name: "MyPkName", table: tableName, columnName1); @@ -65,7 +64,7 @@ public void AddPrimaryKey_AddPrimaryKey_ShouldStillBeNotNull() var column1 = Provider.GetColumnByName(table: tableName, column: columnName1); var column2 = Provider.GetColumnByName(table: tableName, column: columnName2); - Assert.That(column1.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); - Assert.That(column2.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(column1.IsNullable, Is.False); + Assert.That(column2.IsNullable, Is.False); } } \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/Generic/Generic_AddTableTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_AddTableTestsBase.cs index 0c78c68b..2c494152 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_AddTableTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_AddTableTestsBase.cs @@ -20,16 +20,15 @@ public void AddTable_PrimaryKeyWithIdentity_Success() // Act Provider.AddTable(tableName, - new Column(column1Name, DbType.Int32, ColumnProperty.NotNull | ColumnProperty.PrimaryKeyWithIdentity), - new Column(column2Name, DbType.Int32, ColumnProperty.NotNull) - ); + new Column(column1Name,DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(column2Name,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, column1Name) ); // Assert var column1 = Provider.GetColumnByName(tableName, column1Name); var column2 = Provider.GetColumnByName(tableName, column2Name); - Assert.That(column1.ColumnProperty.HasFlag(ColumnProperty.PrimaryKeyWithIdentity), Is.True); - Assert.That(column2.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(column1.IsIdentity, Is.True); + Assert.That(column2.IsNullable, Is.False); } [Test] @@ -42,16 +41,15 @@ public void AddTable_PrimaryKeyAndIdentity_Success() // Act Provider.AddTable(tableName, - new Column(column1Name, DbType.Int32, ColumnProperty.NotNull | ColumnProperty.PrimaryKey | ColumnProperty.Identity), - new Column(column2Name, DbType.Int32, ColumnProperty.NotNull) - ); + new Column(column1Name,DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(column2Name,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, column1Name) ); // Assert var column1 = Provider.GetColumnByName(tableName, column1Name); var column2 = Provider.GetColumnByName(tableName, column2Name); - Assert.That(column1.ColumnProperty.HasFlag(ColumnProperty.PrimaryKeyWithIdentity), Is.True); - Assert.That(column2.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(column1.IsIdentity, Is.True); + Assert.That(column2.IsNullable, Is.False); } [Test] @@ -64,9 +62,8 @@ public void AddTable_PrimaryKeyAndIdentityWithInsertNull_Success() // Act Provider.AddTable(tableName, - new Column(column1Name, DbType.Int32, ColumnProperty.NotNull | ColumnProperty.PrimaryKey | ColumnProperty.Identity), - new Column(column2Name, DbType.Int32, ColumnProperty.NotNull) - ); + new Column(column1Name,DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(column2Name,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, column1Name) ); Provider.Insert(table: tableName, [column2Name], [999]); @@ -86,8 +83,8 @@ public void AddTable_PrimaryKeyAndIdentityWithInsertNull_Success() Assert.That(records.Single().Item1, Is.EqualTo(1)); - Assert.That(column1.ColumnProperty.HasFlag(ColumnProperty.PrimaryKeyWithIdentity), Is.True); - Assert.That(column2.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(column1.IsIdentity, Is.True); + Assert.That(column2.IsNullable, Is.False); } [Test] @@ -100,16 +97,15 @@ public void AddTable_PrimaryKeyAndIdentityWithoutNotNull_Success() // Act Provider.AddTable(tableName, - new Column(column1Name, DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.Identity), - new Column(column2Name, DbType.Int32, ColumnProperty.NotNull) - ); + new Column(column1Name,DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(column2Name,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, column1Name) ); // Assert var column1 = Provider.GetColumnByName(tableName, column1Name); var column2 = Provider.GetColumnByName(tableName, column2Name); - Assert.That(column1.ColumnProperty.HasFlag(ColumnProperty.PrimaryKeyWithIdentity), Is.True); - Assert.That(column2.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(column1.IsIdentity, Is.True); + Assert.That(column2.IsNullable, Is.False); } [Test] @@ -121,13 +117,12 @@ public void AddTable_NotNull_Success() // Act Provider.AddTable(tableName, - new Column(column1Name, DbType.Int32, ColumnProperty.NotNull) - ); + new Column(column1Name,DbType.Int32){IsNullable = false} ); // Assert var column1 = Provider.GetColumnByName(tableName, column1Name); - Assert.That(column1.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(column1.IsNullable, Is.False); } @@ -135,9 +130,8 @@ public void AddTable_NotNull_Success() public void AddTableWithCompoundPrimaryKey() { Provider.AddTable("Test", - new Column("PersonId", DbType.Int32, ColumnProperty.PrimaryKey), - new Column("AddressId", DbType.Int32, ColumnProperty.PrimaryKey) - ); + new Column("PersonId",DbType.Int32){IsNullable = false}, + new Column("AddressId",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "Test", "PersonId", "AddressId") ); Assert.That(Provider.TableExists("Test"), Is.True, "Table doesn't exist"); Assert.That(Provider.PrimaryKeyExists("Test", "PK_Test"), Is.True, "Constraint doesn't exist"); diff --git a/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs index a1902a26..72f26ab3 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_ChangeColumnTestsBase.cs @@ -32,18 +32,17 @@ public void ChangeColumn_NotNullAndNullToNotNull_Success() // Act Provider.AddTable(tableName, - new Column(column1Name, DbType.DateTime, ColumnProperty.NotNull), - new Column(column2Name, DbType.DateTime, ColumnProperty.Null) - ); + new Column(column1Name,DbType.DateTime){IsNullable = false}, + new Column(column2Name,DbType.DateTime) ); // Assert - Provider.ChangeColumn(tableName, new Column(column1Name, DbType.DateTime2, ColumnProperty.NotNull)); - Provider.ChangeColumn(tableName, new Column(column2Name, DbType.DateTime2, ColumnProperty.NotNull)); + Provider.ChangeColumn(tableName, new Column(column1Name,DbType.DateTime2){IsNullable = false}); + Provider.ChangeColumn(tableName, new Column(column2Name,DbType.DateTime2){IsNullable = false}); var column1 = Provider.GetColumnByName(tableName, column1Name); var column2 = Provider.GetColumnByName(tableName, column2Name); - Assert.That(column1.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); - Assert.That(column2.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(column1.IsNullable, Is.False); + Assert.That(column2.IsNullable, Is.False); } [Test] @@ -57,13 +56,12 @@ public void ChangeColumn_RemoveDefaultValue_Success() var testTime = new DateTime(2025, 5, 5, 5, 5, 5, DateTimeKind.Utc); Provider.AddTable(tableName, - new Column(name: column1Name, type: DbType.Int32, property: ColumnProperty.NotNull), - new Column(name: column2Name, type: DbType.DateTime2, property: ColumnProperty.Null, defaultValue: testTime) - ); + new Column(name: column1Name,type: DbType.Int32){IsNullable = false}, + new Column(name: column2Name,type: DbType.DateTime2,defaultValue: testTime) ); // Act Provider.Insert(table: tableName, [column1Name], [1]); - Provider.ChangeColumn(table: tableName, column: new Column(name: column2Name, type: DbType.DateTime2, property: ColumnProperty.Null)); + Provider.ChangeColumn(table: tableName, column: new Column(name: column2Name,type: DbType.DateTime2)); // Assert Provider.Insert(table: tableName, [column1Name], [2]); diff --git a/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs index a55e1aaa..75e0a05f 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_ConstraintExistsBase.cs @@ -19,13 +19,11 @@ public void ConstraintExists_ForeignKeyExists_ReturnsTrue() var fkName = "FK_Task_TaskGroup"; Provider.AddTable("Task", - new Column(name: "Id", type: DbType.Int32, property: ColumnProperty.PrimaryKey), - new Column(name: "TaskGroupId", type: DbType.Int32, property: ColumnProperty.Null) - ); + new Column(name: "Id",type: DbType.Int32){IsNullable = false}, + new Column(name: "TaskGroupId",type: DbType.Int32),new PrimaryKeyConstraint("PK_" + "Task", "Id") ); Provider.AddTable("TaskGroup", - new Column(name: "Id", type: DbType.Int32, property: ColumnProperty.PrimaryKey) - ); + new Column(name: "Id",type: DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "TaskGroup", "Id") ); Provider.AddForeignKey(name: fkName, childTable: tableName, childColumn: "TaskGroupId", parentTable: "TaskGroup", parentColumn: "Id"); diff --git a/src/Migrator.Tests/Providers/Generic/Generic_DefaultValueTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_DefaultValueTestsBase.cs index 58d525bb..5a154f4d 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_DefaultValueTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_DefaultValueTestsBase.cs @@ -14,10 +14,9 @@ public void DefaultValue_Null_Success() const string columnName1Target = "TargetColumn1"; Provider.AddTable(tableNameSource, - new Column(columnName1Target, DbType.Int32, ColumnProperty.Null, null) - ); + new Column(columnName1Target,DbType.Int32,null) ); - Provider.ChangeColumn(tableNameSource, new Column(columnName1Target, DbType.Int32, ColumnProperty.NotNull)); + Provider.ChangeColumn(tableNameSource, new Column(columnName1Target,DbType.Int32){IsNullable = false}); } [Test] @@ -27,10 +26,9 @@ public void DefaultValue_ConvertStringToNotNull_DoesNotThrow() const string columnName1Target = "TargetColumn1"; Provider.AddTable(tableNameSource, - new Column(columnName1Target, DbType.String, 32, ColumnProperty.NotNull) - ); + new Column(columnName1Target,DbType.String,32){IsNullable = false} ); - Provider.ChangeColumn(tableNameSource, new Column(columnName1Target, DbType.String, ColumnProperty.Null)); + Provider.ChangeColumn(tableNameSource, new Column(columnName1Target,DbType.String)); } [Test] @@ -40,11 +38,10 @@ public void RemoveColumnDefaultValue_DoesNotThrow() const string columnName1 = "ColumnName1"; Provider.AddTable(tableNameSource, - new Column(columnName1, DbType.Int32, ColumnProperty.NotNull, 10) - ); + new Column(columnName1, DbType.Int32) { DefaultValue = 10, IsNullable = false} ); Provider.RemoveColumnDefaultValue(tableNameSource, columnName1); - Provider.ChangeColumn(tableNameSource, new Column(columnName1, DbType.Int32, ColumnProperty.Null)); + Provider.ChangeColumn(tableNameSource, new Column(columnName1,DbType.Int32)); } } \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs index 22c3681f..3eb1d250 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_GetColumnsTestsBase.cs @@ -8,12 +8,31 @@ namespace Migrator.Tests.Providers.Generic; public abstract class Generic_GetColumnsTestsBase : TransformationProviderBase { + [Test] + public void NamedTableConstraintsEnforceCompositeKeysAndReturnOrderedMetadata() + { + Provider.AddTable("NamedConstraintModel", + new Column("IdA", DbType.Int32), new Column("IdB", DbType.Int32), new Column("Amount", DbType.Int32), + new PrimaryKeyConstraint("PK_NamedModel", "IdB", "IdA"), + new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_NamedModel", "IdA", "Amount"), + new CheckConstraint("CK_NamedModel", "Amount >= 0")); + Provider.Insert("NamedConstraintModel", new[] { "IdA", "IdB", "Amount" }, new object[] { 1, 2, 3 }); + Provider.Insert("NamedConstraintModel", new[] { "IdA", "IdB", "Amount" }, new object[] { 1, 3, 4 }); + var constraints = Provider.GetTableConstraints("NamedConstraintModel"); + Assert.That(constraints.OfType().Single().KeyColumns.Select(c => c.ToUpperInvariant()), Is.EqualTo(new[] { "IDB", "IDA" })); + Assert.That(constraints.OfType().Single().KeyColumns.Select(c => c.ToUpperInvariant()), Is.EqualTo(new[] { "IDA", "AMOUNT" })); + Assert.That(constraints.OfType().Any(c => c.Name.ToUpperInvariant() == "CK_NAMEDMODEL"), Is.True); + + // On PostgreSQL a failing statement aborts this test's transaction, so check one complete-key violation last. + Assert.Catch(() => Provider.Insert("NamedConstraintModel", new[] { "IdA", "IdB", "Amount" }, new object[] { 1, 2, 5 })); + } + [Test] public void CompositeUniqueDoesNotMarkItsIndividualColumnsUnique() { Provider.AddTable("CompositeUniqueMetadata", new Column("FirstId", DbType.Int32), new Column("SecondId", DbType.Int32)); Provider.AddUniqueConstraint("CompositeUniqueKey", "CompositeUniqueMetadata", "FirstId", "SecondId"); - Assert.That(Provider.GetColumns("CompositeUniqueMetadata").All(c => !c.ColumnProperty.HasFlag(ColumnProperty.Unique)), Is.True); + Assert.That(Provider.GetTableConstraints("CompositeUniqueMetadata").OfType().Single().KeyColumns.Length == 2, Is.True); } [Test] @@ -21,13 +40,13 @@ public void GetColumns_UniqueButNotPrimaryKey_ReturnsFalse() { // Arrange const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique)); + Provider.AddTable(tableName, new Column("Id",DbType.Int32),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + tableName + "_" + "Id", "Id")); // Act var columns = Provider.GetColumns(tableName); // Assert - Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.Null | ColumnProperty.Unique)); + Assert.That(columns.Single().IsNullable, Is.True); } } \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/Generic/Generic_UpdateFromTableToTableTestsBase.cs b/src/Migrator.Tests/Providers/Generic/Generic_UpdateFromTableToTableTestsBase.cs index dce4047e..1bc28246 100644 --- a/src/Migrator.Tests/Providers/Generic/Generic_UpdateFromTableToTableTestsBase.cs +++ b/src/Migrator.Tests/Providers/Generic/Generic_UpdateFromTableToTableTestsBase.cs @@ -29,8 +29,8 @@ public void UpdateFromTableToTable_Success() Provider.AddTable(tableNameSource, - new Column(columnName1Source, DbType.Int32, ColumnProperty.NotNull), - new Column(columnName2Source, DbType.Int32, ColumnProperty.NotNull), + new Column(columnName1Source,DbType.Int32){IsNullable = false}, + new Column(columnName2Source,DbType.Int32){IsNullable = false}, new Column(columnName3Source, DbType.String), new Column(columnName4Source, DbType.String), new Column(columnName5Source, DbType.String) @@ -39,8 +39,8 @@ public void UpdateFromTableToTable_Success() Provider.AddPrimaryKey("PK_Source", tableNameSource, [columnName1Source, columnName2Source]); Provider.AddTable(tableNameTarget, - new Column(columnName1Target, DbType.Int32, ColumnProperty.NotNull), - new Column(columnName2Target, DbType.Int32, ColumnProperty.NotNull), + new Column(columnName1Target,DbType.Int32){IsNullable = false}, + new Column(columnName2Target,DbType.Int32){IsNullable = false}, new Column(columnName3Target, DbType.String), new Column(columnName4Target, DbType.String), new Column(columnName5Target, DbType.String) diff --git a/src/Migrator.Tests/Providers/Generic/RawDefaultRegression.cs b/src/Migrator.Tests/Providers/Generic/RawDefaultRegression.cs new file mode 100644 index 00000000..6fe51efa --- /dev/null +++ b/src/Migrator.Tests/Providers/Generic/RawDefaultRegression.cs @@ -0,0 +1,33 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.Generic; + +internal static class RawDefaultRegression +{ + internal static void AssertRoundTrip(ITransformationProvider provider) + { + provider.AddTable("RawDefaultsSource", new Column("Id", DbType.Int32), + new Column("ExpressionValue", DbType.String, 50) { DefaultValue = RawSql.Insert("LOWER('ABC')") }, + new Column("LiteralValue", DbType.String, 50) { DefaultValue = "LOWER('ABC')" }); + var columns = provider.GetColumns("RawDefaultsSource"); + Assert.That(columns.Single(c => c.Name.Equals("ExpressionValue", StringComparison.OrdinalIgnoreCase)).DefaultValue, Is.TypeOf()); + provider.AddTable("RawDefaultsCopy", columns); + var builder = new MigrationBuilder(); + builder.Create.Table("RawDefaultsFluent").WithColumn("Id").AsInt32() + .WithColumn("ExpressionValue").AsString(50).WithDefaultValue(RawSql.Insert("LOWER('ABC')")) + .WithColumn("LiteralValue").AsString(50).WithDefaultValue("LOWER('ABC')"); + builder.Apply(provider); + foreach (var table in new[] { "RawDefaultsSource", "RawDefaultsCopy", "RawDefaultsFluent" }) + { + provider.Insert(table, ["Id"], [1]); + var quoted = provider.QuoteTableNameIfRequired(table); + Assert.That(provider.ExecuteScalar("SELECT " + provider.QuoteColumnNameIfRequired("ExpressionValue") + " FROM " + quoted), Is.EqualTo("abc")); + Assert.That(provider.ExecuteScalar("SELECT " + provider.QuoteColumnNameIfRequired("LiteralValue") + " FROM " + quoted), Is.EqualTo("LOWER('ABC')")); + } + } +} diff --git a/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscConstraintBase.cs b/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscConstraintBase.cs index d2d2a180..f5ce8c8d 100644 --- a/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscConstraintBase.cs +++ b/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscConstraintBase.cs @@ -56,7 +56,8 @@ public void CanAddPrimaryKey() [Test] public void AddUniqueColumn() { - Provider.AddColumn("TestTwo", "Test", DbType.String, 50, ColumnProperty.Unique); + Provider.AddColumn("TestTwo", new Column("Test", DbType.String, 50)); + Provider.AddUniqueConstraint("UQ_TestTwo_Test", "TestTwo", "Test"); } [Test] @@ -149,17 +150,16 @@ public void AddTableWithCompoundPrimaryKeyShouldKeepNullForOtherProperties() var testTableName = "Test"; Provider.AddTable(testTableName, - new Column("PersonId", DbType.Int32, ColumnProperty.PrimaryKey), - new Column("AddressId", DbType.Int32, ColumnProperty.PrimaryKey), - new Column("Name", DbType.String, 30, ColumnProperty.Null) - ); + new Column("PersonId",DbType.Int32){IsNullable = false}, + new Column("AddressId",DbType.Int32){IsNullable = false}, + new Column("Name",DbType.String,30),new PrimaryKeyConstraint("PK_" + testTableName, "PersonId", "AddressId") ); Assert.That(Provider.TableExists("Test"), Is.True, "Table doesn't exist"); var column = Provider.GetColumnByName("Test", "Name"); Assert.That(column, Is.Not.Null); - Assert.That((column.ColumnProperty & ColumnProperty.Null) == ColumnProperty.Null, Is.True); + Assert.That(column.IsNullable, Is.True); } [Test] @@ -173,13 +173,12 @@ public void GetForeignKeyConstraints_SingleColumn_Success() const string parentIdColumn = "ParentId"; Provider.AddTable(parentTableName, - new Column(idColumn, DbType.Int32, ColumnProperty.PrimaryKey) - ); + new Column(idColumn,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + parentTableName, idColumn) ); Provider.AddTable(childTableName, - new Column(idColumn, DbType.Int32, ColumnProperty.PrimaryKey), + new Column(idColumn,DbType.Int32){IsNullable = false}, new Column(parentIdColumn, DbType.Int32) - ); +,new PrimaryKeyConstraint("PK_" + childTableName, idColumn) ); Provider.AddForeignKey(fkName, childTableName, parentIdColumn, parentTableName, idColumn); @@ -210,14 +209,13 @@ public void GetForeignKeyConstraints_MultiColumnColumn_Success() const string childColumnParentTest = "ParentTest"; Provider.AddTable(parentTableName, - new Column(parentColumnId, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(parentColumnTest, DbType.Int32, ColumnProperty.NotNull) - ); + new Column(parentColumnId,DbType.Int32){IsNullable = false}, + new Column(parentColumnTest,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + parentTableName, parentColumnId) ); Provider.AddTable(childTableName, - new Column(childColumnParentId, DbType.Int32, ColumnProperty.PrimaryKey), + new Column(childColumnParentId,DbType.Int32){IsNullable = false}, new Column(childColumnParentTest, DbType.Int32) - ); +,new PrimaryKeyConstraint("PK_" + childTableName, childColumnParentId) ); Provider.AddUniqueConstraint("MyUniqueConstraint", parentTableName, [parentColumnId, parentColumnTest]); diff --git a/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscTests.cs b/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscTests.cs index 62249ae1..3d65881d 100644 --- a/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscTests.cs +++ b/src/Migrator.Tests/Providers/Generic/TransformationProviderGenericMiscTests.cs @@ -78,11 +78,11 @@ public void GetColumnsContainsProperNullInformation() { if (column.Name == "name") { - Assert.That((column.ColumnProperty & ColumnProperty.NotNull) == ColumnProperty.NotNull, Is.True); + Assert.That(!column.IsNullable, Is.True); } else if (column.Name == "Title") { - Assert.That((column.ColumnProperty & ColumnProperty.Null) == ColumnProperty.Null, Is.True); + Assert.That(column.IsNullable, Is.True); } } } @@ -170,9 +170,9 @@ public void ChangeColumn() [Test] public void ChangeColumn_FromNullToNull() { - Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.Null)); - Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.Null)); - Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.Null)); + Provider.ChangeColumn("TestTwo", new Column("TestId",DbType.String,50)); + Provider.ChangeColumn("TestTwo", new Column("TestId",DbType.String,50)); + Provider.ChangeColumn("TestTwo", new Column("TestId",DbType.String,50)); Provider.Insert("TestTwo", ["Id", "TestId"], [2, "Not an Int val."]); } @@ -186,7 +186,7 @@ public void AddDecimalColumn() [Test] public void AddColumnWithDefault() { - Provider.AddColumn("TestTwo", "TestWithDefault", DbType.Int32, 50, 0, 10); + Provider.AddColumn("TestTwo", new Column("TestWithDefault", DbType.Int32, 50) { DefaultValue = 10 }); Assert.That(Provider.ColumnExists("TestTwo", "TestWithDefault"), Is.True); } @@ -203,21 +203,21 @@ public void AddColumnWithDefaultButNoSize() [Test] public void AddBooleanColumnWithDefault() { - Provider.AddColumn("TestTwo", "TestBoolean", DbType.Boolean, 0, 0, false); + Provider.AddColumn("TestTwo", new Column("TestBoolean", DbType.Boolean) { DefaultValue = false }); Assert.That(Provider.ColumnExists("TestTwo", "TestBoolean"), Is.True); } [Test] public void CanGetNullableFromProvider() { - Provider.AddColumn("TestTwo", "NullableColumn", DbType.String, 30, ColumnProperty.Null); + Provider.AddColumn("TestTwo", new Column("NullableColumn", DbType.String, 30)); var columns = Provider.GetColumns("TestTwo"); foreach (var column in columns) { if (column.Name == "NullableColumn") { - Assert.That((column.ColumnProperty & ColumnProperty.Null) == ColumnProperty.Null, Is.True); + Assert.That(column.IsNullable, Is.True); } } } diff --git a/src/Migrator.Tests/Providers/Hana/HanaProviderTests.cs b/src/Migrator.Tests/Providers/Hana/HanaProviderTests.cs new file mode 100644 index 00000000..fe5aab94 --- /dev/null +++ b/src/Migrator.Tests/Providers/Hana/HanaProviderTests.cs @@ -0,0 +1,189 @@ +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 NUnit.Framework; +using Sap.Data.Hana; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests.Providers.Hana; + +[TestFixture, Category("Hana"), NonParallelizable] +public class HanaProviderTests +{ + private HanaConnection connection; + private string connectionString; + private ITransformationProvider provider; + private string schema; + [SetUp] + public void SetUp() + { + connectionString = Environment.GetEnvironmentVariable("MIGRATOR_HANA") + ?? "Server=localhost:39041;UserID=SYSTEM;Password=MgT9ci7Q4xZ2"; + connection = new HanaConnection(connectionString); + connection.Open(); + schema = "MIGRATOR_" + Guid.NewGuid().ToString("N").ToUpperInvariant(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE SCHEMA " + schema; command.ExecuteNonQuery(); + command.CommandText = "SET SCHEMA " + schema; command.ExecuteNonQuery(); + provider = ProviderFactory.Create(ProviderTypes.Hana, connection, schema, "hana-tests"); + } + [TearDown] + public void TearDown() + { + provider?.Dispose(); + if (connection?.State == ConnectionState.Open && schema != null) + { + using var command = connection.CreateCommand(); command.CommandText = "DROP SCHEMA " + schema + " CASCADE"; command.ExecuteNonQuery(); + } + connection?.Dispose(); + } + [Test] + public void ConnectionStringFactoryOpensAndDisposesOwnedConnection() + { + using var owned = ProviderFactory.Create(ProviderTypes.Hana, connectionString, schema); + Assert.That(Convert.ToInt32(owned.ExecuteScalar("SELECT 1 FROM DUMMY")), Is.EqualTo(1)); + } + + [Test] + public void ImperativeSchemaConstraintsMetadataAndPersistedData() + { + provider.AddTable("Items", new Column("Id", DbType.Int32) { IsIdentity = true }, + new Column("Label", DbType.String, 40) { DefaultValue = "initial" }, + new PrimaryKeyConstraint("PK_Items", "Id"), new UniqueConstraint("UQ_Label", "Label"), + new CheckConstraint("CK_Label", "LENGTH(\"Label\") > 0")); + provider.Insert("Items", ["Label"], ["one"]); + Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT COUNT(*) FROM \"Items\"")), Is.EqualTo(1)); + Assert.That(provider.GetColumns("Items").Single(c => c.Name == "Id").IsIdentity, Is.True); + var constraints = provider.GetTableConstraints("Items"); + Assert.That(constraints.OfType().Single().KeyColumns, Is.EqualTo(new[] { "Id" })); + Assert.That(constraints.OfType().Single().Name, Is.EqualTo("UQ_Label")); + Assert.That(constraints.OfType().Single().Name, Is.EqualTo("CK_Label")); + Assert.Catch(() => provider.Insert("Items", ["Label"], ["one"])); + Assert.Catch(() => provider.Insert("Items", ["Label"], [""])); + Assert.That(provider.GetColumns(schema + ".Items").Select(c => c.Name), Is.EqualTo(new[] { "Id", "Label" })); + } + [Test] + public void FluentAndPreviewCreateEquivalentSchemasAndRawDefaults() + { + MigrationBuilder Definition(string table) + { + var builder = new MigrationBuilder(); + builder.Create.Table(table).WithColumn("Id").AsInt32() + .WithColumn("Created").AsDateTime().WithDefaultValue(RawSql.Insert("CURRENT_TIMESTAMP")) + .WithPrimaryKey("PK_" + table, "Id"); + return builder; + } + provider.AddTable("Imperative", new Column("Id", DbType.Int32), + new Column("Created", DbType.DateTime) { DefaultValue = RawSql.Insert("CURRENT_TIMESTAMP") }, + new PrimaryKeyConstraint("PK_Imperative", "Id")); + Definition("Fluent").Apply(provider); + foreach (var sql in Definition("Preview").Preview(new SqlGenerationContext(ProviderTypes.Hana))) provider.ExecuteNonQuery(sql.TrimEnd(';')); + foreach (var table in new[] { "Imperative", "Fluent", "Preview" }) + { + provider.Insert(table, ["Id"], [1]); + Assert.That(provider.ExecuteScalar("SELECT \"Created\" FROM \"" + table + "\""), Is.TypeOf()); + Assert.That(provider.GetColumns(table).Single(c => c.Name == "Created").DefaultValue, Is.TypeOf()); + Assert.That(provider.GetTableConstraints(table).OfType().Single().KeyColumns, Is.EqualTo(new[] { "Id" })); + } + } + [Test] + public void AlterRenameAndIndexOperationsPreserveData() + { + provider.AddTable("Names", new Column("Id", DbType.Int32), new Column("Label", DbType.String, 20)); + provider.Insert("Names", ["Id", "Label"], [1, "kept"]); + provider.AddColumn("Names", new Column("Extra", DbType.Int32) { DefaultValue = 7 }); + provider.ChangeColumn("Names", new Column("Label", DbType.String, 60)); + ((TransformationProvider)provider).AddColumnDefaultValue("Names", "Extra", 8); + provider.Insert("Names", ["Id", "Label"], [2, "second"]); + Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT \"Extra\" FROM \"Names\" WHERE \"Id\"=2")), Is.EqualTo(8)); + provider.RemoveColumnDefaultValue("Names", "Extra"); + provider.ChangeColumn("Names", new Column("Label", DbType.String, 60) { IsNullable = false }); + provider.ChangeColumn("Names", new Column("Label", DbType.String, 60) { IsNullable = true }); + provider.Insert("Names", ["Id"], [3]); + Assert.That(provider.ExecuteScalar("SELECT \"Extra\" FROM \"Names\" WHERE \"Id\"=3"), Is.EqualTo(DBNull.Value)); + Assert.That(provider.GetColumns("Names").Single(c => c.Name == "Label").IsNullable, Is.True); + provider.RenameColumn("Names", "Label", "Text"); + provider.RenameTable("Names", "Renamed"); + provider.AddIndex("Renamed", new Index { Name = "IX_Text", KeyColumns = ["Text"] }); + Assert.That(provider.IndexExists("Renamed", "IX_Text"), Is.True); + Assert.That(provider.GetIndexes("Renamed").Single(i => i.Name == "IX_Text").KeyColumns, Is.EqualTo(new[] { "Text" })); + Assert.That(provider.ExecuteScalar("SELECT \"Text\" FROM \"Renamed\" WHERE \"Id\"=1"), Is.EqualTo("kept")); + provider.RemoveIndex("Renamed", "IX_Text"); + provider.RemoveColumn("Renamed", "Extra"); + Assert.That(provider.ColumnExists("Renamed", "Extra"), Is.False); + provider.RemoveTable("Renamed"); + Assert.That(provider.TableExists("Renamed"), Is.False); + } + [Test] + public void ForeignKeysPreservePairsAndIndependentActions() + { + provider.AddTable("Parents", new Column("A", DbType.Int32), new Column("B", DbType.Int32), + new PrimaryKeyConstraint("PK_Parents", "B", "A")); + provider.AddTable("Children", new Column("X", DbType.Int32), new Column("Y", DbType.Int32)); + ((IForeignKeyActions)provider).AddForeignKey("FK_Children", "Children", ["X", "Y"], "Parents", ["B", "A"], + ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Restrict); + var key = provider.GetForeignKeyConstraints("Children").Single(); + Assert.That(key.ParentColumns, Is.EqualTo(new[] { "B", "A" })); + Assert.That(key.ChildColumns, Is.EqualTo(new[] { "X", "Y" })); + Assert.That(key.OnDelete, Is.EqualTo("CASCADE")); + Assert.That(key.OnUpdate, Is.EqualTo("RESTRICT")); + provider.Insert("Parents", ["A", "B"], [1, 2]); + provider.Insert("Children", ["X", "Y"], [2, 1]); + provider.Delete("Parents", ["A"], [1]); + Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT COUNT(*) FROM \"Children\"")), Is.Zero); + provider.RemoveForeignKey("Children", "FK_Children"); + Assert.That(provider.GetForeignKeyConstraints("Children"), Is.Empty); + } + [Test] + public void DataTransactionsRollbackAndCallerConnectionSurvives() + { + provider.AddTable("Numbers", new Column("Id", DbType.Int32)); + provider.Insert("Numbers", ["Id"], [1]); + provider.BeginTransaction(); + provider.Insert("Numbers", ["Id"], [2]); + provider.Rollback(); + Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT COUNT(*) FROM \"Numbers\"")), Is.EqualTo(1)); + provider.Dispose(); + Assert.That(connection.State, Is.EqualTo(ConnectionState.Open)); + } + [Test] + public void RunnerHistoryRestartDowngradeAndReadonlyPlan() + { + var runner = new DotNetProjects.Migrator.Migrator(provider, false, typeof(HanaMigration)); + runner.DryRun = true; + runner.MigrateToLastVersion(); + Assert.That(provider.TableExists(provider.SchemaInfoTable), Is.False); + runner.DryRun = false; + runner.MigrateToLastVersion(); + Assert.That(provider.TableExists("RunnerItems"), Is.True); + var restarted = new DotNetProjects.Migrator.Migrator(provider, false, typeof(HanaMigration)); + restarted.MigrateToLastVersion(); + Assert.That(((IMigrationHistory)provider).ReadAppliedMigrations(), Is.EqualTo(new long[] { 1 })); + restarted.MigrateTo(0); + Assert.That(provider.TableExists("RunnerItems"), Is.False); + Assert.That(((IMigrationHistory)provider).ReadAppliedMigrations(), Is.Empty); + } + [Test] + public void UnsupportedCapabilitiesFailBeforeSchemaChanges() + { + Assert.Throws(() => provider.AddTable("InvalidCollation", + new Column("Name", DbType.String, 40) { Collation = Collation.CaseInsensitive })); + Assert.That(provider.TableExists("InvalidCollation"), Is.False); + Assert.Throws(() => provider.CreateDatabases("unused")); + var runner = new DotNetProjects.Migrator.Migrator(provider, false, typeof(HanaMigration)); + runner.Options.TransactionMode = MigrationTransactionMode.WholeSession; + Assert.Catch(() => runner.MigrateToLastVersion()); + Assert.That(provider.TableExists("RunnerItems"), Is.False); + } + [Migration(1, Scope = "hana-tests", Ignore = true)] + public class HanaMigration : Migration + { + public override void Up() => Database.AddTable("RunnerItems", new Column("Id", DbType.Int32), new PrimaryKeyConstraint("PK_RunnerItems", "Id")); + public override void Down() => Database.RemoveTable("RunnerItems"); + } +} diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index a0aecf29..fe5f6db4 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -33,6 +33,27 @@ internal void RunRegression(Action action) finally { TearDown(); } } + [Test] + public void ConstraintMetadataPreservesForeignKeyPairsAndSeparatesUniqueIndexes() + { + provider.AddTable("parents", new Column("first_id", DbType.Int32), new Column("second_id", DbType.Int32), + new PrimaryKeyConstraint("pk_parents", "second_id", "first_id")); + provider.AddTable("children", new Column("left_id", DbType.Int32), new Column("right_id", DbType.Int32)); + provider.AddForeignKey("fk_pair", "children", new[] { "left_id", "right_id" }, "parents", new[] { "second_id", "first_id" }); + provider.AddIndex("children", new DbIndex { Name = "ux_separate", KeyColumns = new[] { "left_id" }, Unique = true }); + var constraints = provider.GetTableConstraints("children"); + var foreignKey = constraints.OfType().Single(); + Assert.That(foreignKey.ChildColumns.Select(c => c.ToLowerInvariant()), Is.EqualTo(new[] { "left_id", "right_id" })); + Assert.That(foreignKey.ParentColumns.Select(c => c.ToLowerInvariant()), Is.EqualTo(new[] { "second_id", "first_id" })); + if (providerType is ProviderTypes.Mysql or ProviderTypes.MariaDB) + Assert.That(constraints.OfType().Single().Name, Is.EqualTo("ux_separate")); + else + Assert.That(constraints.OfType(), Is.Empty); + provider.Insert("parents", new[] { "first_id", "second_id" }, new object[] { 1, 2 }); + provider.Insert("children", new[] { "left_id", "right_id" }, new object[] { 2, 1 }); + AssertDatabaseError(() => provider.Insert("children", new[] { "left_id", "right_id" }, new object[] { 1, 2 })); + } + internal void DropCreatedDatabase() { provider.DropDatabases(provider.GetDatabases().Single()); @@ -202,9 +223,9 @@ internal void AssertDatabaseError(TestDelegate action) } private void CreateItems() => provider.AddTable("items", - new Column("id", DbType.Int32, ColumnProperty.PrimaryKey), - new Column("label", DbType.String, 40, ColumnProperty.Null), - new Column("amount", DbType.Int32, ColumnProperty.NotNull, 7)); + new Column("id",DbType.Int32){IsNullable = false}, + new Column("label",DbType.String,40), + new Column("amount", DbType.Int32) { DefaultValue = 7, IsNullable = false},new PrimaryKeyConstraint("PK_" + "items", "id")); [Test] public void TableAndColumnMetadata() @@ -217,8 +238,8 @@ public void TableAndColumnMetadata() var columns = provider.GetColumns("items"); Assert.That(columns, Has.Length.EqualTo(3)); Assert.That(columns.Single(c => c.Name.Equals("id", StringComparison.OrdinalIgnoreCase)).Type, Is.EqualTo(DbType.Int32)); - Assert.That(columns.Single(c => c.Name.Equals("label", StringComparison.OrdinalIgnoreCase)).ColumnProperty.HasFlag(ColumnProperty.Null), Is.True); - Assert.That(columns.Single(c => c.Name.Equals("amount", StringComparison.OrdinalIgnoreCase)).ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(columns.Single(c => c.Name.Equals("label", StringComparison.OrdinalIgnoreCase)).IsNullable, Is.True); + Assert.That(columns.Single(c => c.Name.Equals("amount", StringComparison.OrdinalIgnoreCase)).IsNullable, Is.False); provider.RemoveTable("items"); Assert.That(provider.TableExists("items"), Is.False); } @@ -257,12 +278,12 @@ public void DataDefaultsAndPersistence() public void AddRenameChangeAndDropColumn() { CreateItems(); - provider.AddColumn("items", new Column("extra", DbType.String, 20, ColumnProperty.Null)); + provider.AddColumn("items", new Column("extra",DbType.String,20)); provider.RenameColumn("items", "extra", "renamed"); - provider.ChangeColumn("items", new Column("renamed", DbType.String, 80, ColumnProperty.NotNull, "fallback")); + provider.ChangeColumn("items", new Column("renamed",DbType.String,80,"fallback"){IsNullable = false}); var changed = provider.GetColumns("items").Single(c => c.Name.Equals("renamed", StringComparison.OrdinalIgnoreCase)); Assert.That(changed.Size, Is.EqualTo(80)); - Assert.That(changed.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(changed.IsNullable, Is.False); provider.Insert("items", ["id"], [1]); Assert.That(provider.ExecuteScalar("SELECT renamed FROM items"), Is.EqualTo("fallback")); provider.RemoveColumnDefaultValue("items", "renamed"); @@ -275,18 +296,18 @@ public void AddRenameChangeAndDropColumn() public void PrimaryKeyAndIdentity() { provider.AddTable("items", - new Column("id", DbType.Int32, ColumnProperty.PrimaryKeyWithIdentity), - new Column("label", DbType.String, 40)); + new Column("id",DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column("label", DbType.String, 40),new PrimaryKeyConstraint("PK_" + "items", "id")); provider.Insert("items", ["label"], ["first"]); provider.Insert("items", ["label"], ["second"]); Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT COUNT(DISTINCT id) FROM items")), Is.EqualTo(2)); - Assert.That(provider.GetColumns("items").Single(c => c.Name.Equals("id", StringComparison.OrdinalIgnoreCase)).ColumnProperty.HasFlag(ColumnProperty.Identity), Is.True); + Assert.That(provider.GetColumns("items").Single(c => c.Name.Equals("id", StringComparison.OrdinalIgnoreCase)).IsIdentity, Is.True); } [Test] public void NamedPrimaryKey() { - provider.AddTable("items", new Column("id", DbType.Int32, ColumnProperty.NotNull)); + provider.AddTable("items", new Column("id",DbType.Int32){IsNullable = false}); provider.AddPrimaryKey("pk_items", "items", "id"); Assert.That(provider.PrimaryKeyExists("items", "pk_items"), Is.True); provider.Insert("items", ["id"], [1]); @@ -316,7 +337,7 @@ public void UniqueAndCheckConstraints() { CreateItems(); // Db2 requires NOT NULL for columns participating in a UNIQUE constraint. - provider.ChangeColumn("items", new Column("label", DbType.String, 40, ColumnProperty.NotNull)); + provider.ChangeColumn("items", new Column("label",DbType.String,40){IsNullable = false}); provider.AddUniqueConstraint("uq_label", "items", "label"); provider.AddCheckConstraint("ck_amount", "items", "amount >= 0"); Assert.That(provider.ConstraintExists("items", "uq_label"), Is.True); diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 18158f3b..640e43e9 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -10,6 +10,25 @@ namespace Migrator.Tests.Providers.Live; [NonParallelizable] public class LiveMetadataRegressionTests { + [TestCase("MySQL", ProviderTypes.Mysql, Category = "MySQL")] + [TestCase("MariaDB", ProviderTypes.MariaDB, Category = "MariaDB")] + public void SemanticCollationEnforcesCaseAndAccentSensitivity(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => + { + var builder = new DotNetProjects.Migrator.Framework.Fluent.MigrationBuilder(); + builder.Create.Table("ci_names").WithColumn("name").AsString(40).WithCollation(Collation.CaseInsensitive) + .WithUniqueConstraint("uq_ci", "name"); + builder.Apply(f.Provider); + f.Provider.Insert("ci_names", ["name"], ["é"]); + f.AssertDatabaseError(() => f.Provider.Insert("ci_names", ["name"], ["É"])); + f.Provider.Insert("ci_names", ["name"], ["e"]); + f.Provider.AddTable("cs_names", new Column("name", DbType.String, 40) { Collation = Collation.CaseSensitive }, + new DotNetProjects.Migrator.Framework.UniqueConstraint("uq_cs", "name")); + f.Provider.Insert("cs_names", ["name"], ["é"]); + f.Provider.Insert("cs_names", ["name"], ["É"]); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT COUNT(*) FROM ci_names")), Is.EqualTo(2)); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT COUNT(*) FROM cs_names")), Is.EqualTo(2)); + }); + [Test, Category("Sybase")] public void SybaseLargeTextMetadataPreservesCapacity() => new LiveDatabaseTests("Sybase", ProviderTypes.Sybase).RunRegression(f => { @@ -54,11 +73,12 @@ public class LiveMetadataRegressionTests [TestCase("Db2", ProviderTypes.IBM_DB2, Category = "Db2")] [TestCase("Firebird", ProviderTypes.Firebird, Category = "Firebird")] [TestCase("Sybase", ProviderTypes.Sybase, Category = "Sybase")] - public void ChangeColumnCreatesRequestedUniqueConstraint(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => + public void ExplicitUniqueAfterChangeColumnEnforcesUniqueness(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => { - f.Provider.AddTable("unique_values", new Column("amount", DbType.Int32, ColumnProperty.NotNull)); + f.Provider.AddTable("unique_values", new Column("amount",DbType.Int32){IsNullable = false}); f.Provider.Insert("unique_values", ["amount"], [7]); - f.Provider.ChangeColumn("unique_values", new Column("amount", DbType.Int64, ColumnProperty.NotNull | ColumnProperty.Unique)); + f.Provider.ChangeColumn("unique_values", new Column("amount",DbType.Int64){IsNullable = false}); + f.Provider.AddUniqueConstraint("UX_unique_values_amount", "unique_values", "amount"); Assert.That(f.Provider.ConstraintExists("unique_values", "UX_unique_values_amount"), Is.True); Assert.That(f.Provider.GetIndexes("unique_values").Any(i => i.UniqueConstraint && i.KeyColumns.Single().Equals("amount", StringComparison.OrdinalIgnoreCase)), Is.True); f.AssertDatabaseError(() => f.Provider.Insert("unique_values", ["amount"], [7L])); @@ -122,12 +142,12 @@ public class LiveMetadataRegressionTests [TestCase("Sybase", ProviderTypes.Sybase, Category = "Sybase")] public void DecimalShapeSurvivesCreateAlterAndCopy(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => { - f.Provider.AddTable("numbers", new Column("amount", DbType.Decimal, ColumnProperty.Null) { Precision = 12, Scale = 3 }); + f.Provider.AddTable("numbers", new Column("amount",DbType.Decimal){Precision = 12,Scale = 3 }); var original = f.Provider.GetColumns("numbers").Single(); Assert.That(original.Precision, Is.EqualTo(12)); Assert.That(original.Scale, Is.EqualTo(3)); f.Provider.Insert("numbers", ["amount"], [123.456m]); - f.Provider.ChangeColumn("numbers", new Column("amount", DbType.Decimal, ColumnProperty.Null) { Precision = 15, Scale = 3 }); + f.Provider.ChangeColumn("numbers", new Column("amount",DbType.Decimal){Precision = 15,Scale = 3 }); var changed = f.Provider.GetColumns("numbers").Single(); Assert.That(changed.Precision, Is.EqualTo(15)); Assert.That(changed.Scale, Is.EqualTo(3)); @@ -136,7 +156,7 @@ public class LiveMetadataRegressionTests var copied = f.Provider.GetColumns("copied_numbers").Single(); Assert.That(copied.Precision, Is.EqualTo(15)); Assert.That(copied.Scale, Is.EqualTo(3)); - f.Provider.AddColumn("copied_numbers", new Column("extra", DbType.Decimal, ColumnProperty.Null) { Precision = 10, Scale = 2 }); + f.Provider.AddColumn("copied_numbers", new Column("extra",DbType.Decimal){Precision = 10,Scale = 2 }); var added = f.Provider.GetColumns("copied_numbers").Single(c => c.Name.Equals("extra", StringComparison.OrdinalIgnoreCase)); Assert.That(added.Precision, Is.EqualTo(10)); Assert.That(added.Scale, Is.EqualTo(2)); @@ -147,12 +167,12 @@ public class LiveMetadataRegressionTests [TestCase("Sybase", ProviderTypes.Sybase, Category = "Sybase")] public void PrimaryKeyMetadataIncludesIdentityAndCompositeMembers(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => { - f.Provider.AddTable("identities", new Column("id", DbType.Int32, ColumnProperty.PrimaryKeyWithIdentity)); - Assert.That(f.Provider.GetColumns("identities").Single().ColumnProperty.HasFlag(ColumnProperty.PrimaryKeyWithIdentity), Is.True); - f.Provider.AddTable("pairs", new Column("first_id", DbType.Int32, ColumnProperty.PrimaryKey), new Column("second_id", DbType.Int32, ColumnProperty.PrimaryKey), new Column("label", DbType.String, 20)); + f.Provider.AddTable("identities", new Column("id",DbType.Int32){IsNullable = false,IsIdentity = true},new PrimaryKeyConstraint("PK_" + "identities", "id")); + Assert.That(f.Provider.GetColumns("identities").Single().IsIdentity, Is.True); + f.Provider.AddTable("pairs", new Column("first_id",DbType.Int32){IsNullable = false}, new Column("second_id",DbType.Int32){IsNullable = false}, new Column("label", DbType.String, 20),new PrimaryKeyConstraint("PK_" + "pairs", "first_id", "second_id")); var columns = f.Provider.GetColumns("pairs"); - Assert.That(columns.Count(c => c.IsPrimaryKey), Is.EqualTo(2)); - Assert.That(columns.Single(c => c.Name.Equals("label", StringComparison.OrdinalIgnoreCase)).IsPrimaryKey, Is.False); + Assert.That(f.Provider.GetTableConstraints("pairs").OfType().Single().KeyColumns.Length, Is.EqualTo(2)); + Assert.That(f.Provider.GetTableConstraints("pairs").OfType().Single().KeyColumns.Any(c => c.Equals("label", StringComparison.OrdinalIgnoreCase)), Is.False); }); [Test, Category("Db2")] @@ -241,7 +261,7 @@ public class LiveMetadataRegressionTests [TestCase("Sybase", ProviderTypes.Sybase, Category = "Sybase")] public void InlineIndexedColumnCreatesIndex(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => { - f.Provider.AddTable("indexed_values", new Column("amount", DbType.Int32, ColumnProperty.Indexed)); + f.Provider.AddTable("indexed_values", new Column("amount",DbType.Int32),new DotNetProjects.Migrator.Framework.Index { Name = "IX_" + "indexed_values" + "_" + "amount", KeyColumns = new[] { "amount" } }); Assert.That(f.Provider.GetIndexes("indexed_values").Any(i => i.KeyColumns.Select(c => c.ToLowerInvariant()).SequenceEqual(new[] { "amount" })), Is.True); }); @@ -299,7 +319,7 @@ public class LiveMetadataRegressionTests [Test, Category("Informix")] public void InformixRemovesConstraintBackedIndexes() => new LiveDatabaseTests("Informix", ProviderTypes.IBM_Informix).RunRegression(f => { - f.Provider.AddTable("numbers", new Column("id", DbType.Int32, ColumnProperty.NotNull), new Column("amount", DbType.Int32, ColumnProperty.NotNull)); + f.Provider.AddTable("numbers", new Column("id",DbType.Int32){IsNullable = false}, new Column("amount",DbType.Int32){IsNullable = false}); f.Provider.AddPrimaryKey("pk_numbers", "numbers", "id"); f.Provider.AddUniqueConstraint("uq_amount", "numbers", "amount"); Assert.That(f.Provider.GetIndexes("numbers").Count(i => i.PrimaryKey), Is.EqualTo(1)); diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProviderGenericTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProviderGenericTests.cs index 5f56b686..669c6fcf 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProviderGenericTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProviderGenericTests.cs @@ -10,6 +10,31 @@ namespace Migrator.Tests.Providers.OracleProvider; [Category("Oracle")] public class OracleTransformationProviderGenericTests : TransformationProviderGenericMiscConstraintBase { + [Test] + public void RawSqlDefaultsRoundTripThroughMetadata() => RawDefaultRegression.AssertRoundTrip(Provider); + + [Test] + public void ForeignKeyMetadataPreservesOrderedPairsAndDeleteAction() + { + Provider.AddTable("MetaParents", new Column("FirstId", DbType.Int32), new Column("SecondId", DbType.Int32), + new PrimaryKeyConstraint("PK_MetaParents", "SecondId", "FirstId")); + Provider.AddTable("MetaChildren", new Column("LeftId", DbType.Int32), new Column("RightId", DbType.Int32)); + Provider.AddForeignKey("FK_MetaPair", "MetaChildren", ["LeftId", "RightId"], "MetaParents", ["SecondId", "FirstId"], ForeignKeyConstraintType.Cascade); + var key = System.Linq.Enumerable.Single(Provider.GetForeignKeyConstraints("MetaChildren")); + Assert.That(key.ChildColumns, Is.EqualTo(new[] { "LEFTID", "RIGHTID" })); + Assert.That(key.ParentColumns, Is.EqualTo(new[] { "SECONDID", "FIRSTID" })); + Assert.That(key.OnDelete, Is.EqualTo("CASCADE")); + Assert.That(key.OnUpdate, Is.EqualTo("NO ACTION")); + } + + [Test] + public void UnsupportedIndexOptionsFailExplicitly() + { + Assert.Throws(() => Provider.AddIndex("TestTwo", + new DotNetProjects.Migrator.Framework.Index { Name = "IX_Unsupported", KeyColumns = ["Id"], IncludeColumns = ["TestId"] })); + Assert.That(Provider.IndexExists("TestTwo", "IX_Unsupported"), Is.False); + } + [SetUp] public async Task SetUpAsync() { @@ -22,9 +47,9 @@ public async Task SetUpAsync() public void ChangeColumn_FromNotNullToNotNull() { Provider.ExecuteNonQuery("DELETE FROM TestTwo"); - Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.Null)); + Provider.ChangeColumn("TestTwo", new Column("TestId",DbType.String,50)); Provider.Insert("TestTwo", ["Id", "TestId"], [3, "Not an Int val."]); - Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.NotNull)); - Provider.ChangeColumn("TestTwo", new Column("TestId", DbType.String, 50, ColumnProperty.NotNull)); + Provider.ChangeColumn("TestTwo", new Column("TestId",DbType.String,50){IsNullable = false}); + Provider.ChangeColumn("TestTwo", new Column("TestId",DbType.String,50){IsNullable = false}); } } diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddColumnTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddColumnTests.cs index 6675de98..6b652d92 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddColumnTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_AddColumnTests.cs @@ -26,18 +26,17 @@ public void AddTable_NotNull_OtherColumnStillNotNull() Provider.AddTable(tableName, - new Column(column1Name, DbType.Int32, ColumnProperty.NotNull) - ); + new Column(column1Name,DbType.Int32){IsNullable = false} ); // Act - Provider.AddColumn(table: tableName, column: new Column(column2Name, DbType.DateTime, ColumnProperty.NotNull)); + Provider.AddColumn(table: tableName, column: new Column(column2Name,DbType.DateTime){IsNullable = false}); // Assert var column1 = Provider.GetColumnByName(tableName, column1Name); var column2 = Provider.GetColumnByName(tableName, column2Name); - Assert.That(column1.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); - Assert.That(column2.ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); + Assert.That(column1.IsNullable, Is.False); + Assert.That(column2.IsNullable, Is.False); } } \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_GetColumns_Tests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_GetColumns_Tests.cs index fbc6fed7..2bd2e1e9 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_GetColumns_Tests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_GetColumns_Tests.cs @@ -124,8 +124,8 @@ public void GetColumns_GetIdentity_Succeeds() Provider.ExecuteNonQuery($"CREATE TABLE {tableName1} ({columnName1} NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY)"); Provider.ExecuteNonQuery($"CREATE TABLE {tableName2} ({columnName1} NUMBER PRIMARY KEY)"); - Provider.AddTable(name: tableName3, new Column(columnName1, DbType.Int32, ColumnProperty.Identity | ColumnProperty.PrimaryKey)); - Provider.AddTable(name: tableName4, new Column(columnName1, DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable(name: tableName3, new Column(columnName1,DbType.Int32){IsNullable = false,IsIdentity = true},new PrimaryKeyConstraint("PK_" + tableName3, columnName1)); + Provider.AddTable(name: tableName4, new Column(columnName1,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName4, columnName1)); // Act var columnTable1 = Provider.GetColumnByName(table: tableName1, column: columnName1); diff --git a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs index 40712dbb..2d6a4187 100644 --- a/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs +++ b/src/Migrator.Tests/Providers/OracleProvider/OracleTransformationProvider_TableExistsTests.cs @@ -14,7 +14,7 @@ public class OracleTransformationProvider_TableExistsTests : OracleTransformatio [Test] public void LegacyForeignKeyOverloadHonorsCascadeDelete() { - Provider.AddTable("CascadeParent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable("CascadeParent", new Column("Id",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "CascadeParent", "Id")); Provider.AddTable("CascadeChild", new Column("ParentId", DbType.Int32)); Provider.AddForeignKey("CascadeForeignKey", "CascadeChild", new[] { "ParentId" }, "CascadeParent", new[] { "Id" }, ForeignKeyConstraintType.Cascade); Provider.Insert("CascadeParent", new[] { "Id" }, new object[] { 1 }); diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs index d271bf58..db1d7070 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs @@ -28,9 +28,8 @@ public async Task SetUpAsync() public void AddTableWithCompoundPrimaryKey() { Provider.AddTable("Test", - new Column("PersonId", DbType.Int32, ColumnProperty.PrimaryKey), - new Column("AddressId", DbType.Int32, ColumnProperty.PrimaryKey) - ); + new Column("PersonId",DbType.Int32){IsNullable = false}, + new Column("AddressId",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "Test", "PersonId", "AddressId") ); Assert.That(Provider.TableExists("Test"), Is.True, "Table doesn't exist"); Assert.That(Provider.PrimaryKeyExists("Test", "PK_Test"), Is.True, "Constraint doesn't exist"); @@ -321,14 +320,12 @@ public void AddIndex_TableNameIsReservedWord_Succeeds() { // Arrange Provider.AddTable("trigger", - new Column(name: "id", type: DbType.Int32, ColumnProperty.PrimaryKeyWithIdentity), - new Column(name: "test_run_id", type: DbType.Int32, ColumnProperty.NotNull) - ); + new Column(name: "id",type: DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(name: "test_run_id",type: DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "trigger", "id") ); Provider.AddTable("statistics", - new Column(name: "id", type: DbType.Int32, ColumnProperty.PrimaryKeyWithIdentity), - new Column(name: "test_run_id", type: DbType.Int32, ColumnProperty.NotNull) - ); + new Column(name: "id",type: DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(name: "test_run_id",type: DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "statistics", "id") ); // Act var addIndexTriggerSql = Provider.AddIndex(name: "IX_trigger__test_run_id", table: "trigger", "test_run_id"); diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ChangeColumnTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ChangeColumnTests.cs index 2d60120d..b8f92b1a 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ChangeColumnTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ChangeColumnTests.cs @@ -29,14 +29,13 @@ public void ChangeColumn_DateTimeOffsetToDateTime_Success() // Act Provider.AddTable(tableName, - new Column(column1Name, DbType.Int32, ColumnProperty.Null), - new Column(column2Name, DbType.DateTimeOffset, ColumnProperty.Null, defaultValue: dateTimeDefaultValue) - ); + new Column(column1Name,DbType.Int32), + new Column(column2Name,DbType.DateTimeOffset,defaultValue: dateTimeDefaultValue) ); Provider.Insert(table: tableName, columns: [column2Name], values: [dateTimeInsert]); // Assert - Provider.ChangeColumn(tableName, new Column(column2Name, DbType.DateTime2, ColumnProperty.NotNull)); + Provider.ChangeColumn(tableName, new Column(column2Name,DbType.DateTime2){IsNullable = false}); var column2 = Provider.GetColumnByName(tableName, column2Name); Assert.That(column2.MigratorDbType, Is.EqualTo(MigratorDbType.DateTime2)); @@ -54,16 +53,15 @@ public void ChangeColumn_DateTimeOffsetToDateTimeGetDefaultValueAndReuseIt_Defau var dateTimeOffsetInsert = new DateTimeOffset(2001, 2, 3, 4, 5, 6, TimeSpan.FromHours(2)); Provider.AddTable(tableName, - new Column(column1Name, DbType.Int32, ColumnProperty.Null), - new Column(column2Name, DbType.DateTimeOffset, ColumnProperty.Null, defaultValue: dateTimeOffsetDefaultValue) - ); + new Column(column1Name,DbType.Int32), + new Column(column2Name,DbType.DateTimeOffset,defaultValue: dateTimeOffsetDefaultValue) ); Provider.Insert(table: tableName, columns: [column2Name], values: [dateTimeOffsetInsert]); // Act var column2 = Provider.GetColumnByName(tableName, column2Name); Assert.That(((DateTimeOffset)column2.DefaultValue).UtcDateTime, Is.EqualTo(dateTimeOffsetDefaultValue.UtcDateTime)); - Provider.ChangeColumn(tableName, new Column(column2Name, DbType.DateTime2, ColumnProperty.NotNull, defaultValue: column2.DefaultValue)); + Provider.ChangeColumn(tableName, new Column(column2Name,DbType.DateTime2,defaultValue: column2.DefaultValue){IsNullable = false}); // Assert @@ -89,8 +87,8 @@ public void GetColumns_GetIdentity_Succeeds() Provider.ExecuteNonQuery($"CREATE TABLE {tableName1} ({columnName1} INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY)"); Provider.ExecuteNonQuery($"CREATE TABLE {tableName2} ({columnName1} INT PRIMARY KEY)"); - Provider.AddTable(name: tableName3, new Column(columnName1, DbType.Int32, ColumnProperty.Identity | ColumnProperty.PrimaryKey)); - Provider.AddTable(name: tableName4, new Column(columnName1, DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable(name: tableName3, new Column(columnName1,DbType.Int32){IsNullable = false,IsIdentity = true},new PrimaryKeyConstraint("PK_" + tableName3, columnName1)); + Provider.AddTable(name: tableName4, new Column(columnName1,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName4, columnName1)); // Act var columnTable1 = Provider.GetColumnByName(table: tableName1, column: columnName1); diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_Tests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_Tests.cs index 26613b31..fa2d40cf 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_Tests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_Tests.cs @@ -8,6 +8,9 @@ namespace Migrator.Tests.Providers.PostgreSQL; [Category("PostgreSQL")] public class PostgreSQLTransformationProvider_GetColumns_Tests : Generic_GetColumnsTestsBase { + [Test] + public void RawSqlDefaultsRoundTripThroughMetadata() => RawDefaultRegression.AssertRoundTrip(Provider); + [SetUp] public async Task SetUpAsync() { diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs index 4a0e1b03..4ed76b0e 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_MetadataScopeTests.cs @@ -23,7 +23,7 @@ public void QualifiedMetadataDoesNotMixSameNamedTablesOrConstraints() Assert.That(Provider.ColumnExists("metadata_b.sample", "id"), Is.False); Assert.That(Provider.ConstraintExists("metadata_a.sample", "same_name"), Is.True); Assert.That(Provider.ConstraintExists("metadata_b.sample", "same_name"), Is.False); - Assert.That(Provider.GetColumns("metadata_a.sample").Single().ColumnProperty.IsSet(ColumnProperty.Unique), Is.True); + Assert.That(Provider.GetTableConstraints("metadata_a.sample").OfType().Any(), Is.True); Assert.That(Provider.GetColumns("metadata_b.sample").Single().MigratorDbType, Is.EqualTo(MigratorDbType.String)); Provider.ExecuteNonQuery("SET LOCAL search_path TO metadata_b"); Assert.That(Provider.GetColumns("sample").Single().Name, Is.EqualTo("value")); @@ -35,7 +35,7 @@ public void QuotedCatalogNamesRemainExactAndAreParameterized() Provider.ExecuteNonQuery("CREATE TABLE \"Meta'Table\" (id integer CONSTRAINT \"Key'Name\" UNIQUE)"); Assert.That(Provider.TableExists("\"Meta'Table\""), Is.True); Assert.That(Provider.ConstraintExists("\"Meta'Table\"", "Key'Name"), Is.True); - Assert.That(Provider.GetColumns("\"Meta'Table\"").Single().ColumnProperty.IsSet(ColumnProperty.Unique), Is.True); + Assert.That(Provider.GetTableConstraints("\"Meta'Table\"").OfType().Any(), Is.True); } [Test] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyWithIdentityTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyWithIdentityTests.cs index 3eaaddf3..983593c0 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyWithIdentityTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyWithIdentityTests.cs @@ -19,9 +19,8 @@ public void AddTableWithPrimaryKeyIdentity_Succeeds() const string propertyName2 = "Color2"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKeyWithIdentity), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unsigned) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(propertyName2,DbType.Int32),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1) ); // Act Provider.Insert(testTableName, [propertyName2], [1]); diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ReservedWordsTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ReservedWordsTests.cs index 58b068c7..9da95692 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ReservedWordsTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ReservedWordsTests.cs @@ -18,9 +18,8 @@ public void AddIndex_IncludeColumnsWithReservedWord_Succeeds() const string propertyName2 = "Host"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKeyWithIdentity), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unsigned) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(propertyName2,DbType.Int32),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1) ); // Act/Assert Provider.AddIndex(testTableName, new Index diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddTableTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddTableTests.cs index 8272fcfa..5ff9d5fd 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddTableTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddTableTests.cs @@ -20,9 +20,8 @@ public async Task SetUpAsync() public void AddTableWithCompoundPrimaryKey() { Provider.AddTable("Test", - new Column("PersonId", DbType.Int32, ColumnProperty.PrimaryKey), - new Column("AddressId", DbType.Int32, ColumnProperty.PrimaryKey) - ); + new Column("PersonId",DbType.Int32){IsNullable = false}, + new Column("AddressId",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "Test", "PersonId", "AddressId") ); Assert.That(Provider.TableExists("Test"), Is.True, "Table doesn't exist"); Assert.That(Provider.PrimaryKeyExists("Test", "PK_Test"), Is.True, "Constraint doesn't exist"); @@ -34,7 +33,7 @@ public void AddTableDateTime() var tableName = "Table1"; var columnName = "Column1"; - Provider.AddTable(tableName, new Column(columnName, DbType.DateTime, ColumnProperty.NotNull)); + Provider.AddTable(tableName, new Column(columnName,DbType.DateTime){IsNullable = false}); var column = Provider.GetColumnByName(tableName, columnName); Assert.That(column.Type, Is.EqualTo(DbType.DateTime)); @@ -46,7 +45,7 @@ public void AddTableDateTime2() var tableName = "Table1"; var columnName = "Column1"; - Provider.AddTable(tableName, new Column(columnName, DbType.DateTime2, ColumnProperty.NotNull)); + Provider.AddTable(tableName, new Column(columnName,DbType.DateTime2){IsNullable = false}); var column = Provider.GetColumnByName(tableName, columnName); Assert.That(column.Type, Is.EqualTo(DbType.DateTime2)); diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs index 8712b4e3..9162fc95 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs @@ -18,41 +18,34 @@ public async Task SetUpAsync() } [TestCase(false), TestCase(true)] - public void ChangeColumnRemovesOwnedUniqueFromTableOrColumnCreation(bool addColumn) + public void ChangeColumnPreservesExplicitUniqueFromTableOrColumnCreation(bool addColumn) { - var definition = new Column("Value", DbType.Int32, ColumnProperty.NotNull | ColumnProperty.Unique); + var definition = new Column("Value", DbType.Int32) { IsNullable = false }; if (addColumn) { Provider.AddTable("CreatedUnique", new Column("Id", DbType.Int32)); Provider.AddColumn("CreatedUnique", definition); + Provider.AddUniqueConstraint("UQ_Created", "CreatedUnique", "Value"); } - else Provider.AddTable("CreatedUnique", definition); - Provider.ChangeColumn("CreatedUnique", new Column("Value", DbType.Int32, ColumnProperty.NotNull)); + else Provider.AddTable("CreatedUnique", definition, + new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_Created", "Value")); + Provider.ChangeColumn("CreatedUnique", new Column("Value", DbType.Int32) { IsNullable = false }); + Assert.That(Provider.ConstraintExists("CreatedUnique", "UQ_Created"), Is.True); + Assert.That(definition.IsNullable, Is.False); Provider.Insert("CreatedUnique", new[] { "Value" }, new object[] { 1 }); - Provider.Insert("CreatedUnique", new[] { "Value" }, new object[] { 1 }); - Assert.That(definition.ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); - Assert.That(Provider.GetIndexes("CreatedUnique"), Is.Empty); - } - - [Test] - public void OwnershipAdoptionRejectsCompositeConstraints() - { - Provider.AddTable("CompositeOwned", new Column("FirstId", DbType.Int32), new Column("SecondId", DbType.Int32)); - Provider.AddUniqueConstraint("UserComposite", "CompositeOwned", "FirstId", "SecondId"); - Assert.Throws(() => ((SqlServerTransformationProvider)Provider).AdoptColumnUniqueConstraint("CompositeOwned", "FirstId", "UserComposite")); - Assert.That(Provider.ConstraintExists("CompositeOwned", "UserComposite"), Is.True); + Assert.Catch(() => Provider.Insert("CreatedUnique", new[] { "Value" }, new object[] { 1 })); } [Test] - public void ExplicitOwnershipAdoptionAllowsLegacyUniqueRemoval() + public void ExplicitUniqueRemovalAllowsDuplicates() { Provider.AddTable("LegacyUnique", new Column("Value", DbType.Int32)); Provider.AddUniqueConstraint("LegacyUniqueConstraint", "LegacyUnique", "Value"); - var sqlServer = (SqlServerTransformationProvider)Provider; - sqlServer.AdoptColumnUniqueConstraint("LegacyUnique", "Value", "LegacyUniqueConstraint"); - sqlServer.AdoptColumnUniqueConstraint("LegacyUnique", "Value", "LegacyUniqueConstraint"); - Provider.ChangeColumn("LegacyUnique", new Column("Value", DbType.Int32, ColumnProperty.Null)); - Assert.That(Provider.ConstraintExists("LegacyUnique", "LegacyUniqueConstraint"), Is.False); + Provider.ChangeColumn("LegacyUnique", new Column("Value", DbType.Int32)); + Assert.That(Provider.ConstraintExists("LegacyUnique", "LegacyUniqueConstraint"), Is.True); + Provider.RemoveConstraint("LegacyUnique", "LegacyUniqueConstraint"); + Provider.ExecuteNonQuery("INSERT INTO LegacyUnique VALUES (1), (1)"); + Assert.That(System.Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM LegacyUnique")), Is.EqualTo(2)); } [Test] @@ -62,11 +55,11 @@ public void ChangeColumn_DateTimeToDateTime2_Success() const string tableName = "TestTable"; const string columnName = "TestColumn"; - Provider.AddTable(tableName, new Column(columnName, DbType.DateTime, ColumnProperty.NotNull)); + Provider.AddTable(tableName, new Column(columnName,DbType.DateTime){IsNullable = false}); var columnBefore = Provider.GetColumnByName(tableName, columnName); // Act - Provider.ChangeColumn(tableName, new Column(columnName, DbType.DateTime2, ColumnProperty.NotNull)); + Provider.ChangeColumn(tableName, new Column(columnName,DbType.DateTime2){IsNullable = false}); // Assert var columnAfter = Provider.GetColumnByName(tableName, columnName); @@ -78,30 +71,13 @@ public void ChangeColumn_DateTimeToDateTime2_Success() [Test] public void ChangeColumn_DoesNotRemoveUserOwnedUniqueOrMutateDefinition() { - Provider.AddTable("UserOwned", new Column("Value", DbType.Int32, ColumnProperty.NotNull)); + Provider.AddTable("UserOwned", new Column("Value",DbType.Int32){IsNullable = false}); Provider.AddUniqueConstraint("UX_UserOwned_Value", "UserOwned", "Value"); - var definition = new Column("Value", DbType.Int32, ColumnProperty.NotNull, 3); + var definition = new Column("Value",DbType.Int32){IsNullable = false, DefaultValue = 3}; Provider.ChangeColumn("UserOwned", definition); Assert.That(Provider.ConstraintExists("UserOwned", "UX_UserOwned_Value"), Is.True); Assert.That(definition.DefaultValue, Is.EqualTo(3)); - Assert.That(definition.ColumnProperty, Is.EqualTo(ColumnProperty.NotNull)); + Assert.That(definition.IsNullable, Is.False); } - [Test] - public void ChangeColumn_WithUniqueThenReChangeToNonUnique_UniqueConstraintShouldBeRemoved() - { - // Arrange - const string tableName = "TestTable"; - const string columnName = "TestColumn"; - - Provider.AddTable(tableName, new Column(columnName, DbType.Int32, ColumnProperty.NotNull)); - - // Act - Provider.ChangeColumn(tableName, new Column(columnName, DbType.Int32, ColumnProperty.NotNull | ColumnProperty.Unique)); - Provider.ChangeColumn(tableName, new Column(columnName, DbType.Int32, ColumnProperty.NotNull)); - - // Assert - var indexes = Provider.GetIndexes(tableName); - Assert.That(indexes, Is.Empty); - } -} \ No newline at end of file +} diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumnsTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumnsTests.cs index 5d8ccc0e..50987586 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumnsTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumnsTests.cs @@ -29,8 +29,8 @@ public void GetColumns_GetIdentity_Succeeds() Provider.ExecuteNonQuery($"CREATE TABLE {tableName1} ({columnName1} INT IDENTITY(1,1) PRIMARY KEY)"); Provider.ExecuteNonQuery($"CREATE TABLE {tableName2} ({columnName1} INT PRIMARY KEY)"); - Provider.AddTable(name: tableName3, new Column(columnName1, DbType.Int32, ColumnProperty.Identity | ColumnProperty.PrimaryKey)); - Provider.AddTable(name: tableName4, new Column(columnName1, DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable(name: tableName3, new Column(columnName1,DbType.Int32){IsNullable = false,IsIdentity = true},new PrimaryKeyConstraint("PK_" + tableName3, columnName1)); + Provider.AddTable(name: tableName4, new Column(columnName1,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName4, columnName1)); // Act var columnTable1 = Provider.GetColumnByName(table: tableName1, column: columnName1); diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs index eece559e..5ac1d1fe 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs @@ -1,4 +1,5 @@ using System.Data; +using DotNetProjects.Migrator.Framework; using System.Threading.Tasks; using DotNetProjects.Migrator.Providers; using DotNetProjects.Migrator.Providers.Impl.SqlServer; @@ -11,6 +12,18 @@ namespace Migrator.Tests.Providers.SQLServer; [Category("SQLServer")] public class SqlServerTransformationProviderGenericTests : TransformationProviderGenericMiscConstraintBase { + [Test] + public void RawSqlDefaultsRoundTripThroughMetadata() => RawDefaultRegression.AssertRoundTrip(Provider); + + [Test] + public void NonClusteredPrimaryKeyRoundTripsAsConstraint() + { + Provider.AddTable("NonClusteredKey", new Column("Id", DbType.Int32), + new PrimaryKeyConstraint("PK_NonClusteredKey", "Id") { NonClustered = true }); + var key = System.Linq.Enumerable.Single(System.Linq.Enumerable.OfType(Provider.GetTableConstraints("NonClusteredKey"))); + Assert.That(key.NonClustered, Is.True); + } + [SetUp] public async Task SetUpAsync() { @@ -19,6 +32,24 @@ public async Task SetUpAsync() AddDefaultTable(); } + [Test] + public void SemanticCollationAndRawDefaultsExecuteInBothApis() + { + Provider.AddTable("SemanticNames", new Column("Name", DbType.String, 40) { Collation = Collation.CaseInsensitive }, + new Column("Token", DbType.Guid) { DefaultValue = RawSql.Insert("NEWID()") }); + Provider.Insert("SemanticNames", ["Name"], ["é"]); + Assert.That(System.Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM SemanticNames WHERE Name=N'É'")), Is.EqualTo(1)); + Assert.That(System.Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM SemanticNames WHERE Name=N'e'")), Is.EqualTo(0)); + Assert.That(Provider.ExecuteScalar("SELECT Token FROM SemanticNames"), Is.TypeOf()); + var builder = new DotNetProjects.Migrator.Framework.Fluent.MigrationBuilder(); + builder.Create.Table("SemanticNamesFluent").WithColumn("Name").AsString(40).WithCollation(Collation.CaseSensitive) + .WithColumn("Token").AsGuid().WithDefaultValue(RawSql.Insert("NEWID()")); + builder.Apply(Provider); + Provider.Insert("SemanticNamesFluent", ["Name"], ["é"]); + Assert.That(System.Convert.ToInt32(Provider.ExecuteScalar("SELECT COUNT(*) FROM SemanticNamesFluent WHERE Name=N'É'")), Is.EqualTo(0)); + Assert.That(Provider.ExecuteScalar("SELECT Token FROM SemanticNamesFluent"), Is.TypeOf()); + } + [Test] public void ByteColumnWillBeCreatedAsBlob() { diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index 074730a8..27d4843e 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -17,7 +17,7 @@ public class SqlServerTransformationProviderTests : SQLServerTransformationProvi public void TimeTypeDefaultAndValueRoundTripThroughMetadata() { var time = new TimeSpan(0, 12, 34, 56, 789); - Provider.AddTable("ClockValues", new Column("Moment", DbType.Time, ColumnProperty.Null, time)); + 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)); @@ -39,8 +39,8 @@ public void ExplicitScriptSplitsGoWithoutSplittingMultilineValues() [Test] public void IndependentForeignKeyActionsCascadeUpdateAndSetNullOnDelete() { - Provider.AddTable("ActionParent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); - Provider.AddTable("ActionChild", new Column("ParentId", DbType.Int32, ColumnProperty.Null)); + Provider.AddTable("ActionParent", new Column("Id",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "ActionParent", "Id")); + Provider.AddTable("ActionChild", new Column("ParentId",DbType.Int32)); ((IForeignKeyActions)Provider).AddForeignKey("ActionForeignKey", "ActionChild", new[] { "ParentId" }, "ActionParent", new[] { "Id" }, ForeignKeyConstraintType.SetNull, ForeignKeyConstraintType.Cascade); Provider.ExecuteNonQuery("INSERT INTO ActionParent VALUES (1); INSERT INTO ActionChild VALUES (1); UPDATE ActionParent SET Id=2 WHERE Id=1"); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderTests.cs index 3d26a7c4..440bba13 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProviderTests.cs @@ -76,16 +76,16 @@ public void AddPrimaryKey_CompositePrimaryKey_Succeeds() Provider.AddPrimaryKey("MyPrimaryKeyName", testTableName, "Id", "Color"); // Assert - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == "Id").ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == "Color").ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == "NotAPrimaryKey").ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains("Id") == true), Is.False); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains("Color") == true), Is.False); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains("NotAPrimaryKey") == true), Is.False); var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); var tableNames = ((SQLiteTransformationProvider)Provider).GetTables(); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == "Id").ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == "Color").ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == "NotAPrimaryKey").ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains("Id") == true), Is.True); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains("Color") == true), Is.True); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains("NotAPrimaryKey") == true), Is.False); // Check for intermediate table residues. Assert.That(tableNames.Where(x => x.Contains(testTableName)), Has.Exactly(1).Items); @@ -101,9 +101,9 @@ public void AddPrimaryKey_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds() var indexName = "MyIndexName"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.Unique | ColumnProperty.NotNull), + new Column(propertyName1,DbType.Int32){IsNullable = false}, new Column(propertyName2, DbType.Int32) - ); +,new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName1, propertyName1) ); Provider.AddIndex(indexName, testTableName, [propertyName1, propertyName2]); var tableInfoBefore = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); @@ -122,11 +122,11 @@ public void AddPrimaryKey_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds() var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.False); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains(propertyName2) == true), Is.False); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains(propertyName2) == true), Is.False); var indexAfter = tableInfoAfter.Indexes.Single(); Assert.That(indexAfter.Name, Is.EqualTo(indexName)); @@ -143,9 +143,8 @@ public void RemovePrimaryKey_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds( var indexName = "MyIndexName"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.Int32),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName2, propertyName2) ); Provider.AddIndex(indexName, testTableName, [propertyName1, propertyName2]); var tableInfoBefore = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); @@ -164,11 +163,11 @@ public void RemovePrimaryKey_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds( var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That(tableInfoBefore.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.False); + Assert.That(tableInfoAfter.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.True); var indexAfter = tableInfoAfter.Indexes.Single(); Assert.That(indexAfter.Name, Is.EqualTo(indexName)); @@ -185,9 +184,9 @@ public void RemoveAllIndexes_HavingIndexAndUnique_RebuildSucceeds() var indexName = "MyIndexName"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), + new Column(propertyName1,DbType.Int32){IsNullable = false}, new Column(propertyName2, DbType.Int32) - ); +,new PrimaryKeyConstraint("PK_" + testTableName, propertyName1) ); Provider.AddIndex(indexName, testTableName, [propertyName1, propertyName2]); Provider.AddUniqueConstraint("MyConstraint", testTableName, [propertyName1, propertyName2]); @@ -209,12 +208,12 @@ public void RemoveAllIndexes_HavingIndexAndUnique_RebuildSucceeds() var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); Assert.That(tableInfoBefore.Uniques, Is.Not.Empty); Assert.That(tableInfoBefore.Indexes, Is.Not.Empty); - Assert.That(tableInfoAfter.Uniques, Is.Empty); + Assert.That(tableInfoAfter.Uniques.Select(u => u.Name), Is.EquivalentTo(tableInfoBefore.Uniques.Select(u => u.Name).Append("MyUniqueConstraintName"))); Assert.That(tableInfoAfter.Indexes, Is.Empty); } } diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddColumnTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddColumnTests.cs index 4b9c6453..0b617734 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddColumnTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddColumnTests.cs @@ -26,9 +26,8 @@ public void AddColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds() const string indexName = "MyIndexName"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.Int32),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName2, propertyName2) ); Provider.AddIndex(indexName, testTableName, [propertyName1, propertyName2]); var tableInfoBefore = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); @@ -36,7 +35,7 @@ public void AddColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds() Provider.ExecuteNonQuery($"INSERT INTO {testTableName} ({propertyName1}, {propertyName2}) VALUES (1, 2)"); // Act - Provider.AddColumn(table: testTableName, new Column(newColumn, DbType.String, ColumnProperty.Null)); + Provider.AddColumn(table: testTableName, new Column(newColumn,DbType.String)); Provider.ExecuteNonQuery($"INSERT INTO {testTableName} ({propertyName1}, {propertyName2}, {newColumn}) VALUES (2, 3, 'Hello')"); // Assert @@ -48,11 +47,11 @@ public void AddColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds() var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That(tableInfoBefore.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That(tableInfoAfter.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.True); var indexAfter = tableInfoAfter.Indexes.Single(); Assert.That(indexAfter.Name, Is.EqualTo(indexName)); @@ -73,10 +72,11 @@ public void AddColumn_HavingNullInPrimaryKey_HasNotNullAfterAddAnotherColumn() var tableInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo("Common_Language"); var script = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("Common_Language"); - var columnProperty = tableInfo.Columns.Single(x => x.Name == "LanguageID").ColumnProperty; + var column = tableInfo.Columns.Single(x => x.Name == "LanguageID"); // Assert - Assert.That(script, Does.Contain("LanguageID TEXT NOT NULL PRIMARY KEY")); + Assert.That(column.IsNullable, Is.False); + Assert.That(tableInfo.PrimaryKey.KeyColumns, Is.EqualTo(new[] { "LanguageID" })); } [Test] @@ -90,10 +90,11 @@ public void AddColumn_HavingNullInPrimaryKey_HasNOTNULLAfterAddAnotherColumn() var tableInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo("Common_Language"); var script = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("Common_Language"); - var columnProperty = tableInfo.Columns.Single(x => x.Name == "LanguageID").ColumnProperty; + var column = tableInfo.Columns.Single(x => x.Name == "LanguageID"); // Assert - Assert.That(script, Does.Contain("LanguageID TEXT NOT NULL PRIMARY KEY")); + Assert.That(column.IsNullable, Is.False); + Assert.That(tableInfo.PrimaryKey.KeyColumns, Is.EqualTo(new[] { "LanguageID" })); } [Test] @@ -107,11 +108,12 @@ public void AddColumn_HavingNotNullInPrimaryKey_Succeds() var tableInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo("Common_Language"); var script = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("Common_Language"); - var columnProperty = tableInfo.Columns.Single(x => x.Name == "LanguageID").ColumnProperty; - var hasNull = columnProperty.IsSet(ColumnProperty.Null); + var column = tableInfo.Columns.Single(x => x.Name == "LanguageID"); + var hasNull = column.IsNullable; // Assert - Assert.That(script, Does.Contain("LanguageID INTEGER NOT NULL PRIMARY KEY")); + Assert.That(column.IsNullable, Is.False); + Assert.That(tableInfo.PrimaryKey.KeyColumns, Is.EqualTo(new[] { "LanguageID" })); Assert.That(hasNull, Is.False); } } diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs index bd535a3f..12ec0408 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddForeignKeyTests.cs @@ -79,17 +79,16 @@ public void AddForeignKey_RenameParentColumWithForeignKeyAndData_ForeignKeyPoint public void AddForeignKey_3_Success() { Provider.AddTable("Task", - new Column(name: "BinId", type: DbType.Int32, property: ColumnProperty.NotNull), - new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), - new Column(name: "EstimatedPickTime", type: DbType.Int32, property: ColumnProperty.Null), - new Column(name: "Id", type: DbType.Int32, property: ColumnProperty.NotNull), - new Column(name: "Item", type: DbType.Int32, property: ColumnProperty.Null), - new Column(name: "Order", type: DbType.Int32, property: ColumnProperty.Null), - new Column(name: "TaskGroupId", type: DbType.Int32, property: ColumnProperty.Null) - ); + new Column(name: "BinId",type: DbType.Int32){IsNullable = false}, + new Column(name: "CreationTimeStamp",type: DbType.DateTime2){IsNullable = false}, + new Column(name: "EstimatedPickTime",type: DbType.Int32), + new Column(name: "Id",type: DbType.Int32){IsNullable = false}, + new Column(name: "Item",type: DbType.Int32), + new Column(name: "Order",type: DbType.Int32), + new Column(name: "TaskGroupId",type: DbType.Int32) ); Provider.AddTable("TaskGroup", - new Column(name: "CreationTimeStamp", type: DbType.DateTime2, property: ColumnProperty.NotNull), + new Column(name: "CreationTimeStamp",type: DbType.DateTime2){IsNullable = false}, new Column(name: "Id", type: DbType.Int32) ); @@ -108,7 +107,7 @@ public void AddForeignKey_Cascade_DeletingParentDeletesReferencingChildren() using var provider = new SQLiteTransformationProvider(new SQLiteDialect(), connection, "default", null); Assert.That(provider.IsPragmaForeignKeysOn(), Is.True); - provider.AddTable("Parent", new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + provider.AddTable("Parent", new Column("Id",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + "Parent", "Id")); provider.AddTable("Child", new Column("ParentId", DbType.Int32)); provider.ExecuteNonQuery("INSERT INTO Parent (Id) VALUES (1), (2)"); provider.ExecuteNonQuery("INSERT INTO Child (ParentId) VALUES (1), (1), (2)"); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddPrimaryKeyTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddPrimaryKeyTests.cs index 23c217e8..81461d03 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddPrimaryKeyTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddPrimaryKeyTests.cs @@ -52,8 +52,7 @@ public void AddPrimaryKey_ColumnGuidNonComposite_ThrowsOnDuplicatesAndNulls() // Arrange/Act Provider.AddTable(tableName, - new Column(columnName1, DbType.Guid, ColumnProperty.PrimaryKey) - ); + new Column(columnName1,DbType.Guid){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, columnName1) ); Provider.Insert(tableName, [columnName1], [guid]); Assert.Throws(() => Provider.Insert(tableName, [columnName1], [guid])); @@ -82,13 +81,13 @@ public void AddPrimaryKey_ColumnGuidComposite_ThrowsOnDuplicatesAndNulls() // NULL != NULL // (A, NULL) != (A, NULL) // Duplicates! You need to set NotNull if you want to prevent it! - Provider.Insert(tableName, [columnName1, columnName2], [guid, null]); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [guid, null])); - Provider.Insert(tableName, [columnName1, columnName2], [guid, null]); - Provider.Insert(tableName, [columnName1, columnName2], [guid, null]); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [guid, null])); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [guid, null])); - Provider.Insert(tableName, [columnName1, columnName2], [null, guid]); - Provider.Insert(tableName, [columnName1, columnName2], [null, guid]); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [null, guid])); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [null, guid])); Provider.Insert(tableName, [columnName1, columnName2], [guid2, guid2]); Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [guid2, guid2])); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddTableTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddTableTests.cs index 1cc48749..0e0d05ad 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddTableTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_AddTableTests.cs @@ -20,26 +20,26 @@ public async Task SetUpAsync() } [Test] - public void AddTable_UniqueOnlyOnColumnLevel_Obsolete_UniquesListIsEmpty() + public void AddTable_ExplicitUniqueConstraint_IsReturnedInMetadata() { const string tableName = "MyTableName"; const string columnName = "MyColumnName"; // Arrange/Act - Provider.AddTable(tableName, new Column(columnName, System.Data.DbType.Int32, ColumnProperty.Unique)); + Provider.AddTable(tableName, new Column(columnName,System.Data.DbType.Int32),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + tableName + "_" + columnName, columnName)); // Assert var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); - Assert.That(createScript, Is.EqualTo("CREATE TABLE MyTableName (MyColumnName INTEGER NULL UNIQUE)")); + Assert.That(Provider.GetTableConstraints(tableName).OfType().Single().KeyColumns, Is.EqualTo(new[] { columnName })); var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); // It is no named unique so it is not listed in the Uniques list. Unique on column level is marked as obsolete. - Assert.That(sqliteInfo.Uniques, Is.Empty); + Assert.That(sqliteInfo.Uniques.Single().Name, Is.EqualTo("UQ_" + tableName + "_" + columnName)); } [Test] - public void AddTable_CompositePrimaryKey_ContainsNull() + public void AddTable_CompositePrimaryKey_EnforcesNotNull() { const string tableName = "MyTableName"; const string columnName1 = "Column1"; @@ -47,19 +47,18 @@ public void AddTable_CompositePrimaryKey_ContainsNull() // Arrange/Act Provider.AddTable(tableName, - new Column(columnName1, System.Data.DbType.Int32, ColumnProperty.PrimaryKey), - new Column(columnName2, System.Data.DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.NotNull) - ); + new Column(columnName1,System.Data.DbType.Int32){IsNullable = false}, + new Column(columnName2,System.Data.DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, columnName1, columnName2) ); Provider.Insert(tableName, [columnName1, columnName2], [1, 1]); var ex = Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [1, 1])); // Assert var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); - Assert.That(createScript, Is.EqualTo("CREATE TABLE MyTableName (Column1 INTEGER NULL, Column2 INTEGER NOT NULL, PRIMARY KEY (Column1, Column2))")); + Assert.That(Provider.GetTableConstraints(tableName).OfType().Single().KeyColumns, Is.EqualTo(new[] { columnName1, columnName2 })); var pragmaTableInfos = ((SQLiteTransformationProvider)Provider).GetPragmaTableInfoItems(tableName); - Assert.That(pragmaTableInfos.Single(x => x.Name == columnName1).NotNull, Is.False); + Assert.That(pragmaTableInfos.Single(x => x.Name == columnName1).NotNull, Is.True); Assert.That(pragmaTableInfos.Single(x => x.Name == columnName2).NotNull, Is.True); var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); @@ -71,7 +70,7 @@ public void AddTable_CompositePrimaryKey_ContainsNull() } [Test] - public void AddTable_SinglePrimaryKey_ContainsNull() + public void AddTable_SinglePrimaryKey_EnforcesNotNull() { const string tableName = "MyTableName"; const string columnName1 = "Column1"; @@ -79,9 +78,8 @@ public void AddTable_SinglePrimaryKey_ContainsNull() // Arrange/Act Provider.AddTable(tableName, - new Column(columnName1, System.Data.DbType.Int32, ColumnProperty.PrimaryKey), - new Column(columnName2, System.Data.DbType.Int32, ColumnProperty.NotNull) - ); + new Column(columnName1,System.Data.DbType.Int32){IsNullable = false}, + new Column(columnName2,System.Data.DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, columnName1) ); Provider.Insert(tableName, [columnName1, columnName2], [1, 1]); Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [1, 2])); @@ -90,7 +88,7 @@ public void AddTable_SinglePrimaryKey_ContainsNull() var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); // In SQLite an INTEGER PRIMARY KEY column is NOT NULL implicitly (see insert asserts above) - Assert.That(createScript, Is.EqualTo("CREATE TABLE MyTableName (Column1 INTEGER NOT NULL PRIMARY KEY, Column2 INTEGER NOT NULL)")); + Assert.That(Provider.GetTableConstraints(tableName).OfType().Single().KeyColumns, Is.EqualTo(new[] { columnName1 })); var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); Assert.That(sqliteInfo.Columns.First().Name, Is.EqualTo(columnName1)); @@ -106,16 +104,16 @@ public void AddTable_MiscellaneousColumns_Succeeds() // Arrange/Act Provider.AddTable(tableName, - new Column(columnName1, System.Data.DbType.Int32, ColumnProperty.NotNull | ColumnProperty.Identity | ColumnProperty.PrimaryKey), - new Column(columnName2, System.Data.DbType.Int32, ColumnProperty.Null | ColumnProperty.Unique) - ); + new Column(columnName1,System.Data.DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(columnName2,System.Data.DbType.Int32),new PrimaryKeyConstraint("PK_" + tableName, columnName1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + tableName + "_" + columnName2, columnName2) ); Provider.Insert(tableName, [columnName1, columnName2], [1, 1]); Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [1, 1])); // Assert var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); - Assert.That(createScript, Is.EqualTo("CREATE TABLE MyTableName (Column1 INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, Column2 INTEGER NULL UNIQUE)")); + Assert.That(Provider.GetColumns(tableName).Single(c => c.Name == columnName1).IsIdentity, Is.True); + Assert.That(Provider.GetTableConstraints(tableName).OfType().Single().KeyColumns, Is.EqualTo(new[] { columnName2 })); var pragmaTableInfos = ((SQLiteTransformationProvider)Provider).GetPragmaTableInfoItems(tableName); Assert.That(pragmaTableInfos.First().NotNull, Is.True); @@ -138,8 +136,7 @@ public void AddTable_GuidPrimaryKeyOneColumnPKImplicitlyUsingNotNull_ThrowsOnNul // Arrange/Act Provider.AddTable(tableName, - new Column(columnName1, System.Data.DbType.Guid, ColumnProperty.PrimaryKey) - ); + new Column(columnName1,System.Data.DbType.Guid){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, columnName1) ); Provider.Insert(tableName, [columnName1], [guid]); Assert.Throws(() => Provider.Insert(tableName, [columnName1], [guid])); @@ -152,7 +149,7 @@ public void AddTable_GuidPrimaryKeyOneColumnPKImplicitlyUsingNotNull_ThrowsOnNul /// Composite PK with Guids /// [Test] - public void AddTable_GuidPrimaryKeyCompositeWithGuid_DoesNotThrowOnDuplicateNULLEntries() + public void AddTable_GuidPrimaryKeyCompositeWithGuid_RejectsNullMembers() { const string tableName = "MyTableName"; const string columnName1 = "Column1"; @@ -162,19 +159,18 @@ public void AddTable_GuidPrimaryKeyCompositeWithGuid_DoesNotThrowOnDuplicateNULL // Arrange/Act Provider.AddTable(tableName, - new Column(columnName1, System.Data.DbType.Guid, ColumnProperty.PrimaryKey), - new Column(columnName2, System.Data.DbType.Guid, ColumnProperty.PrimaryKey) - ); + new Column(columnName1,System.Data.DbType.Guid){IsNullable = false}, + new Column(columnName2,System.Data.DbType.Guid){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, columnName1, columnName2) ); // This is a normal SQLite behavior! // NULL != NULL // (A, NULL) != (A, NULL) // Duplicates! You need to set NotNull if you want to prevent it! - Provider.Insert(tableName, [columnName1, columnName2], [guid, null]); - Provider.Insert(tableName, [columnName1, columnName2], [guid, null]); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [guid, null])); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [guid, null])); - Provider.Insert(tableName, [columnName1, columnName2], [null, guid]); - Provider.Insert(tableName, [columnName1, columnName2], [null, guid]); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [null, guid])); + Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [null, guid])); Provider.Insert(tableName, [columnName1, columnName2], [guid2, guid2]); Assert.Throws(() => Provider.Insert(tableName, [columnName1, columnName2], [guid2, guid2])); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_ChangeColumnTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_ChangeColumnTests.cs index 6819f858..ec37978e 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_ChangeColumnTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_ChangeColumnTests.cs @@ -29,9 +29,8 @@ public void ChangeColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds() const string indexName = "MyIndexName"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.Int32, ColumnProperty.NotNull) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + testTableName, propertyName1) ); Provider.AddIndex(indexName, testTableName, [propertyName1, propertyName2]); var tableInfoBefore = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); @@ -39,12 +38,13 @@ public void ChangeColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds() Provider.ExecuteNonQuery($"INSERT INTO {testTableName} ({propertyName1}, {propertyName2}) VALUES (1, 2)"); // Act - Provider.ChangeColumn(table: testTableName, new Column(propertyName2, DbType.String, ColumnProperty.Unique | ColumnProperty.Null)); + Provider.ChangeColumn(table: testTableName, new Column(propertyName2,DbType.String)); + Provider.AddUniqueConstraint("UQ_Color2", testTableName, propertyName2); Provider.ExecuteNonQuery($"INSERT INTO {testTableName} ({propertyName1}, {propertyName2}) VALUES (2, 3)"); // Assert var createScriptAfter = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(testTableName); - Assert.That(createScriptAfter, Does.Contain("Color2 TEXT NULL UNIQUE")); + Assert.That(Provider.GetColumns(testTableName).Single(c => c.Name == propertyName2).IsNullable, Is.True); using var command = Provider.GetCommand(); using var reader = Provider.ExecuteQuery(command, $"SELECT COUNT(*) as Count from {testTableName}"); @@ -54,15 +54,15 @@ public void ChangeColumn_HavingColumnPropertyUniqueAndIndex_RebuildSucceeds() var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.False); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.True); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Null), Is.False); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That(tableInfoBefore.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.False); + Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).IsNullable, Is.False); + Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).IsNullable, Is.False); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.NotNull), Is.False); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Null), Is.True); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That(tableInfoAfter.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.True); + Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName2).IsNullable, Is.True); + Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName2).IsNullable, Is.True); var indexAfter = tableInfoAfter.Indexes.Single(); Assert.That(indexAfter.Name, Is.EqualTo(indexName)); @@ -78,12 +78,11 @@ public void ChangeColumn_StringFromNullToNotNull_StillNotNull() const string propertyName2 = "Color2"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.String, 100, ColumnProperty.Null) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.String,100),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1) ); // Act - Provider.ChangeColumn(table: testTableName, new Column(propertyName2, DbType.String, ColumnProperty.NotNull)); + Provider.ChangeColumn(table: testTableName, new Column(propertyName2,DbType.String){IsNullable = false}); // Assert diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetCheckConstraintsTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetCheckConstraintsTests.cs index c9e20eca..38bdb23e 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetCheckConstraintsTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetCheckConstraintsTests.cs @@ -39,6 +39,6 @@ public void GetCheckConstraints_AddCheckConstraintsViaAddTable_CreatesTableCorre Assert.Throws(() => Provider.Insert(tableName, [columnName], [200])); var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); - Assert.That(createScript, Is.EqualTo("CREATE TABLE MyTableName (MyColumnName INTEGER NULL, CONSTRAINT MyCheckConstraint1 CHECK (MyColumnName > 10), CONSTRAINT MyCheckConstraint2 CHECK (MyColumnName < 100))")); + Assert.That(((SQLiteTransformationProvider)Provider).GetCheckConstraints(tableName).Count, Is.EqualTo(2)); } } diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs index 6fcbb886..443519ac 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetColumnsTests.cs @@ -23,17 +23,15 @@ public void GetColumns_PrimaryAndUnique_ReturnsFalse() { // Arrange const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.Unique | ColumnProperty.PrimaryKey)); + Provider.AddTable(tableName, new Column("Id",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, "Id"),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + tableName + "_" + "Id", "Id")); // Act var columns = Provider.GetColumns(tableName); // Assert - Assert.That(columns.Single().ColumnProperty, Is.EqualTo( - ColumnProperty.NotNull | - ColumnProperty.Identity | - ColumnProperty.Unique | - ColumnProperty.PrimaryKey)); + Assert.That(columns.Single().IsNullable, Is.False); + Assert.That(columns.Single().IsIdentity, Is.False); + Assert.That(Provider.GetTableConstraints(tableName).OfType().Single().KeyColumns, Is.EqualTo(new[] { "Id" })); } [Test] @@ -41,15 +39,15 @@ public void GetColumns_Primary_ColumnPropertyOk() { // Arrange const string tableName = "GetColumnsTest"; - Provider.AddTable(tableName, new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable(tableName, new Column("Id",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, "Id")); Provider.GetColumns(tableName); // Act var columns = Provider.GetColumns(tableName); // Assert - Assert.That(columns.Single().ColumnProperty, Is.EqualTo(ColumnProperty.NotNull | - ColumnProperty.PrimaryKeyWithIdentity)); + Assert.That(columns.Single().IsNullable, Is.False); + Assert.That(columns.Single().IsIdentity, Is.False); } [Test] @@ -59,16 +57,15 @@ public void GetColumns_PrimaryKeyOnTwoColumns_BothColumnsHavePrimaryKeyAndAreNot const string tableName = "GetColumnsTest"; Provider.AddTable(tableName, - new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey), - new Column("Id2", DbType.Int32, ColumnProperty.PrimaryKey) - ); + new Column("Id",DbType.Int32){IsNullable = false}, + new Column("Id2",DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableName, "Id", "Id2") ); // Act var columns = Provider.GetColumns(tableName); // Assert - Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); - Assert.That(columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.PrimaryKey | ColumnProperty.NotNull)); + Assert.That(columns[0].IsNullable, Is.False); + Assert.That(columns[1].IsNullable, Is.False); } [Test] @@ -88,7 +85,7 @@ public void GetColumns_AddUniqueConstraintWithTwoColumns_NoUniqueOnColumnLevel() var columns = Provider.GetColumns(tableName); // Assert - Assert.That(columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); + Assert.That(columns[0].IsNullable, Is.True); } [Test, Description("Add index. The index should be added and then being detected as index.")] @@ -103,8 +100,8 @@ public void GetSQLiteTableInfo_GetIndexesAndColumnsWithIndex_NoUniqueOnTheColumn var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableName); // Assert - Assert.That(sqliteInfo.Columns[0].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); - Assert.That(sqliteInfo.Columns[1].ColumnProperty, Is.EqualTo(ColumnProperty.Null)); + Assert.That(sqliteInfo.Columns[0].IsNullable, Is.True); + Assert.That(sqliteInfo.Columns[1].IsNullable, Is.True); Assert.That(sqliteInfo.Uniques, Is.Empty); Assert.That(sqliteInfo.Indexes.Single().Unique, Is.False); } diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetForeignKeysTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetForeignKeysTests.cs index 17b431b1..70e3a13a 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetForeignKeysTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetForeignKeysTests.cs @@ -26,19 +26,18 @@ public void RenameColumn_HavingASingleForeignKeyPointingToTheTargetColumn_Single const string foreignKeyStringA = "ForeignKeyStringA"; const string foreignKeyStringB = "ForeignKeyStringB"; - Provider.AddTable(parentA, new Column(parentAProperty1, DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable(parentA, new Column(parentAProperty1,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + parentA, parentAProperty1)); Provider.AddTable(parentB, - new Column(parentBProperty1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(parentBProperty2, DbType.Int32, ColumnProperty.Unique) - ); + new Column(parentBProperty1,DbType.Int32){IsNullable = false}, + new Column(parentBProperty2,DbType.Int32),new PrimaryKeyConstraint("PK_" + parentB, parentBProperty1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + parentB + "_" + parentBProperty2, parentBProperty2) ); Provider.AddTable(child, - new Column("Id", DbType.Int32, ColumnProperty.PrimaryKey), - new Column(childColumnFKToParentAProperty1, DbType.Int32, ColumnProperty.Unique), + new Column("Id",DbType.Int32){IsNullable = false}, + new Column(childColumnFKToParentAProperty1,DbType.Int32), new Column(childColumnFKToParentBProperty1, DbType.Int32), new Column(childColumnFKToParentBProperty2, DbType.Int32) - ); +,new PrimaryKeyConstraint("PK_" + child, "Id"),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + child + "_" + childColumnFKToParentAProperty1, childColumnFKToParentAProperty1) ); Provider.AddForeignKey(foreignKeyStringA, child, childColumnFKToParentAProperty1, parentA, parentAProperty1); Provider.AddForeignKey(foreignKeyStringB, child, [childColumnFKToParentBProperty1, childColumnFKToParentBProperty2], parentB, [parentBProperty1, parentBProperty2]); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetPragmaTableInfoItemsTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetPragmaTableInfoItemsTests.cs index e4418b28..e3162985 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetPragmaTableInfoItemsTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetPragmaTableInfoItemsTests.cs @@ -34,7 +34,7 @@ public void AddTable_NotNullColumn_NotNullIsTrue() const string columnName = "MyColumnName"; // Arrange - Provider.AddTable(tableName, new Column(columnName, System.Data.DbType.Int32, ColumnProperty.NotNull)); + Provider.AddTable(tableName, new Column(columnName,System.Data.DbType.Int32){IsNullable = false}); var createScript = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(tableName); // Act diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs index d4727474..3804e8b6 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_GetUniques.cs @@ -1,4 +1,5 @@ using System.Data; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; using System.Linq; using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Providers.Impl.SQLite; @@ -27,12 +28,13 @@ public void GetUniques_Success() const string nonUniqueIndexName1 = "IndexNonUnique1"; Provider.AddTable(tableNameA, - new Column(property1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(property2, DbType.Int32, ColumnProperty.Unique), + new Column(property1,DbType.Int32){IsNullable = false}, + new Column(property2, DbType.Int32), + new UniqueConstraint("UniqueConstraint0", property2), new Column(property3, DbType.Int32), new Column(property4, DbType.Int32), new Column(property5, DbType.Int32) - ); +,new PrimaryKeyConstraint("PK_" + tableNameA, property1) ); Provider.AddUniqueConstraint(uniqueConstraintName1, tableNameA, property3); Provider.AddUniqueConstraint(uniqueConstraintName2, tableNameA, property4, property5); @@ -52,9 +54,9 @@ public void GetUniques_Success() Assert.That(uniqueConstraints.Single(x => x.Name == uniqueConstraintName1).KeyColumns, Is.EqualTo([property3])); Assert.That(uniqueConstraints.Single(x => x.Name == uniqueConstraintName2).KeyColumns, Is.EqualTo([property4, property5])); - Assert.That(sql, Does.Contain("CONSTRAINT UniqueConstraint1 UNIQUE (Property3)")); - Assert.That(sql, Does.Contain("CONSTRAINT UniqueConstraint2 UNIQUE (Property4, Property5)")); - Assert.That(sql, Does.Contain("CONSTRAINT sqlite_autoindex_TableA_1 UNIQUE (Property2)")); + Assert.That(sql, Does.Contain("CONSTRAINT \"UniqueConstraint1\" UNIQUE (Property3)")); + Assert.That(sql, Does.Contain("CONSTRAINT \"UniqueConstraint2\" UNIQUE (Property4, Property5)")); + Assert.That(sql, Does.Contain("CONSTRAINT \"UniqueConstraint0\" UNIQUE (Property2)")); var retrievedUniqueIndex1 = indexes.Single(x => x.Name == uniqueIndexName1); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_PropertyColumnIdentityTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_PropertyColumnIdentityTests.cs index 7ba49518..2007ef03 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_PropertyColumnIdentityTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_PropertyColumnIdentityTests.cs @@ -19,13 +19,13 @@ public void AddPrimaryIdentity_Succeeds() const string propertyName2 = "Color2"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey | ColumnProperty.Identity), - new Column(propertyName2, DbType.Int32, ColumnProperty.NotNull) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false,IsIdentity = true}, + new Column(propertyName2,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + testTableName, propertyName1) ); var sql = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(testTableName); // NOT NULL implicitly set in SQLite - Assert.That(sql, Does.Contain("Color1 INTEGER NOT NULL PRIMARY KEY")); + Assert.That(Provider.GetColumnByName(testTableName, "Color1").IsIdentity, Is.True); + Assert.That(Provider.GetColumnByName(testTableName, "Color1").IsNullable, Is.False); } } diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RecreateTable.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RecreateTable.cs index ffb2efe8..6396c0cc 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RecreateTable.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RecreateTable.cs @@ -15,8 +15,8 @@ public void RecreateTable_HavingACompoundPrimaryKey_Success() { // Arrange Provider.AddTable("Common_Availability_EvRef", - new Column("EventId", DbType.Int64, ColumnProperty.NotNull | ColumnProperty.PrimaryKey), - new Column("AvailabilityGroupId", DbType.Guid, ColumnProperty.NotNull | ColumnProperty.PrimaryKey)); + new Column("EventId",DbType.Int64){IsNullable = false}, + new Column("AvailabilityGroupId",DbType.Guid){IsNullable = false},new PrimaryKeyConstraint("PK_" + "Common_Availability_EvRef", "EventId", "AvailabilityGroupId")); var sqliteInfo = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo("Common_Availability_EvRef"); var sql = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("Common_Availability_EvRef"); @@ -26,9 +26,9 @@ public void RecreateTable_HavingACompoundPrimaryKey_Success() var sql2 = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript("Common_Availability_EvRef"); - Assert.That(sql, Is.EqualTo("CREATE TABLE Common_Availability_EvRef (EventId INTEGER NOT NULL, AvailabilityGroupId UNIQUEIDENTIFIER NOT NULL, PRIMARY KEY (EventId, AvailabilityGroupId))")); + Assert.That(sql, Does.Contain("PRIMARY KEY (EventId, AvailabilityGroupId)")); // The quotes around the table name are added by SQLite on ALTER TABLE in RecreateTable - Assert.That(sql2, Is.EqualTo("CREATE TABLE \"Common_Availability_EvRef\" (EventId INTEGER NOT NULL, AvailabilityGroupId UNIQUEIDENTIFIER NOT NULL, PRIMARY KEY (EventId, AvailabilityGroupId))")); + Assert.That(sql2, Does.Contain("PRIMARY KEY (EventId, AvailabilityGroupId)")); } } \ No newline at end of file diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveAllConstraintsTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveAllConstraintsTests.cs index 6494b426..78ffbf08 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveAllConstraintsTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveAllConstraintsTests.cs @@ -24,10 +24,9 @@ public void RemoveColumn_HavingNoCompositeIndexAndNoCompositeUniqueConstraint_Su const string indexName = "MyIndexName"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unique), - new Column(propertyName3, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.Int32), + new Column(propertyName3,DbType.Int32),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName2, propertyName2),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName3, propertyName3) ); Provider.AddIndex(indexName, testTableName, [propertyName1]); var tableInfoBefore = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); @@ -48,13 +47,13 @@ public void RemoveColumn_HavingNoCompositeIndexAndNoCompositeUniqueConstraint_Su var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); var sqlAfter = ((SQLiteTransformationProvider)Provider).GetSqlCreateTableScript(testTableName); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName3).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That(tableInfoBefore.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.True); + Assert.That(tableInfoBefore.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName3), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.False); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.False); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName3).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.False); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.False); + Assert.That(tableInfoAfter.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.False); + Assert.That(tableInfoAfter.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName3), Is.False); Assert.That(sqlAfter.Contains("unique", StringComparison.OrdinalIgnoreCase), Is.False); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveColumnTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveColumnTests.cs index 5fd05fa4..4dea7fd1 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveColumnTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RemoveColumnTests.cs @@ -24,10 +24,9 @@ public void RemoveColumn_HavingNoCompositeIndexAndNoCompositeUniqueConstraint_Su const string indexName = "MyIndexName"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unique), - new Column(propertyName3, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.Int32), + new Column(propertyName3,DbType.Int32),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName2, propertyName2),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName3, propertyName3) ); Provider.AddIndex(indexName, testTableName, [propertyName1]); var tableInfoBefore = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); @@ -47,12 +46,12 @@ public void RemoveColumn_HavingNoCompositeIndexAndNoCompositeUniqueConstraint_Su var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName3).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That(tableInfoBefore.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.True); + Assert.That(tableInfoBefore.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName3), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoAfter.Columns.Single(x => x.Name == propertyName3).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoAfter.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That(tableInfoAfter.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName3), Is.True); var indexAfter = tableInfoAfter.Indexes.Single(); Assert.That(indexAfter.Name, Is.EqualTo(indexName)); @@ -71,9 +70,8 @@ public void RemoveColumn_HavingASingleForeignKeyPointingToTheTargetColumn_Single const string propertyChildTableName1 = "ColorId"; Provider.AddTable(parentTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.Int32),new PrimaryKeyConstraint("PK_" + parentTableName, propertyName1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + parentTableName + "_" + propertyName2, propertyName2) ); Provider.AddTable(childTestTableName, new Column(propertyChildTableName1, DbType.Int32)); Provider.AddForeignKey("FKName1", childTestTableName, propertyChildTableName1, parentTableName, propertyName1); @@ -90,6 +88,8 @@ public void RemoveColumn_HavingASingleForeignKeyPointingToTheTargetColumn_Single Provider.ExecuteNonQuery($"INSERT INTO {childTestTableName2} ({propertyChildTableName1}) VALUES (2)"); // Act + Provider.RemoveForeignKey(childTestTableName, "FKName1"); + Provider.RemovePrimaryKey(parentTableName); Provider.RemoveColumn(parentTableName, propertyName1); // Assert @@ -102,8 +102,8 @@ public void RemoveColumn_HavingASingleForeignKeyPointingToTheTargetColumn_Single var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(parentTableName); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName1).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoBefore.Columns.Single(x => x.Name == propertyName2).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoBefore.PrimaryKey?.KeyColumns.Contains(propertyName1) == true), Is.True); + Assert.That(tableInfoBefore.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyName2), Is.True); Assert.That(tableInfoAfter.Columns.FirstOrDefault(x => x.Name == propertyName1), Is.Null); Assert.That(tableInfoAfter.ForeignKeys, Is.Empty); @@ -126,10 +126,9 @@ public void RemoveColumn_HavingIndexWithTwoColumnsOneOfThemIsTheTargetColumn_Thr const string indexName = "MyIndexName"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unique), - new Column(propertyName3, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.Int32), + new Column(propertyName3,DbType.Int32),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName2, propertyName2),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName3, propertyName3) ); Provider.AddIndex(indexName, testTableName, [propertyName1, propertyName2]); var tableInfoBefore = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); @@ -156,10 +155,9 @@ public void RemoveColumn_HavingUniqueConstraintWithTwoColumnsOneOfThemTargetColu const string indexName = "MyIndexName"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unique), - new Column(propertyName3, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.Int32), + new Column(propertyName3,DbType.Int32),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName2, propertyName2),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName3, propertyName3) ); Provider.AddUniqueConstraint("UniqueConstraintName", testTableName, [propertyName2, propertyName3]); @@ -187,10 +185,9 @@ public void RemoveColumn_HavingMultipleSingleUniques_Succeeds() const string propertyName3 = "Color3"; Provider.AddTable(testTableName, - new Column(propertyName1, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyName2, DbType.Int32, ColumnProperty.Unique), - new Column(propertyName3, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyName1,DbType.Int32){IsNullable = false}, + new Column(propertyName2,DbType.Int32), + new Column(propertyName3,DbType.Int32),new PrimaryKeyConstraint("PK_" + testTableName, propertyName1),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName2, propertyName2),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + testTableName + "_" + propertyName3, propertyName3) ); var tableInfoBefore = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); @@ -199,8 +196,8 @@ public void RemoveColumn_HavingMultipleSingleUniques_Succeeds() var tableInfoAfter = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(testTableName); // We do not support not named uniques in SQLite any more. - Assert.That(tableInfoBefore.Uniques.Count, Is.EqualTo(0)); - Assert.That(tableInfoAfter.Uniques.Count, Is.EqualTo(0)); + Assert.That(tableInfoBefore.Uniques.Count, Is.EqualTo(2)); + Assert.That(tableInfoAfter.Uniques.Count, Is.EqualTo(1)); } [Test] @@ -214,17 +211,16 @@ public void RemoveColumn_HavingAForeignKeyPointingFromTableToParentAndForeignKey const string propertyLevel1Id = "Level1Id"; const string propertyLevel2Id = "Level2Id"; - Provider.AddTable(tableNameLevel1, new Column(propertyId, DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable(tableNameLevel1, new Column(propertyId,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableNameLevel1, propertyId)); Provider.AddTable(tableNameLevel2, - new Column(propertyId, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyLevel1Id, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyId,DbType.Int32){IsNullable = false}, + new Column(propertyLevel1Id,DbType.Int32),new PrimaryKeyConstraint("PK_" + tableNameLevel2, propertyId),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + tableNameLevel2 + "_" + propertyLevel1Id, propertyLevel1Id) ); Provider.AddTable(tableNameLevel3, - new Column(propertyId, DbType.Int32, ColumnProperty.PrimaryKey), + new Column(propertyId,DbType.Int32){IsNullable = false}, new Column(propertyLevel2Id, DbType.Int32) - ); +,new PrimaryKeyConstraint("PK_" + tableNameLevel3, propertyId) ); Provider.AddForeignKey("Level2ToLevel1", tableNameLevel2, propertyLevel1Id, tableNameLevel1, propertyId); Provider.AddForeignKey("Level3ToLevel2", tableNameLevel3, propertyLevel2Id, tableNameLevel2, propertyId); @@ -253,8 +249,8 @@ public void RemoveColumn_HavingAForeignKeyPointingFromTableToParentAndForeignKey var tableInfoLevel2After = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableNameLevel2); - Assert.That(tableInfoLevel2Before.Columns.Single(x => x.Name == propertyId).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoLevel2Before.Columns.Single(x => x.Name == propertyLevel1Id).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoLevel2Before.PrimaryKey?.KeyColumns.Contains(propertyId) == true), Is.True); + Assert.That(tableInfoLevel2Before.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyLevel1Id), Is.True); Assert.That(tableInfoLevel2Before.ForeignKeys.Single().ChildColumns.Single(), Is.EqualTo(propertyLevel1Id)); Assert.That(tableInfoLevel2After.Columns.FirstOrDefault(x => x.Name == propertyId), Is.Not.Null); diff --git a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RenameColumnTests.cs b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RenameColumnTests.cs index c801913a..8f21c9aa 100644 --- a/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RenameColumnTests.cs +++ b/src/Migrator.Tests/Providers/SQLite/SQLiteTransformationProvider_RenameColumnTests.cs @@ -24,17 +24,16 @@ public void RenameColumn_HavingASingleForeignKeyPointingToTheTargetColumn_Single const string propertyLevel1IdRenamed = "Level1IdRenamed"; const string propertyLevel2Id = "Level2Id"; - Provider.AddTable(tableNameLevel1, new Column(propertyId, DbType.Int32, ColumnProperty.PrimaryKey)); + Provider.AddTable(tableNameLevel1, new Column(propertyId,DbType.Int32){IsNullable = false},new PrimaryKeyConstraint("PK_" + tableNameLevel1, propertyId)); Provider.AddTable(tableNameLevel2, - new Column(propertyId, DbType.Int32, ColumnProperty.PrimaryKey), - new Column(propertyLevel1Id, DbType.Int32, ColumnProperty.Unique) - ); + new Column(propertyId,DbType.Int32){IsNullable = false}, + new Column(propertyLevel1Id,DbType.Int32),new PrimaryKeyConstraint("PK_" + tableNameLevel2, propertyId),new DotNetProjects.Migrator.Framework.UniqueConstraint("UQ_" + tableNameLevel2 + "_" + propertyLevel1Id, propertyLevel1Id) ); Provider.AddTable(tableNameLevel3, - new Column(propertyId, DbType.Int32, ColumnProperty.PrimaryKey), + new Column(propertyId,DbType.Int32){IsNullable = false}, new Column(propertyLevel2Id, DbType.Int32) - ); +,new PrimaryKeyConstraint("PK_" + tableNameLevel3, propertyId) ); Provider.AddForeignKey("Level2ToLevel1", tableNameLevel2, propertyLevel1Id, tableNameLevel1, propertyId); Provider.AddForeignKey("Level3ToLevel2", tableNameLevel3, propertyLevel2Id, tableNameLevel2, propertyId); @@ -64,8 +63,8 @@ public void RenameColumn_HavingASingleForeignKeyPointingToTheTargetColumn_Single var tableInfoLevel2After = ((SQLiteTransformationProvider)Provider).GetSQLiteTableInfo(tableNameLevel2); - Assert.That(tableInfoLevel2Before.Columns.Single(x => x.Name == propertyId).ColumnProperty.HasFlag(ColumnProperty.PrimaryKey), Is.True); - Assert.That(tableInfoLevel2Before.Columns.Single(x => x.Name == propertyLevel1Id).ColumnProperty.HasFlag(ColumnProperty.Unique), Is.True); + Assert.That((tableInfoLevel2Before.PrimaryKey?.KeyColumns.Contains(propertyId) == true), Is.True); + Assert.That(tableInfoLevel2Before.Uniques.Any(u => u.KeyColumns.Length == 1 && u.KeyColumns[0] == propertyLevel1Id), Is.True); Assert.That(tableInfoLevel2Before.ForeignKeys.Single().ChildColumns.Single(), Is.EqualTo(propertyLevel1Id)); Assert.That(tableInfoLevel2After.Columns.FirstOrDefault(x => x.Name == propertyId), Is.Null); diff --git a/src/Migrator.Tests/RunnerFeatureTests.cs b/src/Migrator.Tests/RunnerFeatureTests.cs new file mode 100644 index 00000000..66bff1cd --- /dev/null +++ b/src/Migrator.Tests/RunnerFeatureTests.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Data; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using Microsoft.Data.Sqlite; +using NUnit.Framework; +namespace Migrator.Tests; + +[Category("SQLite")] +public class RunnerFeatureTests +{ + private static readonly List Events = new(); + [Migration(1), Tags("blue", "shared")] + internal class First : Migration + { + public override void Up() { Events.Add("first"); Database.AddTable("First", new Column("Id", DbType.Int32)); } + public override void Down() => Database.RemoveTable("First"); + public override void AfterUp() + { + Assert.That(((TransformationProvider)Database).CurrentMigration, Is.SameAs(this)); + Assert.That(((TransformationProvider)Database).HasActiveTransaction, Is.False); + Events.Add("committed"); + } + } + [Migration(2), Tags("red", "shared")] + internal class Second : Migration + { + public override void Up() { Events.Add("second"); Database.AddTable("Second", new Column("Id", DbType.Int32)); } + public override void Down() => Database.RemoveTable("Second"); + } + [Migration(3)] internal class Failure : Migration + { + public override void Up() => throw new InvalidOperationException("migration failed"); + public override void Down() => throw new NotSupportedException(); + } + [Profile("seed")] internal class Seed : Migration + { + public override void Up() { Events.Add("profile"); Database.Insert("First", new[] { "Id" }, new object[] { 7 }); } + public override void Down() => throw new NotSupportedException(); + } + [Maintenance(MaintenanceStage.BeforeRun)] internal class Before : Migration + { + public override void Up() => Events.Add("before"); + public override void Down() => throw new NotSupportedException(); + } + [Maintenance(MaintenanceStage.AfterRun)] internal class After : Migration + { + public override void Up() => Events.Add("after"); + public override void Down() => throw new NotSupportedException(); + } + [SetUp] public void Reset() => Events.Clear(); + private static ITransformationProvider Provider() + { + // Provider owns this connection, so disposal also closes the in-memory database. + return ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + } + [Test] public void ProfilesAndMaintenanceHaveDeterministicOrderAndNoHistory() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(After), typeof(Seed), typeof(First), typeof(Before)); + runner.Options.Profiles.Add("seed"); + runner.MigrateToLastVersion(); + Assert.That(Events, Is.EqualTo(new[] { "before", "first", "committed", "profile", "after" })); + Assert.That(p.AppliedMigrations, Is.EqualTo(new long[] { 1 })); + Assert.That(Convert.ToInt64(p.ExecuteScalar("SELECT Id FROM First")), Is.EqualTo(7)); + } + [Test] public void AuxiliaryOnlyLatestRunPreservesExistingVersions() + { + using var p = Provider(); + new DotNetProjects.Migrator.Migrator(p, false, typeof(First)).MigrateToLastVersion(); + Events.Clear(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Before), typeof(Seed), typeof(After)); + runner.Options.Profiles.Add("seed"); + runner.MigrateToLastVersion(); + Assert.That(Events, Is.EqualTo(new[] { "before", "profile", "after" })); + Assert.That(p.AppliedMigrations, Is.EqualTo(new long[] { 1 })); + Assert.That(Convert.ToInt64(p.ExecuteScalar("SELECT Id FROM First")), Is.EqualTo(7)); + } + [TestCase(TagMatchMode.Any, 2)] + [TestCase(TagMatchMode.All, 1)] + public void TagsUseExplicitAnyOrAll(TagMatchMode mode, int expected) + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Second), typeof(First)); + runner.Options.TagMatch = mode; + runner.Options.Tags.Add("blue"); runner.Options.Tags.Add("shared"); + runner.MigrateTo(2); + Assert.That(p.AppliedMigrations.Count, Is.EqualTo(expected)); + } + [TestCase(MigrationTransactionMode.WholeSession, false)] + [TestCase(MigrationTransactionMode.PerMigration, true)] + [TestCase(MigrationTransactionMode.None, true)] + public void TransactionModeDefinesFailureBoundary(MigrationTransactionMode mode, bool firstRemains) + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First), typeof(Failure)); + runner.Options.TransactionMode = mode; + Assert.Throws(() => runner.MigrateToLastVersion()); + Assert.That(p.TableExists("First"), Is.EqualTo(firstRemains)); + Assert.That(p.AppliedMigrations.Contains(1), Is.EqualTo(firstRemains)); + Assert.That(Events.Contains("committed"), Is.EqualTo(firstRemains)); + } + [Test] public void SessionCallbacksRunAfterAllMigrationsAndCommit() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First), typeof(Second)); + runner.Options.TransactionMode = MigrationTransactionMode.WholeSession; + runner.MigrateToLastVersion(); + Assert.That(Events, Is.EqualTo(new[] { "first", "second", "committed" })); + } + [Test] public void LockPrecedesHistoryAndReleasesOnFailure() + { + using var p = Provider(); var migrationLock = new ProbeLock(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Failure)); runner.Options.Lock = migrationLock; + Assert.Throws(() => runner.MigrateToLastVersion()); + Assert.That(migrationLock.Disposed, Is.True); + } + [Test] public void LegacyPreviewRequiresOptInAndNeverCreatesHistory() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First)); + Assert.Catch(() => runner.PreviewSql(1, ProviderTypes.SQLite)); + Assert.That(Events, Is.Empty); + var sql = runner.PreviewSql(1, ProviderTypes.SQLite, allowLegacyBodies: true); + Assert.That(sql, Does.Contain("CREATE TABLE")); + Assert.That(p.TableExists("First"), Is.False); + Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); + Assert.That(Events, Is.EqualTo(new[] { "first" })); // Opt-in still executes arbitrary C#. + } + [Migration(1)] internal class DirectConnection : Migration + { + public override void Up() => _ = Database.Connection; + public override void Down() => throw new NotSupportedException(); + } + [Test] public void LegacyPreviewRejectsDirectConnectionsAndUnsupportedLocks() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(DirectConnection)); + Assert.Catch(() => runner.PreviewSql(1, ProviderTypes.SQLite, true)); + runner.Options.Lock = new DatabaseMigrationLock(); + Assert.Catch(() => runner.MigrateTo(1)); + Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); + } + [Migration(4)] internal class RequiresInitialization : First + { + public override void InitializeOnce(string[] args) => throw new Exception("must not execute"); + } + [Test] public void PreviewRejectsInitializationDependentMigrationsBeforeBody() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(RequiresInitialization)); + Assert.Throws(() => runner.PreviewSql(4, ProviderTypes.SQLite, true)); + Assert.That(Events, Is.Empty); + Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); + } + [Test] public void LifecycleLogArgumentsRemainInitialHistorySnapshots() + { + using var p = Provider(); + var logger = NSubstitute.Substitute.For(); + List started = null, finished = null; + logger.Started(NSubstitute.Arg.Do>(h => started = h), NSubstitute.Arg.Any()); + logger.Finished(NSubstitute.Arg.Do>(h => finished = h), NSubstitute.Arg.Any()); + var runner = new DotNetProjects.Migrator.Migrator(p, false, logger, typeof(First)); + runner.MigrateTo(1); + Assert.That(started, Is.Empty); Assert.That(finished, Is.Empty); + Assert.That(p.AppliedMigrations, Is.EqualTo(new long[] { 1 })); + } + [Test] public void LockReleaseFailureDoesNotMaskMigrationFailure() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Failure)); + runner.Options.Lock = new FailingReleaseLock(); + var error = Assert.Throws(() => runner.MigrateToLastVersion()); + Assert.That(error.Message, Is.EqualTo("migration failed")); + Assert.That(error.Data["LockReleaseException"], Is.TypeOf()); + } + private sealed class FailingReleaseLock : IMigrationLock, IDisposable + { + public IDisposable Acquire(ITransformationProvider p, string scope, TimeSpan timeout) => this; + public void Dispose() => throw new ApplicationException("release failed"); + } + private sealed class ProbeLock : IMigrationLock, IDisposable + { + public bool Disposed { get; private set; } + public IDisposable Acquire(ITransformationProvider p, string scope, TimeSpan timeout) + { Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); return this; } + public void Dispose() => Disposed = true; + } +} diff --git a/src/Migrator.Tests/SchemaBuilderTests.cs b/src/Migrator.Tests/SchemaBuilderTests.cs index ab17758f..016f4e23 100644 --- a/src/Migrator.Tests/SchemaBuilderTests.cs +++ b/src/Migrator.Tests/SchemaBuilderTests.cs @@ -1,55 +1,52 @@ -using System.Data; -using System.Linq; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Framework.SchemaBuilder; -using NSubstitute; -using NUnit.Framework; -namespace Migrator.Tests; - -[TestFixture] -public class SchemaBuilderTests -{ - [Test] - public void TableExecutesOnceWithCompletedColumnDefinitions() - { - var builder = new SchemaBuilder(); - builder.AddTable("Users").AddColumn("Id").OfType(DbType.Int32).WithProperty(ColumnProperty.PrimaryKey); - builder.AddColumn("Name").OfType(DbType.String).WithSize(100).WithDefaultValue("guest"); - var provider = Substitute.For(); - foreach (var expression in builder.Expressions) expression.Create(provider); - provider.Received(1).AddTable("Users", Arg.Is(fields => fields.Length == 2 - && ((Column)fields[0]).Name == "Id" && ((Column)fields[0]).Type == DbType.Int32 - && ((Column)fields[0]).IsPrimaryKey && ((Column)fields[1]).Name == "Name" - && ((Column)fields[1]).Type == DbType.String && ((Column)fields[1]).Size == 100 - && (string)((Column)fields[1]).DefaultValue == "guest")); - Assert.That(provider.ReceivedCalls().Count(call => call.GetMethodInfo().Name == "AddTable"), Is.EqualTo(1)); - Assert.That(provider.ReceivedCalls().Any(call => call.GetMethodInfo().Name == "AddColumn"), Is.False); - } - - [Test] - public void ForeignKeyExecutesAfterCompletedChildTableWithCorrectDirectionAndAction() - { - var builder = new SchemaBuilder(); - builder.AddTable("Child").AddColumn("ParentId").OfType(DbType.Int32) - .AsForeignKey().ReferencedTo("Parent", "Id").WithConstraint(ForeignKeyConstraintType.Cascade); - var provider = Substitute.For(); - foreach (var expression in builder.Expressions) expression.Create(provider); - Received.InOrder(() => - { - provider.AddTable("Child", Arg.Is(fields => fields.Length == 1 && ((Column)fields[0]).Name == "ParentId")); - provider.AddForeignKey("FK_Child_ParentId_Parent_Id", "Child", Arg.Is(names => names.SequenceEqual(new[] { "ParentId" })), - "Parent", Arg.Is(names => names.SequenceEqual(new[] { "Id" })), ForeignKeyConstraintType.Cascade); - }); - } - - [Test] - public void ExistingTableColumnUsesAddColumnWithAuthoredOptions() - { - var builder = new SchemaBuilder(); - builder.WithTable("Existing").AddColumn("Name").OfType(DbType.String).WithSize(80).WithDefaultValue("guest"); - var provider = Substitute.For(); - foreach (var expression in builder.Expressions) expression.Create(provider); - provider.Received(1).AddColumn("Existing", "Name", DbType.String, 80, ColumnProperty.None, "guest"); - Assert.That(provider.ReceivedCalls().Any(call => call.GetMethodInfo().Name == "AddTable"), Is.False); - } +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using NSubstitute; +using NUnit.Framework; +namespace Migrator.Tests; + +public class SchemaBuilderTests +{ + [Test] + public void TableExecutesOnceWithCompletedColumnAndConstraintDefinitions() + { + var builder = new MigrationBuilder(); + builder.Create.Table("Users").WithColumn("Id").AsInt32() + .WithColumn("Name").AsString(100).WithDefaultValue("guest") + .WithPrimaryKey("PK_Users", "Id"); + var provider = Substitute.For(); + builder.Apply(provider); + provider.Received(1).AddTable("Users", Arg.Is(fields => fields.Length == 3 + && ((Column)fields[0]).Name == "Id" && ((Column)fields[0]).Type == DbType.Int32 + && ((Column)fields[1]).Size == 100 && (string)((Column)fields[1]).DefaultValue == "guest" + && ((PrimaryKeyConstraint)fields[2]).KeyColumns.SequenceEqual(new[] { "Id" }))); + Assert.That(provider.ReceivedCalls().Any(call => call.GetMethodInfo().Name == "AddColumn"), Is.False); + } + [Test] + public void ForeignKeyExecutesAfterCompletedTableWithIndependentActions() + { + var builder = new MigrationBuilder(); + builder.Create.Table("Child").WithColumn("ParentId").AsInt32(); + builder.Create.ForeignKey("FK_Child", "Child", new[] { "ParentId" }, "Parent", new[] { "Id" }, + ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Restrict); + var provider = Substitute.For(); + builder.Apply(provider); + Received.InOrder(() => { + provider.AddTable("Child", Arg.Any()); + ((IForeignKeyActions)provider).AddForeignKey("FK_Child", "Child", Arg.Is(c => c.SequenceEqual(new[] { "ParentId" })), + "Parent", Arg.Is(c => c.SequenceEqual(new[] { "Id" })), ForeignKeyConstraintType.Cascade, ForeignKeyConstraintType.Restrict); + }); + } + [Test] + public void ExistingTableColumnRetainsAuthoredOptions() + { + var builder = new MigrationBuilder(); + builder.Create.Column("Name", "Existing").AsString(80).NotNullable().WithDefaultValue("guest"); + var provider = Substitute.For(); + builder.Apply(provider); + provider.Received(1).AddColumn("Existing", Arg.Is(c => c.Name == "Name" && c.Size == 80 + && !c.IsNullable && (string)c.DefaultValue == "guest")); + Assert.That(provider.ReceivedCalls().Any(call => call.GetMethodInfo().Name == "AddTable"), Is.False); + } } diff --git a/src/Migrator.Tests/SchemaConstraintTests.cs b/src/Migrator.Tests/SchemaConstraintTests.cs new file mode 100644 index 00000000..e512a9a7 --- /dev/null +++ b/src/Migrator.Tests/SchemaConstraintTests.cs @@ -0,0 +1,207 @@ +using System; +using DotNetProjects.Migrator; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers; +using NUnit.Framework; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; + +namespace Migrator.Tests; + +[Category("SQLite")] +public class SchemaConstraintTests +{ + [Test] + public void NamedCompositeConstraintsPreserveOrderAndEnforceWholeKeys() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + var first = new Column("First", DbType.Int32); + provider.AddTable("OrderedKeys", first, new Column("Second", DbType.Int32), new Column("Label", DbType.String), + new PrimaryKeyConstraint("Primary key", "Second", "First"), + new UniqueConstraint("Unique pair", "First", "Label"), + new CheckConstraint("Check label", "length(Label) > 0 AND instr(Label, ',') = 0")); + var constraints = provider.GetTableConstraints("OrderedKeys"); + Assert.That(constraints.OfType().Single().KeyColumns, Is.EqualTo(new[] { "Second", "First" })); + Assert.That(constraints.OfType().Single().Name, Is.EqualTo("Unique pair")); + Assert.That(constraints.OfType().Single().CheckConstraintString, Does.Contain("instr(Label, ',')")); + Assert.That(first.IsNullable, Is.True, "Creating a key must not mutate caller-owned columns."); + provider.ExecuteNonQuery("INSERT INTO OrderedKeys VALUES (1, 2, 'a'), (1, 3, 'b')"); + Assert.Catch(() => provider.ExecuteNonQuery("INSERT INTO OrderedKeys VALUES (1, 2, 'c')")); + Assert.Catch(() => provider.ExecuteNonQuery("INSERT INTO OrderedKeys VALUES (1, 4, 'a')")); + Assert.Catch(() => provider.ExecuteNonQuery("INSERT INTO OrderedKeys VALUES (NULL, 4, 'x')")); + Assert.Catch(() => provider.ExecuteNonQuery("INSERT INTO OrderedKeys VALUES (9, 9, 'a,b')")); + } + + [Test] + public void NamedIdentityKeyAndQuotedNamesRoundTrip() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("IdentityKeys", new Column("Id",DbType.Int32){IsIdentity = true}, + new PrimaryKeyConstraint("PK \"quoted\"", "Id")); + provider.ExecuteNonQuery("INSERT INTO IdentityKeys DEFAULT VALUES"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM IdentityKeys")), Is.EqualTo(1)); + Assert.That(provider.GetTableConstraints("IdentityKeys").Single().Name, Is.EqualTo("PK \"quoted\"")); + } + + [Test] + public void UnnamedLegacyConstraintsHaveNoInventedNamesAndUniqueIndexesStaySeparate() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.ExecuteNonQuery("CREATE TABLE \"Old'Table\" (Id INTEGER PRIMARY KEY, Value TEXT UNIQUE CHECK (length(Value) > 0))"); + provider.ExecuteNonQuery("CREATE UNIQUE INDEX ExtraIndex ON \"Old'Table\"(Value)"); + var constraints = provider.GetTableConstraints("Old'Table"); + Assert.That(constraints.Length, Is.EqualTo(3)); + Assert.That(constraints.All(c => c.Name == null), Is.True); + Assert.That(constraints.OfType().Single().KeyColumns, Is.EqualTo(new[] { "Value" })); + } + + [Test] + public void RebuildPreservesNamedKeyOrderAndColumnOrder() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("RebuiltKeys", new Column("First", DbType.Int32), new Column("Second", DbType.Int32), + new Column("Label", DbType.String, 20), new PrimaryKeyConstraint("PK ordered", "Second", "First")); + provider.ExecuteNonQuery("INSERT INTO RebuiltKeys VALUES (1, 2, 'kept')"); + provider.ChangeColumn("RebuiltKeys", new Column("First", DbType.Int64)); + var key = provider.GetTableConstraints("RebuiltKeys").OfType().Single(); + Assert.That(key.Name, Is.EqualTo("PK ordered")); + Assert.That(key.KeyColumns, Is.EqualTo(new[] { "Second", "First" })); + Assert.That(((DotNetProjects.Migrator.Providers.Impl.SQLite.SQLiteTransformationProvider)provider).GetPragmaTableInfoItems("RebuiltKeys").OrderBy(c => c.Cid).Select(c => c.Name), Is.EqualTo(new[] { "First", "Second", "Label" })); + Assert.That(provider.ExecuteScalar("SELECT Label FROM RebuiltKeys WHERE First=1 AND Second=2"), Is.EqualTo("kept")); + Assert.Catch(() => provider.ExecuteNonQuery("INSERT INTO RebuiltKeys VALUES (1, 2, 'duplicate')")); + Assert.Catch(() => provider.ExecuteNonQuery("INSERT INTO RebuiltKeys VALUES (NULL, 3, 'null')")); + Assert.Throws(() => provider.RemoveColumn("RebuiltKeys", "First")); + Assert.That(provider.ColumnExists("RebuiltKeys", "First"), Is.True); + provider.RemovePrimaryKey("RebuiltKeys"); + Assert.That(provider.GetTableConstraints("RebuiltKeys").OfType(), Is.Empty); + provider.ExecuteNonQuery("INSERT INTO RebuiltKeys VALUES (1, 2, 'allowed')"); + } + + [Test] + public void RebuildPreservesNamedIdentityAndSequenceHighWater() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("RebuiltIdentity", new Column("Id", DbType.Int32) { IsIdentity = true }, + new Column("Value", DbType.String, 20), new PrimaryKeyConstraint("PK identity", "Id")); + provider.ExecuteNonQuery("INSERT INTO RebuiltIdentity VALUES (40, 'removed')"); + provider.ExecuteNonQuery("DELETE FROM RebuiltIdentity"); + provider.ChangeColumn("RebuiltIdentity", new Column("Value", DbType.String, 40)); + provider.ExecuteNonQuery("INSERT INTO RebuiltIdentity (Value) VALUES ('next')"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM RebuiltIdentity")), Is.EqualTo(41)); + Assert.That(provider.GetTableConstraints("RebuiltIdentity").OfType().Single().Name, Is.EqualTo("PK identity")); + } + + [Test] + public void FluentNamedDefinitionsAreCompleteBeforeExecution() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + var builder = new MigrationBuilder(); + builder.Create.Table("FluentKeys").WithColumn("Id").AsInt32() + .WithPrimaryKey("PK_FluentKeys", "Id").WithUniqueConstraint("UQ_FluentKeys", "Id"); + Assert.That(builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite)).Single(), Does.Contain("CONSTRAINT \"PK_FluentKeys\" PRIMARY KEY")); + builder.Apply(provider); + Assert.That(provider.GetTableConstraints("FluentKeys").Length, Is.EqualTo(2)); + } + + [Test] + public void OfflineIdentityPreviewExecutesTheSameSchemaAsImperativeCreation() + { + var builder = new MigrationBuilder(); + builder.Create.Table("PreviewIdentity").WithColumn("Id").AsInt32().Identity() + .WithColumn("Value").AsString(20).WithCollation("NOCASE") + .WithPrimaryKey("PK_PreviewIdentity", "Id") + .WithUniqueConstraint("UQ_Value", "Value"); + var sql = builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite)).Single(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.ExecuteNonQuery(sql); + provider.ExecuteNonQuery("INSERT INTO PreviewIdentity (Value) VALUES ('Hello')"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT Id FROM PreviewIdentity")), Is.EqualTo(1)); + Assert.Catch(() => provider.ExecuteNonQuery("INSERT INTO PreviewIdentity (Value) VALUES ('HELLO')")); + Assert.That(provider.GetTableConstraints("PreviewIdentity").OfType().Single().Name, Is.EqualTo("PK_PreviewIdentity")); + } + + [Test] + public void RawDefaultsWorkInBothApisAndSurviveMetadataAndRebuild() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("RawImperative", + new Column("Id", DbType.Int32), + new Column("Token", DbType.String, 40) { DefaultValue = RawSql.Insert("lower(hex(randomblob(8)))") }, + new Column("Literal", DbType.String, 40) { DefaultValue = "lower(hex(randomblob(8)))" }); + var builder = new MigrationBuilder(); + builder.Create.Table("RawFluent").WithColumn("Id").AsInt32() + .WithColumn("Token").AsString(40).WithDefaultValue(RawSql.Insert("lower(hex(randomblob(8)))")) + .WithColumn("Literal").AsString(40).WithDefaultValue("lower(hex(randomblob(8)))"); + builder.Apply(provider); + foreach (var table in new[] { "RawImperative", "RawFluent" }) + { + var defaultExpression = provider.GetColumns(table).Single(c => c.Name == "Token").DefaultValue; + Assert.That(defaultExpression, Is.TypeOf()); + provider.ChangeColumn(table, new Column("Id", DbType.Int64)); + provider.ExecuteNonQuery("INSERT INTO " + table + " (Id) VALUES (1)"); + Assert.That(provider.ExecuteScalar("SELECT length(Token) FROM " + table), Is.EqualTo(16)); + Assert.That(provider.ExecuteScalar("SELECT Literal FROM " + table), Is.EqualTo("lower(hex(randomblob(8)))")); + } + var preview = new MigrationBuilder(); + preview.Create.Table("RawPreview").WithColumn("Token").AsString(40) + .WithDefaultValue(RawSql.Insert("lower(hex(randomblob(8)))")); + provider.ExecuteNonQuery(preview.Preview(new SqlGenerationContext(ProviderTypes.SQLite)).Single()); + provider.ExecuteNonQuery("INSERT INTO RawPreview DEFAULT VALUES"); + Assert.That(provider.ExecuteScalar("SELECT length(Token) FROM RawPreview"), Is.EqualTo(16)); + } + + [Test] + public void SemanticCollationDoesNotSilentlyDowngradeUnicodeToAscii() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + Assert.Throws(() => provider.AddTable("UnicodeNames", + new Column("Name", DbType.String, 40) { Collation = Collation.CaseInsensitive })); + Assert.That(provider.TableExists("UnicodeNames"), Is.False); + var builder = new MigrationBuilder(); + builder.Create.Table("AsciiNames").WithColumn("Name").AsString(40).WithCollation(Collation.AsciiIgnoreCase) + .WithUniqueConstraint("UQ_Ascii", "Name"); + provider.ExecuteNonQuery(builder.Preview(new SqlGenerationContext(ProviderTypes.SQLite)).Single()); + provider.Insert("AsciiNames", ["Name"], ["hello"]); + Assert.Catch(() => provider.Insert("AsciiNames", ["Name"], ["HELLO"])); + provider.Insert("AsciiNames", ["Name"], ["é"]); + provider.Insert("AsciiNames", ["Name"], ["É"]); + Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT COUNT(*) FROM AsciiNames")), Is.EqualTo(3)); + Assert.Throws(() => provider.ChangeColumn("AsciiNames", + new Column("Name", DbType.String, 80) { Collation = Collation.AsciiIgnoreCase })); + Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT COUNT(*) FROM AsciiNames")), Is.EqualTo(3)); + } + + [Test] + public void MetadataDoesNotConfuseConcatenatedExpressionsWithStringLiterals() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.AddTable("ConcatDefault", new Column("Id", DbType.Int32), + new Column("Value", DbType.String, 30) { DefaultValue = RawSql.Insert("'A' || 'B'") }); + Assert.That(provider.GetColumns("ConcatDefault").Single(c => c.Name == "Value").DefaultValue, Is.TypeOf()); + provider.ChangeColumn("ConcatDefault", new Column("Id", DbType.Int64)); + provider.ExecuteNonQuery("INSERT INTO ConcatDefault (Id) VALUES (1)"); + Assert.That(provider.ExecuteScalar("SELECT Value FROM ConcatDefault"), Is.EqualTo("AB")); + } + + [Test] + public void ConstraintTokenizerRecognizesCommentsAdjacentToKeywords() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + provider.ExecuteNonQuery("CREATE TABLE CommentedKey (Id INTEGER NOT NULL, Label TEXT, CONSTRAINT/*name*/pk PRIMARY/*kind*/KEY(Id))"); + Assert.That(provider.GetTableConstraints("CommentedKey").OfType().Single().Name, Is.EqualTo("pk")); + provider.ChangeColumn("CommentedKey", new Column("Label", DbType.String, 40)); + Assert.That(provider.GetTableConstraints("CommentedKey").OfType().Single().Name, Is.EqualTo("pk")); + provider.Insert("CommentedKey", ["Id"], [1]); + Assert.Catch(() => provider.Insert("CommentedKey", ["Id"], [1])); + } + + [Test] + public void InvalidKeyDefinitionsFailBeforeCreatingTheTable() + { + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + Assert.Throws(() => provider.AddTable("InvalidKey", new Column("Id", DbType.Int32), new PrimaryKeyConstraint("PK_Invalid", "Missing"))); + Assert.That(provider.TableExists("InvalidKey"), Is.False); + } +} diff --git a/src/Migrator.Tests/ScriptTests.cs b/src/Migrator.Tests/ScriptTests.cs index f1cbb8da..65fc2c42 100644 --- a/src/Migrator.Tests/ScriptTests.cs +++ b/src/Migrator.Tests/ScriptTests.cs @@ -1,39 +1,39 @@ -using System; -using System.IO; -using DotNetProjects.Migrator; -using DotNetProjects.Migrator.Framework; -using NUnit.Framework; -namespace Migrator.Tests; -public class ScriptTests -{ - [Test] - public void GoInsideMultilineStringsCommentsAndQuotedIdentifiersDoesNotSplit() - { - var batches = SqlScriptBatches.SplitSqlServer("SELECT 'line\nGO\n''quoted''';\nGO -- next batch\n/* outer\n/* nested */\nGO\n*/ SELECT [line\nGO\n]]name];\ngo\nSELECT 3;"); - Assert.That(batches.Count, Is.EqualTo(3)); - Assert.That(batches[0], Does.Contain("GO")); - Assert.That(batches[1], Does.Contain("GO")); - Assert.That(batches[2].Trim(), Is.EqualTo("SELECT 3;")); - } - [TestCase("GO 2")] - [TestCase(":r other.sql")] - [TestCase("!! echo value")] - public void UnsupportedClientCommandsAreRejectedBeforeExecution(string command) - => Assert.Throws(() => SqlScriptBatches.SplitSqlServer("SELECT 1;\nGO\n" + command)); - - [Test, Category("SQLite")] - public void ExplicitFileAndEmbeddedResourceScriptsPersistData() - { - using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); connection.Open(); - using var provider = ProviderFactory.Create(DotNetProjects.Migrator.Providers.ProviderTypes.SQLite, connection, null); - var file = Path.GetTempFileName(); - try - { - File.WriteAllText(file, "CREATE TABLE ScriptData (Id INTEGER); INSERT INTO ScriptData VALUES (1);"); - provider.ExecuteScript(file); - provider.ExecuteResourceScript(typeof(ScriptTests).Assembly, "Migrator.Tests.ScriptResource.sql"); - Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT SUM(Id) FROM ScriptData")), Is.EqualTo(3)); - } - finally { File.Delete(file); } - } +using System; +using System.IO; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using NUnit.Framework; +namespace Migrator.Tests; +public class ScriptTests +{ + [Test] + public void GoInsideMultilineStringsCommentsAndQuotedIdentifiersDoesNotSplit() + { + var batches = SqlScriptBatches.SplitSqlServer("SELECT 'line\nGO\n''quoted''';\nGO -- next batch\n/* outer\n/* nested */\nGO\n*/ SELECT [line\nGO\n]]name];\ngo\nSELECT 3;"); + Assert.That(batches.Count, Is.EqualTo(3)); + Assert.That(batches[0], Does.Contain("GO")); + Assert.That(batches[1], Does.Contain("GO")); + Assert.That(batches[2].Trim(), Is.EqualTo("SELECT 3;")); + } + [TestCase("GO 2")] + [TestCase(":r other.sql")] + [TestCase("!! echo value")] + public void UnsupportedClientCommandsAreRejectedBeforeExecution(string command) + => Assert.Throws(() => SqlScriptBatches.SplitSqlServer("SELECT 1;\nGO\n" + command)); + + [Test, Category("SQLite")] + public void ExplicitFileAndEmbeddedResourceScriptsPersistData() + { + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); connection.Open(); + using var provider = ProviderFactory.Create(DotNetProjects.Migrator.Providers.ProviderTypes.SQLite, connection, null); + var file = Path.GetTempFileName(); + try + { + File.WriteAllText(file, "CREATE TABLE ScriptData (Id INTEGER); INSERT INTO ScriptData VALUES (1);"); + provider.ExecuteScript(file); + provider.ExecuteResourceScript(typeof(ScriptTests).Assembly, "Migrator.Tests.ScriptResource.sql"); + Assert.That(Convert.ToInt64(provider.ExecuteScalar("SELECT SUM(Id) FROM ScriptData")), Is.EqualTo(3)); + } + finally { File.Delete(file); } + } } diff --git a/src/Migrator.Tests/Settings/ConfigurationReader.cs b/src/Migrator.Tests/Settings/ConfigurationReader.cs index b1214e61..a3bcc2ac 100644 --- a/src/Migrator.Tests/Settings/ConfigurationReader.cs +++ b/src/Migrator.Tests/Settings/ConfigurationReader.cs @@ -1,73 +1,73 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Extensions.Configuration; -using Migrator.Tests.Settings.Interfaces; -using Migrator.Tests.Settings.Models; - -namespace Migrator.Tests.Settings; - -/// -/// Reads the configuration from appsettings. -/// -public class ConfigurationReader() : IConfigurationReader -{ - private const string AspnetCoreVariableString = "ASPNETCORE_ENVIRONMENT"; - - /// - /// Gets the database connection config by its ID. - /// - /// Use one of the IDs in - /// - public DatabaseConnectionConfig GetDatabaseConnectionConfigById(string id) - { - var overrideConnection = Environment.GetEnvironmentVariable("MIGRATOR_" + id.ToUpperInvariant()); - if (!string.IsNullOrEmpty(overrideConnection)) - return new DatabaseConnectionConfig { Id = id, ConnectionString = overrideConnection }; - - var configurationRoot = GetConfigurationRoot(); - var aspNetCoreVariable = GetAspNetCoreEnvironmentVariable(); - - var databaseConnectionConfigs = configurationRoot.GetSection("DatabaseConnectionConfigs") - .Get>() ?? throw new KeyNotFoundException(); - - return databaseConnectionConfigs.SingleOrDefault(x => x.Id == id); - } - - /// - /// Gets the configuration root. Currently it is not used for production therefore we do not use appsettings.json. - /// Your personal appsettings.Development.json will be used if your ASPNETCORE_ENVIRONMENT env variable is set to "Development". - /// - /// - public IConfigurationRoot GetConfigurationRoot() - { - - var builder = new ConfigurationBuilder() - .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false); - var aspNetCoreVariableName = GetAspNetCoreEnvironmentVariable(); - +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Migrator.Tests.Settings.Interfaces; +using Migrator.Tests.Settings.Models; + +namespace Migrator.Tests.Settings; + +/// +/// Reads the configuration from appsettings. +/// +public class ConfigurationReader() : IConfigurationReader +{ + private const string AspnetCoreVariableString = "ASPNETCORE_ENVIRONMENT"; + + /// + /// Gets the database connection config by its ID. + /// + /// Use one of the IDs in + /// + public DatabaseConnectionConfig GetDatabaseConnectionConfigById(string id) + { + var overrideConnection = Environment.GetEnvironmentVariable("MIGRATOR_" + id.ToUpperInvariant()); + if (!string.IsNullOrEmpty(overrideConnection)) + return new DatabaseConnectionConfig { Id = id, ConnectionString = overrideConnection }; + + var configurationRoot = GetConfigurationRoot(); + var aspNetCoreVariable = GetAspNetCoreEnvironmentVariable(); + + var databaseConnectionConfigs = configurationRoot.GetSection("DatabaseConnectionConfigs") + .Get>() ?? throw new KeyNotFoundException(); + + return databaseConnectionConfigs.SingleOrDefault(x => x.Id == id); + } + + /// + /// Gets the configuration root. Currently it is not used for production therefore we do not use appsettings.json. + /// Your personal appsettings.Development.json will be used if your ASPNETCORE_ENVIRONMENT env variable is set to "Development". + /// + /// + public IConfigurationRoot GetConfigurationRoot() + { + + var builder = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false); + var aspNetCoreVariableName = GetAspNetCoreEnvironmentVariable(); + if (!string.IsNullOrEmpty(aspNetCoreVariableName)) { builder = builder.AddJsonFile($"appsettings.{aspNetCoreVariableName}.json", optional: true, reloadOnChange: false); - } - - return builder.Build(); - } - - private static string GetAspNetCoreEnvironmentVariable() - { - var aspNetCoreVariable = Environment.GetEnvironmentVariable(AspnetCoreVariableString, EnvironmentVariableTarget.Process); - - if (string.IsNullOrEmpty(aspNetCoreVariable)) - { - aspNetCoreVariable = Environment.GetEnvironmentVariable(AspnetCoreVariableString, EnvironmentVariableTarget.User); - } - else if (string.IsNullOrEmpty(aspNetCoreVariable)) - { - aspNetCoreVariable = Environment.GetEnvironmentVariable(AspnetCoreVariableString, EnvironmentVariableTarget.Machine); - } - - return aspNetCoreVariable; - } -} + } + + return builder.Build(); + } + + private static string GetAspNetCoreEnvironmentVariable() + { + var aspNetCoreVariable = Environment.GetEnvironmentVariable(AspnetCoreVariableString, EnvironmentVariableTarget.Process); + + if (string.IsNullOrEmpty(aspNetCoreVariable)) + { + aspNetCoreVariable = Environment.GetEnvironmentVariable(AspnetCoreVariableString, EnvironmentVariableTarget.User); + } + else if (string.IsNullOrEmpty(aspNetCoreVariable)) + { + aspNetCoreVariable = Environment.GetEnvironmentVariable(AspnetCoreVariableString, EnvironmentVariableTarget.Machine); + } + + return aspNetCoreVariable; + } +} diff --git a/src/Migrator.Tests/Support/JoiningTableTransformationProviderExtensions.cs b/src/Migrator.Tests/Support/JoiningTableTransformationProviderExtensions.cs index ec11d4eb..cab590ec 100644 --- a/src/Migrator.Tests/Support/JoiningTableTransformationProviderExtensions.cs +++ b/src/Migrator.Tests/Support/JoiningTableTransformationProviderExtensions.cs @@ -32,8 +32,8 @@ public static ITransformationProvider AddManyToManyJoiningTable(this ITransforma var joinRhsKey = Inflector.Singularize(rhsTableName) + "Id"; database.AddTable(joiningTableWithSchema, - new Column(joinLhsKey, DbType.Guid, ColumnProperty.NotNull), - new Column(joinRhsKey, DbType.Guid, ColumnProperty.NotNull)); + new Column(joinLhsKey,DbType.Guid){IsNullable = false}, + new Column(joinRhsKey,DbType.Guid){IsNullable = false}); var pkName = "PK_" + joiningTableName; diff --git a/src/Migrator.Tests/ToolingTests.cs b/src/Migrator.Tests/ToolingTests.cs new file mode 100644 index 00000000..7a81d65d --- /dev/null +++ b/src/Migrator.Tests/ToolingTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Data; +using System.IO; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Extensions.DependencyInjection; +using DotNetProjects.Migrator.Providers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using NSubstitute; +namespace Migrator.Tests; + +public class ToolingTests +{ + public sealed class Dependency { public bool Activated { get; set; } } + [Migration(900001, Scope = "tooling-spec")] + public class InjectedMigration(Dependency dependency) : Migration + { + public override void Up() { dependency.Activated = true; Database.AddTable("Injected", new Column("Id", DbType.Int32)); } + public override void Down() => Database.RemoveTable("Injected"); + } + [Test, Category("SQLite")] + public void DependencyInjectionResolvesConstructorAndOptions() + { + var services = new ServiceCollection(); var dependency = new Dependency(); + services.AddSingleton(dependency); + services.AddMigrator(_ => ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null, "tooling-spec"), typeof(ToolingTests).Assembly, + options => options.TransactionMode = MigrationTransactionMode.WholeSession); + using var container = services.BuildServiceProvider(); using var scope = container.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + runner.MigrateToLastVersion(); + Assert.That(dependency.Activated, Is.True); + Assert.That(scope.ServiceProvider.GetRequiredService().TableExists("Injected"), Is.True); + } + [TestCase(new[] { "bad-command" }, 2)] + [TestCase(new[] { "--help" }, 0)] + [TestCase(new[] { "rollback", "--provider", "SQLite" }, 2)] + public void CliReturnsMeaningfulArgumentExitCodes(string[] args, int exit) + { + using var output = new StringWriter(); using var error = new StringWriter(); + Assert.That(MigratorCommand.Run(args, output, error), Is.EqualTo(exit)); + } + [Migration(900002, Scope = "cli-spec")] + public class CliMigration : DotNetProjects.Migrator.Framework.Fluent.AutoReversingMigration + { + public override void BuildUp(DotNetProjects.Migrator.Framework.Fluent.MigrationBuilder migration) + => migration.Create.Table("CliExample").WithColumn("Id").AsInt32(); + } + [Test, Category("SQLite")] + public void CliMigratesReadsStatusAndRollsBackWithPackagedDriver() + { + var file = Path.Combine(Path.GetTempPath(), "migrator-cli-" + Guid.NewGuid().ToString("N") + ".db"); + var environmentName = "MIGRATOR_TEST_" + Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(environmentName, "Data Source=" + file + ";Pooling=False"); + try + { + foreach (var command in new[] { "migrate", "status", "rollback" }) + { + using var output = new StringWriter(); using var error = new StringWriter(); + var args = new System.Collections.Generic.List { command, "--assembly", typeof(ToolingTests).Assembly.Location, "--provider", "SQLite", "--scope", "cli-spec", "--connection-env", environmentName }; + if (command == "rollback") args.AddRange(new[] { "--target", "0" }); + Assert.That(MigratorCommand.Run(args.ToArray(), output, error), Is.Zero, error.ToString()); + if (command == "status") Assert.That(output.ToString(), Does.Contain("900002 applied")); + } + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + file + ";Pooling=False"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null, "cli-spec"); + Assert.That(provider.TableExists("CliExample"), Is.False); + Assert.That(((IMigrationHistory)provider).ReadAppliedMigrations(), Is.Empty); + } + finally { Environment.SetEnvironmentVariable(environmentName, null); File.Delete(file); } + } + [Test, Category("SQLite")] + public void RollbackCommandRejectsAnUpwardTargetWithoutCreatingUserTables() + { + var file = Path.Combine(Path.GetTempPath(), "migrator-rollback-" + Guid.NewGuid().ToString("N") + ".db"); + var variable = "MIGRATOR_TEST_" + Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(variable, "Data Source=" + file + ";Pooling=False"); + try + { + using var output = new StringWriter(); using var error = new StringWriter(); + Assert.That(MigratorCommand.Run(new[] { "rollback", "--assembly", typeof(ToolingTests).Assembly.Location, + "--provider", "SQLite", "--scope", "cli-spec", "--connection-env", variable, + "--target", "900002" }, output, error), Is.EqualTo(1)); + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + file + ";Pooling=False"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null, "cli-spec"); + Assert.That(provider.TableExists("CliExample"), Is.False); + Assert.That(((IMigrationHistory)provider).ReadAppliedMigrations(), Is.Empty); + } + finally { Environment.SetEnvironmentVariable(variable, null); File.Delete(file); } + } + [Migration(900003, Scope = "cli-errors")] + public class FailingCliMigration : Migration + { + internal static int Kind; + public override void Up() => throw Kind switch + { + 1 => new ArgumentException("SECRET_VALUE"), + 2 => new TimeoutException("SECRET_VALUE"), + _ => new NotSupportedException("SECRET_VALUE") + }; + public override void Down() => throw new NotSupportedException(); + } + [TestCase(1), TestCase(2), TestCase(3), Category("SQLite"), NonParallelizable] + public void CliClassifiesMigrationBodyExceptionsAsExecutionFailure(int kind) + { + var environmentName = "MIGRATOR_TEST_" + Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(environmentName, "Data Source=:memory:"); + FailingCliMigration.Kind = kind; + try + { + using var output = new StringWriter(); using var error = new StringWriter(); + var exit = MigratorCommand.Run(new[] { "migrate", "--assembly", typeof(ToolingTests).Assembly.Location, "--provider", "SQLite", "--scope", "cli-errors", "--connection-env", environmentName }, output, error); + Assert.That(exit, Is.EqualTo(1)); + Assert.That(error.ToString(), Does.Not.Contain("SECRET_VALUE")); + } + finally { Environment.SetEnvironmentVariable(environmentName, null); } + } + [Test] public void LoggingAdapterOmitsProviderMessagesAndDoesNotFormatSqlBraces() + { + var sink = NSubstitute.Substitute.For(); + var logger = new MigrationLogger(sink); + Assert.DoesNotThrow(() => logger.Log("SECRET_VALUE {")); + logger.Warn("SECRET_VALUE"); logger.Trace("SECRET_VALUE"); logger.ApplyingDBChange("SECRET_VALUE"); + logger.Exception("SECRET_VALUE", new Exception("SECRET_VALUE")); + foreach (var call in sink.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "Log")) + Assert.That(call.GetArguments()[2].ToString(), Does.Not.Contain("SECRET_VALUE")); + } + [Test] public void CliCanListWithoutOpeningDatabase() + { + using var output = new StringWriter(); using var error = new StringWriter(); + var exit = MigratorCommand.Run(new[] { "list", "--assembly", typeof(ToolingTests).Assembly.Location, "--provider", "SQLite", "--scope", "tooling-spec" }, output, error); + Assert.That(exit, Is.Zero, error.ToString()); + Assert.That(output.ToString(), Does.Contain("900001")); + } +} diff --git a/src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj b/src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj new file mode 100644 index 00000000..048b0656 --- /dev/null +++ b/src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj @@ -0,0 +1,12 @@ + + Exenet9.0enabletruemigratorDotNetProjects.Migrator.Tool13.0.0-preview.1MPL-1.1 + + + + + + + + + + diff --git a/src/Migrator.Tool/Program.cs b/src/Migrator.Tool/Program.cs new file mode 100644 index 00000000..93701fe1 --- /dev/null +++ b/src/Migrator.Tool/Program.cs @@ -0,0 +1,127 @@ +using System.Reflection; +using System.Runtime.Loader; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Loggers; +using DotNetProjects.Migrator.Providers; + +return MigratorCommand.Run(args, Console.Out, Console.Error); + +public static class MigratorCommand +{ + public static int Run(string[] args, TextWriter output, TextWriter error) + { + try { return Execute(args, output); } + catch (CliUsageException ex) { error.WriteLine("Invalid arguments: " + ex.Option + ". Use --help."); return 2; } + catch (UnsupportedMigrationFeatureException) { error.WriteLine("The requested operation is unsupported by this provider or preview mode."); return 3; } + catch (MigrationLockTimeoutException) { error.WriteLine("Migration lock acquisition timed out."); return 4; } + catch (Exception ex) { error.WriteLine("Migration command failed (" + ex.GetType().Name + "). Exception details are omitted because they may contain credentials or SQL values."); return 1; } + } + private sealed class CliUsageException(string option) : Exception { public string Option { get; } = option; } + private static int Execute(string[] args, TextWriter output) + { + if (args.Length == 0 || args.Contains("--help")) + { + output.WriteLine("migrator --assembly PATH --provider NAME"); + output.WriteLine("--connection-env NAME (default MIGRATOR_CONNECTION), --scope NAME, --schema NAME, --target VERSION"); + output.WriteLine("--tags a,b --tag-match Any|All --profiles a,b --transaction PerMigration|None|WholeSession"); + output.WriteLine("--timeout SECONDS --lock --lock-timeout SECONDS --output PATH --offline --allow-legacy-preview"); + output.WriteLine("rollback requires --target. Offline SQL assumes empty history. Legacy preview executes trusted arbitrary C#."); + return 0; + } + var command = args[0]; + if (!new[] { "list", "status", "validate", "migrate", "rollback", "plan", "sql" }.Contains(command)) throw new CliUsageException("command"); + var values = new Dictionary(StringComparer.Ordinal); + var flags = new HashSet { "--lock", "--offline", "--allow-legacy-preview" }; + var allowed = new HashSet { "--assembly", "--provider", "--connection-env", "--scope", "--schema", "--target", "--tags", "--tag-match", "--profiles", "--transaction", "--timeout", "--lock-timeout", "--output" }; + for (var i = 1; i < args.Length; i++) + { + var key = args[i]; + if (values.ContainsKey(key)) throw new CliUsageException(key); + if (flags.Contains(key)) values.Add(key, "true"); + else if (allowed.Contains(key) && i + 1 < args.Length && !args[i + 1].StartsWith("--")) values.Add(key, args[++i]); + else throw new CliUsageException(key); + } + string Value(string key, string fallback = null) => values.GetValueOrDefault(key, fallback); + T EnumValue(string key, string fallback) where T : struct, Enum => Enum.TryParse(Value(key, fallback), true, out var result) && Enum.IsDefined(result) ? result : throw new CliUsageException(key); + var providerType = EnumValue("--provider", "none"); + if (providerType == ProviderTypes.none) throw new CliUsageException("--provider"); + var assemblyPath = Path.GetFullPath(Value("--assembly") ?? throw new CliUsageException("--assembly")); + var resolver = new AssemblyDependencyResolver(assemblyPath); + Assembly Resolving(AssemblyLoadContext context, AssemblyName name) + { + var path = resolver.ResolveAssemblyToPath(name); + return path == null ? null : context.LoadFromAssemblyPath(path); + } + AssemblyLoadContext.Default.Resolving += Resolving; + try + { + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); + var scope = Value("--scope", "default"); + var types = MigrationLoader.GetMigrationTypes(assembly).Where(t => + (t.GetCustomAttribute()?.Scope ?? t.GetCustomAttribute()?.Scope ?? t.GetCustomAttribute()?.Scope) is not string ownScope || ownScope == scope).ToArray(); + var tags = Value("--tags", "").Split(',', StringSplitOptions.RemoveEmptyEntries); + var tagMatch = EnumValue("--tag-match", "Any"); + bool Selected(Type t) + { + var own = t.GetCustomAttribute()?.Tags ?? Array.Empty(); + return tags.Length == 0 || (tagMatch == TagMatchMode.All ? tags.All(own.Contains) : tags.Any(own.Contains)); + } + var versioned = types.Where(t => t.GetCustomAttribute() != null && Selected(t)).OrderBy(MigrationLoader.GetMigrationVersion).ToArray(); + var target = Value("--target") is { } targetString ? long.TryParse(targetString, out var parsed) && parsed >= 0 ? parsed : throw new CliUsageException("--target") : versioned.Select(MigrationLoader.GetMigrationVersion).DefaultIfEmpty(0).Max(); + if (command == "rollback" && !values.ContainsKey("--target")) throw new CliUsageException("--target"); + if (command == "list") + { + foreach (var type in versioned) output.WriteLine(MigrationLoader.GetMigrationVersion(type) + " " + type.FullName); + return 0; + } + if (values.ContainsKey("--offline")) + { + if (command != "sql" || values.ContainsKey("--profiles") || types.Any(t => t.GetCustomAttribute() != null)) throw new UnsupportedMigrationFeatureException("CLI operation is unsupported."); + var plan = MigrationPlanner.Create(versioned.Select(MigrationLoader.GetMigrationVersion), Array.Empty(), target); + var migrations = plan.Select(step => ((IMigration)Activator.CreateInstance(versioned.Single(t => MigrationLoader.GetMigrationVersion(t) == step.Version)), step.IsUp)); + Write(MigrationSqlPreview.Generate(providerType, migrations, values.ContainsKey("--allow-legacy-preview"))); + return 0; + } + var connectionString = Environment.GetEnvironmentVariable(Value("--connection-env", "MIGRATOR_CONNECTION")) ?? throw new CliUsageException("--connection-env"); + var providerName = providerType switch + { + ProviderTypes.SQLite => "Microsoft.Data.Sqlite", ProviderTypes.SqlServer or ProviderTypes.SqlServer2005 => "Microsoft.Data.SqlClient", + ProviderTypes.PostgreSQL or ProviderTypes.PostgreSQL82 => "Npgsql", ProviderTypes.Mysql or ProviderTypes.MariaDB => "MySql.Data.MySqlClient", + ProviderTypes.Oracle => "Oracle.ManagedDataAccess.Client", ProviderTypes.Firebird => "FirebirdSql.Data.FirebirdClient", + _ => throw new UnsupportedMigrationFeatureException("CLI operation is unsupported.") + }; + System.Data.Common.DbProviderFactories.RegisterFactory(providerName, providerType switch + { + ProviderTypes.SQLite => Microsoft.Data.Sqlite.SqliteFactory.Instance, + ProviderTypes.SqlServer or ProviderTypes.SqlServer2005 => Microsoft.Data.SqlClient.SqlClientFactory.Instance, + ProviderTypes.PostgreSQL or ProviderTypes.PostgreSQL82 => Npgsql.NpgsqlFactory.Instance, + ProviderTypes.Mysql or ProviderTypes.MariaDB => MySql.Data.MySqlClient.MySqlClientFactory.Instance, + ProviderTypes.Oracle => Oracle.ManagedDataAccess.Client.OracleClientFactory.Instance, + ProviderTypes.Firebird => FirebirdSql.Data.FirebirdClient.FirebirdClientFactory.Instance, + _ => throw new UnsupportedMigrationFeatureException("CLI operation is unsupported.") + }); + using var provider = ProviderFactory.Create(providerType, connectionString, Value("--schema"), scope, providerName); + if (values.ContainsKey("--timeout")) provider.CommandTimeout = Seconds("--timeout", "30"); + var runner = new Migrator(provider, false, new Logger(false), types); + runner.Options.Tags.UnionWith(tags); runner.Options.TagMatch = tagMatch; + runner.Options.Profiles.UnionWith(Value("--profiles", "").Split(',', StringSplitOptions.RemoveEmptyEntries)); + runner.Options.TransactionMode = EnumValue("--transaction", "PerMigration"); + if (values.ContainsKey("--lock")) runner.Options.Lock = new DatabaseMigrationLock(); + runner.Options.LockTimeout = TimeSpan.FromSeconds(Seconds("--lock-timeout", "30")); + switch (command) + { + case "status": foreach (var applied in ((IMigrationHistory)provider).ReadAppliedMigrations()) output.WriteLine(applied + " applied"); break; + case "validate": _ = runner.Plan(target); output.WriteLine("Migration plan is valid."); break; + case "plan": foreach (var step in runner.Plan(target)) output.WriteLine(step.Version + (step.IsUp ? " up" : " down")); break; + case "sql": Write(runner.PreviewSql(target, providerType, values.ContainsKey("--allow-legacy-preview"))); break; + case "rollback": runner.RollbackTo(target); output.WriteLine("Rollback completed."); break; + default: runner.MigrateTo(target); output.WriteLine("Migration completed."); break; + } + return 0; + int Seconds(string key, string fallback) => int.TryParse(Value(key, fallback), out var seconds) && seconds >= 0 ? seconds : throw new CliUsageException(key); + void Write(string sql) { if (Value("--output") is { } path) File.WriteAllText(path, sql); else output.WriteLine(sql); } + } + finally { AssemblyLoadContext.Default.Resolving -= Resolving; } + } +} diff --git a/src/Migrator/DatabaseMigrationLock.cs b/src/Migrator/DatabaseMigrationLock.cs new file mode 100644 index 00000000..d6e35d90 --- /dev/null +++ b/src/Migrator/DatabaseMigrationLock.cs @@ -0,0 +1,76 @@ +using System; +using System.Buffers.Binary; +using System.Data; +using System.Diagnostics; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.Mysql; +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +namespace DotNetProjects.Migrator; + +/// Session-owned database locks for SQL Server, PostgreSQL and MySQL/MariaDB. +public sealed class DatabaseMigrationLock : IMigrationLock +{ + public IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout) + { + if (timeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(timeout)); + var kind = provider.Dialect switch + { + SqlServerDialect => 0, PostgreSQLDialect => 1, MysqlDialect => 2, + _ => throw new UnsupportedMigrationFeatureException("Database migration locking is supported on SQL Server, PostgreSQL and MySQL/MariaDB.") + }; + var connection = provider.Connection; + if (connection.State != ConnectionState.Open) throw new MigrationException("Migration locking requires an open connection."); + var resource = "Migrator.NET:" + connection.Database + ":" + provider.SchemaInfoTable + ":" + scope; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(resource)); + object key = kind == 1 ? BinaryPrimitives.ReadInt64BigEndian(hash) : Convert.ToHexString(hash); + var acquire = kind switch + { + 0 => "DECLARE @result int; EXEC @result=sys.sp_getapplock @Resource=@key, @LockMode='Exclusive', @LockOwner='Session', @LockTimeout=0; SELECT @result", + 1 => "SELECT pg_try_advisory_lock(@key)", + _ => "SELECT GET_LOCK(@key, 0)" + }; + var release = kind switch + { + 0 => "DECLARE @result int; EXEC @result=sys.sp_releaseapplock @Resource=@key, @LockOwner='Session'; SELECT @result", + 1 => "SELECT pg_advisory_unlock(@key)", + _ => "SELECT RELEASE_LOCK(@key)" + }; + var watch = Stopwatch.StartNew(); + while (true) + { + var value = Scalar(connection, acquire, key); + if (value == null || value == DBNull.Value) throw new MigrationException("Database lock acquisition returned no result."); + var code = Convert.ToInt32(value, CultureInfo.InvariantCulture); + if (kind == 0 ? code >= 0 : code == 1) return new Lease(connection, release, key, kind); + if (kind == 0 && code != -1) throw new MigrationException("Database lock acquisition failed with code " + code); + if (watch.Elapsed >= timeout) throw new TimeoutException("Timed out acquiring the migration lock."); + Thread.Sleep((int)Math.Min(50, Math.Max(1, (timeout - watch.Elapsed).TotalMilliseconds))); + } + } + private static object Scalar(IDbConnection connection, string sql, object key) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; command.CommandTimeout = 30; + var parameter = command.CreateParameter(); parameter.ParameterName = "@key"; parameter.Value = key; + parameter.DbType = key is long ? DbType.Int64 : DbType.String; + command.Parameters.Add(parameter); + return command.ExecuteScalar(); + } + private sealed class Lease(IDbConnection connection, string release, object key, int kind) : IDisposable + { + private bool disposed; + public void Dispose() + { + if (disposed) return; + var value = Scalar(connection, release, key); + if (value == null || value == DBNull.Value || (kind == 0 ? Convert.ToInt32(value) < 0 : Convert.ToInt32(value) != 1)) + throw new MigrationException("The database did not confirm migration lock release."); + disposed = true; + } + } +} diff --git a/src/Migrator/DotNetProjects.Migrator.csproj b/src/Migrator/DotNetProjects.Migrator.csproj index 83bd3d3c..f285e83b 100644 --- a/src/Migrator/DotNetProjects.Migrator.csproj +++ b/src/Migrator/DotNetProjects.Migrator.csproj @@ -2,7 +2,9 @@ net9.0 - false + true + false + false DotNetProjects.Migrator DotNetProjects.Migrator latest @@ -16,9 +18,9 @@ True https://github.com/dotnetprojects/Migrator.NET MPL-1.1 - 9.0.0.0 - 9.0.0.0 - 9.0.0.0 + 13.0.0.0 + 13.0.0.0 + 13.0.0-preview.1 diff --git a/src/Migrator/Framework/CheckConstraint.cs b/src/Migrator/Framework/CheckConstraint.cs index d682fa9a..0845f89d 100644 --- a/src/Migrator/Framework/CheckConstraint.cs +++ b/src/Migrator/Framework/CheckConstraint.cs @@ -3,7 +3,7 @@ namespace DotNetProjects.Migrator.Framework; /// /// Currently only used for SQLite /// -public class CheckConstraint : IDbField +public class CheckConstraint : TableConstraint { public CheckConstraint() { } @@ -22,5 +22,4 @@ public CheckConstraint(string name, string checkConstraintText) /// /// Gets or sets the name of the CHECK constraint. /// - public string Name { get; set; } } diff --git a/src/Migrator/Framework/Collation.cs b/src/Migrator/Framework/Collation.cs new file mode 100644 index 00000000..fd55c71a --- /dev/null +++ b/src/Migrator/Framework/Collation.cs @@ -0,0 +1,28 @@ +using System; + +namespace DotNetProjects.Migrator.Framework; + +/// Comparison intent, resolved by the dialect. Linguistic ordering, +/// normalization and trailing-space behavior remain database-specific. +public sealed record Collation +{ + public CollationKind Kind { get; } + public string Name { get; } + private Collation(CollationKind kind, string name = null) { Kind = kind; Name = name; } + public static Collation Binary { get; } = new(CollationKind.Binary); + /// Unicode, case-sensitive and accent-sensitive comparison. + public static Collation CaseSensitive { get; } = new(CollationKind.CaseSensitive); + /// Unicode, case-insensitive and accent-sensitive comparison. + public static Collation CaseInsensitive { get; } = new(CollationKind.CaseInsensitive); + /// Fold ASCII A-Z only. Does not request Unicode case folding. + public static Collation AsciiIgnoreCase { get; } = new(CollationKind.AsciiIgnoreCase); + public static Collation Named(string name) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("A collation name is required.", nameof(name)); + return new(CollationKind.Named, name); + } + public static implicit operator Collation(string name) => name == null ? null : Named(name); + public override string ToString() => Name ?? Kind.ToString(); +} + +public enum CollationKind { Named, Binary, CaseSensitive, CaseInsensitive, AsciiIgnoreCase } diff --git a/src/Migrator/Framework/Column.cs b/src/Migrator/Framework/Column.cs index ca29f441..d6a1a022 100644 --- a/src/Migrator/Framework/Column.cs +++ b/src/Migrator/Framework/Column.cs @@ -50,38 +50,6 @@ public Column(string name, DbType type, object defaultValue) DefaultValue = defaultValue; } - public Column(string name, DbType type, ColumnProperty property) - { - Name = name; - Type = type; - ColumnProperty = property; - } - - public Column(string name, DbType type, int size, ColumnProperty property) - { - Name = name; - Type = type; - Size = size; - ColumnProperty = property; - } - - public Column(string name, DbType type, int size, ColumnProperty property, object defaultValue) - { - Name = name; - Type = type; - Size = size; - ColumnProperty = property; - DefaultValue = defaultValue; - } - - public Column(string name, DbType type, ColumnProperty property, object defaultValue) - { - Name = name; - Type = type; - ColumnProperty = property; - DefaultValue = defaultValue; - } - public Column(string name, MigratorDbType type) { Name = name; @@ -102,37 +70,12 @@ public Column(string name, MigratorDbType type, object defaultValue) DefaultValue = defaultValue; } - public Column(string name, MigratorDbType type, ColumnProperty property) - { - Name = name; - MigratorDbType = type; - ColumnProperty = property; - } - - public Column(string name, MigratorDbType type, int size, ColumnProperty property) - { - Name = name; - MigratorDbType = type; - Size = size; - ColumnProperty = property; - } + public Column(string name, DbType type, int size, object defaultValue) : this(name, type, size) { DefaultValue = defaultValue; } + public Column(string name, MigratorDbType type, int size, object defaultValue) : this(name, type, size) { DefaultValue = defaultValue; } - public Column(string name, MigratorDbType type, int size, ColumnProperty property, object defaultValue) - { - Name = name; - MigratorDbType = type; - Size = size; - ColumnProperty = property; - DefaultValue = defaultValue; - } - - public Column(string name, MigratorDbType type, ColumnProperty property, object defaultValue) - { - Name = name; - MigratorDbType = type; - ColumnProperty = property; - DefaultValue = defaultValue; - } + public bool IsNullable { get; set; } = true; + public bool IsUnsigned { get; set; } + public Collation Collation { get; set; } public string Name { get; set; } @@ -162,7 +105,6 @@ public DbType Type /// public int? Scale { get; set; } - public ColumnProperty ColumnProperty { get; set; } public object DefaultValue { @@ -181,18 +123,5 @@ public object DefaultValue } } - public bool IsIdentity - { - get { return (ColumnProperty & ColumnProperty.Identity) == ColumnProperty.Identity; } - } - - public bool IsPrimaryKey - { - get { return (ColumnProperty & ColumnProperty.PrimaryKey) == ColumnProperty.PrimaryKey; } - } - - public bool IsPrimaryKeyNonClustered - { - get { return (ColumnProperty & ColumnProperty.PrimaryKeyNonClustered) == ColumnProperty.PrimaryKeyNonClustered; } - } + public bool IsIdentity { get; set; } } diff --git a/src/Migrator/Framework/ColumnAttribute.cs b/src/Migrator/Framework/ColumnAttribute.cs new file mode 100644 index 00000000..a8736e5f --- /dev/null +++ b/src/Migrator/Framework/ColumnAttribute.cs @@ -0,0 +1,10 @@ +namespace DotNetProjects.Migrator.Framework; + +/// SQL clauses for column attributes; table constraints are modeled separately. +public enum ColumnAttribute +{ + Null, + NotNull, + Identity, + Unsigned +} diff --git a/src/Migrator/Framework/ColumnProperty.cs b/src/Migrator/Framework/ColumnProperty.cs deleted file mode 100644 index 75daca10..00000000 --- a/src/Migrator/Framework/ColumnProperty.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; - -namespace DotNetProjects.Migrator.Framework; - -/// -/// Represents a table column properties. -/// -[Flags] -public enum ColumnProperty -{ - None = 0, - - /// - /// Null is allowable - /// - Null = 1 << 0, - - /// - /// Null is not allowable - /// - NotNull = 1 << 1, - - /// - /// Identity column, autoinc - /// - Identity = 1 << 2, - - /// - /// Unique Column. This is marked being obsolete since you cannot add a name for the constraint which makes it difficult to remove the constraint again. - /// - [Obsolete("Use method 'AddUniqueConstraint' instead. This is marked being obsolete since you cannot add a name for the constraint which makes it difficult to remove the constraint again.")] - Unique = 1 << 3, - - /// - /// Indexed Column - /// - [Obsolete("Use method 'AddIndex'")] - Indexed = 1 << 4, - - /// - /// Unsigned Column. Not used in SQLite there is only one integer data type => INTEGER. - /// - Unsigned = 1 << 5, - - /// - /// CaseSensitive. Currently only used in SQLite, MySQL and SQL Server - /// - CaseSensitive = 1 << 6, - - // /// - // /// Foreign Key - // /// - // [Obsolete("Use method 'AddForeignKey' instead. The flag does not make sense on column level.")] - // ForeignKey = 1 << 7, - - /// - /// Primary Key. For compound PKs use AddPrimaryKey instead. - /// - [Obsolete("Use AddPrimaryKey instead.")] - PrimaryKey = 1 << 8, - - /// - /// Primary key with identity. This is shorthand for and - /// - PrimaryKeyWithIdentity = PrimaryKey | Identity, - - /// - /// Primary key non clustered. - /// - PrimaryKeyNonClustered = 1 << 10 | PrimaryKey -} diff --git a/src/Migrator/Framework/ColumnPropertyExtensions.cs b/src/Migrator/Framework/ColumnPropertyExtensions.cs deleted file mode 100644 index 989b11fb..00000000 --- a/src/Migrator/Framework/ColumnPropertyExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace DotNetProjects.Migrator.Framework; - -public static class ColumnPropertyExtensions -{ - public static bool IsSet(this ColumnProperty columnProperty, ColumnProperty flags) - { - return flags != 0 && columnProperty.HasFlag(flags); - } - - public static bool IsNotSet(this ColumnProperty columnProperty, ColumnProperty flags) - { - return flags == 0 || !columnProperty.HasFlag(flags); - } - - public static ColumnProperty Set(this ColumnProperty columnProperty, ColumnProperty flags) - { - return columnProperty | flags; - } - - public static ColumnProperty Clear(this ColumnProperty columnProperty, ColumnProperty flags) - { - return columnProperty & ~flags; - } -} \ No newline at end of file diff --git a/src/Migrator/Framework/Fluent/FluentMigration.cs b/src/Migrator/Framework/Fluent/FluentMigration.cs index 9bb7a5b5..a99c1b32 100644 --- a/src/Migrator/Framework/Fluent/FluentMigration.cs +++ b/src/Migrator/Framework/Fluent/FluentMigration.cs @@ -68,6 +68,7 @@ public sealed class TableInspector(ITransformationProvider provider, string tabl public Column Column(string name) => provider.GetColumnByName(table, name); public Index[] Indexes() => provider.GetIndexes(table); public ForeignKeyConstraint[] ForeignKeys() => provider.GetForeignKeyConstraints(table); + public TableConstraint[] ConstraintDefinitions() => provider.GetTableConstraints(table); public string[] Constraints() => (provider as DotNetProjects.Migrator.Providers.TransformationProvider)?.GetConstraints(table) ?? throw new NotSupportedException("Provider does not expose constraint enumeration."); public int ContentSize(string column) => provider.GetColumnContentSize(table, column); public int? NullableContentSize(string column) => provider.GetNullableColumnContentSize(table, column); diff --git a/src/Migrator/Framework/Fluent/MigrationBuilder.cs b/src/Migrator/Framework/Fluent/MigrationBuilder.cs index ae1a2023..d030c77d 100644 --- a/src/Migrator/Framework/Fluent/MigrationBuilder.cs +++ b/src/Migrator/Framework/Fluent/MigrationBuilder.cs @@ -60,8 +60,11 @@ public sealed class TableBuilder private Column current; private string engine; internal TableBuilder(MigrationBuilder builder, string name) => builder.Add(() => new CreateTableOperation(name, engine, fields.Select(Definitions.Copy).ToArray())); - public TableBuilder WithColumn(string name) { current = new Column(name, DbType.String, ColumnProperty.Null); fields.Add(current); return this; } + public TableBuilder WithColumn(string name) { current = new Column(name,DbType.String); fields.Add(current); return this; } public TableBuilder WithFields(params IDbField[] values) { fields.AddRange(values.Select(Definitions.Copy)); return this; } + public TableBuilder WithPrimaryKey(string name, params string[] columns) { fields.Add(new PrimaryKeyConstraint(name, columns)); return this; } + public TableBuilder WithUniqueConstraint(string name, params string[] columns) { fields.Add(new UniqueConstraint(name, columns)); return this; } + public TableBuilder WithCheckConstraint(string name, string expression) { fields.Add(new CheckConstraint(name, expression)); return this; } public TableBuilder WithEngine(string value) { engine = value; return this; } private Column Current => current ?? throw new InvalidOperationException("Call WithColumn first."); public TableBuilder OfType(DbType type) { Current.Type = type; return this; } @@ -75,17 +78,16 @@ public sealed class TableBuilder public TableBuilder WithSize(int size) { Current.Size = size; return this; } public TableBuilder WithPrecision(int precision, int scale) { Current.Precision = precision; Current.Scale = scale; return this; } public TableBuilder WithDefaultValue(object value) { Current.DefaultValue = value; return this; } - public TableBuilder WithProperty(ColumnProperty value) { Current.ColumnProperty = value; return this; } - public TableBuilder NotNullable() { Current.ColumnProperty = (Current.ColumnProperty & ~ColumnProperty.Null) | ColumnProperty.NotNull; return this; } - public TableBuilder Nullable() { Current.ColumnProperty = (Current.ColumnProperty & ~ColumnProperty.NotNull) | ColumnProperty.Null; return this; } - public TableBuilder PrimaryKey() { Current.ColumnProperty |= ColumnProperty.PrimaryKey; return NotNullable(); } - public TableBuilder Identity() { Current.ColumnProperty |= ColumnProperty.Identity; return this; } - public TableBuilder Unique() { Current.ColumnProperty |= ColumnProperty.Unique; return this; } + public TableBuilder NotNullable() { Current.IsNullable = false; return this; } + public TableBuilder Nullable() { Current.IsNullable = true; return this; } + public TableBuilder Unsigned() { Current.IsUnsigned = true; return this; } + public TableBuilder WithCollation(Collation name) { Current.Collation = name; return this; } + public TableBuilder Identity() { Current.IsIdentity = true; return this; } } public sealed class ColumnBuilder { private readonly Column column; - internal ColumnBuilder(MigrationBuilder builder, string table, string name, bool alter) { column = new Column(name, DbType.String, ColumnProperty.Null); builder.Add(() => new ColumnOperation(table, Definitions.CopyColumn(column), alter)); } + internal ColumnBuilder(MigrationBuilder builder, string table, string name, bool alter) { column = new Column(name,DbType.String); builder.Add(() => new ColumnOperation(table, Definitions.CopyColumn(column), alter)); } public ColumnBuilder OfType(DbType value) { column.Type = value; return this; } public ColumnBuilder OfType(MigratorDbType value) { column.MigratorDbType = value; return this; } public ColumnBuilder AsInt32() => OfType(DbType.Int32); @@ -94,11 +96,11 @@ public sealed class ColumnBuilder public ColumnBuilder WithSize(int value) { column.Size = value; return this; } public ColumnBuilder WithPrecision(int precision, int scale) { column.Precision = precision; column.Scale = scale; return this; } public ColumnBuilder WithDefaultValue(object value) { column.DefaultValue = value; return this; } - public ColumnBuilder WithProperty(ColumnProperty value) { column.ColumnProperty = value; return this; } - public ColumnBuilder NotNullable() { column.ColumnProperty = (column.ColumnProperty & ~ColumnProperty.Null) | ColumnProperty.NotNull; return this; } - public ColumnBuilder Nullable() { column.ColumnProperty = (column.ColumnProperty & ~ColumnProperty.NotNull) | ColumnProperty.Null; return this; } - public ColumnBuilder Identity() { column.ColumnProperty |= ColumnProperty.Identity; return this; } - public ColumnBuilder PrimaryKey() { column.ColumnProperty |= ColumnProperty.PrimaryKey; return NotNullable(); } + public ColumnBuilder NotNullable() { column.IsNullable = false; return this; } + public ColumnBuilder Nullable() { column.IsNullable = true; return this; } + public ColumnBuilder Unsigned() { column.IsUnsigned = true; return this; } + public ColumnBuilder WithCollation(Collation name) { column.Collation = name; return this; } + public ColumnBuilder Identity() { column.IsIdentity = true; return this; } } public sealed class AlterRoot(MigrationBuilder builder) { diff --git a/src/Migrator/Framework/Fluent/Operations.cs b/src/Migrator/Framework/Fluent/Operations.cs index 6232abc6..bfca7461 100644 --- a/src/Migrator/Framework/Fluent/Operations.cs +++ b/src/Migrator/Framework/Fluent/Operations.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Data; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; using System.Linq; using DotNetProjects.Migrator.Providers; using Index = DotNetProjects.Migrator.Framework.Index; @@ -30,14 +31,26 @@ public override void Apply(ITransformationProvider p) public override MigrationOperation Reverse() => new RemoveOperation(RemoveKind.Table, Table); public override string ToSql(SqlGenerationContext c) { - if (Engine != null || Fields.Any(f => f is not Column)) throw new NotSupportedException("Preview this table's constraints as separate operations."); - var columns = Fields.Cast().Select(Definitions.CopyColumn).ToArray(); - var pks = columns.Where(x => x.IsPrimaryKey).ToArray(); - if (pks.Length > 1) foreach (var column in pks) column.ColumnProperty &= ~ColumnProperty.PrimaryKey; + if (c.Provider is ProviderTypes.SQLite or ProviderTypes.MonoSQLite) + { + if (Engine != null || Fields.Any(f => f is Index)) + throw new NotSupportedException("Preview table indexes as separate operations; SQLite table engines are unsupported."); + var sql = DotNetProjects.Migrator.Providers.Impl.SQLite.SQLiteTableSql.Generate(c.Dialect, c.Table(Table), Fields); + c.AddTable(Table, Fields.OfType()); + return sql + ";"; + } + if (Engine != null || Fields.Any(f => f is not (Column or PrimaryKeyConstraint or UniqueConstraint or CheckConstraint))) throw new NotSupportedException("This table contains an unsupported preview definition."); + var columns = Fields.OfType().Select(Definitions.CopyColumn).ToArray(); + var primary = Fields.OfType().SingleOrDefault(); + if (primary != null) + { + foreach (var column in columns.Where(x => primary.KeyColumns.Contains(x.Name))) + column.IsNullable = false; + } var definitions = columns.Select(c.Column).ToList(); - if (pks.Length > 1) definitions.Add($"PRIMARY KEY ({string.Join(", ", pks.Select(x => c.Quote(x.Name)))})"); + definitions.AddRange(Fields.OfType().Select(c.Dialect.GetTableConstraintSql)); c.AddTable(Table, columns); - return $"CREATE TABLE {c.Table(Table)} ({string.Join(", ", definitions)});"; + return $"CREATE {(c.Provider == ProviderTypes.Hana ? "ROW " : "")}TABLE {c.Table(Table)} ({string.Join(", ", definitions)});"; } } public sealed record ColumnOperation(string Table, Column Column, bool Alter = false) : MigrationOperation @@ -49,7 +62,7 @@ public override string ToSql(SqlGenerationContext c) c.RequireTable(Table); if (Alter) throw new NotSupportedException("Altering columns needs provider-specific schema inspection; use explicit SQL preview."); c.AddColumn(Table, Column); - return $"ALTER TABLE {c.Table(Table)} ADD {c.Column(Column)};"; + return c.Provider == ProviderTypes.Hana ? $"ALTER TABLE {c.Table(Table)} ADD ({c.Column(Column)});" : $"ALTER TABLE {c.Table(Table)} ADD {c.Column(Column)};"; } } public enum RemoveKind { Table, Column, ForeignKey, Constraint, PrimaryKey, Default, Index, AllIndexes, AllConstraints, ForeignKeysForColumn, Truncate } @@ -238,13 +251,14 @@ public static class Definitions ViewJoin j => new ViewJoin(j.TableName, j.TableAlias, j.ColumnName, j.ParentTableName, j.ParentTableAlias, j.ParentColumnName, j.JoinType), _ => throw new NotSupportedException("Unknown view element.") }; - public static Column CopyColumn(Column c) => new(c.Name, c.Type, c.Size, c.ColumnProperty, c.DefaultValue is byte[] b ? b.Clone() : c.DefaultValue) { Precision = c.Precision, Scale = c.Scale, MigratorDbType = c.MigratorDbType }; + public static Column CopyColumn(Column c) => new(c.Name, c.Type, c.Size, c.DefaultValue is byte[] b ? b.Clone() : c.DefaultValue) { Precision = c.Precision, Scale = c.Scale, MigratorDbType = c.MigratorDbType, IsNullable = c.IsNullable, IsIdentity = c.IsIdentity, IsUnsigned = c.IsUnsigned, Collation = c.Collation }; public static IDbField Copy(IDbField field) => field switch { Column c => CopyColumn(c), Index i => new Index { Name = i.Name, Unique = i.Unique, Clustered = i.Clustered, KeyColumns = (string[])i.KeyColumns.Clone(), IncludeColumns = (string[])i.IncludeColumns.Clone(), FilterItems = i.FilterItems.Select(f => new DotNetProjects.Migrator.Providers.Models.Indexes.FilterItem { ColumnName = f.ColumnName, Filter = f.Filter, Value = f.Value }).ToList() }, ForeignKeyConstraint f => new ForeignKeyConstraint(f.Name, f.ParentTable, (string[])f.ParentColumns.Clone(), f.ChildTable, (string[])f.ChildColumns.Clone()) { OnDelete = f.OnDelete, OnUpdate = f.OnUpdate, Match = f.Match, Id = f.Id }, - Unique u => new Unique { Name = u.Name, KeyColumns = (string[])u.KeyColumns.Clone() }, + PrimaryKeyConstraint k => new PrimaryKeyConstraint(k.Name, k.KeyColumns) { NonClustered = k.NonClustered }, + UniqueConstraint u => new UniqueConstraint { Name = u.Name, KeyColumns = (string[])u.KeyColumns.Clone() }, CheckConstraint c => new CheckConstraint(c.Name, c.CheckConstraintString), _ => throw new NotSupportedException($"Cannot snapshot {field.GetType().Name}.") }; diff --git a/src/Migrator/Framework/ForeignKeyConstraint.cs b/src/Migrator/Framework/ForeignKeyConstraint.cs index 1a5af872..44569fed 100644 --- a/src/Migrator/Framework/ForeignKeyConstraint.cs +++ b/src/Migrator/Framework/ForeignKeyConstraint.cs @@ -1,6 +1,6 @@ namespace DotNetProjects.Migrator.Framework; -public class ForeignKeyConstraint : IDbField +public class ForeignKeyConstraint : TableConstraint { public ForeignKeyConstraint() { } @@ -9,9 +9,9 @@ public ForeignKeyConstraint(string name, string parentTable, string[] parentcolu { Name = name; ParentTable = parentTable; - ParentColumns = parentcolumns; + ParentColumns = (string[])parentcolumns.Clone(); ChildTable = childTable; - ChildColumns = childColumns; + ChildColumns = (string[])childColumns.Clone(); } /// @@ -19,7 +19,6 @@ public ForeignKeyConstraint(string name, string parentTable, string[] parentcolu /// Currently used for SQLite /// public int? Id { get; set; } - public string Name { get; set; } public string ParentTable { get; set; } public string[] ParentColumns { get; set; } public string ChildTable { get; set; } diff --git a/src/Migrator/Framework/IColumn.cs b/src/Migrator/Framework/IColumn.cs index 14b49729..caafd50c 100644 --- a/src/Migrator/Framework/IColumn.cs +++ b/src/Migrator/Framework/IColumn.cs @@ -17,7 +17,11 @@ namespace DotNetProjects.Migrator.Framework; public interface IColumn { - ColumnProperty ColumnProperty { get; set; } + bool IsNullable { get; set; } + bool IsUnsigned { get; set; } + Collation Collation { get; set; } + int? Precision { get; set; } + int? Scale { get; set; } string Name { get; set; } @@ -27,10 +31,8 @@ public interface IColumn int Size { get; set; } - bool IsIdentity { get; } + bool IsIdentity { get; set; } - bool IsPrimaryKey { get; } - bool IsPrimaryKeyNonClustered { get; } object DefaultValue { get; set; } } diff --git a/src/Migrator/Framework/IDialect.cs b/src/Migrator/Framework/IDialect.cs index ffe0cf78..b46df7e3 100644 --- a/src/Migrator/Framework/IDialect.cs +++ b/src/Migrator/Framework/IDialect.cs @@ -4,6 +4,12 @@ namespace DotNetProjects.Migrator.Framework; public interface IDialect { + string QuoteIdentifier(string name); + string GetCollationSql(string name); + string GetCollationSql(Collation collation); + + string GetTableConstraintSql(TableConstraint constraint); + int MaxKeyLength { get; } int MaxFieldNameLength { get; } bool ColumnNameNeedsQuote { get; } @@ -50,9 +56,9 @@ public interface IDialect /// The . DbType GetDbType(string databaseTypeName); - void RegisterProperty(ColumnProperty property, string sql); + void RegisterColumnAttribute(ColumnAttribute property, string sql); - string SqlForProperty(ColumnProperty property, Column column); + string SqlForColumnAttribute(ColumnAttribute property, Column column); string Default(object defaultValue); diff --git a/src/Migrator/Framework/ITransformationProvider.cs b/src/Migrator/Framework/ITransformationProvider.cs index 63fd04cd..a1a43116 100644 --- a/src/Migrator/Framework/ITransformationProvider.cs +++ b/src/Migrator/Framework/ITransformationProvider.cs @@ -10,6 +10,9 @@ namespace DotNetProjects.Migrator.Framework; /// public interface ITransformationProvider : IDisposable { + /// Read named table constraints with ordered key columns; indexes are separate objects. + TableConstraint[] GetTableConstraints(string table); + /// /// Get this provider or a NoOp provider if you are not running in the context of 'provider'. /// @@ -38,28 +41,6 @@ public interface ITransformationProvider : IDisposable /// ILogger Logger { get; set; } - /// - /// Add a column to an existing table - /// - /// The name of the table that will get the new column - /// The name of the new column - /// The data type for the new columnd - /// The precision or size of the column - /// Properties that can be ORed together - /// The default value of the column if no value is given in a query - void AddColumn(string table, string column, DbType type, int size, ColumnProperty property, object defaultValue); - - /// - /// Add a column to an existing table - /// - /// The name of the table that will get the new column - /// The name of the new column - /// The data type for the new columnd - /// The precision or size of the column - /// Properties that can be ORed together - /// The default value of the column if no value is given in a query - void AddColumn(string table, string column, MigratorDbType type, int size, ColumnProperty property, object defaultValue); - /// /// Add a column to an existing table /// @@ -94,44 +75,6 @@ public interface ITransformationProvider : IDisposable /// The precision or size of the column void AddColumn(string table, string column, MigratorDbType type, int size); - /// - /// Add a column to an existing table - /// - /// The name of the table that will get the new column - /// The name of the new column - /// The data type for the new columnd - /// The precision or size of the column - /// Properties that can be ORed together - void AddColumn(string table, string column, DbType type, int size, ColumnProperty property); - - /// - /// Add a column to an existing table - /// - /// The name of the table that will get the new column - /// The name of the new column - /// The data type for the new columnd - /// The precision or size of the column - /// Properties that can be ORed together - void AddColumn(string table, string column, MigratorDbType type, int size, ColumnProperty property); - - /// - /// Add a column to an existing table - /// - /// The name of the table that will get the new column - /// The name of the new column - /// The data type for the new columnd - /// Properties that can be ORed together - void AddColumn(string table, string column, DbType type, ColumnProperty property); - - /// - /// Add a column to an existing table - /// - /// The name of the table that will get the new column - /// The name of the new column - /// The data type for the new columnd - /// Properties that can be ORed together - void AddColumn(string table, string column, MigratorDbType type, ColumnProperty property); - /// /// Add a column to an existing table with the default column size. /// @@ -673,12 +616,6 @@ IDataReader SelectComplex(IDbCommand cmd, string table, string[] columns, string /// IDbCommand GetCommand(); - /// - /// Execute a schema builder - /// - /// - void ExecuteSchemaBuilder(SchemaBuilder.SchemaBuilder schemaBuilder); - void RemoveAllForeignKeys(string tableName, string columnName); diff --git a/src/Migrator/Framework/Index.cs b/src/Migrator/Framework/Index.cs index 5376a944..643a8cc3 100644 --- a/src/Migrator/Framework/Index.cs +++ b/src/Migrator/Framework/Index.cs @@ -17,7 +17,7 @@ public class Index : IDbField public bool Clustered { get; set; } /// - /// Indicates whether it is a primary key constraint. If you want to set a primary key use in + /// Indicates whether it is a primary key constraint. If you want to set a primary key use in /// public bool PrimaryKey { get; internal set; } diff --git a/src/Migrator/Framework/RawSql.cs b/src/Migrator/Framework/RawSql.cs new file mode 100644 index 00000000..5cd522b9 --- /dev/null +++ b/src/Migrator/Framework/RawSql.cs @@ -0,0 +1,19 @@ +using System; + +namespace DotNetProjects.Migrator.Framework; + +/// An explicit, trusted SQL expression used as a column default. +/// Expressions are provider-specific and are never quoted as string literals. +public sealed record RawSql +{ + public string Sql { get; } + private RawSql(string sql) + { + if (string.IsNullOrWhiteSpace(sql)) throw new ArgumentException("A SQL expression is required.", nameof(sql)); + Sql = sql; + } + + /// Insert an expression verbatim. Never pass untrusted input. + public static RawSql Insert(string sql) => new(sql); + public override string ToString() => Sql; +} diff --git a/src/Migrator/Framework/SchemaBuilder/AddColumnExpression.cs b/src/Migrator/Framework/SchemaBuilder/AddColumnExpression.cs deleted file mode 100644 index 3a070dd9..00000000 --- a/src/Migrator/Framework/SchemaBuilder/AddColumnExpression.cs +++ /dev/null @@ -1,39 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public class AddColumnExpression : ISchemaBuilderExpression -{ - private readonly IFluentColumn _column; - private readonly string _toTable; - - public AddColumnExpression(string toTable, IFluentColumn column) - { - _column = column; - _toTable = toTable; - } - - public void Create(ITransformationProvider provider) - { - provider.AddColumn(_toTable, _column.Name, _column.Type, _column.Size, _column.ColumnProperty, _column.DefaultValue); - - if (_column.ForeignKey != null) - { - provider.AddForeignKey( - "FK_" + _toTable + "_" + _column.Name + "_" + _column.ForeignKey.PrimaryTable + "_" + - _column.ForeignKey.PrimaryKey, - _toTable, _column.Name, _column.ForeignKey.PrimaryTable, _column.ForeignKey.PrimaryKey, _column.Constraint); - } - } -} \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs b/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs deleted file mode 100644 index ac3a39bf..00000000 --- a/src/Migrator/Framework/SchemaBuilder/AddTableExpression.cs +++ /dev/null @@ -1,36 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -using System.Collections.Generic; -using System.Linq; -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public class AddTableExpression : ISchemaBuilderExpression -{ - private readonly string _newTable; - public List Columns { get; } = new(); - - public AddTableExpression(string newTable) - { - _newTable = newTable; - } - - public void Create(ITransformationProvider provider) - { - var fields = Columns.Select(c => (IDbField)new Column(c.Name, c.Type, c.Size, c.ColumnProperty, c.DefaultValue)).ToList(); - provider.AddTable(_newTable, fields.ToArray()); - foreach (var c in Columns.Where(c => c.ForeignKey != null)) - provider.AddForeignKey("FK_" + _newTable + "_" + c.Name + "_" + c.ForeignKey.PrimaryTable + "_" + c.ForeignKey.PrimaryKey, - _newTable, new[] { c.Name }, c.ForeignKey.PrimaryTable, new[] { c.ForeignKey.PrimaryKey }, c.Constraint); - } -} diff --git a/src/Migrator/Framework/SchemaBuilder/DeleteTableExpression.cs b/src/Migrator/Framework/SchemaBuilder/DeleteTableExpression.cs deleted file mode 100644 index f6e928f0..00000000 --- a/src/Migrator/Framework/SchemaBuilder/DeleteTableExpression.cs +++ /dev/null @@ -1,29 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public class DeleteTableExpression : ISchemaBuilderExpression -{ - private readonly string _tableName; - - public DeleteTableExpression(string tableName) - { - _tableName = tableName; - } - - public void Create(ITransformationProvider provider) - { - provider.RemoveTable(_tableName); - } -} \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/FluentColumn.cs b/src/Migrator/Framework/SchemaBuilder/FluentColumn.cs deleted file mode 100644 index f6a39776..00000000 --- a/src/Migrator/Framework/SchemaBuilder/FluentColumn.cs +++ /dev/null @@ -1,81 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -using System.Data; - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public class FluentColumn : IFluentColumn -{ - private readonly Column _inner; - - public FluentColumn(string columnName) - { - _inner = new Column(columnName); - } - - public ColumnProperty ColumnProperty - { - get { return _inner.ColumnProperty; } - set { _inner.ColumnProperty = value; } - } - - public string Name - { - get { return _inner.Name; } - set { _inner.Name = value; } - } - - public DbType Type - { - get { return _inner.Type; } - set { _inner.Type = value; } - } - - public MigratorDbType MigratorDbType - { - get { return _inner.MigratorDbType; } - set { _inner.MigratorDbType = value; } - } - - public int Size - { - get { return _inner.Size; } - set { _inner.Size = value; } - } - - public bool IsIdentity - { - get { return _inner.IsIdentity; } - } - - public bool IsPrimaryKey - { - get { return _inner.IsPrimaryKey; } - } - - public object DefaultValue - { - get { return _inner.DefaultValue; } - set { _inner.DefaultValue = value; } - } - - public ForeignKeyConstraintType Constraint { get; set; } - - public ForeignKey ForeignKey { get; set; } - - public bool IsPrimaryKeyNonClustered - { - get { return _inner.IsPrimaryKeyNonClustered; } - } -} diff --git a/src/Migrator/Framework/SchemaBuilder/ForeignKey.cs b/src/Migrator/Framework/SchemaBuilder/ForeignKey.cs deleted file mode 100644 index e3318f82..00000000 --- a/src/Migrator/Framework/SchemaBuilder/ForeignKey.cs +++ /dev/null @@ -1,27 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public class ForeignKey -{ - public ForeignKey(string primaryTable, string primaryKey) - { - PrimaryTable = primaryTable; - PrimaryKey = primaryKey; - } - - public string PrimaryTable { get; set; } - - public string PrimaryKey { get; set; } -} \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/IColumnOptions.cs b/src/Migrator/Framework/SchemaBuilder/IColumnOptions.cs deleted file mode 100644 index 0b28b6aa..00000000 --- a/src/Migrator/Framework/SchemaBuilder/IColumnOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Data; - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public interface IColumnOptions -{ - SchemaBuilder OfType(DbType dbType); - - SchemaBuilder WithSize(int size); - - IForeignKeyOptions AsForeignKey(); -} \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/IDeleteTableOptions.cs b/src/Migrator/Framework/SchemaBuilder/IDeleteTableOptions.cs deleted file mode 100644 index 9b3581fd..00000000 --- a/src/Migrator/Framework/SchemaBuilder/IDeleteTableOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public interface IDeleteTableOptions -{ - SchemaBuilder WithTable(string name); - - SchemaBuilder AddTable(string name); - - IDeleteTableOptions DeleteTable(string name); -} \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/IFluentColumn.cs b/src/Migrator/Framework/SchemaBuilder/IFluentColumn.cs deleted file mode 100644 index e5a90e0a..00000000 --- a/src/Migrator/Framework/SchemaBuilder/IFluentColumn.cs +++ /dev/null @@ -1,21 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public interface IFluentColumn : IColumn -{ - ForeignKeyConstraintType Constraint { get; set; } - - ForeignKey ForeignKey { get; set; } -} \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/IForeignKeyOptions.cs b/src/Migrator/Framework/SchemaBuilder/IForeignKeyOptions.cs deleted file mode 100644 index ecd4ecd8..00000000 --- a/src/Migrator/Framework/SchemaBuilder/IForeignKeyOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public interface IForeignKeyOptions -{ - SchemaBuilder ReferencedTo(string primaryKeyTable, string primaryKeyColumn); -} \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/ISchemaBuilderExpression.cs b/src/Migrator/Framework/SchemaBuilder/ISchemaBuilderExpression.cs deleted file mode 100644 index fe616f96..00000000 --- a/src/Migrator/Framework/SchemaBuilder/ISchemaBuilderExpression.cs +++ /dev/null @@ -1,19 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public interface ISchemaBuilderExpression -{ - void Create(ITransformationProvider provider); -} \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/RenameTableExpression.cs b/src/Migrator/Framework/SchemaBuilder/RenameTableExpression.cs deleted file mode 100644 index 15625861..00000000 --- a/src/Migrator/Framework/SchemaBuilder/RenameTableExpression.cs +++ /dev/null @@ -1,31 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public class RenameTableExpression : ISchemaBuilderExpression -{ - private readonly string _newName; - private readonly string _oldName; - - public RenameTableExpression(string oldName, string newName) - { - _oldName = oldName; - _newName = newName; - } - - public void Create(ITransformationProvider provider) - { - provider.RenameTable(_oldName, _newName); - } -} \ No newline at end of file diff --git a/src/Migrator/Framework/SchemaBuilder/SchemaBuilder.cs b/src/Migrator/Framework/SchemaBuilder/SchemaBuilder.cs deleted file mode 100644 index 9aae0607..00000000 --- a/src/Migrator/Framework/SchemaBuilder/SchemaBuilder.cs +++ /dev/null @@ -1,190 +0,0 @@ -#region License - -//The contents of this file are subject to the Mozilla Public License -//Version 1.1 (the "License"); you may not use this file except in -//compliance with the License. You may obtain a copy of the License at -//http://www.mozilla.org/MPL/ -//Software distributed under the License is distributed on an "AS IS" -//basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the -//License for the specific language governing rights and limitations -//under the License. - -#endregion - -using System; -using System.Collections.Generic; -using System.Data; - -namespace DotNetProjects.Migrator.Framework.SchemaBuilder; - -public class SchemaBuilder : IColumnOptions, IForeignKeyOptions, IDeleteTableOptions -{ - private readonly IList _exprs; - private IFluentColumn _currentColumn; - private string _currentTable; - private AddTableExpression _creatingTable; - - public SchemaBuilder() - { - _exprs = new List(); - } - - public IEnumerable Expressions - { - get { return _exprs; } - } - - public SchemaBuilder OfType(DbType columnType) - { - _currentColumn.Type = columnType; - - return this; - } - - public SchemaBuilder WithSize(int size) - { - if (size == 0) - { - throw new ArgumentNullException("size", "Size must be greater than zero"); - } - - _currentColumn.Size = size; - - return this; - } - - public IForeignKeyOptions AsForeignKey() - { - return this; - } - - /// - /// Adds a Table to be created to the Schema - /// - /// Table name to be created - /// SchemaBuilder for chaining - public SchemaBuilder AddTable(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException("name"); - } - - _creatingTable = new AddTableExpression(name); - _exprs.Add(_creatingTable); - _currentTable = name; - - return this; - } - - public IDeleteTableOptions DeleteTable(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException("name"); - } - - _creatingTable = null; - _currentTable = ""; - _currentColumn = null; - - _exprs.Add(new DeleteTableExpression(name)); - - return this; - } - - /// - /// Reference an existing table. - /// - /// Table to reference - /// SchemaBuilder for chaining - public SchemaBuilder WithTable(string name) - { - _creatingTable = null; - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException("name"); - } - - _currentTable = name; - - return this; - } - - public SchemaBuilder ReferencedTo(string primaryKeyTable, string primaryKeyColumn) - { - _currentColumn.Constraint = ForeignKeyConstraintType.NoAction; - _currentColumn.ForeignKey = new ForeignKey(primaryKeyTable, primaryKeyColumn); - return this; - } - - /// - /// Reference an existing table. - /// - /// Table to reference - /// SchemaBuilder for chaining - public SchemaBuilder RenameTable(string newName) - { - if (string.IsNullOrEmpty(newName)) - { - throw new ArgumentNullException("newName"); - } - - _creatingTable = null; - _exprs.Add(new RenameTableExpression(_currentTable, newName)); - _currentTable = newName; - - return this; - } - - /// - /// Adds a Column to be created - /// - /// Column name to be added - /// IColumnOptions to restrict chaining - public IColumnOptions AddColumn(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException("name"); - } - - if (string.IsNullOrEmpty(_currentTable)) - { - throw new ArgumentException("missing referenced table"); - } - - IFluentColumn column = new FluentColumn(name); - _currentColumn = column; - - if (_creatingTable != null) _creatingTable.Columns.Add(column); - else _exprs.Add(new AddColumnExpression(_currentTable, column)); - return this; - } - - public SchemaBuilder WithProperty(ColumnProperty columnProperty) - { - _currentColumn.ColumnProperty = columnProperty; - - return this; - } - - public SchemaBuilder WithDefaultValue(object defaultValue) - { - if (defaultValue == null) - { - throw new ArgumentNullException("defaultValue", "DefaultValue cannot be null or empty"); - } - - _currentColumn.DefaultValue = defaultValue; - - return this; - } - - public SchemaBuilder WithConstraint(ForeignKeyConstraintType action) - { - _currentColumn.Constraint = action; - - return this; - } -} \ No newline at end of file diff --git a/src/Migrator/Framework/TableConstraint.cs b/src/Migrator/Framework/TableConstraint.cs new file mode 100644 index 00000000..0baf6ce0 --- /dev/null +++ b/src/Migrator/Framework/TableConstraint.cs @@ -0,0 +1,24 @@ +namespace DotNetProjects.Migrator.Framework; + +/// A table-level constraint. Column order is significant for keys. +public abstract class TableConstraint : IDbField +{ + public string Name { get; set; } +} + +public sealed class PrimaryKeyConstraint : TableConstraint +{ + public PrimaryKeyConstraint() { } + public PrimaryKeyConstraint(string name, params string[] columns) + { Name = name; KeyColumns = (string[])columns.Clone(); } + public string[] KeyColumns { get; set; } = []; + public bool NonClustered { get; set; } +} + +public class UniqueConstraint : TableConstraint +{ + public UniqueConstraint() { } + public UniqueConstraint(string name, params string[] columns) + { Name = name; KeyColumns = (string[])columns.Clone(); } + public string[] KeyColumns { get; set; } = []; +} diff --git a/src/Migrator/Framework/Unique.cs b/src/Migrator/Framework/Unique.cs deleted file mode 100644 index 6ad3ce93..00000000 --- a/src/Migrator/Framework/Unique.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace DotNetProjects.Migrator.Framework; - -public class Unique : IDbField -{ - public string Name { get; set; } - - public string[] KeyColumns { get; set; } -} diff --git a/src/Migrator/MigrationExecution.cs b/src/Migrator/MigrationExecution.cs index eb7c148b..9a50aca3 100644 --- a/src/Migrator/MigrationExecution.cs +++ b/src/Migrator/MigrationExecution.cs @@ -7,29 +7,48 @@ namespace DotNetProjects.Migrator; internal static class MigrationExecution { - internal static void Execute(ITransformationProvider provider, IMigration migration, MigrationStep step, ILogger logger) + internal static void Execute(ITransformationProvider provider, IMigration migration, MigrationStep step, ILogger logger, + bool transaction = true, bool inSession = false, bool recordHistory = true, bool callbacks = true) { var concrete = provider as TransformationProvider; - if (concrete?.HasActiveTransaction == true) + try + { + void Body() + { + if (concrete != null) concrete.CurrentMigration = migration; + if (step.IsUp) { logger.MigrateUp(step.Version, migration.Name); migration.Up(); } + else { logger.MigrateDown(step.Version, migration.Name); migration.Down(); } + if (provider is SQLiteTransformationProvider sqlite && !sqlite.CheckForeignKeyIntegrity()) + throw new MigrationException("Migration would leave invalid SQLite foreign keys."); + if (recordHistory) + { + var scope = migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope; + if (step.IsUp) provider.MigrationApplied(step.Version, scope); + else provider.MigrationUnApplied(step.Version, scope); + } + } + if (inSession) Body(); else InTransaction(provider, transaction, Body); + } + catch (Exception ex) { logger.Exception(step.Version, migration.Name, ex); throw; } + finally { if (concrete != null) concrete.CurrentMigration = null; } + // Session callbacks are deferred until the outer transaction commits. + if (callbacks) After(provider, migration, step.IsUp); + } + + internal static void InTransaction(ITransformationProvider provider, bool transaction, Action body) + { + if ((provider as TransformationProvider)?.HasActiveTransaction == true) throw new MigrationException("The runner cannot take ownership of an existing provider transaction."); var sqlite = provider as SQLiteTransformationProvider; - var foreignKeys = sqlite?.IsPragmaForeignKeysOn() == true; + var foreignKeys = transaction && sqlite?.IsPragmaForeignKeysOn() == true; Exception failure = null; var began = false; try { if (foreignKeys) sqlite.SetPragmaForeignKeys(false); - provider.BeginTransaction(); - began = true; - if (concrete != null) concrete.CurrentMigration = migration; - if (step.IsUp) { logger.MigrateUp(step.Version, migration.Name); migration.Up(); } - else { logger.MigrateDown(step.Version, migration.Name); migration.Down(); } - if (sqlite != null && !sqlite.CheckForeignKeyIntegrity()) - throw new MigrationException("Migration would leave invalid SQLite foreign keys."); - if (step.IsUp) provider.MigrationApplied(step.Version, migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope); - else provider.MigrationUnApplied(step.Version, migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope); - provider.Commit(); - began = false; + if (transaction) { provider.BeginTransaction(); began = true; } + body(); + if (transaction) { provider.Commit(); began = false; } } catch (Exception ex) { @@ -39,21 +58,18 @@ internal static void Execute(ITransformationProvider provider, IMigration migrat try { provider.Rollback(); } catch (Exception rollback) { ex.Data["RollbackException"] = rollback; } } - logger.Exception(step.Version, migration.Name, ex); throw; } finally { - if (concrete != null) concrete.CurrentMigration = null; try { if (foreignKeys) sqlite.SetPragmaForeignKeys(true); } catch (Exception restore) { if (failure == null) throw; failure.Data["ConnectionRestoreException"] = restore; } + (provider as IMigrationHistory)?.InvalidateHistory(); } - // These callbacks intentionally run after commit; failure cannot be rolled back. - After(provider, migration, step.IsUp); } internal static void After(ITransformationProvider provider, IMigration migration, bool up) { diff --git a/src/Migrator/MigrationLoader.cs b/src/Migrator/MigrationLoader.cs index 4be11204..932164b3 100644 --- a/src/Migrator/MigrationLoader.cs +++ b/src/Migrator/MigrationLoader.cs @@ -26,7 +26,7 @@ public MigrationLoader(ITransformationProvider provider, Assembly migrationAssem provider.Logger.Trace("Loaded migrations:"); foreach (var t in _migrationsTypes) { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); + provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); } } } @@ -41,7 +41,7 @@ public MigrationLoader(ITransformationProvider provider, bool trace, params Type provider.Logger.Trace("Loaded migrations:"); foreach (var t in _migrationsTypes) { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); + provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); } } } @@ -70,9 +70,14 @@ public virtual long LastVersion } } + public Func Activator { get; set; } + public IEnumerable SelectedTypes => _migrationsTypes.Where(t => - _provider is not IMigrationHistory history || - t.GetCustomAttribute()?.Scope is not string scope || scope == history.Scope); + t.GetCustomAttribute() != null && InScope(t.GetCustomAttribute().Scope)); + + internal bool InScope(string scope) => scope == null || _provider is not IMigrationHistory history || scope == history.Scope; + internal IEnumerable AuxiliaryTypes => _migrationsTypes.Where(t => t.GetCustomAttribute() == null); + public virtual void AddMigrations(Assembly migrationAssembly) { @@ -112,26 +117,13 @@ public static List GetMigrationTypes(Assembly asm) var migrations = new List(); foreach (var t in asm.GetExportedTypes()) { - - -#if NETSTANDARD - var attrib = t.GetTypeInfo().GetCustomAttribute(); - if (attrib != null && typeof(IMigration).GetTypeInfo().IsAssignableFrom(t) && !attrib.Ignore) - { + if (t.IsAbstract || !typeof(IMigration).IsAssignableFrom(t)) continue; + var versioned = t.GetCustomAttribute(); + if (versioned != null ? !versioned.Ignore : + t.GetCustomAttribute() != null || t.GetCustomAttribute() != null) migrations.Add(t); - } -#else - var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); - if (attrib != null && typeof(IMigration).IsAssignableFrom(t) && !attrib.Ignore) - { - migrations.Add(t); - } -#endif - - } - - migrations.Sort(new MigrationTypeComparer(true)); + migrations = migrations.OrderBy(t => t.GetCustomAttribute()?.Version ?? 0).ThenBy(t => t.FullName, StringComparer.Ordinal).ToList(); return migrations; } @@ -149,8 +141,7 @@ public static long GetMigrationVersion(Type t) public List GetAvailableMigrations() { - _migrationsTypes.Sort(new MigrationTypeComparer(true)); - return SelectedTypes.Select(GetMigrationVersion).ToList(); + return SelectedTypes.Select(GetMigrationVersion).OrderBy(v => v).ToList(); } public virtual IMigration GetMigration(long version) @@ -170,6 +161,6 @@ public virtual IMigration GetMigration(long version) public virtual IMigration CreateInstance(Type migrationType) { - return (IMigration)Activator.CreateInstance(migrationType); + return Activator != null ? Activator(migrationType) ?? throw new MigrationException("Migration activator returned null.") : (IMigration)System.Activator.CreateInstance(migrationType); } } diff --git a/src/Migrator/MigrationSqlPreview.cs b/src/Migrator/MigrationSqlPreview.cs new file mode 100644 index 00000000..46cfbc37 --- /dev/null +++ b/src/Migrator/MigrationSqlPreview.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers; +namespace DotNetProjects.Migrator; + +public static class MigrationSqlPreview +{ + /// Generates SQL without a database. C# authoring code still executes and must be trusted. + public static string Generate(ProviderTypes provider, IEnumerable<(IMigration Migration, bool Up)> migrations, + bool allowLegacyBodies = false, Func existingTables = null) + { + var context = new SqlGenerationContext(provider, existingTables); + var sql = new List(); + foreach (var (migration, up) in migrations) + { + var initialization = migration.GetType().GetInterfaceMap(typeof(IMigration)); + var initializeIndex = Array.FindIndex(initialization.InterfaceMethods, m => m.Name == nameof(IMigration.InitializeOnce)); + if (initialization.TargetMethods[initializeIndex].DeclaringType != typeof(Migration)) + throw new UnsupportedMigrationFeatureException("Preview rejects migrations with an InitializeOnce hook because executing initialization would violate read-only preview semantics."); + var original = migration.Database; + var proxy = DispatchProxy.Create(); + var recorder = (PreviewProvider)(object)proxy; + try + { + migration.Database = proxy; + IReadOnlyList operations; + if (migration is FluentMigration fluent) operations = fluent.GetOperations(up); + else + { + if (!allowLegacyBodies) throw new UnsupportedMigrationFeatureException("Imperative SQL preview requires explicit allowLegacyBodies opt-in. Arbitrary C# cannot be sandboxed."); + if (up) migration.Up(); else migration.Down(); + operations = recorder.Operations; + } + foreach (var operation in operations) + { + try { sql.Add(operation.ToSql(context)); } + catch (NotSupportedException ex) { throw new UnsupportedMigrationFeatureException("This operation cannot be previewed.", ex); } + } + } + finally { migration.Database = original; } + } + return string.Join(Environment.NewLine, sql.Where(s => !string.IsNullOrWhiteSpace(s))); + } + + // Every method is denied unless explicitly mapped to a captured operation. No connection is exposed. + public class PreviewProvider : DispatchProxy + { + internal readonly List Operations = new(); + protected override object Invoke(MethodInfo method, object[] args) + { + MigrationOperation operation = method.Name switch + { + "AddTable" when args.Length == 2 && args[1] is IDbField[] fields => new CreateTableOperation((string)args[0], null, fields.Select(Definitions.Copy).ToArray()), + "AddColumn" when args.Length == 2 && args[1] is Column column => new ColumnOperation((string)args[0], Definitions.CopyColumn(column)), + "RemoveTable" => new RemoveOperation(RemoveKind.Table, (string)args[0]), + "RenameTable" => new RenameOperation((string)args[0], (string)args[1]), + "RenameColumn" => new RenameOperation((string)args[0], (string)args[2], (string)args[1]), + "Insert" when args.Length == 3 && args[1] is string[] columns && args[2] is object[] values => new DataOperation(DataKind.Insert, (string)args[0], (string[])columns.Clone(), (object[])values.Clone()), + "ExecuteNonQuery" when args.Length == 1 => new SqlOperation((string)args[0]), + _ => throw new UnsupportedMigrationFeatureException("SQL preview blocks provider member " + method.Name + ". Use a structured operation or an explicit SQL script.") + }; + Operations.Add(operation); + return method.ReturnType == typeof(int) ? 0 : null; + } + } +} diff --git a/src/Migrator/Migrator.cs b/src/Migrator/Migrator.cs index f900dd5c..9d94f03c 100644 --- a/src/Migrator/Migrator.cs +++ b/src/Migrator/Migrator.cs @@ -26,6 +26,7 @@ namespace DotNetProjects.Migrator; /// public class Migrator { + public RunnerOptions Options { get; init; } = new(); private readonly MigrationLoader _migrationLoader; private readonly ITransformationProvider _provider; @@ -182,12 +183,14 @@ public long? LastAppliedMigrationVersion /// public void MigrateToLastVersion() { - if (_migrationLoader.GetAvailableMigrations().Count == 0) + var versions = SelectedMigrationTypes.Select(MigrationLoader.GetMigrationVersion).ToArray(); + if (versions.Length == 0 && Options.Profiles.Count == 0 && + !_migrationLoader.AuxiliaryTypes.Any(t => t.GetCustomAttribute() is { } a && _migrationLoader.InScope(a.Scope))) { Logger.Warn("No migrations found for the effective scope."); return; } - MigrateTo(_migrationLoader.LastVersion); + MigrateTo(versions.DefaultIfEmpty(0).Max(), false, versions.Length == 0); } /// @@ -201,43 +204,143 @@ public void MigrateToLastVersion() /// If dryrun is set, don't write any changes to the database. /// /// The version that must became the current one - public IReadOnlyList Plan(long version) + private IEnumerable SelectedMigrationTypes => _migrationLoader.SelectedTypes.Where(t => + { + if (Options.Tags.Count == 0) return true; + var tags = t.GetCustomAttribute()?.Tags ?? Array.Empty(); + return Options.TagMatch == TagMatchMode.All ? Options.Tags.All(tags.Contains) : Options.Tags.Any(tags.Contains); + }); + + private IReadOnlyList CreatePlan(IEnumerable applied, long version) { _migrationLoader.CheckForDuplicatedVersion(); + var selected = SelectedMigrationTypes.Select(MigrationLoader.GetMigrationVersion).ToHashSet(); + var known = _migrationLoader.GetAvailableMigrations().ToHashSet(); + // Filtered migrations stay applied; unknown history must still fail a downgrade. + return MigrationPlanner.Create(selected, applied.Where(v => selected.Contains(v) || !known.Contains(v)), version); + } + + public IReadOnlyList Plan(long version) + { if (_provider is not IMigrationHistory history) throw new NotSupportedException("Read-only planning requires IMigrationHistory on custom providers."); - return MigrationPlanner.Create(_migrationLoader.GetAvailableMigrations(), history.ReadAppliedMigrations(), version); + return CreatePlan(history.ReadAppliedMigrations(), version); } - public void MigrateTo(long version) + public string PreviewSql(long version, ProviderTypes provider, bool allowLegacyBodies = false) { - _migrationLoader.CheckForDuplicatedVersion(); - var history = DryRun - ? _provider is IMigrationHistory reader ? reader.ReadAppliedMigrations().ToList() - : throw new NotSupportedException("DryRun requires IMigrationHistory on custom providers.") - : new List(_provider.AppliedMigrations); - var plan = MigrationPlanner.Create(_migrationLoader.GetAvailableMigrations(), history, version); - Logger.Started(history, version); - var firstRun = true; + _migrationLoader.Activator = Options.Activator; + var plan = Plan(version); + var migrations = new List<(IMigration, bool)>(); + void AddMaintenance(MaintenanceStage stage) + { + foreach (var type in _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } a && a.Stage == stage && _migrationLoader.InScope(a.Scope)) + .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal)) + migrations.Add((_migrationLoader.CreateInstance(type), true)); + } + AddMaintenance(MaintenanceStage.BeforeRun); foreach (var step in plan) { - if (DryRun) + AddMaintenance(MaintenanceStage.BeforeMigration); + migrations.Add((_migrationLoader.GetMigration(step.Version), step.IsUp)); + AddMaintenance(MaintenanceStage.AfterMigration); + } + foreach (var name in Options.Profiles) + if (!_migrationLoader.AuxiliaryTypes.Any(t => t.GetCustomAttribute() is { } a && a.Name == name && _migrationLoader.InScope(a.Scope))) + throw new MigrationException("Unknown profile: " + name); + foreach (var type in _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } a && Options.Profiles.Contains(a.Name) && _migrationLoader.InScope(a.Scope)) + .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal)) + migrations.Add((_migrationLoader.CreateInstance(type), true)); + AddMaintenance(MaintenanceStage.AfterRun); + return MigrationSqlPreview.Generate(provider, migrations, allowLegacyBodies, + table => _provider.TableExists(table) ? _provider.GetColumns(table) : throw new MigrationException("Preview table does not exist: " + table)); + } + + public void MigrateTo(long version) => MigrateTo(version, false); + + /// Run only downward steps; validate the target after acquiring the configured lock. + public void RollbackTo(long version) => MigrateTo(version, true); + + private void MigrateTo(long version, bool downOnly, bool preserveVersion = false) + { + if (DryRun) + { + var preview = preserveVersion ? Array.Empty() : Plan(version); + if (downOnly && preview.Any(step => step.IsUp)) throw new MigrationException("Rollback cannot apply upward migrations."); + foreach (var step in preview) + if (step.IsUp) Logger.MigrateUp(step.Version, "Preview"); else Logger.MigrateDown(step.Version, "Preview"); + return; + } + if (Options.LockTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(Options.LockTimeout)); + var session = Options.TransactionMode == MigrationTransactionMode.WholeSession; + if (session && _provider.Dialect is not (Providers.Impl.SQLite.SQLiteDialect or Providers.Impl.PostgreSQL.PostgreSQLDialect or Providers.Impl.SqlServer.SqlServerDialect)) + throw new UnsupportedMigrationFeatureException("Whole-session transactions require a verified transactional DDL provider (SQLite, PostgreSQL or SQL Server)."); + _migrationLoader.Activator = Options.Activator; + IDisposable AcquireLock() + { + try { return Options.Lock?.Acquire(_provider, (_provider as IMigrationHistory)?.Scope, Options.LockTimeout); } + catch (TimeoutException ex) { throw new MigrationLockTimeoutException(ex); } + } + var lease = AcquireLock(); + Exception failure = null; + try + { + (_provider as IMigrationHistory)?.InvalidateHistory(); + var history = new List(_provider.AppliedMigrations); + var initialHistory = new List(history); + if (preserveVersion) version = history.DefaultIfEmpty(0).Max(); + var plan = CreatePlan(history, version); + if (downOnly && (version >= history.DefaultIfEmpty(0).Max() || plan.Any(step => step.IsUp))) + throw new MigrationException("Rollback requires a lower target and cannot apply upward migrations."); + var profiles = _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } p && Options.Profiles.Contains(p.Name) && _migrationLoader.InScope(p.Scope)) + .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal).ToArray(); + foreach (var name in Options.Profiles) + if (!profiles.Any(t => t.GetCustomAttribute().Name == name)) throw new MigrationException("Unknown profile: " + name); + var afterCommit = new List(); + var firstRun = true; + void Execute(IMigration migration, MigrationStep step, bool record) { - if (step.IsUp) Logger.MigrateUp(step.Version, "Preview"); - else Logger.MigrateDown(step.Version, "Preview"); - continue; + migration.Database = _provider; + if (firstRun) { migration.InitializeOnce(_args); firstRun = false; } + MigrationExecution.Execute(_provider, migration, step, Logger, + Options.TransactionMode == MigrationTransactionMode.PerMigration, session, record, !session); + if (session) afterCommit.Add(() => MigrationExecution.After(_provider, migration, step.IsUp)); } - var migration = _migrationLoader.GetMigration(step.Version); - if (firstRun) + void Maintenance(MaintenanceStage stage) { - migration.InitializeOnce(_args); - firstRun = false; + foreach (var type in _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } a && a.Stage == stage && _migrationLoader.InScope(a.Scope)) + .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal)) + Execute(_migrationLoader.CreateInstance(type), new MigrationStep(0, true), false); + } + void Run() + { + Maintenance(MaintenanceStage.BeforeRun); + foreach (var step in plan) + { + Maintenance(MaintenanceStage.BeforeMigration); + Execute(_migrationLoader.GetMigration(step.Version), step, true); + if (step.IsUp) history.Add(step.Version); else history.Remove(step.Version); + Maintenance(MaintenanceStage.AfterMigration); + } + foreach (var type in profiles) Execute(_migrationLoader.CreateInstance(type), new MigrationStep(0, true), false); + Maintenance(MaintenanceStage.AfterRun); + } + Logger.Started(new List(initialHistory), version); + if (session) MigrationExecution.InTransaction(_provider, true, Run); else Run(); + foreach (var callback in afterCommit) callback(); + history.Sort(); + Logger.Finished(new List(initialHistory), version); + } + catch (Exception ex) { failure = ex; throw; } + finally + { + try { lease?.Dispose(); } + catch (Exception release) + { + if (failure == null) throw; + failure.Data["LockReleaseException"] = release; } - MigrationExecution.Execute(_provider, migration, step, Logger); - if (step.IsUp) history.Add(step.Version); - else history.Remove(step.Version); } - history.Sort(); - Logger.Finished(history, version); } } + diff --git a/src/Migrator/ProviderFactory.cs b/src/Migrator/ProviderFactory.cs index acb1fc2b..df7f7931 100644 --- a/src/Migrator/ProviderFactory.cs +++ b/src/Migrator/ProviderFactory.cs @@ -50,6 +50,8 @@ public static Dialect DialectForProvider(ProviderTypes providerType) { switch (providerType) { + case ProviderTypes.Hana: + return new DotNetProjects.Migrator.Providers.Impl.Hana.HanaDialect(); case ProviderTypes.SQLite: return (Dialect)Activator.CreateInstance(typeof(SQLiteDialect)); case ProviderTypes.MonoSQLite: diff --git a/src/Migrator/Providers/CatalogDefaultValue.cs b/src/Migrator/Providers/CatalogDefaultValue.cs index d8ada5be..422ee9e8 100644 --- a/src/Migrator/Providers/CatalogDefaultValue.cs +++ b/src/Migrator/Providers/CatalogDefaultValue.cs @@ -1,6 +1,7 @@ using System; using System.Data; using System.Globalization; +using DotNetProjects.Migrator.Framework; namespace DotNetProjects.Migrator.Providers; @@ -8,21 +9,18 @@ namespace DotNetProjects.Migrator.Providers; // from expression objects. Keep expressions unquoted when a column is recreated. internal static class CatalogDefaultValue { - private sealed record Expression(string Sql) - { - public override string ToString() => Sql; - } - internal static object Parse(string source, DbType type) { var value = source.Trim(); while (HasOuterParentheses(value)) value = value[1..^1].Trim(); if (value.Equals("NULL", StringComparison.OrdinalIgnoreCase)) return null; - if (value.StartsWith("'") && value.EndsWith("'")) + if (value.StartsWith("N'", StringComparison.OrdinalIgnoreCase)) value = value[1..]; + if (System.Text.RegularExpressions.Regex.IsMatch(value, @"\A'(?:[^']|'')*'\z")) { 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.Guid && Guid.TryParse(literal, out var guid)) return guid; return literal; } if (type == DbType.Boolean) @@ -30,6 +28,10 @@ internal static object Parse(string source, DbType type) if (bool.TryParse(value, out var boolean)) return boolean; if (value is "0" or "1") return value == "1"; } + if (type == DbType.SByte && sbyte.TryParse(value, CultureInfo.InvariantCulture, out var signedByte)) return signedByte; + if (type == DbType.UInt16 && ushort.TryParse(value, CultureInfo.InvariantCulture, out var unsignedSmall)) return unsignedSmall; + if (type == DbType.UInt32 && uint.TryParse(value, CultureInfo.InvariantCulture, out var unsignedInteger)) return unsignedInteger; + if (type == DbType.UInt64 && ulong.TryParse(value, CultureInfo.InvariantCulture, out var unsignedLarge)) return unsignedLarge; if (type == DbType.Byte && byte.TryParse(value, CultureInfo.InvariantCulture, out var tiny)) return tiny; if (type == DbType.Int16 && short.TryParse(value, CultureInfo.InvariantCulture, out var small)) return small; if (type == DbType.Int32 && int.TryParse(value, CultureInfo.InvariantCulture, out var integer)) return integer; @@ -37,7 +39,7 @@ internal static object Parse(string source, DbType type) if (type is DbType.Decimal or DbType.VarNumeric or DbType.Currency && decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) return number; if (type == DbType.Double && double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var floating)) return floating; if (type == DbType.Single && float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var real)) return real; - return new Expression(value); + return RawSql.Insert(source.Trim()); } private static bool HasOuterParentheses(string value) diff --git a/src/Migrator/Providers/ColumnPropertiesMapper.cs b/src/Migrator/Providers/ColumnPropertiesMapper.cs index da3e6213..5b4e0a3c 100644 --- a/src/Migrator/Providers/ColumnPropertiesMapper.cs +++ b/src/Migrator/Providers/ColumnPropertiesMapper.cs @@ -1,251 +1,73 @@ +using System; using System.Collections.Generic; using DotNetProjects.Migrator.Framework; namespace DotNetProjects.Migrator.Providers; -/// -/// This is basically a just a helper base class -/// per-database implementors may want to override ColumnSql -/// +/// Renders column attributes. Keys, constraints and indexes belong to the table definition. public class ColumnPropertiesMapper { - /// - /// the type of the column - /// protected string _ColumnSql; - - /// - /// Sql if this column has a default value - /// protected object _DefaultVal; - protected Dialect _Dialect; - - /// - /// Sql if This column is Indexed - /// - protected bool _Indexed; - - /// The name of the column protected string _Name; - - /// The SQL type - public string Type { get; private set; } - - public ColumnPropertiesMapper(Dialect dialect, string typeString) - { - _Dialect = dialect; - Type = typeString; - } - - /// - /// The sql for this column, override in database-specific implementation classes - /// - public virtual string ColumnSql - { - get { return _ColumnSql; } - } - - public string Name - { - get { return _Name; } - set { _Name = value; } - } - - public object Default - { - get { return _DefaultVal; } - set { _DefaultVal = value; } - } - - public string QuotedName - { - get { return _Dialect.Quote(Name); } - } - - public string IndexSql - { - get - { - if (_Dialect.SupportsIndex && _Indexed) - { - return string.Format("INDEX({0})", _Dialect.Quote(_Name)); - } - - return null; - } - } - - public virtual void MapColumnProperties(Column column) + public string Type { get; } + public ColumnPropertiesMapper(Dialect dialect, string typeString) { _Dialect = dialect; Type = typeString; } + public virtual string ColumnSql => _ColumnSql; + public string Name { get => _Name; set => _Name = value; } + public object Default { get => _DefaultVal; set => _DefaultVal = value; } + public string QuotedName => _Dialect.QuoteIdentifier(Name); + public virtual void MapColumnProperties(Column column) => Map(column, true); + public virtual void MapColumnPropertiesWithoutDefault(Column column) => Map(column, false); + private void Map(Column column, bool includeDefault) { Name = column.Name; - - _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); - - var vals = new List(); - - AddName(vals); - - AddType(vals); - - AddCaseSensitive(column, vals); - - AddIdentity(column, vals); - - AddUnsigned(column, vals); - - AddNotNull(column, vals); - - AddNull(column, vals); - - AddPrimaryKey(column, vals); - - AddPrimaryKeyNonClustered(column, vals); - - AddIdentityAgain(column, vals); - - AddUnique(column, vals); - - AddForeignKey(column, vals); - - AddDefaultValue(column, vals); - - _ColumnSql = string.Join(" ", vals.ToArray()); + var values = new List(); + AddName(values); AddType(values); AddCollation(column, values); + AddIdentity(column, values); AddUnsigned(column, values); + AddNotNull(column, values); AddNull(column, values); + AddIdentityAgain(column, values); + if (includeDefault) AddDefaultValue(column, values); + _ColumnSql = string.Join(" ", values); } - - public virtual void MapColumnPropertiesWithoutDefault(Column column) + protected virtual void AddCollation(Column column, List values) { - Name = column.Name; - - _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); - - var vals = new List(); - - AddName(vals); - - AddType(vals); - - AddCaseSensitive(column, vals); - - AddIdentity(column, vals); - - AddUnsigned(column, vals); - - AddNotNull(column, vals); - - AddNull(column, vals); - - AddPrimaryKey(column, vals); - - AddIdentityAgain(column, vals); - - AddPrimaryKeyNonClustered(column, vals); - - AddUnique(column, vals); - - AddForeignKey(column, vals); - - _ColumnSql = string.Join(" ", vals.ToArray()); - } - - protected virtual void AddCaseSensitive(Column column, List vals) - { - AddValueIfSelected(column, ColumnProperty.CaseSensitive, vals); - } - - protected virtual void AddDefaultValue(Column column, List vals) - { - if (column.DefaultValue != null) - { - vals.Add(_Dialect.Default(column.DefaultValue)); - } - } - - protected virtual void AddForeignKey(Column column, List vals) - { - // TODO Does that really make sense? - // AddValueIfSelected(column, ColumnProperty.ForeignKey, vals); - } - - protected virtual void AddUnique(Column column, List vals) - { - AddValueIfSelected(column, ColumnProperty.Unique, vals); - } - - protected virtual void AddIdentityAgain(Column column, List vals) - { - if (_Dialect.IdentityNeedsType) - { - AddValueIfSelected(column, ColumnProperty.Identity, vals); - } - } - protected virtual void AddPrimaryKeyNonClustered(Column column, List vals) - { - if (_Dialect.SupportsNonClustered) - { - AddValueIfSelected(column, ColumnProperty.PrimaryKeyNonClustered, vals); - } - } - protected virtual void AddPrimaryKey(Column column, List vals) - { - AddValueIfSelected(column, ColumnProperty.PrimaryKey, vals); - } - - protected virtual void AddNull(Column column, List vals) - { - if (!PropertySelected(column.ColumnProperty, ColumnProperty.PrimaryKey)) - { - if (_Dialect.NeedsNullForNullableWhenAlteringTable) - { - AddValueIfSelected(column, ColumnProperty.Null, vals); - } - } - } - - protected virtual void AddNotNull(Column column, List vals) - { - if (!PropertySelected(column.ColumnProperty, ColumnProperty.Null) && (!PropertySelected(column.ColumnProperty, ColumnProperty.PrimaryKey) || _Dialect.NeedsNotNullForIdentity)) + if (column.Collation != null) { - AddValueIfSelected(column, ColumnProperty.NotNull, vals); + if (column.Type is not (System.Data.DbType.String or System.Data.DbType.AnsiString or System.Data.DbType.StringFixedLength or System.Data.DbType.AnsiStringFixedLength)) + throw new NotSupportedException("Collation requires a text column."); + values.Add(_Dialect.GetCollationSql(column.Collation)); } } - - protected virtual void AddUnsigned(Column column, List vals) + protected virtual void AddDefaultValue(Column column, List values) { - if (_Dialect.IsUnsignedCompatible(column.Type)) - { - AddValueIfSelected(column, ColumnProperty.Unsigned, vals); - } + if (column.DefaultValue != null) values.Add(_Dialect.Default(column.DefaultValue)); } - - protected virtual void AddIdentity(Column column, List vals) + protected virtual void AddIdentity(Column column, List values) { - if (!_Dialect.IdentityNeedsType) - { - AddValueIfSelected(column, ColumnProperty.Identity, vals); - } + if (!_Dialect.IdentityNeedsType && column.IsIdentity) values.Add(_Dialect.SqlForColumnAttribute(ColumnAttribute.Identity, column)); } - - protected virtual void AddType(List vals) + protected virtual void AddIdentityAgain(Column column, List values) { - vals.Add(Type); + if (_Dialect.IdentityNeedsType && column.IsIdentity) values.Add(_Dialect.SqlForColumnAttribute(ColumnAttribute.Identity, column)); } - - protected virtual void AddName(List vals) + protected virtual void AddNull(Column column, List values) { - vals.Add(_Dialect.ColumnNameNeedsQuote || _Dialect.IsReservedWord(Name) ? QuotedName : Name); + if (column.IsNullable && _Dialect.NeedsNullForNullableWhenAlteringTable) + values.Add(_Dialect.SqlForColumnAttribute(ColumnAttribute.Null, column)); } - - protected virtual void AddValueIfSelected(Column column, ColumnProperty property, ICollection vals) + protected virtual void AddNotNull(Column column, List values) { - if (PropertySelected(column.ColumnProperty, property)) - { - vals.Add(_Dialect.SqlForProperty(property, column)); - } + if (!column.IsNullable) values.Add(_Dialect.SqlForColumnAttribute(ColumnAttribute.NotNull, column)); } - - public static bool PropertySelected(ColumnProperty source, ColumnProperty comparison) + protected virtual void AddUnsigned(Column column, List values) { - return (source & comparison) == comparison; + if (!column.IsUnsigned) return; + if (!_Dialect.IsUnsignedCompatible(column.Type)) throw new NotSupportedException("Unsigned is unsupported for this column type."); + var sql = _Dialect.SqlForColumnAttribute(ColumnAttribute.Unsigned, column); + if (string.IsNullOrWhiteSpace(sql)) throw new NotSupportedException("Unsigned columns are unsupported by this dialect."); + 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); } diff --git a/src/Migrator/Providers/ConstraintMetadataReader.cs b/src/Migrator/Providers/ConstraintMetadataReader.cs new file mode 100644 index 00000000..342facf2 --- /dev/null +++ b/src/Migrator/Providers/ConstraintMetadataReader.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +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.DB2; +using DotNetProjects.Migrator.Providers.Impl.Firebird; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; + +namespace DotNetProjects.Migrator.Providers; + +internal static class ConstraintMetadataReader +{ + public static TableConstraint[] Read(TransformationProvider provider, string table) + { + string sql; + var parameterTable = table; + string schema = null; + var oracle = provider.Dialect is OracleDialect; + if (provider.Dialect is SqlServerDialect) + sql = @"SELECT kc.name, CASE WHEN kc.type='PK' AND ix.type=2 THEN 'PN' ELSE kc.type END, c.name, ic.key_ordinal, CAST(NULL AS nvarchar(max)) + FROM sys.key_constraints kc JOIN sys.indexes ix ON ix.object_id=kc.parent_object_id AND ix.index_id=kc.unique_index_id JOIN sys.index_columns ic ON ic.object_id=kc.parent_object_id AND ic.index_id=kc.unique_index_id + JOIN sys.columns c ON c.object_id=ic.object_id AND c.column_id=ic.column_id + WHERE kc.parent_object_id=OBJECT_ID(@lookup_table) AND ic.key_ordinal>0 + UNION ALL SELECT name, 'C', NULL, 0, definition FROM sys.check_constraints WHERE parent_object_id=OBJECT_ID(@lookup_table) + ORDER BY 1,4"; + else if (provider.Dialect is PostgreSQLDialect) + { + parameterTable = provider.QuoteTableNameIfRequired(table); + sql = @"SELECT c.conname, c.contype::text, a.attname, k.ordinality, CASE WHEN c.contype='c' THEN pg_get_expr(c.conbin,c.conrelid) END + FROM pg_constraint c LEFT JOIN LATERAL unnest(c.conkey) WITH ORDINALITY k(attnum,ordinality) ON c.contype<>'c' + LEFT JOIN pg_attribute a ON a.attrelid=c.conrelid AND a.attnum=k.attnum + WHERE c.conrelid=to_regclass(@lookup_table) AND c.contype IN ('p','u','c') ORDER BY c.conname,k.ordinality"; + } + else if (provider.Dialect is DB2Dialect) + { + parameterTable = table.StartsWith('"') ? table.Trim('"').Replace("\"\"", "\"") : table.ToUpperInvariant(); + sql = @"SELECT c.CONSTNAME,c.TYPE,k.COLNAME,k.COLSEQ,ch.TEXT + FROM SYSCAT.TABCONST c LEFT JOIN SYSCAT.KEYCOLUSE k + ON k.TABSCHEMA=c.TABSCHEMA AND k.TABNAME=c.TABNAME AND k.CONSTNAME=c.CONSTNAME AND c.TYPE IN ('P','U') + LEFT JOIN SYSCAT.CHECKS ch ON ch.TABSCHEMA=c.TABSCHEMA AND ch.TABNAME=c.TABNAME AND ch.CONSTNAME=c.CONSTNAME + WHERE c.TABSCHEMA=CURRENT SCHEMA AND c.TABNAME=@lookup_table AND c.TYPE IN ('P','U','K') + ORDER BY c.CONSTNAME,k.COLSEQ"; + } + else if (provider.Dialect is FirebirdDialect) + { + parameterTable = table.StartsWith('"') ? table.Trim('"').Replace("\"\"", "\"") : table.ToUpperInvariant(); + sql = @"SELECT TRIM(c.RDB$CONSTRAINT_NAME),TRIM(c.RDB$CONSTRAINT_TYPE),TRIM(k.RDB$FIELD_NAME),k.RDB$FIELD_POSITION, + (SELECT FIRST 1 t.RDB$TRIGGER_SOURCE FROM RDB$CHECK_CONSTRAINTS ch JOIN RDB$TRIGGERS t ON t.RDB$TRIGGER_NAME=ch.RDB$TRIGGER_NAME + WHERE ch.RDB$CONSTRAINT_NAME=c.RDB$CONSTRAINT_NAME) + FROM RDB$RELATION_CONSTRAINTS c LEFT JOIN RDB$INDEX_SEGMENTS k ON k.RDB$INDEX_NAME=c.RDB$INDEX_NAME AND c.RDB$CONSTRAINT_TYPE IN ('PRIMARY KEY','UNIQUE') + WHERE c.RDB$RELATION_NAME=@lookup_table AND c.RDB$CONSTRAINT_TYPE IN ('PRIMARY KEY','UNIQUE','CHECK') + ORDER BY c.RDB$CONSTRAINT_NAME,k.RDB$FIELD_POSITION"; + } + 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; + 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') + ORDER BY c.CONSTRAINT_NAME,k.POSITION" + : @"SELECT c.CONSTRAINT_NAME,c.CONSTRAINT_TYPE,k.COLUMN_NAME,k.ORDINAL_POSITION,ch.CHECK_CLAUSE + FROM information_schema.TABLE_CONSTRAINTS c LEFT JOIN information_schema.KEY_COLUMN_USAGE k + ON k.CONSTRAINT_SCHEMA=c.CONSTRAINT_SCHEMA AND k.TABLE_NAME=c.TABLE_NAME AND k.CONSTRAINT_NAME=c.CONSTRAINT_NAME + LEFT JOIN information_schema.CHECK_CONSTRAINTS ch ON ch.CONSTRAINT_SCHEMA=c.CONSTRAINT_SCHEMA AND ch.CONSTRAINT_NAME=c.CONSTRAINT_NAME + WHERE c.TABLE_NAME=@lookup_table AND c.TABLE_SCHEMA=COALESCE(@lookup_schema,DATABASE()) AND c.CONSTRAINT_TYPE IN ('PRIMARY KEY','UNIQUE','CHECK') + ORDER BY c.CONSTRAINT_NAME,k.ORDINAL_POSITION"; + } + else throw new NotSupportedException("Structured constraint inspection is not implemented for " + provider.Dialect.GetType().Name + "."); + + var constraints = new List(); + using (var command = provider.CreateCommand()) + { + AddParameter(command, "lookup_table", parameterTable); + if (oracle || provider.Dialect is MysqlDialect) AddParameter(command, "lookup_schema", schema); + using var reader = provider.ExecuteQuery(command, sql); + string lastName = null; + TableConstraint current = null; + var keys = new List(); + void Complete() + { + if (current is PrimaryKeyConstraint pk) pk.KeyColumns = keys.ToArray(); + if (current is UniqueConstraint unique) unique.KeyColumns = keys.ToArray(); + if (current != null) constraints.Add(current); + } + while (reader.Read()) + { + var name = reader.GetString(0); + if (name != lastName) + { + Complete(); keys.Clear(); lastName = name; + current = reader.GetString(1).Trim().ToUpperInvariant() switch + { + "P" or "PK" or "PRIMARY KEY" => new PrimaryKeyConstraint { Name = name }, + "PN" => new PrimaryKeyConstraint { Name = name, NonClustered = true }, + "U" or "UQ" or "UNIQUE" => new UniqueConstraint { Name = name }, + "C" or "K" or "CHECK" => new CheckConstraint(name, reader.IsDBNull(4) ? null : CheckExpression(reader.GetString(4))), + _ => throw new MigrationException("Unknown catalog constraint type.") + }; + } + if (!reader.IsDBNull(2)) keys.Add(reader.GetString(2)); + } + Complete(); + } + constraints.AddRange(provider.GetForeignKeyConstraints(table)); + return constraints.ToArray(); + } + + internal static string CheckExpression(string source) + { + var text = source.Trim(); + if (text.StartsWith("CHECK", StringComparison.OrdinalIgnoreCase)) + { + text = text[5..].Trim(); + if (text.StartsWith("(") && text.EndsWith(")")) text = text[1..^1]; + } + return text; + } + + private static void AddParameter(IDbCommand command, string name, object value) + { + var parameter = command.CreateParameter(); parameter.ParameterName = name; + parameter.DbType = DbType.String; parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } +} diff --git a/src/Migrator/Providers/DbProviderFactoriesHelper.cs b/src/Migrator/Providers/DbProviderFactoriesHelper.cs index 651d8f78..4fc110b1 100644 --- a/src/Migrator/Providers/DbProviderFactoriesHelper.cs +++ b/src/Migrator/Providers/DbProviderFactoriesHelper.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Data.Common; using System.Linq; +using System.Reflection; namespace DotNetProjects.Migrator.Providers; @@ -37,7 +38,14 @@ public static DbProviderFactory GetFactory(string providerName, string assemblyN #if NETSTANDARD return null; #else - return (DbProviderFactory)AppDomain.CurrentDomain.CreateInstanceAndUnwrap(assemblyName, factoryProviderType); + var type = Assembly.Load(assemblyName).GetType(factoryProviderType, throwOnError: true); + const BindingFlags flags = BindingFlags.Public | BindingFlags.Static; + // ADO.NET factories commonly expose a singleton and have a private constructor. + if (type.GetField("Instance", flags)?.GetValue(null) is DbProviderFactory fieldFactory) + return fieldFactory; + if (type.GetProperty("Instance", flags)?.GetValue(null) is DbProviderFactory propertyFactory) + return propertyFactory; + return (DbProviderFactory)Activator.CreateInstance(type); #endif } } diff --git a/src/Migrator/Providers/Dialect.cs b/src/Migrator/Providers/Dialect.cs index 8a3fcf11..dd051307 100644 --- a/src/Migrator/Providers/Dialect.cs +++ b/src/Migrator/Providers/Dialect.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Data; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; using System.Globalization; using System.Linq; using DotNetProjects.Migrator.Framework; @@ -14,7 +15,7 @@ namespace DotNetProjects.Migrator.Providers; /// public abstract class Dialect : IDialect { - private readonly Dictionary _propertyMap = []; + private readonly Dictionary _propertyMap = []; private readonly HashSet _reservedWords = []; private readonly TypeNames _typeNames = new(); private readonly List _unsignedCompatibleTypes = []; @@ -30,11 +31,44 @@ public abstract class Dialect : IDialect protected Dialect() { - RegisterProperty(ColumnProperty.Null, "NULL"); - RegisterProperty(ColumnProperty.NotNull, "NOT NULL"); - RegisterProperty(ColumnProperty.Unique, "UNIQUE"); - RegisterProperty(ColumnProperty.PrimaryKey, "PRIMARY KEY"); - RegisterProperty(ColumnProperty.PrimaryKeyNonClustered, " NONCLUSTERED"); + RegisterColumnAttribute(ColumnAttribute.Null, "NULL"); + RegisterColumnAttribute(ColumnAttribute.NotNull, "NOT NULL"); + } + + /// Render a named table constraint without accessing a database. + public virtual string GetCollationSql(Collation collation) + { + if (collation == null) throw new ArgumentNullException(nameof(collation)); + return GetCollationSql(collation.Kind == CollationKind.Named ? collation.Name : ResolveCollation(collation.Kind)); + } + + /// Override to map semantic requests to collations installed on the target engine. + protected virtual string ResolveCollation(CollationKind kind) => + throw new NotSupportedException($"{GetType().Name} cannot resolve {kind}. Use Collation.Named with an installed collation; no weaker comparison is substituted."); + + public virtual string GetCollationSql(string name) => throw new NotSupportedException("Column collations are not supported by this dialect."); + + 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)); + var body = constraint switch + { + PrimaryKeyConstraint p when p.NonClustered && !SupportsNonClustered => throw new System.NotSupportedException("This dialect does not support nonclustered primary keys."), + PrimaryKeyConstraint p => $"PRIMARY KEY{(p.NonClustered ? " NONCLUSTERED" : "")} ({Keys(p.KeyColumns)})", + UniqueConstraint u => $"UNIQUE ({Keys(u.KeyColumns)})", + CheckConstraint c when !string.IsNullOrWhiteSpace(c.CheckConstraintString) => $"CHECK ({c.CheckConstraintString})", + _ => throw new System.NotSupportedException($"No table-constraint SQL generator for {constraint.GetType().Name}.") + }; + return constraint.Name == null ? body : $"CONSTRAINT {QuoteIdentifier(constraint.Name)} {body}"; + } + + /// Quote one identifier atom, escaping its delimiter; never split a name on dots. + public virtual string QuoteIdentifier(string name) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Identifier must not be empty.", nameof(name)); + var closing = QuoteTemplate[^1].ToString(); + return string.Format(CultureInfo.InvariantCulture, QuoteTemplate, name.Replace(closing, closing + closing)); } public virtual int MaxKeyLength @@ -326,7 +360,7 @@ public virtual DbType GetDbType(string databaseTypeName) return _typeNames.GetDbType(databaseTypeName); } - public void RegisterProperty(ColumnProperty property, string sql) + public void RegisterColumnAttribute(ColumnAttribute property, string sql) { if (!_propertyMap.ContainsKey(property)) { @@ -335,7 +369,7 @@ public void RegisterProperty(ColumnProperty property, string sql) _propertyMap[property] = sql; } - public virtual string SqlForProperty(ColumnProperty property, Column column) + public virtual string SqlForColumnAttribute(ColumnAttribute property, Column column) { if (_propertyMap.ContainsKey(property)) { @@ -371,6 +405,7 @@ public virtual string QuoteTableNameIfRequired(string tableName) public virtual string Default(object defaultValue) { + if (defaultValue is RawSql expression) return "DEFAULT " + expression.Sql; if (defaultValue is string && defaultValue.ToString() == string.Empty) { defaultValue = "''"; diff --git a/src/Migrator/Providers/ForeignKeyMetadataReader.cs b/src/Migrator/Providers/ForeignKeyMetadataReader.cs new file mode 100644 index 00000000..0f76ca6a --- /dev/null +++ b/src/Migrator/Providers/ForeignKeyMetadataReader.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; +using DotNetProjects.Migrator.Providers.Impl.Mysql; +using DotNetProjects.Migrator.Providers.Impl.DB2; +using DotNetProjects.Migrator.Providers.Impl.Firebird; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; + +namespace DotNetProjects.Migrator.Providers; + +internal static class ForeignKeyMetadataReader +{ + public static ForeignKeyConstraint[] Read(TransformationProvider provider, string table) + { + var parameterTable = 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, + 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 + JOIN sys.columns pc ON pc.object_id=k.referenced_object_id AND pc.column_id=k.referenced_column_id + WHERE f.parent_object_id=OBJECT_ID(@lookup_table) ORDER BY f.name,k.constraint_column_id"; + 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 + 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 + JOIN pg_attribute pc ON pc.attrelid=c.confrelid AND pc.attnum=k.parentnum + WHERE c.conrelid=to_regclass(@lookup_table) AND c.contype='f' ORDER BY c.conname,k.ordinality"; + } + 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; + 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 + WHERE k.TABLE_NAME=@lookup_table AND k.TABLE_SCHEMA=COALESCE(@lookup_schema,DATABASE()) + AND k.REFERENCED_TABLE_NAME IS NOT NULL ORDER BY k.CONSTRAINT_NAME,k.ORDINAL_POSITION"; + } + 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; + 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' + FROM ALL_CONSTRAINTS c JOIN ALL_CONS_COLUMNS cc ON cc.OWNER=c.OWNER AND cc.CONSTRAINT_NAME=c.CONSTRAINT_NAME + JOIN ALL_CONSTRAINTS p ON p.OWNER=c.R_OWNER AND p.CONSTRAINT_NAME=c.R_CONSTRAINT_NAME + JOIN ALL_CONS_COLUMNS pc ON pc.OWNER=p.OWNER AND pc.CONSTRAINT_NAME=p.CONSTRAINT_NAME AND pc.POSITION=cc.POSITION + WHERE c.CONSTRAINT_TYPE='R' AND c.TABLE_NAME=:lookup_table + AND c.OWNER=COALESCE(:lookup_schema,SYS_CONTEXT('USERENV','CURRENT_SCHEMA')) + ORDER BY c.CONSTRAINT_NAME,cc.POSITION"; + } + else if (provider.Dialect is DB2Dialect) + { + parameterTable = table.StartsWith('"') ? table.Trim('"') : table.ToUpperInvariant(); + sql = @"SELECT r.CONSTNAME,r.REFTABNAME,c.COLNAME,p.COLNAME,c.COLSEQ,r.DELETERULE,r.UPDATERULE + FROM SYSCAT.REFERENCES r JOIN SYSCAT.KEYCOLUSE c ON c.TABSCHEMA=r.TABSCHEMA AND c.TABNAME=r.TABNAME AND c.CONSTNAME=r.CONSTNAME + JOIN SYSCAT.KEYCOLUSE p ON p.TABSCHEMA=r.REFTABSCHEMA AND p.TABNAME=r.REFTABNAME AND p.CONSTNAME=r.REFKEYNAME AND p.COLSEQ=c.COLSEQ + WHERE r.TABSCHEMA=CURRENT SCHEMA AND r.TABNAME=@lookup_table ORDER BY r.CONSTNAME,c.COLSEQ"; + } + else if (provider.Dialect is FirebirdDialect) + { + parameterTable = table.StartsWith('"') ? table.Trim('"') : table.ToUpperInvariant(); + sql = @"SELECT TRIM(c.RDB$CONSTRAINT_NAME),TRIM(p.RDB$RELATION_NAME),TRIM(ck.RDB$FIELD_NAME),TRIM(pk.RDB$FIELD_NAME), + ck.RDB$FIELD_POSITION,TRIM(r.RDB$DELETE_RULE),TRIM(r.RDB$UPDATE_RULE) + FROM RDB$RELATION_CONSTRAINTS c JOIN RDB$REF_CONSTRAINTS r ON r.RDB$CONSTRAINT_NAME=c.RDB$CONSTRAINT_NAME + JOIN RDB$RELATION_CONSTRAINTS p ON p.RDB$CONSTRAINT_NAME=r.RDB$CONST_NAME_UQ + JOIN RDB$INDEX_SEGMENTS ck ON ck.RDB$INDEX_NAME=c.RDB$INDEX_NAME + JOIN RDB$INDEX_SEGMENTS pk ON pk.RDB$INDEX_NAME=p.RDB$INDEX_NAME AND pk.RDB$FIELD_POSITION=ck.RDB$FIELD_POSITION + WHERE c.RDB$RELATION_NAME=@lookup_table ORDER BY c.RDB$CONSTRAINT_NAME,ck.RDB$FIELD_POSITION"; + } + else throw new NotSupportedException("Foreign-key metadata is unsupported by " + provider.Dialect.GetType().Name + "."); + using var command = provider.CreateCommand(); + AddParameter(command, "lookup_table", parameterTable); + if (provider.Dialect is MysqlDialect or OracleDialect) AddParameter(command, "lookup_schema", schema); + var rows = new List<(string Name, string Parent, string ChildColumn, string ParentColumn, string Delete, string Update)>(); + using (var reader = provider.ExecuteQuery(command, sql)) + while (reader.Read()) + rows.Add((reader.GetString(0).Trim(), reader.GetString(1).Trim(), reader.GetString(2).Trim(), reader.GetString(3).Trim(), + Action(reader.GetString(5)), Action(reader.GetString(6)))); + return rows.GroupBy(r => r.Name).Select(group => new ForeignKeyConstraint(group.Key, group.First().Parent, + group.Select(r => r.ParentColumn).ToArray(), table, group.Select(r => r.ChildColumn).ToArray()) + { OnDelete = group.First().Delete, OnUpdate = group.First().Update }).ToArray(); + } + private static string Action(string value) => value.Trim().ToUpperInvariant() switch + { + "A" => "NO ACTION", "R" => "RESTRICT", "C" => "CASCADE", "N" => "SET NULL", "D" => "SET DEFAULT", + "NO ACTION" or "RESTRICT" or "CASCADE" or "SET NULL" or "SET DEFAULT" => value.Trim().ToUpperInvariant(), + _ => throw new MigrationException("Unknown foreign-key action in catalog: " + value) + }; + private static void AddParameter(IDbCommand command, string name, object value) + { + var parameter = command.CreateParameter(); parameter.ParameterName = name; parameter.DbType = DbType.String; + parameter.Value = value ?? DBNull.Value; command.Parameters.Add(parameter); + } +} diff --git a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs index aeb19070..f84d6fa7 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs @@ -39,7 +39,7 @@ public DB2Dialect() RegisterColumnType(DbType.AnsiString, int.MaxValue, "CLOB"); RegisterColumnType(DbType.StringFixedLength, 255, "CHAR($l)"); RegisterColumnType(DbType.AnsiStringFixedLength, 255, "CHAR($l)"); - RegisterProperty(ColumnProperty.Identity, "GENERATED BY DEFAULT AS IDENTITY"); + RegisterColumnAttribute(ColumnAttribute.Identity, "GENERATED BY DEFAULT AS IDENTITY"); } public override ColumnPropertiesMapper GetColumnMapper(Column column) @@ -55,16 +55,17 @@ private sealed class NativeColumnMapper(Dialect dialect, string type) : ColumnPr public override void MapColumnProperties(Column column) { Name = column.Name; - _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); + var parts = new System.Collections.Generic.List(); AddName(parts); AddType(parts); + AddCollation(column, parts); + AddUnsigned(column, parts); AddIdentityAgain(column, parts); AddDefaultValue(column, parts); - if (column.IsPrimaryKey || column.ColumnProperty.HasFlag(ColumnProperty.NotNull)) + if (!column.IsNullable) parts.Add("NOT NULL"); - AddPrimaryKey(column, parts); - AddUnique(column, parts); + _ColumnSql = string.Join(" ", parts); } } diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index 11fed953..d36a01db 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -30,8 +30,6 @@ public override void AddColumn(string table, Column column) => public override void AddTable(string name, string engine, params IDbField[] fields) { base.AddTable(name, engine, fields); - foreach (var column in fields.OfType().Where(c => c.ColumnProperty.HasFlag(ColumnProperty.Indexed))) - AddIndex(name, new Index { KeyColumns = [column.Name] }); } public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( @@ -67,7 +65,7 @@ public override Column[] GetColumns(string table) }; var column = new Column(reader.GetString(0).Trim(), type) { - ColumnProperty = reader.GetString(2) == "Y" ? ColumnProperty.Null : ColumnProperty.NotNull + IsNullable = reader.GetString(2) == "Y" }; if (!reader.IsDBNull(3)) column.DefaultValue = CatalogDefaultValue.Parse(reader.GetString(3), type); if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(4)); @@ -77,8 +75,7 @@ public override Column[] GetColumns(string table) column.Scale = Convert.ToInt32(reader.GetValue(7)); } if (type == DbType.VarNumeric) column.Precision = Convert.ToInt32(reader.GetValue(4)) == 8 ? 16 : 34; - if (reader.GetString(5) == "Y") column.ColumnProperty |= ColumnProperty.Identity; - if (!reader.IsDBNull(6)) column.ColumnProperty |= ColumnProperty.PrimaryKey; + if (reader.GetString(5) == "Y") column.IsIdentity = true; columns.Add(column); } return columns.ToArray(); @@ -129,17 +126,14 @@ public override string AddIndex(string table, Index index) public override void ChangeColumn(string table, Column column) { - var isUniqueSet = column.ColumnProperty.HasFlag(ColumnProperty.Unique); - column.ColumnProperty &= ~ColumnProperty.Unique; + var prefix = $"ALTER TABLE {Identifier(table)} ALTER COLUMN {Identifier(column.Name)}"; var type = _dialect.GetColumnMapper(column).Type; ExecuteNonQuery($"{prefix} SET DATA TYPE {type}"); if (column.DefaultValue != null || GetColumns(table).Single(c => c.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase)).DefaultValue != null) ExecuteNonQuery($"{prefix} {(column.DefaultValue == null ? "DROP DEFAULT" : "SET " + _dialect.Default(column.DefaultValue))}"); - ExecuteNonQuery($"{prefix} {(column.ColumnProperty.HasFlag(ColumnProperty.NotNull) ? "SET" : "DROP")} NOT NULL"); + ExecuteNonQuery($"{prefix} {(!column.IsNullable ? "SET" : "DROP")} NOT NULL"); Reorganize(table); - if (isUniqueSet) - AddUniqueConstraint($"UX_{table}_{column.Name}", table, [column.Name]); } public override void RemoveColumn(string tableName, string column) diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdColumnPropertiesMapper.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdColumnPropertiesMapper.cs index a9a4d833..afee889b 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdColumnPropertiesMapper.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdColumnPropertiesMapper.cs @@ -14,23 +14,19 @@ public override void MapColumnProperties(Column column) { Name = column.Name; - _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); var vals = new List(); AddName(vals); AddType(vals); + AddCollation(column, vals); + AddUnsigned(column, vals); AddIdentity(column, vals); AddIdentityAgain(column, vals); - AddPrimaryKey(column, vals); - - AddUnique(column, vals); - - AddForeignKey(column, vals); AddDefaultValue(column, vals); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs index 3d9f85d8..75061921 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs @@ -5,6 +5,8 @@ namespace DotNetProjects.Migrator.Providers.Impl.Firebird; public class FirebirdDialect : Dialect { + public override string GetCollationSql(string name) => "COLLATE " + QuoteIdentifier(name); + // This flag controls MySQL-style inline INDEX syntax, not CREATE INDEX support. public override bool SupportsIndex => false; @@ -34,7 +36,7 @@ public FirebirdDialect() RegisterColumnType(DbType.String, int.MaxValue, "BLOB SUB_TYPE TEXT"); RegisterColumnType(DbType.Time, "TIME"); - RegisterProperty(ColumnProperty.Identity, "GENERATED BY DEFAULT AS IDENTITY"); + RegisterColumnAttribute(ColumnAttribute.Identity, "GENERATED BY DEFAULT AS IDENTITY"); this.RegisterUnsignedCompatible(DbType.Int16); this.RegisterUnsignedCompatible(DbType.Int32); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index 062643c8..dab77056 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -32,8 +32,6 @@ public override void AddColumn(string table, Column column) => public override void AddTable(string name, string engine, params IDbField[] fields) { base.AddTable(name, engine, fields); - foreach (var column in fields.OfType().Where(c => c.ColumnProperty.HasFlag(ColumnProperty.Indexed))) - AddIndex(name, new Index { KeyColumns = [column.Name] }); } public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( @@ -98,7 +96,7 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, type = DbType.Decimal; var column = new Column(reader.GetString(0), type) { - ColumnProperty = !reader.IsDBNull(2) && Convert.ToInt32(reader.GetValue(2)) == 1 ? ColumnProperty.NotNull : ColumnProperty.Null + IsNullable = !(!reader.IsDBNull(2) && Convert.ToInt32(reader.GetValue(2)) == 1) }; if (type == DbType.Decimal) { @@ -108,8 +106,7 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, if (!reader.IsDBNull(3)) column.DefaultValue = ReadDefault(reader.GetString(3), type); if (!reader.IsDBNull(4)) column.Size = Convert.ToInt32(reader.GetValue(4)); if (Convert.ToInt32(reader.GetValue(1)) == 261 && type == DbType.String) column.Size = int.MaxValue; - if (!reader.IsDBNull(5)) column.ColumnProperty |= ColumnProperty.Identity; - if (primaryColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.PrimaryKey; + if (!reader.IsDBNull(5)) column.IsIdentity = true; result.Add(column); } return result.ToArray(); @@ -160,16 +157,13 @@ public override void RenameColumn(string tableName, string oldColumnName, string public override void ChangeColumn(string table, Column column) { - var isUniqueSet = column.ColumnProperty.HasFlag(ColumnProperty.Unique); - column.ColumnProperty &= ~ColumnProperty.Unique; + var prefix = $"ALTER TABLE {QuoteTableNameIfRequired(table)} ALTER {QuoteColumnNameIfRequired(column.Name)}"; var type = _dialect.GetColumnMapper(column).Type; ExecuteNonQuery($"{prefix} TYPE {type}"); if (column.DefaultValue != null || GetColumns(table).Single(c => c.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase)).DefaultValue != null) ExecuteNonQuery($"{prefix} {(column.DefaultValue == null ? "DROP DEFAULT" : "SET " + _dialect.Default(column.DefaultValue))}"); - ExecuteNonQuery($"{prefix} {(column.ColumnProperty.HasFlag(ColumnProperty.NotNull) ? "SET" : "DROP")} NOT NULL"); - if (isUniqueSet) - AddUniqueConstraint($"UX_{table}_{column.Name}", table, [column.Name]); + ExecuteNonQuery($"{prefix} {(!column.IsNullable ? "SET" : "DROP")} NOT NULL"); } public override string AddIndex(string table, Index index) diff --git a/src/Migrator/Providers/Impl/Hana/HanaDialect.cs b/src/Migrator/Providers/Impl/Hana/HanaDialect.cs new file mode 100644 index 00000000..cf94b0aa --- /dev/null +++ b/src/Migrator/Providers/Impl/Hana/HanaDialect.cs @@ -0,0 +1,59 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; + +namespace DotNetProjects.Migrator.Providers.Impl.Hana; + +public class HanaDialect : Dialect +{ + public HanaDialect() + { + RegisterColumnType(DbType.Int16, "SMALLINT"); + RegisterColumnType(DbType.Int32, "INTEGER"); + RegisterColumnType(DbType.Int64, "BIGINT"); + RegisterColumnType(DbType.Byte, "TINYINT"); + RegisterColumnType(DbType.Boolean, "BOOLEAN"); + RegisterColumnType(DbType.Decimal, "DECIMAL(18, 4)"); + RegisterColumnType(DbType.Currency, "DECIMAL(18, 4)"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); + RegisterColumnType(DbType.Double, "DOUBLE"); + RegisterColumnType(DbType.Single, "REAL"); + RegisterColumnType(DbType.String, "NVARCHAR(255)"); + RegisterColumnType(DbType.String, 5000, "NVARCHAR($l)"); + RegisterColumnType(DbType.String, int.MaxValue, "NCLOB"); + RegisterColumnType(DbType.AnsiString, "VARCHAR(255)"); + RegisterColumnType(DbType.AnsiString, 5000, "VARCHAR($l)"); + RegisterColumnType(DbType.AnsiString, int.MaxValue, "CLOB"); + RegisterColumnType(DbType.StringFixedLength, "NCHAR(255)"); + RegisterColumnType(DbType.StringFixedLength, 5000, "NCHAR($l)"); + RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); + RegisterColumnType(DbType.AnsiStringFixedLength, 5000, "CHAR($l)"); + RegisterColumnType(DbType.Binary, "BLOB"); + RegisterColumnType(DbType.Binary, 5000, "VARBINARY($l)"); + RegisterColumnType(DbType.Date, "DATE"); + RegisterColumnType(DbType.Time, "TIME"); + RegisterColumnType(DbType.DateTime, "TIMESTAMP"); + RegisterColumnType(DbType.DateTime2, "TIMESTAMP"); + RegisterColumnAttribute(ColumnAttribute.Identity, "GENERATED BY DEFAULT AS IDENTITY"); + } + public override bool TableNameNeedsQuote => true; + public override bool ColumnNameNeedsQuote => true; + public override bool ConstraintNameNeedsQuote => true; + public override bool IdentityNeedsType => true; + 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 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) + }; + public override ITransformationProvider GetTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) => + new HanaTransformationProvider(dialect, connectionString, defaultSchema, scope, providerName); + public override ITransformationProvider GetTransformationProvider(Dialect dialect, IDbConnection connection, string defaultSchema, string scope, string providerName) => + new HanaTransformationProvider(dialect, connection, defaultSchema, scope); +} diff --git a/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs b/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs new file mode 100644 index 00000000..7c534561 --- /dev/null +++ b/src/Migrator/Providers/Impl/Hana/HanaTransformationProvider.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; + +namespace DotNetProjects.Migrator.Providers.Impl.Hana; + +/// SAP HANA 2 provider. DDL uses the engine's default autocommit behavior; +/// whole-session transactional DDL and native migration locks are not advertised. +public class HanaTransformationProvider : TransformationProvider +{ + public HanaTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) + : base(dialect, connectionString, defaultSchema, scope) + { + var factory = DbProviderFactoriesHelper.GetFactory(string.IsNullOrEmpty(providerName) ? "Sap.Data.Hana" : providerName, + "Sap.Data.Hana.Net.v8.0", "Sap.Data.Hana.HanaFactory"); + _connection = factory.CreateConnection(); + _connection.ConnectionString = connectionString; + _connection.Open(); + } + public HanaTransformationProvider(Dialect dialect, IDbConnection connection, string defaultSchema, string scope) + : base(dialect, connection, defaultSchema, scope) { } + + // SAP's ADO.NET driver uses positional parameters. + public override string GenerateParameterName(int index) => "?"; + public override string GenerateParameterNameParameter(int index) => "p" + index; + private (string Schema, string Table) Name(string table) + { + var parts = table.Split('.'); + if (parts.Length > 2 || parts.Any(string.IsNullOrWhiteSpace) || parts.Any(x => x.Contains('"'))) + throw new NotSupportedException("HANA names must be unquoted table or schema.table names. Embedded dots/quotes require explicit SQL."); + return (parts.Length == 2 ? parts[0] : _defaultSchema, parts[^1]); + } + public override string QuoteTableNameIfRequired(string table) + { + var name = Name(table); + return (name.Schema == null ? "" : Dialect.QuoteIdentifier(name.Schema) + ".") + Dialect.QuoteIdentifier(name.Table); + } + public override string QuoteColumnNameIfRequired(string column) => Dialect.QuoteIdentifier(column); + private IDbCommand Catalog(string sql, params object[] values) + { + var command = CreateCommand(); + command.CommandText = sql; + for (var i = 0; i < values.Length; i++) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = "p" + i; parameter.DbType = DbType.String; + parameter.Value = values[i] ?? DBNull.Value; command.Parameters.Add(parameter); + } + return command; + } + private bool Exists(string view, string table) + { + var name = Name(table); + using var command = Catalog("SELECT COUNT(*) FROM SYS." + view + " WHERE SCHEMA_NAME=COALESCE(?,CURRENT_SCHEMA) AND " + (view == "VIEWS" ? "VIEW_NAME" : "TABLE_NAME") + "=?", name.Schema, name.Table); + return Convert.ToInt32(command.ExecuteScalar()) > 0; + } + public override bool TableExists(string table) => Exists("TABLES", table); + public override bool ViewExists(string table) => Exists("VIEWS", table); + public override string[] GetTables() + { + using var command = Catalog("SELECT TABLE_NAME FROM SYS.TABLES WHERE SCHEMA_NAME=COALESCE(?,CURRENT_SCHEMA) ORDER BY TABLE_NAME", _defaultSchema); + using var reader = command.ExecuteReader(); + var names = new List(); while (reader.Read()) names.Add(reader.GetString(0)); return names.ToArray(); + } + public override List GetDatabases() => [Convert.ToString(ExecuteScalar("SELECT DATABASE_NAME FROM SYS.M_DATABASE"))]; + public override void SwitchDatabase(string databaseName) => throw new NotSupportedException("Connect to the target HANA tenant explicitly."); + public override void CreateDatabases(string databaseName) => throw new NotSupportedException("HANA tenant administration requires an explicit SYSTEMDB connection and operation."); + public override void DropDatabases(string databaseName) => throw new NotSupportedException("HANA tenant administration requires an explicit SYSTEMDB connection and operation."); + public override void KillDatabaseConnections(string databaseName) => throw new NotSupportedException("Use explicit HANA connection administration."); + + public override void AddTable(string table, string engine, string columns) + { + if (engine != null && engine is not ("ROW" or "COLUMN")) throw new NotSupportedException("HANA table engine must be ROW or COLUMN."); + ExecuteNonQuery($"CREATE {engine ?? "ROW"} TABLE {QuoteTableNameIfRequired(table)} ({columns})"); + } + public override void AddColumn(string table, string definition) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD ({definition})"); + public override void ChangeColumn(string table, string definition) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ALTER ({definition})"); + public override void ChangeColumn(string table, Column column) + { + if (column.IsIdentity) throw new NotSupportedException("Changing HANA identity properties requires explicit SQL."); + ChangeColumn(table, _dialect.GetAndMapColumnProperties(column.CopyDefinition()).ColumnSql); + } + public override void RemoveColumn(string table, string column) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} DROP ({QuoteColumnNameIfRequired(column)})"); + public override void RemoveTable(string table) => ExecuteNonQuery("DROP TABLE " + QuoteTableNameIfRequired(table)); + public override void RenameTable(string table, string name) => ExecuteNonQuery($"RENAME TABLE {QuoteTableNameIfRequired(table)} TO {QuoteTableNameIfRequired(name)}"); + public override void RenameColumn(string table, string column, string name) => ExecuteNonQuery($"RENAME COLUMN {QuoteTableNameIfRequired(table)}.{QuoteColumnNameIfRequired(column)} TO {QuoteColumnNameIfRequired(name)}"); + public override void RemoveColumnDefaultValue(string table, string column) => AddColumnDefaultValue(table, column, RawSql.Insert("NULL")); + public override void AddColumnDefaultValue(string table, string column, object value) + { + var definition = GetColumns(table).SingleOrDefault(c => c.Name == column) + ?? throw new ArgumentException("HANA column does not exist: " + column, nameof(column)); + if (definition.IsIdentity) throw new NotSupportedException("HANA identity defaults cannot be changed."); + definition.DefaultValue = value ?? RawSql.Insert("NULL"); + ChangeColumn(table, definition); + } + public override int TruncateTable(string table) => ExecuteNonQuery("TRUNCATE TABLE " + QuoteTableNameIfRequired(table)); + public override void AddForeignKey(string name, string child, string[] columns, string parent, string[] parentColumns, ForeignKeyConstraintType onDelete, ForeignKeyConstraintType onUpdate) => + base.AddForeignKey(name, child, columns, parent, parentColumns, + onDelete == ForeignKeyConstraintType.NoAction ? ForeignKeyConstraintType.Restrict : onDelete, + onUpdate == ForeignKeyConstraintType.NoAction ? ForeignKeyConstraintType.Restrict : onUpdate); + public override string[] GetConstraints(string table) => GetTableConstraints(table).Select(c => c.Name).ToArray(); + public override bool ConstraintExists(string table, string name) => GetConstraints(table).Contains(name, StringComparer.Ordinal); + protected override string GetPrimaryKeyConstraintName(string table) => GetTableConstraints(table).OfType().SingleOrDefault()?.Name; + public override bool PrimaryKeyExists(string table, string name) => GetPrimaryKeyConstraintName(table) is string actual && actual == name; + public override void RemoveAllForeignKeys(string table, string column) + { + foreach (var key in GetForeignKeyConstraints(table).Where(k => k.ChildColumns.Contains(column))) RemoveForeignKey(table, key.Name); + } + public override Column[] GetColumns(string table) + { + var name = Name(table); + using var command = Catalog("SELECT COLUMN_NAME,DATA_TYPE_NAME,LENGTH,SCALE,IS_NULLABLE,DEFAULT_VALUE,GENERATION_TYPE FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME=COALESCE(?,CURRENT_SCHEMA) AND TABLE_NAME=? ORDER BY POSITION", name.Schema, name.Table); + using var reader = command.ExecuteReader(); var columns = new List(); + while (reader.Read()) + { + var typeName = reader.GetString(1); + var type = typeName switch + { + "TINYINT" => DbType.Byte, "SMALLINT" => DbType.Int16, "INTEGER" => DbType.Int32, "BIGINT" => DbType.Int64, + "BOOLEAN" => DbType.Boolean, "DECIMAL" => DbType.Decimal, "REAL" => DbType.Single, "DOUBLE" => DbType.Double, + "VARCHAR" or "CLOB" => DbType.AnsiString, "NVARCHAR" or "NCLOB" or "SHORTTEXT" => DbType.String, + "CHAR" => DbType.AnsiStringFixedLength, "NCHAR" => DbType.StringFixedLength, + "BINARY" or "VARBINARY" or "BLOB" => DbType.Binary, + "DATE" => DbType.Date, "TIME" => DbType.Time, "TIMESTAMP" or "SECONDDATE" => DbType.DateTime, + _ => throw new NotSupportedException("HANA catalog type is not representable: " + typeName) + }; + var generation = reader.IsDBNull(6) ? null : reader.GetString(6); + if (!string.IsNullOrEmpty(generation) && !generation.Contains("IDENTITY")) throw new NotSupportedException("HANA computed column metadata requires explicit SQL."); + var column = new Column(reader.GetString(0), type) { IsNullable = reader.GetString(4) == "TRUE", IsIdentity = generation?.Contains("IDENTITY") == true }; + if (type == DbType.Decimal) { column.Precision = Convert.ToInt32(reader.GetValue(2)); column.Scale = Convert.ToInt32(reader.GetValue(3)); } + else if (typeName is "NCLOB" or "CLOB") column.Size = int.MaxValue; + else if (type is DbType.String or DbType.AnsiString or DbType.StringFixedLength or DbType.AnsiStringFixedLength || typeName == "VARBINARY") column.Size = Convert.ToInt32(reader.GetValue(2)); + if (!reader.IsDBNull(5) && !column.IsIdentity) column.DefaultValue = CatalogDefaultValue.Parse(reader.GetString(5), type); + columns.Add(column); + } + return columns.ToArray(); + } + public override TableConstraint[] GetTableConstraints(string table) + { + var name = Name(table); + var rows = new List<(string Name, string Column, bool Primary, bool Unique, string Check)>(); + using (var command = Catalog("SELECT CONSTRAINT_NAME,COLUMN_NAME,IS_PRIMARY_KEY,IS_UNIQUE_KEY,CHECK_CONDITION FROM SYS.CONSTRAINTS WHERE SCHEMA_NAME=COALESCE(?,CURRENT_SCHEMA) AND TABLE_NAME=? ORDER BY CONSTRAINT_NAME,POSITION", name.Schema, name.Table)) + using (var reader = command.ExecuteReader()) + while (reader.Read()) rows.Add((reader.GetString(0), reader.IsDBNull(1) ? null : reader.GetString(1), !reader.IsDBNull(2) && reader.GetString(2) == "TRUE", !reader.IsDBNull(3) && reader.GetString(3) == "TRUE", reader.IsDBNull(4) ? null : reader.GetString(4))); + var constraints = rows.GroupBy(r => r.Name).Select(g => g.First().Primary ? (TableConstraint)new PrimaryKeyConstraint(g.Key, g.Select(r => r.Column).ToArray()) + : g.First().Unique ? new UniqueConstraint(g.Key, g.Select(r => r.Column).ToArray()) + : g.First().Check != null ? new CheckConstraint(g.Key, g.First().Check) + : throw new NotSupportedException("Unsupported HANA constraint: " + g.Key)).ToList(); + constraints.AddRange(GetForeignKeyConstraints(table)); return constraints.ToArray(); + } + public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) + { + var name = Name(table); + var rows = new List<(string Name, string Column, string ParentSchema, string Parent, string ParentColumn, string Delete, string Update)>(); + using (var command = Catalog("SELECT CONSTRAINT_NAME,COLUMN_NAME,REFERENCED_SCHEMA_NAME,REFERENCED_TABLE_NAME,REFERENCED_COLUMN_NAME,DELETE_RULE,UPDATE_RULE FROM SYS.REFERENTIAL_CONSTRAINTS WHERE SCHEMA_NAME=COALESCE(?,CURRENT_SCHEMA) AND TABLE_NAME=? ORDER BY CONSTRAINT_NAME,POSITION", name.Schema, name.Table)) + using (var reader = command.ExecuteReader()) + while (reader.Read()) rows.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetString(4), reader.GetString(5), reader.GetString(6))); + return rows.GroupBy(r => r.Name).Select(g => new ForeignKeyConstraint(g.Key, g.First().ParentSchema + "." + g.First().Parent, + g.Select(r => r.ParentColumn).ToArray(), table, g.Select(r => r.Column).ToArray()) + { OnDelete = g.First().Delete, OnUpdate = g.First().Update }).ToArray(); + } + public override string AddIndex(string table, Index index) + { + if (index.Clustered || index.IncludeColumns?.Length > 0 || index.FilterItems?.Count > 0) + throw new NotSupportedException("HANA index INCLUDE, clustered and filtered options are not supported by this provider."); + if (index.KeyColumns?.Length is not > 0) throw new ArgumentException("Index key columns are required.", nameof(index)); + var name = index.Name ?? "IX_" + Name(table).Table + "_" + string.Join("_", index.KeyColumns); + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {Dialect.QuoteIdentifier(name)} ON {QuoteTableNameIfRequired(table)} ({string.Join(", ", index.KeyColumns.Select(QuoteColumnNameIfRequired))})"); + return name; + } + public override Index[] GetIndexes(string table) + { + var name = Name(table); + var rows = new List<(string Name, string Column, string Constraint)>(); + using (var command = Catalog("SELECT INDEX_NAME,COLUMN_NAME,CONSTRAINT FROM SYS.INDEX_COLUMNS WHERE SCHEMA_NAME=COALESCE(?,CURRENT_SCHEMA) AND TABLE_NAME=? ORDER BY INDEX_NAME,POSITION", name.Schema, name.Table)) + using (var reader = command.ExecuteReader()) + while (reader.Read()) rows.Add((reader.GetString(0), reader.GetString(1), reader.IsDBNull(2) ? "" : reader.GetString(2))); + var constraints = GetTableConstraints(table).ToDictionary(c => c.Name, StringComparer.Ordinal); + return rows.GroupBy(r => r.Name).Select(g => new Index { Name = g.Key, KeyColumns = g.Select(r => r.Column).ToArray(), + Unique = g.First().Constraint.Contains("UNIQUE") || g.First().Constraint == "PRIMARY_KEY", + PrimaryKey = constraints.TryGetValue(g.Key, out var c) && c is PrimaryKeyConstraint, + UniqueConstraint = constraints.TryGetValue(g.Key, out var u) && u is UniqueConstraint }).ToArray(); + } + public override bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == name); + public override void RemoveIndex(string table, string name) + { + var schema = Name(table).Schema; + ExecuteNonQuery("DROP INDEX " + (schema == null ? "" : Dialect.QuoteIdentifier(schema) + ".") + Dialect.QuoteIdentifier(name)); + } + public override void RemoveAllIndexes(string table) + { + foreach (var index in GetIndexes(table).Where(i => !i.PrimaryKey && !i.UniqueConstraint)) RemoveIndex(table, index.Name); + } +} diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 18f376d3..0b703122 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -1,3 +1,4 @@ +using System; using System.Data; using DotNetProjects.Migrator.Framework; @@ -39,7 +40,23 @@ public InformixDialect() RegisterColumnType(DbType.AnsiStringFixedLength, 32767, "CHAR($l)"); RegisterColumnType(DbType.String, int.MaxValue, "TEXT"); RegisterColumnType(DbType.AnsiString, int.MaxValue, "TEXT"); - RegisterProperty(ColumnProperty.Identity, ""); + RegisterColumnAttribute(ColumnAttribute.Identity, ""); + } + + public override string GetTableConstraintSql(TableConstraint constraint) + { + var copy = constraint switch + { + PrimaryKeyConstraint p => (TableConstraint)new PrimaryKeyConstraint(null, p.KeyColumns) { NonClustered = p.NonClustered }, + DotNetProjects.Migrator.Framework.UniqueConstraint u => new DotNetProjects.Migrator.Framework.UniqueConstraint(null, u.KeyColumns), + CheckConstraint c => new CheckConstraint(null, c.CheckConstraintString), + _ => throw new NotSupportedException("Unsupported Informix table 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; } public override string Default(object value) => value is bool boolean ? (boolean ? "DEFAULT 't'" : "DEFAULT 'f'") : base.Default(value); @@ -58,15 +75,16 @@ private sealed class NativeColumnMapper(Dialect dialect, string type) : ColumnPr public override void MapColumnProperties(Column column) { Name = column.Name; - _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); + var parts = new System.Collections.Generic.List(); AddName(parts); AddType(parts); + AddCollation(column, parts); + AddUnsigned(column, parts); AddIdentityAgain(column, parts); AddDefaultValue(column, parts); AddNotNull(column, parts); - AddPrimaryKey(column, parts); - AddUnique(column, parts); + _ColumnSql = string.Join(" ", parts); } } diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 5b3a7c90..e9174cfb 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -1,3 +1,4 @@ +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; using System; using System.Collections.Generic; using System.Data; @@ -29,8 +30,6 @@ public override void AddColumn(string table, Column column) => public override void AddTable(string name, string engine, params IDbField[] fields) { base.AddTable(name, engine, fields); - foreach (var column in fields.OfType().Where(c => c.ColumnProperty.HasFlag(ColumnProperty.Indexed))) - AddIndex(name, new Index { KeyColumns = [column.Name] }); } public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( @@ -74,7 +73,7 @@ public override Column[] GetColumns(string table) if (extendedType == "boolean") type = DbType.Boolean; var column = new Column(reader.GetString(0).Trim(), type) { - ColumnProperty = (code & 256) != 0 ? ColumnProperty.NotNull : ColumnProperty.Null + IsNullable = !((code & 256) != 0) }; if (type is DbType.String or DbType.StringFixedLength) { @@ -90,14 +89,57 @@ public override Column[] GetColumns(string table) column.Precision = length >> 8; column.Scale = (length & 255) == 255 ? null : length & 255; } - if ((code & 255) is 6 or 18 or 53) column.ColumnProperty |= ColumnProperty.Identity; + if ((code & 255) is 6 or 18 or 53) column.IsIdentity = true; if (!reader.IsDBNull(5)) column.DefaultValue = ReadDefault(reader.IsDBNull(3) ? "" : reader.GetString(3), reader.GetString(5).Trim(), type); - if (primaryColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.PrimaryKey; columns.Add(column); } return columns.ToArray(); } + public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) + { + var rows = new List<(string Name, string Parent, string ChildIndex, string ParentIndex, string Delete)>(); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, $"SELECT c.constrname,t2.tabname,c.idxname,p.idxname,r.delrule FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid JOIN sysreferences r ON r.constrid=c.constrid JOIN sysconstraints p ON p.constrid=r.primary JOIN systables t2 ON t2.tabid=r.ptabid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND t2.owner=USER ORDER BY c.constrname")) + while (reader.Read()) + rows.Add((reader.GetString(0).Trim(), reader.GetString(1).Trim(), reader.GetString(2).Trim(), reader.GetString(3).Trim(), reader.GetString(4).Trim())); + var childIndexes = GetIndexes(table).ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase); + return rows.Select(row => new ForeignKeyConstraint(row.Name, row.Parent, + GetIndexes(row.Parent).Single(i => i.Name.Equals(row.ParentIndex, StringComparison.OrdinalIgnoreCase)).KeyColumns, + table, childIndexes[row.ChildIndex].KeyColumns) + { OnDelete = row.Delete == "C" ? "CASCADE" : "RESTRICT", OnUpdate = "RESTRICT" }).ToArray(); + } + + public override TableConstraint[] GetTableConstraints(string table) + { + var indexes = GetIndexes(table).ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase); + var constraints = new List(); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, $"SELECT c.constrname,c.constrtype,c.idxname FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND c.constrtype IN ('P','U') ORDER BY c.constrname")) + { + while (reader.Read()) + { + var name = reader.GetString(0).Trim(); + var index = indexes[reader.GetString(2).Trim()]; + constraints.Add(reader.GetString(1).Trim() == "P" + ? new PrimaryKeyConstraint(name, index.KeyColumns) + : new DotNetProjects.Migrator.Framework.UniqueConstraint(name, index.KeyColumns)); + } + } + var checks = new Dictionary(); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, $"SELECT c.constrname,ch.checktext FROM sysconstraints c JOIN systables t ON t.tabid=c.tabid JOIN syschecks ch ON ch.constrid=c.constrid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND c.constrtype='C' AND ch.type='T' ORDER BY c.constrname,ch.seqno")) + while (reader.Read()) + { + var name = reader.GetString(0).Trim(); + if (!checks.TryGetValue(name, out var text)) checks[name] = text = new System.Text.StringBuilder(); + text.Append(reader.GetString(1)); + } + constraints.AddRange(checks.Select(c => new CheckConstraint(c.Key, ConstraintMetadataReader.CheckExpression(c.Value.ToString())))); + constraints.AddRange(GetForeignKeyConstraints(table)); + return constraints.ToArray(); + } + private static object ReadDefault(string catalogValue, string kind, DbType type) { // SYSDEFAULTS stores literal text without SQL quotes, and prefixes non-character diff --git a/src/Migrator/Providers/Impl/Ingres/IngresDialect.cs b/src/Migrator/Providers/Impl/Ingres/IngresDialect.cs index a6ce8103..faf61259 100644 --- a/src/Migrator/Providers/Impl/Ingres/IngresDialect.cs +++ b/src/Migrator/Providers/Impl/Ingres/IngresDialect.cs @@ -46,8 +46,8 @@ public IngresDialect() this.RegisterColumnType(DbType.String, 1073741823, "LONGTEXT"); this.RegisterColumnType(DbType.Time, "TIME"); - this.RegisterProperty(ColumnProperty.Unsigned, "UNSIGNED"); - this.RegisterProperty(ColumnProperty.Identity, "AUTO_INCREMENT"); + this.RegisterColumnAttribute(ColumnAttribute.Unsigned, "UNSIGNED"); + this.RegisterColumnAttribute(ColumnAttribute.Identity, "AUTO_INCREMENT"); this.RegisterUnsignedCompatible(DbType.Int16); this.RegisterUnsignedCompatible(DbType.Int32); diff --git a/src/Migrator/Providers/Impl/Mysql/MariaDBDialect.cs b/src/Migrator/Providers/Impl/Mysql/MariaDBDialect.cs index 2f8f614b..924dd0eb 100644 --- a/src/Migrator/Providers/Impl/Mysql/MariaDBDialect.cs +++ b/src/Migrator/Providers/Impl/Mysql/MariaDBDialect.cs @@ -5,6 +5,12 @@ namespace DotNetProjects.Migrator.Providers.Impl.Mysql; public class MariaDBDialect : MysqlDialect { + protected override string ResolveCollation(CollationKind kind) => kind switch + { + CollationKind.Binary => "utf8mb4_nopad_bin", CollationKind.CaseSensitive => "utf8mb4_uca1400_nopad_as_cs", CollationKind.CaseInsensitive => "utf8mb4_uca1400_nopad_as_ci", + _ => throw new System.NotSupportedException("MariaDB cannot resolve " + kind + ". Use an installed named collation.") + }; + public override ITransformationProvider GetTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) { return new MariaDBTransformationProvider(dialect, connectionString, scope, providerName); diff --git a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs index 5ad933d7..7e06754c 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -207,9 +207,8 @@ public override Column[] GetColumns(string table) "tinyblob" or "mediumblob" or "blob" or "binary" or "varbinary" or "longblob" => DbType.Binary, _ => DbType.String }; var column = new Column(reader.GetString(0), type); - column.ColumnProperty = reader.GetString(2) == "YES" ? ColumnProperty.Null : ColumnProperty.NotNull; - if (reader.GetString(4).Contains("auto_increment")) column.ColumnProperty |= ColumnProperty.Identity; - if (reader.GetString(6) == "PRI") column.ColumnProperty |= ColumnProperty.PrimaryKey; + column.IsNullable = reader.GetString(2) == "YES"; + if (reader.GetString(4).Contains("auto_increment")) column.IsIdentity = true; if (!reader.IsDBNull(3)) column.DefaultValue = ReadDefault(reader.GetString(3), type, reader.GetString(4)); if (type == DbType.Decimal) { diff --git a/src/Migrator/Providers/Impl/Mysql/MysqlDialect.cs b/src/Migrator/Providers/Impl/Mysql/MysqlDialect.cs index fa51e466..d60d348f 100644 --- a/src/Migrator/Providers/Impl/Mysql/MysqlDialect.cs +++ b/src/Migrator/Providers/Impl/Mysql/MysqlDialect.cs @@ -5,6 +5,14 @@ namespace DotNetProjects.Migrator.Providers.Impl.Mysql; public class MysqlDialect : Dialect { + protected override string ResolveCollation(CollationKind kind) => kind switch + { + CollationKind.Binary => "utf8mb4_0900_bin", CollationKind.CaseSensitive => "utf8mb4_0900_as_cs", CollationKind.CaseInsensitive => "utf8mb4_0900_as_ci", + _ => base.ResolveCollation(kind) + }; + + public override string GetCollationSql(string name) => "COLLATE " + QuoteIdentifier(name); + public MysqlDialect() { // TODO: As per http://dev.mysql.com/doc/refman/5.0/en/char.html 5.0.3 and above @@ -52,9 +60,8 @@ public MysqlDialect() RegisterColumnType(DbType.String, int.MaxValue, "LONGTEXT"); RegisterColumnType(DbType.Time, "TIME"); - RegisterProperty(ColumnProperty.Unsigned, "UNSIGNED"); - RegisterProperty(ColumnProperty.Identity, "AUTO_INCREMENT"); - RegisterProperty(ColumnProperty.CaseSensitive, "BINARY"); + RegisterColumnAttribute(ColumnAttribute.Unsigned, "UNSIGNED"); + RegisterColumnAttribute(ColumnAttribute.Identity, "AUTO_INCREMENT"); RegisterUnsignedCompatible(DbType.Int16); RegisterUnsignedCompatible(DbType.Int32); diff --git a/src/Migrator/Providers/Impl/Oracle/OracleColumnPropertiesMapper.cs b/src/Migrator/Providers/Impl/Oracle/OracleColumnPropertiesMapper.cs index 56f672d4..b3e0a9d7 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleColumnPropertiesMapper.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleColumnPropertiesMapper.cs @@ -13,25 +13,21 @@ public override void MapColumnProperties(Column column) { Name = column.Name; - _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); var vals = new List(); AddName(vals); AddType(vals); + AddCollation(column, vals); AddIdentity(column, vals); AddUnsigned(column, vals); - AddPrimaryKey(column, vals); AddIdentityAgain(column, vals); - AddUnique(column, vals); - - AddForeignKey(column, vals); AddDefaultValue(column, vals); diff --git a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs index 4d5a2b32..49fef510 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs @@ -6,6 +6,8 @@ namespace DotNetProjects.Migrator.Providers.Impl.Oracle; public class OracleDialect : Dialect { + public override string GetCollationSql(string name) => "COLLATE " + QuoteIdentifier(name); + public OracleDialect() { RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); @@ -50,13 +52,13 @@ public OracleDialect() RegisterColumnType(DbType.Guid, "RAW(16)"); RegisterColumnType(MigratorDbType.Interval, "interval day (9) to second (9)"); - RegisterProperty(ColumnProperty.Identity, "GENERATED ALWAYS AS IDENTITY"); + RegisterColumnAttribute(ColumnAttribute.Identity, "GENERATED ALWAYS AS IDENTITY"); // the original Migrator.Net code had this, but it's a bad idea - when // apply a "null" migration to a "not-null" field, it just leaves it as "not-null" and it silently fails // because Oracle doesn't consider ALTER TABLE MODIFY (column ) as being a request to make the field null. - //RegisterProperty(ColumnProperty.Null, String.Empty); + //RegisterColumnAttribute(ColumnAttribute.Null, String.Empty); AddReservedWords("ACCOUNT", "ACTIVATE", "ADMIN", "ADVISE", "AFTER", "ALL_ROWS", "ALLOCATE", "ANALYZE", "ARCHIVE", "ARCHIVELOG", "ARRAY", "AT", "AUTHENTICATED", "AUTHORIZATION", "AUTOEXTEND", "AUTOMATIC", "BACKUP", "BECOME", "BEFORE", "BEGIN", "BFILE", "BITMAP", "BLOB", "BLOCK", "BODY", "CACHE", "CACHE_INSTANCES", "CANCEL", "CASCADE", "CAST", "CFILE", "CHAINED", "CHANGE", "CHAR_CS", "CHARACTER", "CHECKPOINT", "CHOOSE", "CHUNK", "CLEAR", "CLOB", "CLONE", "CLOSE", "CLOSE_CACHED_OPEN_CURSORS", "COALESCE", "COLUMNS", "COMMIT", "COMMITTED", "COMPATIBILITY", "COMPILE", "COMPLETE", "COMPOSITE_LIMIT", "COMMENT", "COMPUTE", "CONNECT_TIME", "CONSTRAINT", "CONSTRAINTS", "CONTENTS", "CONTINUE", "CONTROLFILE", "CONVERT", "COST", "CPU_PER_CALL", "CPU_PER_SESSION", "CURRENT_SCHEMA", "CURREN_USER", "CURSOR", "CYCLE", "DANGLING", "DATABASE", "DATAFILE", "DATAFILES", "DATAOBJNO", "DBA", "DBHIGH", "DBLOW", "DBMAC", "DEALLOCATE", "DEBUG", "DEC", "DECLARE", "DEFERRABLE", "DEFERRED", "DEGREE", "DEREF", "DIRECTORY", "DISABLE", "DISCONNECT", "DISMOUNT", "DISTRIBUTED", "DML", "DOUBLE", "DUMP", "EACH", "ENABLE", "END", "ENFORCE", "ENTRY", "ESCAPE", "EXCEPT", "EXCEPTIONS", "EXCHANGE", "EXCLUDING", "EXECUTE", "EXPIRE", "EXPLAIN", "EXTENT", "EXTENTS", "EXTERNALLY", "FAILED_LOGIN_ATTEMPTS", "FALSE", "FAST", "FIRST_ROWS", "FLAGGER", "FLOB", "FLUSH", "FORCE", "FOREIGN", "FREELIST", "FREELISTS", "FULL", "FUNCTION", "GLOBAL", "GLOBALLY", "GLOBAL_NAME", "GROUPS", "HASH", "HASHKEYS", "HEADER", "HEAP", "IDGENERATORS", "IDLE_TIME", "IF", "INCLUDING", "INCREMENT", "INDEXED", "INDEXES", "INDICATOR", "IND_PARTITION", "INITIALLY", "INITRANS", "INSTANCE", "INSTANCES", "INSTEAD", "INT", "INTERMEDIATE", "ISOLATION", "ISOLATION_LEVEL", "KEEP", "KEY", "KILL", "LABEL", "LAYER", "LESS", "LIBRARY", "LIMIT", "LINK", "LIST", "LOB", "LOCAL", "LOCKED", "LOG", "LOGFILE", "LOGGING", "LOGICAL_READS_PER_CALL", "LOGICAL_READS_PER_SESSION", "MANAGE", "MASTER", "MAX", "MAXARCHLOGS", "MAXDATAFILES", "MAXINSTANCES", "MAXLOGFILES", "MAXLOGHISTORY", "MAXLOGMEMBERS", "MAXSIZE", "MAXTRANS", "MAXVALUE", "MIN", "MEMBER", "MINIMUM", "MINEXTENTS", "MINVALUE", "MLS_LABEL_FORMAT", "MOUNT", "MOVE", "MTS_DISPATCHERS", "MULTISET", "NATIONAL", "NCHAR", "NCHAR_CS", "NCLOB", "NEEDED", "NESTED", "NETWORK", "NEW", "NEXT", "NOARCHIVELOG", "NOCACHE", "NOCYCLE", "NOFORCE", "NOLOGGING", "NOMAXVALUE", "NOMINVALUE", "NONE", "NOORDER", "NOOVERRIDE", "NOPARALLEL", "NOPARALLEL", "NOREVERSE", "NORMAL", "NOSORT", "NOTHING", "NUMBER", "NUMERIC", "NVARCHAR2", "OBJECT", "OBJNO", "OBJNO_REUSE", "OFF", "OID", "OIDINDEX", "OLD", "ONLY", "OPCODE", "OPEN", "OPTIMAL", "OPTIMIZER_GOAL", "ORGANIZATION", "OSLABEL", "OVERFLOW", "OWN", "ORDER", "PACKAGE", "PARALLEL", "PARTITION", "PASSWORD", "PASSWORD_GRACE_TIME", "PASSWORD_LIFE_TIME", "PASSWORD_LOCK_TIME", "PASSWORD_REUSE_MAX", "PASSWORD_REUSE_TIME", "PASSWORD_VERIFY_FUNCTION", "PCTINCREASE", "PCTTHRESHOLD", "PCTUSED", "PCTVERSION", "PERCENT", "PERMANENT", "PLAN", "PLSQL_DEBUG", "POST_TRANSACTION", "PRECISION", "PRESERVE", "PRIMARY", "PRIVATE", "PRIVATE_SGA", "PRIVILEGE", "PROCEDURE", "PROFILE", "PURGE", "QUEUE", "QUOTA", "RANGE", "RBA", "READ", "READUP", "REAL", "REBUILD", "RECOVER", "RECOVERABLE", "RECOVERY", "REF", "REFERENCES", "REFERENCING", "REFRESH", "REPLACE", "RESET", "RESETLOGS", "RESIZE", "RESTRICTED", "RETURN", "RETURNING", "REUSE", "REVERSE", "ROLE", "ROLES", "ROLLBACK", "RULE", "SAMPLE", "SAVEPOINT", "SB4", "SCAN_INSTANCES", "SCHEMA", "SCN", "SCOPE", "SD_ALL", "SD_INHIBIT", "SD_SHOW", "SEGMENT", "SEG_BLOCK", "SEG_FILE", "SEQUENCE", "SERIALIZABLE", "SESSION_CACHED_CURSORS", "SESSIONS_PER_USER", "SIZE", "SHARED", "SHARED_POOL", "SHRINK", "SKIP", "SKIP_UNUSABLE_INDEXES", "SNAPSHOT", "SOME", "SORT", "SPECIFICATION", "SPLIT", "SQL_TRACE", "STANDBY", "STATEMENT_ID", "STATISTICS", "STOP", "STORAGE", "STORE", "STRUCTURE", "SWITCH", "SYS_OP_ENFORCE_NOT_NULL$", "SYS_OP_NTCIMG$", "SYSDBA", "SYSOPER", "SYSTEM", "TABLES", "TABLESPACE", "TABLESPACE_NO", "TABNO", "TEMPORARY", "THAN", "THE", "THREAD", "TIMESTAMP", "TIME", "TOPLEVEL", "TRACE", "TRACING", "TRANSACTION", "TRANSITIONAL", "TRIGGERS", "TRUE", "TRUNCATE", "TX", "TYPE", "UB2", "UBA", "UNARCHIVED", "UNDO", "UNLIMITED", "UNLOCK", "UNRECOVERABLE", "UNTIL", "UNUSABLE", "UNUSED", "UPDATABLE", "USAGE", "USE", "USING", "VALIDATION", "VALUE", "VALUES", "VARYING", "VIEW", "WHEN", "WITHOUT", "WORK", "WRITE", "WRITEDOWN", "WRITEUP", "XID", "YEAR", "ZONE"); } diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index 1b3a7ddd..5e7b5f71 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -56,38 +56,8 @@ public override void DropDatabases(string databaseName) } } - public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) - { - var constraints = new List(); - var foreignKeyConstraintItems = _oracleSystemDataLoader.GetForeignKeyConstraintItems(table); - - var schemaChildTableGroups = foreignKeyConstraintItems.GroupBy(x => new { x.SchemaName, x.ChildTableName }).Count(); - - if (schemaChildTableGroups > 1) - { - throw new MigrationException($"Duplicates found (grouping by schema name and child table name). Since we do not offer schemas in '{nameof(GetForeignKeyConstraints)}' at this moment in time we cannot filter your target schema. Your database use the same table name in different schemas."); - } - - var groups = foreignKeyConstraintItems.GroupBy(x => x.ForeignKeyName); - - foreach (var group in groups) - { - var first = group.First(); - - var foreignKeyConstraint = new ForeignKeyConstraint - { - Name = first.ForeignKeyName, - ParentTable = first.ParentTableName, - ParentColumns = [.. group.Select(x => x.ParentColumnName).Distinct()], - ChildTable = first.ChildTableName, - ChildColumns = [.. group.Select(x => x.ChildColumnName).Distinct()] - }; - - constraints.Add(foreignKeyConstraint); - } - - return [.. constraints]; - } + public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) => + ForeignKeyMetadataReader.Read(this, table); public override void AddForeignKey(string name, string primaryTable, string[] primaryColumns, string refTable, string[] refColumns, ForeignKeyConstraintType constraint) @@ -102,7 +72,8 @@ public override string AddIndex(string table, Index index) ValidateIndex(tableName: table, index: index); var hasFilterItems = index.FilterItems != null && index.FilterItems.Count > 0; - // Oracle does not support included columns and clustered indexes. We ignore the values given in the properties SILENTLY for backwards compatibility. + if (index.IncludeColumns?.Length > 0 || index.Clustered) + throw new NotSupportedException("Oracle does not support included columns or SQL Server-style clustered indexes. Use an explicit Oracle operation."); if (index.Unique && hasFilterItems) { @@ -196,56 +167,18 @@ protected override string GetPrimaryKeyname(string tableName) public override void ChangeColumn(string table, Column column) { - column = column.CopyDefinition(); - var existingColumn = GetColumnByName(table, column.Name); - - if (column.Type == DbType.String) - { - RenameColumn(table, column.Name, TemporaryColumnName); - - // check if this is not-null - var isNotNull = (column.ColumnProperty & ColumnProperty.NotNull) == ColumnProperty.NotNull; - - // remove the not-null option - column.ColumnProperty = (column.ColumnProperty & ~ColumnProperty.NotNull); - - AddColumn(table, column); - CopyDataFromOneColumnToAnother(table, TemporaryColumnName, column.Name); - RemoveColumn(table, TemporaryColumnName); - //RenameColumn(table, TemporaryColumnName, column.Name); - - var columnName = QuoteColumnNameIfRequired(column.Name); - - // now set the column to not-null - if (isNotNull) - { - using var cmd = CreateCommand(); - ExecuteQuery(cmd, string.Format("ALTER TABLE {0} MODIFY ({1} NOT NULL)", table, columnName)); - } - } - else - { - // String changes replace the column, which already removes its default. - // For in-place changes Oracle otherwise retains the existing default. - if (column.DefaultValue == null) RemoveColumnDefaultValue(table, column.Name); - if (((existingColumn.ColumnProperty & ColumnProperty.NotNull) == ColumnProperty.NotNull) - && ((column.ColumnProperty & ColumnProperty.NotNull) == ColumnProperty.NotNull)) - { - // was not null, and is being change to not-null - drop the not-null all together - column.ColumnProperty = column.ColumnProperty & ~ColumnProperty.NotNull; - } - else if - (((existingColumn.ColumnProperty & ColumnProperty.Null) == ColumnProperty.Null) - && ((column.ColumnProperty & ColumnProperty.Null) == ColumnProperty.Null)) - { - // was null, and is being changed to null - drop the null all together - column.ColumnProperty = column.ColumnProperty & ~ColumnProperty.Null; - } - - var mapper = _dialect.GetAndMapColumnProperties(column); - - ChangeColumn(table, mapper.ColumnSql); - } + var existing = GetColumnByName(table, column.Name); + var definition = column.CopyDefinition(); + if (definition.DefaultValue == null) RemoveColumnDefaultValue(table, definition.Name); + // Oracle rejects restating an existing NOT NULL constraint. Render type/default + // separately and change nullability only when its value actually changes. + definition.IsNullable = true; + var mapper = _dialect.GetAndMapColumnProperties(definition); + var sql = mapper.ColumnSql; + if (sql.EndsWith(" NULL", StringComparison.Ordinal)) sql = sql[..^5]; + if (existing.IsNullable != column.IsNullable) + sql += column.IsNullable ? " NULL" : " NOT NULL"; + ChangeColumn(table, sql); } private void CopyDataFromOneColumnToAnother(string table, string fromColumn, string toColumn) @@ -519,24 +452,23 @@ public override Column[] GetColumns(string table) var column = new Column(columnName, DbType.String) { - ColumnProperty = isNullable ? ColumnProperty.Null : ColumnProperty.NotNull + IsNullable = isNullable }; - if (uniqueColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.Unique; var isIdentity = userTabIdentityCols.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); var isPrimaryKey = primaryKeyItems.Any(x => x.ColumnName.Equals(columnName, StringComparison.OrdinalIgnoreCase)); if (isIdentity && isPrimaryKey) { - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.PrimaryKeyWithIdentity); + column.IsIdentity = true; } else if (isIdentity) { - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.Identity); + column.IsIdentity = true; } else if (isPrimaryKey) { - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.PrimaryKey); + } // Oracle does not have unsigned types. All NUMBER types can hold positive or negative values so we do not return DbType.UIntX types. @@ -638,14 +570,19 @@ public override Column[] GetColumns(string table) // dataDefaultString contains ISEQ$$ if the column is an identity column if ( !string.IsNullOrWhiteSpace(dataDefaultString) && - (column.Type == DbType.String || !dataDefaultString.Equals("null", StringComparison.OrdinalIgnoreCase)) && + !dataDefaultString.Trim().Equals("null", StringComparison.OrdinalIgnoreCase) && !dataDefaultString.Contains("ISEQ$$") && !dataDefaultString.Contains(".nextval")) { // This is only necessary because older versions of this migrator added single quotes for numerics. var singleQuoteStrippedString = dataDefaultString.Replace("'", ""); - if (column.Type == DbType.Int16 || column.Type == DbType.Int32 || column.Type == DbType.Int64) + var parsedDefault = CatalogDefaultValue.Parse(dataDefaultString, column.Type); + if (column.Type is DbType.String or DbType.AnsiString or DbType.StringFixedLength or DbType.AnsiStringFixedLength + || (parsedDefault is RawSql && !Regex.IsMatch(dataDefaultString, + @"(?i)^\s*(TO_TIMESTAMP\s*\(|TIMESTAMP\s*'|HEXTORAW\s*\()"))) + column.DefaultValue = parsedDefault; + else if (column.Type == DbType.Int16 || column.Type == DbType.Int32 || column.Type == DbType.Int64) { column.DefaultValue = long.Parse(singleQuoteStrippedString, CultureInfo.InvariantCulture); } @@ -882,40 +819,12 @@ public override void RemoveColumnDefaultValue(string table, string column) public override void AddTable(string name, params IDbField[] fields) { GuardAgainstMaximumIdentifierLengthForOracle(name); - name = QuoteTableNameIfRequired(name); - - var columns = fields.Where(x => x is Column).Cast().ToArray(); - + var columns = fields.OfType().ToArray(); GuardAgainstMaximumColumnNameLengthForOracle(name, columns); - + foreach (var identity in columns.Where(c => c.IsIdentity)) + if (identity.Type is not (DbType.Int16 or DbType.Int32 or DbType.Int64 or DbType.UInt16 or DbType.UInt32 or DbType.UInt64)) + throw new MigrationException("Oracle identity columns require an integer type."); base.AddTable(name, fields); - - // Should be refactored - if (columns.Any(c => c.ColumnProperty == ColumnProperty.PrimaryKeyWithIdentity || - (c.ColumnProperty.HasFlag(ColumnProperty.Identity) && c.ColumnProperty.HasFlag(ColumnProperty.PrimaryKey)))) - { - var identityColumn = columns.First(x => x.ColumnProperty.HasFlag(ColumnProperty.Identity) && x.ColumnProperty.HasFlag(ColumnProperty.PrimaryKey)); - - List allowedIdentityDbTypes = [DbType.Int16, DbType.Int32, DbType.Int64, DbType.UInt16, DbType.UInt32, DbType.UInt64]; - - if (!allowedIdentityDbTypes.Contains(identityColumn.Type)) - { - var allowedIdentityDbTypesStringList = allowedIdentityDbTypes.Select(x => x.ToString()).ToList(); - var allowedIdentityDbTypesString = $"{string.Join(", ", allowedIdentityDbTypesStringList[..^1])} and {allowedIdentityDbTypesStringList[^1..]}"; - - throw new MigrationException($"Identity columns can only be used with {allowedIdentityDbTypesString}"); - } - - var identityColumnNameQuoted = QuoteColumnNameIfRequired(identityColumn.Name); - - using var cmd = CreateCommand(); - // We use ALWAYS in order to prevent sequence problems in cases of misuse of the column by an unexperienced user. Inserting data will result in an exception. - ExecuteQuery(cmd, $"ALTER TABLE {name} MODIFY {identityColumnNameQuoted} GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1 NOCACHE NOCYCLE)"); - } - else if (columns.Any(x => x.ColumnProperty.HasFlag(ColumnProperty.Identity) && !x.ColumnProperty.HasFlag(ColumnProperty.PrimaryKey))) - { - throw new MigrationException("Identity without Primary is currently not supported by this migrator"); - } } public override void RemoveTable(string name) diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs index 2987d1d8..1cd7fea1 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLDialect.cs @@ -6,6 +6,14 @@ namespace DotNetProjects.Migrator.Providers.Impl.PostgreSQL; public class PostgreSQLDialect : Dialect { + protected override string ResolveCollation(CollationKind kind) => kind switch + { + CollationKind.Binary => "C", + _ => base.ResolveCollation(kind) + }; + + public override string GetCollationSql(string name) => "COLLATE " + QuoteIdentifier(name); + public PostgreSQLDialect() { RegisterColumnType(DbType.AnsiStringFixedLength, "char(255)"); @@ -47,7 +55,7 @@ public PostgreSQLDialect() RegisterColumnType(DbType.Guid, "uuid"); RegisterColumnType(MigratorDbType.Interval, "interval"); - RegisterProperty(ColumnProperty.Identity, "GENERATED ALWAYS AS IDENTITY"); + RegisterColumnAttribute(ColumnAttribute.Identity, "GENERATED ALWAYS AS IDENTITY"); AddReservedWords("ABS", "ABSOLUTE", "ACCESS", "ACTION", "ADA", "ADD", "ADMIN", "AFTER", "AGGREGATE", "ALIAS", "ALL", "ALLOCATE", "ALTER", "ANALYSE", "ANALYZE", "AND", "ANY", "ARE", "ARRAY", "AS", "ASC", "ASENSITIVE", "ASSERTION", "ASSIGNMENT", "ASYMMETRIC", "AT", "ATOMIC", "AUTHORIZATION", "AVG", "BACKWARD", "BEFORE", "BEGIN", "BETWEEN", "BIGINT", "BINARY", @@ -138,10 +146,10 @@ public override string Default(object defaultValue) return base.Default(defaultValue); } - //public override string SqlForProperty(ColumnProperty property, Column column) + //public override string SqlForColumnAttribute(ColumnAttribute property, Column column) //{ - // if (property == ColumnProperty.Identity && (column.Type == DbType.Int64 || column.Type == DbType.UInt32 || column.Type == DbType.UInt64)) + // if (property == ColumnAttribute.Identity && (column.Type == DbType.Int64 || column.Type == DbType.UInt32 || column.Type == DbType.UInt64)) // return "bigserial"; - // return base.SqlForProperty(property, column); + // return base.SqlForColumnAttribute(property, column); //} } diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index dd1714e3..25ab5284 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -373,9 +373,6 @@ public override void ChangeColumn(string table, Column column) { var oldColumn = GetColumnByName(table, column.Name); - var isUniqueSet = column.ColumnProperty.IsSet(ColumnProperty.Unique); - - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.Unique); var mapper = _dialect.GetAndMapColumnProperties(column); @@ -408,7 +405,7 @@ public override void ChangeColumn(string table, Column column) ChangeColumn(table, change2); } - if (column.ColumnProperty.HasFlag(ColumnProperty.NotNull)) + if (!column.IsNullable) { var change3 = string.Format("{0} SET NOT NULL", QuoteColumnNameIfRequired(mapper.Name)); ChangeColumn(table, change3); @@ -419,10 +416,7 @@ public override void ChangeColumn(string table, Column column) ChangeColumn(table, change3); } - if (isUniqueSet) - { - AddUniqueConstraint(string.Format("UX_{0}_{1}", table, column.Name), table, [column.Name]); - } + } public override void CreateDatabases(string databaseName) @@ -609,22 +603,34 @@ public override Column[] GetColumns(string table) Size = size ?? 0 }; - column.ColumnProperty |= isNullable ? ColumnProperty.Null : ColumnProperty.NotNull; - if (uniqueColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.Unique; + column.IsNullable = isNullable; if (isPrimaryKey) { - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.PrimaryKey); + } if (isIdentity) { - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.Identity); + column.IsIdentity = true; } - if (columnInfo.ColumnDefault != null) + if (columnInfo.ColumnDefault != null && !isIdentity) { - if (column.MigratorDbType == MigratorDbType.Int16 || column.MigratorDbType == MigratorDbType.Int32 || column.MigratorDbType == MigratorDbType.Int64) + // Catalog casts on literal values retain the existing CLR conversion. + // All other expressions must survive inspection without evaluation or quoting. + var parsedDefault = CatalogDefaultValue.Parse(columnInfo.ColumnDefault, column.Type); + var isCastLiteral = Regex.IsMatch(columnInfo.ColumnDefault, + @"\A'(?:[^']|'')*'(?:::[A-Za-z0-9_ .\[\](),]+)?\z"); + if (column.Type is DbType.String or DbType.AnsiString or DbType.StringFixedLength or DbType.AnsiStringFixedLength) + { + var literal = Regex.Match(columnInfo.ColumnDefault, @"\A('(?:[^']|'')*')(?:::[A-Za-z0-9_ .\[\](),]+)?\z"); + column.DefaultValue = literal.Success + ? CatalogDefaultValue.Parse(literal.Groups[1].Value, column.Type) : parsedDefault; + } + else if (parsedDefault is RawSql && !isCastLiteral) + column.DefaultValue = parsedDefault; + else if (column.MigratorDbType == MigratorDbType.Int16 || column.MigratorDbType == MigratorDbType.Int32 || column.MigratorDbType == MigratorDbType.Int64) { var match = stripSingleQuoteRegEx.Match(columnInfo.ColumnDefault); if (match.Success) diff --git a/src/Migrator/Providers/Impl/SQLite/Models/SQLiteTableInfo.cs b/src/Migrator/Providers/Impl/SQLite/Models/SQLiteTableInfo.cs index c839b034..3a4c8ea4 100644 --- a/src/Migrator/Providers/Impl/SQLite/Models/SQLiteTableInfo.cs +++ b/src/Migrator/Providers/Impl/SQLite/Models/SQLiteTableInfo.cs @@ -15,6 +15,9 @@ public class SQLiteTableInfo /// public List Columns { get; set; } = []; + /// The named primary key, with members in declared key order. + public PrimaryKeyConstraint PrimaryKey { get; set; } + /// /// Gets or sets the indexes of a table. /// @@ -33,7 +36,7 @@ public class SQLiteTableInfo /// /// Gets or sets the unique definitions. /// - public List Uniques { get; set; } = []; + public List Uniques { get; set; } = []; /// /// Gets or sets the check constraint definitions. diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteColumnPropertiesMapper.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteColumnPropertiesMapper.cs index 442a7834..79fcfd2a 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteColumnPropertiesMapper.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteColumnPropertiesMapper.cs @@ -1,36 +1,2 @@ -using System.Collections.Generic; -using DotNetProjects.Migrator.Framework; - namespace DotNetProjects.Migrator.Providers.Impl.SQLite; - -public class SQLiteColumnPropertiesMapper : ColumnPropertiesMapper -{ - public SQLiteColumnPropertiesMapper(Dialect dialect, string type) : base(dialect, type) - { - } - - protected override void AddNull(Column column, List vals) - { - var isPrimaryKeySelected = PropertySelected(column.ColumnProperty, ColumnProperty.PrimaryKey); - var isNullSelected = PropertySelected(column.ColumnProperty, ColumnProperty.Null); - var isNotNullSelected = PropertySelected(column.ColumnProperty, ColumnProperty.NotNull); - - if (isNullSelected || (!isNotNullSelected && !isPrimaryKeySelected)) - { - AddValueIfSelected(column, ColumnProperty.Null, vals); - } - } - - protected override void AddNotNull(Column column, List vals) - { - if (column.ColumnProperty.HasFlag(ColumnProperty.NotNull)) - { - AddValueIfSelected(column, ColumnProperty.NotNull, vals); - } - } - - protected virtual void AddValueIfSelected(Column column, ColumnProperty property, ICollection vals) - { - vals.Add(_Dialect.SqlForProperty(property, column)); - } -} \ No newline at end of file +public class SQLiteColumnPropertiesMapper(Dialect dialect, string typeString) : ColumnPropertiesMapper(dialect, typeString); diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteConstraintParser.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteConstraintParser.cs new file mode 100644 index 00000000..c5c4f983 --- /dev/null +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteConstraintParser.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DotNetProjects.Migrator.Framework; + +namespace DotNetProjects.Migrator.Providers.Impl.SQLite; + +// Tokenize DDL rather than matching identifiers/expressions with regular expressions. +internal static class SQLiteConstraintParser +{ + private sealed record Token(string Text, int Start, int End, bool Quoted = false) + { + public bool Is(string value) => !Quoted && Text.Equals(value, StringComparison.OrdinalIgnoreCase); + } + + public static TableConstraint[] Parse(string sql) + { + var tokens = Tokenize(sql); + var start = tokens.FindIndex(t => t.Is("(")); + if (start < 0) throw new MigrationException("SQLite CREATE TABLE has no column definition list."); + var end = Close(tokens, start); + var result = new List(); + var first = start + 1; + var depth = 0; + for (var i = first; i <= end; i++) + { + if (i == end || (depth == 0 && tokens[i].Is(","))) + { + ParseDefinition(sql, tokens.GetRange(first, i - first), result); + first = i + 1; + } + else if (tokens[i].Is("(")) depth++; + else if (tokens[i].Is(")")) depth--; + } + return result.ToArray(); + } + + private static void ParseDefinition(string sql, List tokens, List result) + { + if (tokens.Count == 0) return; + var tableLevel = tokens[0].Is("CONSTRAINT") || tokens[0].Is("PRIMARY") || tokens[0].Is("UNIQUE") || tokens[0].Is("FOREIGN") || tokens[0].Is("CHECK"); + var column = tableLevel ? null : tokens[0].Text; + string name = null; + for (var i = tableLevel ? 0 : 1; i < tokens.Count; i++) + { + if (tokens[i].Is("CONSTRAINT")) + { + if (++i >= tokens.Count) throw new MigrationException("Missing SQLite constraint name."); + name = tokens[i].Text; + } + else if (tokens[i].Is("PRIMARY") && i + 1 < tokens.Count && tokens[i + 1].Is("KEY")) + { + i++; + var keys = column == null ? Columns(tokens, ref i) : new[] { column }; + result.Add(new PrimaryKeyConstraint(name, keys)); name = null; + } + else if (tokens[i].Is("UNIQUE")) + { + var keys = column == null ? Columns(tokens, ref i) : new[] { column }; + result.Add(new UniqueConstraint(name, keys)); name = null; + } + else if (tokens[i].Is("CHECK")) + { + if (i + 1 >= tokens.Count || !tokens[i + 1].Is("(")) throw new MigrationException("Missing CHECK expression."); + var close = Close(tokens, i + 1); + result.Add(new CheckConstraint(name, sql[tokens[i + 1].End..tokens[close].Start])); + i = close; name = null; + } + else if (tokens[i].Is("FOREIGN") && i + 1 < tokens.Count && tokens[i + 1].Is("KEY")) + { + i++; + var children = Columns(tokens, ref i); + if (++i >= tokens.Count || !tokens[i].Is("REFERENCES")) throw new MigrationException("Missing foreign-key reference."); + result.Add(Reference(tokens, ref i, name, children)); name = null; + } + else if (tokens[i].Is("REFERENCES") && column != null) + { + result.Add(Reference(tokens, ref i, name, new[] { column })); name = null; + } + else if (tokens[i].Is("(")) i = Close(tokens, i); + } + } + + private static ForeignKeyConstraint Reference(List tokens, ref int index, string name, string[] children) + { + if (++index >= tokens.Count) throw new MigrationException("Missing referenced table."); + var parent = tokens[index].Text; + string[] parents = []; + if (index + 1 < tokens.Count && tokens[index + 1].Is("(")) parents = Columns(tokens, ref index); + var fk = new ForeignKeyConstraint(name, parent, parents, null, children) { OnDelete = "NO ACTION", OnUpdate = "NO ACTION" }; + while (index + 1 < tokens.Count) + { + if (tokens[index + 1].Is("ON")) + { + index += 2; + if (index + 1 >= tokens.Count) throw new MigrationException("Incomplete foreign-key action."); + var delete = tokens[index].Is("DELETE"); + if (!delete && !tokens[index].Is("UPDATE")) throw new MigrationException("Unknown foreign-key action."); + var action = tokens[++index].Text.ToUpperInvariant(); + if (action is "SET" or "NO") + { + if (++index >= tokens.Count) throw new MigrationException("Incomplete foreign-key action."); + action += " " + tokens[index].Text.ToUpperInvariant(); + } + if (delete) fk.OnDelete = action; else fk.OnUpdate = action; + } + else if (tokens[index + 1].Is("MATCH")) + { + index += 2; + if (index >= tokens.Count) throw new MigrationException("Incomplete MATCH clause."); + fk.Match = tokens[index].Text; + } + else break; + } + return fk; + } + + private static string[] Columns(List tokens, ref int index) + { + if (++index >= tokens.Count || !tokens[index].Is("(")) throw new MigrationException("Missing constraint column list."); + var end = Close(tokens, index); + var columns = new List(); + for (var i = index + 1; i < end; i += 2) + { + columns.Add(tokens[i].Text); + if (i + 1 < end && !tokens[i + 1].Is(",")) throw new NotSupportedException("Constraint column modifiers require explicit schema support."); + } + index = end; + if (columns.Count == 0) throw new MigrationException("Constraint column list is empty."); + return columns.ToArray(); + } + + private static int Close(List tokens, int open) + { + var depth = 0; + for (var i = open; i < tokens.Count; i++) + { + if (tokens[i].Is("(")) depth++; + if (tokens[i].Is(")") && --depth == 0) return i; + } + throw new MigrationException("Unbalanced SQLite definition."); + } + + private static List Tokenize(string sql) + { + var result = new List(); + for (var i = 0; i < sql.Length;) + { + if (char.IsWhiteSpace(sql[i])) { i++; continue; } + if (i + 1 < sql.Length && sql[i] == '-' && sql[i + 1] == '-') { while (i < sql.Length && sql[i] != '\n') i++; continue; } + if (i + 1 < sql.Length && sql[i] == '/' && sql[i + 1] == '*') + { + var end = sql.IndexOf("*/", i + 2, StringComparison.Ordinal); + if (end < 0) throw new MigrationException("Unterminated SQL comment."); + i = end + 2; continue; + } + var start = i; + if (sql[i] is '\'' or '"' or '`' or '[') + { + var close = sql[i++] == '[' ? ']' : sql[start]; + var text = new System.Text.StringBuilder(); var closed = false; + while (i < sql.Length) + { + var ch = sql[i++]; + if (ch == close) + { + if (i < sql.Length && sql[i] == close) { text.Append(close); i++; } + else { closed = true; break; } + } + else text.Append(ch); + } + if (!closed) throw new MigrationException("Unterminated quoted SQL token."); + result.Add(new Token(text.ToString(), start, i, true)); + } + else if (sql[i] is '(' or ')' or ',' or '.') { result.Add(new Token(sql[i++].ToString(), start, i)); } + else + { + while (i < sql.Length && !char.IsWhiteSpace(sql[i]) && sql[i] is not ('(' or ')' or ',' or '.' or '\'' or '"' or '`' or '[') && + !(i + 1 < sql.Length && ((sql[i] == '/' && sql[i + 1] == '*') || (sql[i] == '-' && sql[i + 1] == '-')))) i++; + result.Add(new Token(sql[start..i], start, i)); + } + } + return result; + } +} diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteDialect.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteDialect.cs index d4036da3..63fe8266 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteDialect.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteDialect.cs @@ -5,6 +5,14 @@ namespace DotNetProjects.Migrator.Providers.Impl.SQLite; public class SQLiteDialect : Dialect { + protected override string ResolveCollation(CollationKind kind) => kind switch + { + CollationKind.Binary => "BINARY", CollationKind.AsciiIgnoreCase => "NOCASE", + _ => base.ResolveCollation(kind) + }; + + public override string GetCollationSql(string name) => "COLLATE " + QuoteIdentifier(name); + public SQLiteDialect() { RegisterColumnType(DbType.Binary, "BINARY"); @@ -37,8 +45,7 @@ public SQLiteDialect() RegisterColumnType(DbType.Boolean, "BOOLEAN"); // Important for Dapper to know it should map to a bool RegisterColumnType(DbType.Guid, "UNIQUEIDENTIFIER"); - RegisterProperty(ColumnProperty.Identity, "AUTOINCREMENT"); - RegisterProperty(ColumnProperty.CaseSensitive, "COLLATE NOCASE"); + RegisterColumnAttribute(ColumnAttribute.Identity, "AUTOINCREMENT"); AddReservedWords("ABORT", "ACTION", "ADD", "AFTER", "ALL", "ALTER", "ANALYZE", "AND", "AS", "ASC", "ATTACH", "AUTOINCREMENT", "BEFORE", "BEGIN", "BETWEEN", "BY", "CASCADE", "CASE", "CAST", "CHECK", "COLLATE", "COLUMN", @@ -58,6 +65,7 @@ public SQLiteDialect() public override string Default(object defaultValue) { + if (defaultValue is RawSql expression) return "DEFAULT (" + expression.Sql + ")"; if (defaultValue is bool) { return string.Format("DEFAULT {0}", (bool)defaultValue ? "1" : "0"); diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs new file mode 100644 index 00000000..e6022fdb --- /dev/null +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTableSql.cs @@ -0,0 +1,128 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using DotNetProjects.Migrator.Framework; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; +using Index = DotNetProjects.Migrator.Framework.Index; + +namespace DotNetProjects.Migrator.Providers.Impl.SQLite; + +/// Pure SQLite table rendering shared by execution and offline preview. +internal static class SQLiteTableSql +{ + public static string Generate(Dialect dialect, string quotedTable, IDbField[] fields) + { + if (fields.Any(f => f is not (Column or PrimaryKeyConstraint or UniqueConstraint or CheckConstraint or ForeignKeyConstraint or Index))) + throw new NotSupportedException("Unsupported SQLite table definition."); + if (!fields.OfType().Any()) throw new MigrationException("A table requires columns."); + var columns = fields.Where(x => x is Column) + .Cast() + .Select(column => column.CopyDefinition()) + .ToArray(); + + var explicitKeys = fields.OfType().ToArray(); + if (explicitKeys.Length > 1) throw new MigrationException("A table can have only one primary key."); + var explicitKey = explicitKeys.SingleOrDefault(); + if (explicitKey != null) + { + TransformationProvider.ValidateKeyColumns(explicitKey.Name, explicitKey.KeyColumns, columns); + if (explicitKey.NonClustered) throw new NotSupportedException("SQLite does not support nonclustered primary keys."); + foreach (var column in columns.Where(c => explicitKey.KeyColumns.Contains(c.Name, StringComparer.OrdinalIgnoreCase))) + column.IsNullable = false; + var identities = columns.Where(c => c.IsIdentity).ToArray(); + if (identities.Length != 0 && (identities.Length != 1 || explicitKey.KeyColumns.Length != 1 || !explicitKey.KeyColumns[0].Equals(identities[0].Name, StringComparison.OrdinalIgnoreCase) || dialect.GetTypeName(identities[0].Type) != "INTEGER")) + throw new MigrationException("SQLite identity requires one INTEGER primary-key column."); + } + foreach (var unique in fields.OfType()) TransformationProvider.ValidateKeyColumns(unique.Name, unique.KeyColumns, columns); + + if (explicitKey == null && columns.Any(c => c.IsIdentity)) + throw new MigrationException("SQLite identity requires an explicit INTEGER primary-key constraint."); + var columnSql = columns.Select(column => + { + var mapped = column.CopyDefinition(); + mapped.IsIdentity = false; + var sql = dialect.GetAndMapColumnProperties(mapped).ColumnSql; + if (column.IsIdentity) + sql += (explicitKey.Name == null ? "" : $" CONSTRAINT {dialect.QuoteIdentifier(explicitKey.Name)}") + " PRIMARY KEY AUTOINCREMENT"; + return sql; + }).ToList(); + if (explicitKey != null && !columns.Any(c => c.IsIdentity)) + columnSql.Add(dialect.GetTableConstraintSql(explicitKey)); + var table = quotedTable; + var stringBuilder = new StringBuilder($"CREATE TABLE {table} ({string.Join(", ", columnSql)}"); + + // Uniques + var uniques = fields.Where(x => x is UniqueConstraint).Cast().ToArray(); + + foreach (var u in uniques) + { + if (!string.IsNullOrEmpty(u.Name)) + { + stringBuilder.Append($", CONSTRAINT {dialect.QuoteIdentifier(u.Name)}"); + } + else + { + stringBuilder.Append(", "); + } + + var uniqueColumnsCommaSeparated = string.Join(", ", u.KeyColumns.Select(dialect.QuoteColumnNameIfRequired)); + stringBuilder.Append($" UNIQUE ({uniqueColumnsCommaSeparated})"); + } + + // Foreign keys + var foreignKeys = fields.Where(x => x is ForeignKeyConstraint).Cast().ToArray(); + + List foreignKeyStrings = []; + + foreach (var fk in foreignKeys) + { + var sourceColumnNamesQuotedString = string.Join(", ", fk.ChildColumns.Select(dialect.QuoteColumnNameIfRequired)); + var parentColumnNamesQuotedString = string.Join(", ", fk.ParentColumns.Select(dialect.QuoteColumnNameIfRequired)); + var parentTableNameQuoted = dialect.QuoteTableNameIfRequired(fk.ParentTable); + + var foreignKeySql = (fk.Name == null ? "" : $"CONSTRAINT {dialect.QuoteIdentifier(fk.Name)} ") + + $"FOREIGN KEY ({sourceColumnNamesQuotedString}) REFERENCES {parentTableNameQuoted}" + + (fk.ParentColumns.Length == 0 ? "" : $"({parentColumnNamesQuotedString})"); + if (!string.IsNullOrWhiteSpace(fk.OnDelete) && !string.Equals(fk.OnDelete, "NO ACTION", StringComparison.OrdinalIgnoreCase)) + { + foreignKeySql += $" ON DELETE {ValidateAction(fk.OnDelete)}"; + } + + if (!string.IsNullOrWhiteSpace(fk.OnUpdate)) foreignKeySql += $" ON UPDATE {ValidateAction(fk.OnUpdate)}"; + foreignKeyStrings.Add(foreignKeySql); + } + + if (foreignKeyStrings.Count > 0) + { + stringBuilder.Append(", "); + stringBuilder.Append(string.Join(", ", foreignKeyStrings)); + } + + // Check Constraints + var checkConstraints = fields.Where(x => x is CheckConstraint).OfType().ToArray(); + List checkConstraintStrings = []; + + foreach (var checkConstraint in checkConstraints) + { + checkConstraintStrings.Add(dialect.GetTableConstraintSql(checkConstraint)); + } + + if (checkConstraintStrings.Count > 0) + { + stringBuilder.Append($", {string.Join(", ", checkConstraintStrings)}"); + } + + stringBuilder.Append(')'); + + return stringBuilder.ToString(); + } + + private static string ValidateAction(string action) + { + var value = action.ToUpperInvariant(); + if (value is not ("CASCADE" or "RESTRICT" or "SET NULL" or "SET DEFAULT" or "NO ACTION")) + throw new MigrationException("Unsupported foreign-key action: " + action); + return value; + } +} diff --git a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs index 6c3b5b6a..a21e8646 100644 --- a/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SQLite/SQLiteTransformationProvider.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Data; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; using System.Globalization; using System.Linq; using System.Text; @@ -123,18 +124,23 @@ public string GetSqlCreateTableScript(string table) { string sqlCreateTableScript = null; - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, string.Format("SELECT sql FROM sqlite_master WHERE type='table' AND lower(name)=lower('{0}')", table))) - { - if (reader.Read()) - { - sqlCreateTableScript = reader.IsDBNull(0) ? null : (string)reader[0]; - } - } + using var cmd = CreateCommand(); + var parameter = cmd.CreateParameter(); parameter.ParameterName = "@name"; parameter.Value = table; cmd.Parameters.Add(parameter); + using var reader = ExecuteQuery(cmd, "SELECT sql FROM sqlite_master WHERE type='table' AND name=@name COLLATE NOCASE"); + if (reader.Read()) sqlCreateTableScript = reader.IsDBNull(0) ? null : reader.GetString(0); return sqlCreateTableScript; } + public override TableConstraint[] GetTableConstraints(string table) + { + var script = GetSqlCreateTableScript(table); + if (string.IsNullOrWhiteSpace(script)) throw new MigrationException("Table does not exist: " + table); + var constraints = SQLiteConstraintParser.Parse(script); + foreach (var foreignKey in constraints.OfType()) foreignKey.ChildTable = table; + return constraints; + } + public override ForeignKeyConstraint[] GetForeignKeyConstraints(string tableName) { List foreignKeyConstraints = []; @@ -166,61 +172,16 @@ public override ForeignKeyConstraint[] GetForeignKeyConstraints(string tableName return []; } - var createTableScript = GetSqlCreateTableScript(tableName); - // GeneratedRegex - var regEx = new Regex(@"CONSTRAINT\s+\w+\s+FOREIGN\s+KEY\s*\([^)]+\)\s+REFERENCES\s+[\w""]+\s*\([^)]+\)"); - var matchesCollection = regEx.Matches(createTableScript); - var fkParts = matchesCollection.Cast().ToList().Where(x => x.Success).Select(x => x.Value).ToList(); - - if (fkParts.Count != foreignKeyConstraints.Count) - { - throw new Exception($"Cannot extract all foreign keys out of the create table script in SQLite. Did you use a name as foreign key constraint for all constraints in table '{tableName}' in this or older migrations?"); - } - - List foreignKeyExtracts = []; - - foreach (var fkPart in fkParts) - { - var regexParenthesis = new Regex(@"\(([^)]+)\)"); - var parenthesisContents = regexParenthesis.Matches(fkPart).Cast().Select(x => x.Groups[1].Value).ToList(); - - if (parenthesisContents.Count != 2) - { - throw new Exception("Cannot extract parenthesis of foreign key constraint"); - } - - var foreignKeyExtract = new ForeignKeyExtract() - { - ChildColumnNames = parenthesisContents[0].Split(',').Select(x => x.Trim()).ToList(), - ForeignKeyString = fkPart, - ParentColumnNames = parenthesisContents[1].Split(',').Select(x => x.Trim()).ToList(), - }; - - var foreignKeyConstraintNameRegex = new Regex(@"CONSTRAINT\s+(\w+)\s+FOREIGN\s+KEY"); - var foreignKeyNameMatch = foreignKeyConstraintNameRegex.Match(fkPart); - - if (!foreignKeyNameMatch.Success) - { - throw new Exception("Could not extract the foreign key constraint name"); - } - - foreignKeyExtract.ForeignKeyName = foreignKeyNameMatch.Groups[1].Value; - - foreignKeyExtracts.Add(foreignKeyExtract); - } - - foreach (var foreignKeyConstraint in foreignKeyConstraints) + var declared = GetTableConstraints(tableName).OfType().ToList(); + foreach (var foreignKey in foreignKeyConstraints) { - foreach (var foreignKeyExtract in foreignKeyExtracts) - { - if ( - foreignKeyExtract.ChildColumnNames.SequenceEqual(foreignKeyConstraint.ChildColumns) && - foreignKeyExtract.ParentColumnNames.SequenceEqual(foreignKeyConstraint.ParentColumns) - ) - { - foreignKeyConstraint.Name = foreignKeyExtract.ForeignKeyName; - } - } + var definition = declared.FirstOrDefault(candidate => + candidate.ChildColumns.SequenceEqual(foreignKey.ChildColumns, StringComparer.OrdinalIgnoreCase) && + candidate.ParentTable.Equals(foreignKey.ParentTable, StringComparison.OrdinalIgnoreCase) && + (candidate.ParentColumns.Length == 0 || candidate.ParentColumns.SequenceEqual(foreignKey.ParentColumns, StringComparer.OrdinalIgnoreCase))); + if (definition == null) throw new MigrationException("Cannot match a SQLite foreign key to its declaration."); + foreignKey.Name = definition.Name; + declared.Remove(definition); } return foreignKeyConstraints.ToArray(); @@ -412,12 +373,12 @@ public override void RemoveForeignKey(string table, string name) } var sqliteTableInfo = GetSQLiteTableInfo(table); - if (!sqliteTableInfo.ForeignKeys.Any(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + if (!sqliteTableInfo.ForeignKeys.Any(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase))) { throw new MigrationException($"Foreign key '{name}' does not exist."); } - sqliteTableInfo.ForeignKeys.RemoveAll(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + sqliteTableInfo.ForeignKeys.RemoveAll(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); RecreateTable(sqliteTableInfo); } @@ -465,7 +426,7 @@ public override void RemoveColumn(string tableName, string column) var info = GetSQLiteTableInfo(tableName); var definition = info.Columns.SingleOrDefault(c => c.Name.Equals(column, StringComparison.OrdinalIgnoreCase)); bool Matches(string name) => string.Equals(name, column, StringComparison.OrdinalIgnoreCase); - var dependent = definition == null || definition.IsPrimaryKey || definition.ColumnProperty.HasFlag(ColumnProperty.Unique) + var dependent = definition == null || info.PrimaryKey?.KeyColumns.Contains(column, StringComparer.OrdinalIgnoreCase) == true || info.Uniques.Any(u => u.KeyColumns.Contains(column, StringComparer.OrdinalIgnoreCase)) || info.CheckConstraints.Count != 0 || info.Uniques.Any(u => u.KeyColumns.Any(Matches)) || info.Indexes.Any(i => i.KeyColumns.Any(Matches) || i.FilterItems.Count != 0) @@ -502,6 +463,9 @@ public override void RemoveColumn(string tableName, string column) var sqliteInfoMainTable = GetSQLiteTableInfo(tableName); + if (sqliteInfoMainTable.PrimaryKey?.KeyColumns.Any(x => x.Equals(column, StringComparison.OrdinalIgnoreCase)) == true) + throw new MigrationException("Remove the named primary-key constraint before removing one of its columns."); + var checkConstraints = sqliteInfoMainTable.CheckConstraints; if (checkConstraints.Any(x => x.CheckConstraintString.Contains(column, StringComparison.OrdinalIgnoreCase))) @@ -655,6 +619,9 @@ public override void RenameColumn(string tableName, string oldColumnName, string var column = sqliteTableInfo.Columns.First(x => x.Name.Equals(oldColumnName, StringComparison.OrdinalIgnoreCase)); column.Name = newColumnName; + if (sqliteTableInfo.PrimaryKey != null) + sqliteTableInfo.PrimaryKey.KeyColumns = sqliteTableInfo.PrimaryKey.KeyColumns + .Select(x => x.Equals(oldColumnName, StringComparison.OrdinalIgnoreCase) ? newColumnName : x).ToArray(); foreach (var foreignKey in sqliteTableInfo.ForeignKeys) { @@ -726,47 +693,17 @@ public override void RemoveColumnDefaultValue(string tableName, string columnNam public override void AddPrimaryKey(string name, string tableName, params string[] columnNames) { - if (!TableExists(tableName)) - { - throw new Exception("Table does not exist"); - } - - var sqliteTableInfo = GetSQLiteTableInfo(tableName); - - foreach (var column in sqliteTableInfo.Columns) - { - if (columnNames.Any(x => x.Equals(column.Name, StringComparison.OrdinalIgnoreCase))) - { - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.PrimaryKey); - } - else - { - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.PrimaryKey); - } - } - - var columnNamesList = columnNames.ToList(); - - var columnsReordered = sqliteTableInfo.Columns.OrderBy(x => - { - var index = columnNamesList.IndexOf(x.Name); - return index >= 0 ? index : int.MaxValue; - }).ToList(); - - sqliteTableInfo.Columns = columnsReordered; - - RecreateTable(sqliteTableInfo); + var info = GetSQLiteTableInfo(tableName) ?? throw new MigrationException("Table does not exist."); + if (info.PrimaryKey != null) throw new MigrationException("The table already has a primary key. Remove it explicitly first."); + ValidateKeyColumns(name, columnNames, info.Columns.ToArray()); + info.PrimaryKey = new PrimaryKeyConstraint(name, columnNames); + RecreateTable(info); } public override bool PrimaryKeyExists(string table, string name) { - var sqliteTableInfo = GetSQLiteTableInfo(table); - - // SQLite does not offer named primary keys BUT since there can only be one primary key per table we return true if there is any primary key. - - var hasPrimaryKey = sqliteTableInfo.Columns.Any(x => x.ColumnProperty.IsSet(ColumnProperty.PrimaryKey)); - - return hasPrimaryKey; + var key = GetTableConstraints(table).OfType().SingleOrDefault(); + return key != null && string.Equals(key.Name, name, StringComparison.OrdinalIgnoreCase); } public override void AddUniqueConstraint(string name, string table, params string[] columns) @@ -778,12 +715,12 @@ public override void AddUniqueConstraint(string name, string table, params strin var sqliteTableInfo = GetSQLiteTableInfo(table); - if (sqliteTableInfo.Uniques.Any(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + if (sqliteTableInfo.Uniques.Any(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase))) { throw new MigrationException("A unique constraint with the same name already exists."); } - var uniqueConstraint = new Unique() { KeyColumns = columns, Name = name }; + var uniqueConstraint = new UniqueConstraint() { KeyColumns = columns, Name = name }; sqliteTableInfo.Uniques.Add(uniqueConstraint); RecreateTable(sqliteTableInfo); @@ -792,8 +729,8 @@ public override void AddUniqueConstraint(string name, string table, params strin public override void RemoveConstraint(string table, string name) { var sqliteTableInfo = GetSQLiteTableInfo(table); - sqliteTableInfo.Uniques.RemoveAll(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - sqliteTableInfo.CheckConstraints.RemoveAll(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + sqliteTableInfo.Uniques.RemoveAll(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + sqliteTableInfo.CheckConstraints.RemoveAll(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); RecreateTable(sqliteTableInfo); } @@ -809,12 +746,19 @@ public SQLiteTableInfo GetSQLiteTableInfo(string tableName) { TableNameMapping = new MappingInfo { OldName = tableName, NewName = tableName }, Columns = GetColumns(tableName).ToList(), + PrimaryKey = GetTableConstraints(tableName).OfType().SingleOrDefault(), ForeignKeys = GetForeignKeyConstraints(tableName).ToList(), Indexes = GetIndexes(tableName).ToList(), Uniques = GetUniques(tableName).ToList(), CheckConstraints = GetCheckConstraints(tableName) }; + if (sqliteTable.PrimaryKey != null) + { + var columnOrder = GetPragmaTableInfoItems(tableName).ToDictionary(c => c.Name, c => c.Cid, StringComparer.OrdinalIgnoreCase); + sqliteTable.Columns = sqliteTable.Columns.OrderBy(c => columnOrder[c.Name]).ToList(); + } + sqliteTable.ColumnMappings = sqliteTable.Columns .Select(x => new MappingInfo @@ -928,7 +872,8 @@ private void RecreateTableCore(SQLiteTableInfo sqliteTableInfo) var targetIntermediateTableQuoted = QuoteTableNameIfRequired($"{sqliteTableInfo.TableNameMapping.NewName}{IntermediateTableSuffix}"); var targetTableQuoted = QuoteTableNameIfRequired($"{sqliteTableInfo.TableNameMapping.NewName}"); - var columnDbFields = sqliteTableInfo.Columns.Cast(); + var columns = sqliteTableInfo.Columns.Select(c => c.CopyDefinition()).ToArray(); + var columnDbFields = columns.Cast(); var foreignKeyDbFields = sqliteTableInfo.ForeignKeys.Cast(); var indexDbFields = sqliteTableInfo.Indexes.Cast(); var uniqueDbFields = sqliteTableInfo.Uniques.Cast(); @@ -937,6 +882,7 @@ private void RecreateTableCore(SQLiteTableInfo sqliteTableInfo) var dbFields = columnDbFields.Concat(foreignKeyDbFields) .Concat(uniqueDbFields) .Concat(checkConstraintDbFields) + .Concat(sqliteTableInfo.PrimaryKey == null ? Array.Empty() : new IDbField[] { sqliteTableInfo.PrimaryKey }) .ToArray(); // ToHashSet() not available in older .NET versions so we create it old-fashioned. @@ -1037,28 +983,6 @@ public override void AddColumn(string table, string columnName, MigratorDbType t AddColumn(table, column); } - public override void AddColumn(string table, string columnName, DbType type, ColumnProperty property) - { - var column = new Column(columnName, type, property); - - AddColumn(table, column); - } - - public override void AddColumn(string table, string columnName, MigratorDbType type, ColumnProperty property) - { - var column = new Column(columnName, type, property); - - AddColumn(table, column); - } - - public override void AddColumn(string table, string columnName, MigratorDbType type, int size, ColumnProperty property, - object defaultValue) - { - var column = new Column(columnName, type, property) { Size = size, DefaultValue = defaultValue }; - - AddColumn(table, column); - } - public override void AddColumn(string table, string columnName, DbType type) { var column = new Column(columnName, type); @@ -1073,20 +997,6 @@ public override void AddColumn(string table, string columnName, MigratorDbType t AddColumn(table, column); } - public override void AddColumn(string table, string columnName, DbType type, int size, ColumnProperty property) - { - var column = new Column(columnName, type, size, property); - - AddColumn(table, column); - } - - public override void AddColumn(string table, string columnName, MigratorDbType type, int size, ColumnProperty property) - { - var column = new Column(columnName, type, size, property); - - AddColumn(table, column); - } - public override void AddColumn(string table, string columnName, DbType type, object defaultValue) { var column = new Column(columnName, type, defaultValue); @@ -1114,11 +1024,8 @@ public override void ChangeColumn(string table, Column column) throw new Exception("Column does not exists."); } - sqliteInfo.Columns = sqliteInfo.Columns - .Where(x => !x.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - sqliteInfo.Columns.Add(column); + var columnIndex = sqliteInfo.Columns.FindIndex(x => x.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase)); + sqliteInfo.Columns[columnIndex] = column.CopyDefinition(); RecreateTable(sqliteInfo); } @@ -1220,17 +1127,8 @@ public override Column[] GetColumns(string tableName) { var pragmaTableInfoItems = GetPragmaTableInfoItems(tableName); - // Column provides no way to store the primary key sequence number and we do not want to change the class for all database types for now - // so we sort the columns. - var tableInfoPrimaryKeys = pragmaTableInfoItems.Where(x => x.Pk > 0) - .OrderBy(x => x.Pk) - .ToList(); - - var tableInfoNonPrimaryKeys = pragmaTableInfoItems.Where(x => x.Pk < 1) - .OrderBy(x => x.Cid) - .ToList(); - - var pragmaTableInfoItemsSorted = tableInfoPrimaryKeys.Concat(tableInfoNonPrimaryKeys).ToList(); + var tableInfoPrimaryKeys = pragmaTableInfoItems.Where(x => x.Pk > 0).ToList(); + var pragmaTableInfoItemsSorted = pragmaTableInfoItems.OrderBy(x => x.Cid).ToList(); var columns = new List(); @@ -1243,106 +1141,17 @@ public override Column[] GetColumns(string tableName) if (pragmaTableInfoItem.NotNull) { - column.ColumnProperty |= ColumnProperty.NotNull; + column.IsNullable = false; } else { - column.ColumnProperty |= ColumnProperty.Null; + column.IsNullable = true; } var defValue = pragmaTableInfoItem.DfltValue == DBNull.Value ? null : pragmaTableInfoItem.DfltValue; - if (defValue is string v && v.StartsWith("'") && v.EndsWith("'")) - { - column.DefaultValue = v.Substring(1, v.Length - 2); - } - else - { - column.DefaultValue = defValue; - } - - if (column.DefaultValue != null) - { - if (column.Type == DbType.Int16 || column.Type == DbType.Int32 || column.Type == DbType.Int64) - { - column.DefaultValue = long.Parse(column.DefaultValue.ToString()); - } - else if (column.Type == DbType.UInt16 || column.Type == DbType.UInt32 || column.Type == DbType.UInt64) - { - column.DefaultValue = ulong.Parse(column.DefaultValue.ToString()); - } - else if (column.Type == DbType.Double || column.Type == DbType.Single) - { - column.DefaultValue = double.Parse(column.DefaultValue.ToString()); - } - else if (column.Type == DbType.Boolean) - { - column.DefaultValue = column.DefaultValue.ToString().Trim() == "1" || column.DefaultValue.ToString().Trim().ToUpper() == "TRUE"; - } - else if (column.Type == DbType.DateTime || column.Type == DbType.DateTime2) - { - if (column.DefaultValue is string defVal) - { - var dt = defVal; - - if (defVal.StartsWith("'")) - { - dt = defVal.Substring(1, defVal.Length - 2); - } - - var d = DateTime.ParseExact(dt, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal); - column.DefaultValue = d; - } - } - else if (column.Type == DbType.Guid) - { - if (column.DefaultValue is string defVal) - { - var dt = defVal; - - if (defVal.StartsWith("'")) - { - dt = defVal.Substring(1, defVal.Length - 2); - } - - var d = Guid.Parse(dt); - column.DefaultValue = d; - } - } - else if (column.Type == DbType.Boolean) - { - throw new NotSupportedException("SQLite does not support default values for BLOB columns."); - } - } - - if (pragmaTableInfoItem.Pk > 0) - { - if (new[] { DbType.UInt16, DbType.UInt32, DbType.UInt64, DbType.Int16, DbType.Int32, DbType.Int64 }.Contains(column.Type)) - { - column.ColumnProperty |= ColumnProperty.PrimaryKey; - column.ColumnProperty |= ColumnProperty.NotNull; - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.Null); - } - else - { - column.ColumnProperty |= ColumnProperty.PrimaryKey; - } - } - - var indexListItems = GetPragmaIndexListItems(tableName); - var uniqueConstraints = indexListItems.Where(x => x.Unique && x.Origin == "u"); - - foreach (var uniqueConstraint in uniqueConstraints) - { - var indexInfos = GetPragmaIndexInfo(uniqueConstraint.Name); - - if (indexInfos.Count == 1 && indexInfos.First().Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase)) - { - column.ColumnProperty |= ColumnProperty.Unique; - - break; - } - } + column.DefaultValue = defValue is string sqlDefault + ? CatalogDefaultValue.Parse(sqlDefault, column.Type) : defValue; var tableScript = GetSqlCreateTableScript(tableName); @@ -1351,9 +1160,9 @@ public override Column[] GetColumns(string tableName) var hasCompoundPrimaryKey = tableInfoPrimaryKeys.Count > 1; // Implicit in SQLite - if (columnTableInfoItem.Type == "INTEGER" && columnTableInfoItem.Pk == 1 && !hasCompoundPrimaryKey) + if (columnTableInfoItem.Type == "INTEGER" && columnTableInfoItem.Pk == 1 && !hasCompoundPrimaryKey && Regex.IsMatch(tableScript, @"\bAUTOINCREMENT\b", RegexOptions.IgnoreCase)) { - column.ColumnProperty |= ColumnProperty.Identity; + column.IsIdentity = true; } columns.Add(column); @@ -1484,127 +1293,10 @@ public override Index[] GetIndexes(string table) public override void AddTable(string name, string engine, params IDbField[] fields) { - var columns = fields.Where(x => x is Column) - .Cast() - .Select(column => column.CopyDefinition()) - .ToArray(); - - var pks = GetPrimaryKeys(columns); - var hasCompoundPrimaryKey = pks.Count > 1; - - var columnProviders = new List(columns.Length); - - foreach (var column in columns) - { - if (!hasCompoundPrimaryKey && column.IsPrimaryKey) - { - // We implicitly set NOT NULL for non-composite primary keys like in other RDBMS. - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.Null); - column.ColumnProperty = column.ColumnProperty.Set(ColumnProperty.NotNull); - } - - if (hasCompoundPrimaryKey && column.IsPrimaryKey) - { - // We remove PrimaryKey here and readd it as compound later ("...PRIMARY KEY(column1,column2)"); - column.ColumnProperty &= ~ColumnProperty.PrimaryKey; - - // AUTOINCREMENT cannot be used in compound primary keys in SQLite so we remove Identity here - column.ColumnProperty &= ~ColumnProperty.Identity; - } - - var mapper = _dialect.GetAndMapColumnProperties(column); - columnProviders.Add(mapper); - } - - var columnsAndIndexes = JoinColumnsAndIndexes(columnProviders); - + if (engine != null) throw new NotSupportedException("SQLite does not support table engines."); var table = _dialect.TableNameNeedsQuote ? _dialect.Quote(name) : QuoteTableNameIfRequired(name); - StringBuilder stringBuilder = new(); - - stringBuilder.Append(string.Format("CREATE TABLE {0} ({1}", table, columnsAndIndexes)); - - if (hasCompoundPrimaryKey) - { - stringBuilder.Append(string.Format(", PRIMARY KEY ({0})", string.Join(", ", pks.ToArray()))); - } - - - // Uniques - var uniques = fields.Where(x => x is Unique).Cast().ToArray(); - - foreach (var u in uniques) - { - if (!string.IsNullOrEmpty(u.Name)) - { - stringBuilder.Append($", CONSTRAINT {QuoteConstraintNameIfRequired(u.Name)}"); - } - else - { - stringBuilder.Append(", "); - } - - var uniqueColumnsCommaSeparated = string.Join(", ", u.KeyColumns.Select(QuoteColumnNameIfRequired)); - stringBuilder.Append($" UNIQUE ({uniqueColumnsCommaSeparated})"); - } - - // Foreign keys - var foreignKeys = fields.Where(x => x is ForeignKeyConstraint).Cast().ToArray(); - - List foreignKeyStrings = []; - - foreach (var fk in foreignKeys) - { - var sourceColumnNamesQuotedString = string.Join(", ", fk.ChildColumns.Select(QuoteColumnNameIfRequired)); - var parentColumnNamesQuotedString = string.Join(", ", fk.ParentColumns.Select(QuoteColumnNameIfRequired)); - var parentTableNameQuoted = QuoteTableNameIfRequired(fk.ParentTable); - - if (string.IsNullOrWhiteSpace(fk.Name)) - { - throw new Exception("No foreign key constraint name given"); - } - - var foreignKeySql = $"CONSTRAINT {QuoteConstraintNameIfRequired(fk.Name)} FOREIGN KEY ({sourceColumnNamesQuotedString}) REFERENCES {parentTableNameQuoted}({parentColumnNamesQuotedString})"; - if (!string.IsNullOrWhiteSpace(fk.OnDelete) && !string.Equals(fk.OnDelete, "NO ACTION", StringComparison.OrdinalIgnoreCase)) - { - foreignKeySql += $" ON DELETE {ValidateForeignKeyAction(fk.OnDelete)}"; - } - - if (!string.IsNullOrWhiteSpace(fk.OnUpdate)) foreignKeySql += $" ON UPDATE {ValidateForeignKeyAction(fk.OnUpdate)}"; - foreignKeyStrings.Add(foreignKeySql); - } - - if (foreignKeyStrings.Count > 0) - { - stringBuilder.Append(", "); - stringBuilder.Append(string.Join(", ", foreignKeyStrings)); - } - - // Check Constraints - var checkConstraints = fields.Where(x => x is CheckConstraint).OfType().ToArray(); - List checkConstraintStrings = []; - - foreach (var checkConstraint in checkConstraints) - { - checkConstraintStrings.Add($"CONSTRAINT {QuoteConstraintNameIfRequired(checkConstraint.Name)} CHECK ({checkConstraint.CheckConstraintString})"); - } - - if (checkConstraintStrings.Count > 0) - { - stringBuilder.Append($", {string.Join(", ", checkConstraintStrings)}"); - } - - stringBuilder.Append(')'); - - ExecuteNonQuery(stringBuilder.ToString()); - - var indexes = fields.Where(x => x is Index) - .Cast() - .ToArray(); - - foreach (var index in indexes) - { - AddIndex(name, index); - } + ExecuteNonQuery(SQLiteTableSql.Generate(_dialect, table, fields)); + foreach (var index in fields.OfType()) AddIndex(name, index); } public override string AddIndex(string table, Index index) @@ -1676,49 +1368,27 @@ public override string AddIndex(string table, Index index) protected override string GetPrimaryKeyConstraintName(string table) { - throw new NotImplementedException(); + return GetTableConstraints(table).OfType().SingleOrDefault()?.Name; } public override void RemoveAllConstraints(string table) { - RemovePrimaryKey(table); - - var sqliteTableInfo = GetSQLiteTableInfo(table); - - // Remove unique constraints - sqliteTableInfo.Uniques = []; - - foreach (var column in sqliteTableInfo.Columns) - { - column.ColumnProperty &= ~ColumnProperty.PrimaryKey; - column.ColumnProperty &= ~ColumnProperty.Unique; - } - - sqliteTableInfo.ForeignKeys.Clear(); - sqliteTableInfo.CheckConstraints.Clear(); - - RecreateTable(sqliteTableInfo); + var info = GetSQLiteTableInfo(table); + info.PrimaryKey = null; + info.Uniques.Clear(); + info.ForeignKeys.Clear(); + info.CheckConstraints.Clear(); + foreach (var column in info.Columns) column.IsIdentity = false; + RecreateTable(info); } public override void RemovePrimaryKey(string tableName) { - if (!TableExists(tableName)) - { - return; - } - - var sqliteInfoTable = GetSQLiteTableInfo(tableName); - - foreach (var column in sqliteInfoTable.Columns) - { - if (column.IsPrimaryKey) - { - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.PrimaryKey); - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.PrimaryKeyWithIdentity); - } - } - - RecreateTable(sqliteInfoTable); + if (!TableExists(tableName)) return; + var info = GetSQLiteTableInfo(tableName); + info.PrimaryKey = null; + foreach (var column in info.Columns) column.IsIdentity = false; + RecreateTable(info); } public override void RemoveAllIndexes(string tableName) @@ -1730,97 +1400,13 @@ public override void RemoveAllIndexes(string tableName) var sqliteInfoTable = GetSQLiteTableInfo(tableName); - sqliteInfoTable.Uniques = []; sqliteInfoTable.Indexes = []; RecreateTable(sqliteInfoTable); } - public List GetUniques(string tableName) - { - if (!TableExists(tableName)) - { - throw new Exception($"Table '{tableName}' does not exist."); - } - - var regEx = new Regex(@"(?<=,)\s*(CONSTRAINT\s+\w+\s+)?UNIQUE\s*\(\s*[\w\s,]+\s*\)\s*(?=,|\s*\))"); - var regExConstraintName = new Regex(@"(?<=CONSTRAINT\s+)\w+(?=\s+)"); - var regExParenthesis = new Regex(@"(?<=\().+(?=\))"); - - List uniques = []; - - var pragmaIndexListItems = GetPragmaIndexListItems(tableName); - - // Here we filter for origin u and unique while in "GetIndexes()" we exclude them. - // If "pk" is set then it was added by using a primary key. If so this is handled by "GetColumns()". - // If "c" is set it was created by using CREATE INDEX. - var uniqueConstraints = pragmaIndexListItems.Where(x => x.Unique && x.Origin == "u") - .ToList(); - - foreach (var uniqueConstraint in uniqueConstraints) - { - var indexInfos = GetPragmaIndexInfo(uniqueConstraint.Name); - - var columns = indexInfos.OrderBy(x => x.SeqNo) - .Select(x => x.Name) - .ToArray(); - - var unique = new Unique - { - Name = uniqueConstraint.Name, - KeyColumns = columns - }; - - uniques.Add(unique); - } - - var createScript = GetSqlCreateTableScript(tableName); - - var matches = regEx.Matches(createScript); - if (matches.Count == 0) - { - return []; - } - - var constraintNames = matches - .OfType() - .Where(x => x.Success && !string.IsNullOrWhiteSpace(x.Value)) - .Select(x => x.Value.Trim()) - .ToList(); - - // We can only use the ones containing a starting with CONSTRAINT - var matchesHavingName = constraintNames.Where(x => x.StartsWith("CONSTRAINT")).ToList(); - - foreach (var constraintString in matchesHavingName) - { - var constraintNameMatch = regExConstraintName.Match(constraintString); - - if (!constraintNameMatch.Success) - { - throw new Exception("Cannot extract constraint name. Please file an issue"); - } - - var constraintName = constraintNameMatch.Value; - - var parenthesisMatch = regExParenthesis.Match(constraintString); - - if (!parenthesisMatch.Success) - { - throw new Exception("Cannot extract parenthesis content for UNIQUE constraint. Please file an issue"); - } - - var columns = parenthesisMatch.Value.Split(',').Select(x => x.Trim()).ToList(); - - var unique = uniques.Where(x => x.KeyColumns.SequenceEqual(columns)).SingleOrDefault(); - - if (unique != null) - { - unique.Name = constraintName; - } - } - - return uniques; - } + public List GetUniques(string tableName) => GetTableConstraints(tableName) + .OfType().ToList(); public List GetPragmaIndexInfo(string indexNameNotQuoted) { @@ -1968,57 +1554,7 @@ public override void CopyDataFromTableToTable(string sourceTableName, List GetCheckConstraints(string tableName) - { - if (!TableExists(tableName)) - { - throw new Exception($"Table '{tableName}' does not exist."); - } - - var checkConstraintRegex = new Regex(@"(?<=,)[^,]+\s+[^,]+check[^,]+(?=[,|\)])", RegexOptions.IgnoreCase); - var braceContentRegex = new Regex(@"(?<=^\().+(?=\)$)"); - - var script = GetSqlCreateTableScript(tableName); - - var matches = checkConstraintRegex.Matches(script); - - if (matches == null) - { - return []; - } - - var checkStrings = matches.OfType() - .Where(x => x.Success) - .Select(x => x.Value) - .ToList(); - - List checkConstraints = []; - - foreach (var checkString in checkStrings) - { - var splitted = checkString.Trim().Split(' ') - .Select(x => x.Trim()) - .ToList(); - - if (!splitted[0].Equals("CONSTRAINT", StringComparison.OrdinalIgnoreCase) || !splitted[2].Equals("CHECK", StringComparison.OrdinalIgnoreCase)) - { - throw new Exception($"Cannot parse check constraint in table {tableName}"); - } - - var checkConstraintStringWithBraces = string.Join(" ", splitted.Skip(3)).Trim(); - var checkConstraintString = braceContentRegex.Match(checkConstraintStringWithBraces); - - var checkConstraint = new CheckConstraint - { - Name = splitted[1], - CheckConstraintString = checkConstraintString.Value - }; - - checkConstraints.Add(checkConstraint); - } - - return checkConstraints; - } + public List GetCheckConstraints(string tableName) => GetTableConstraints(tableName).OfType().ToList(); protected override void ConfigureParameterWithValue(IDbDataParameter parameter, int index, object value) { diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs index 3968c1e4..cdb578b7 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerDialect.cs @@ -6,6 +6,22 @@ namespace DotNetProjects.Migrator.Providers.Impl.SqlServer; public class SqlServerDialect : Dialect { + protected override string ResolveCollation(CollationKind kind) => kind switch + { + CollationKind.Binary => "Latin1_General_100_BIN2", CollationKind.CaseSensitive => "Latin1_General_100_CS_AS_SC", CollationKind.CaseInsensitive => "Latin1_General_100_CI_AS_SC", + _ => base.ResolveCollation(kind) + }; + + public override string GetCollationSql(string name) + { + // T-SQL requires a collation token rather than a bracket-delimited identifier. + if (string.IsNullOrWhiteSpace(name) || !System.Text.RegularExpressions.Regex.IsMatch(name, @"\A[A-Za-z][A-Za-z0-9_]*\z")) + throw new ArgumentException("SQL Server requires an unquoted collation name containing letters, digits and underscores.", nameof(name)); + return "COLLATE " + name; + } + + public override bool NeedsNullForNullableWhenAlteringTable => true; + public const string DboSchemaName = "dbo"; public SqlServerDialect() @@ -53,7 +69,7 @@ public SqlServerDialect() RegisterColumnType(DbType.VarNumeric, 38, "NUMERIC($l,0)"); RegisterColumnType(MigratorDbType.Interval, "BIGINT"); - RegisterProperty(ColumnProperty.Identity, "IDENTITY"); + RegisterColumnAttribute(ColumnAttribute.Identity, "IDENTITY"); AddReservedWords("ADD", "EXCEPT", "PERCENT", "ALL", "EXEC", "PLAN", "ALTER", "EXECUTE", "PRECISION", "AND", "EXISTS", "PRIMARY", "ANY", "EXIT", "PRINT", "AS", "FETCH", "PROC", "ASC", "FILE", "PROCEDURE", "AUTHORIZATION", "FILLFACTOR", "PUBLIC", "BACKUP", "FOR", "RAISERROR", "BEGIN", "FOREIGN", "READ", "BETWEEN", "FREETEXT", "READTEXT", "BREAK", "FREETEXTTABLE", "RECONFIGURE", "BROWSE", "FROM", "REFERENCES", "BULK", "FULL", "REPLICATION", "BY", "FUNCTION", "RESTORE", "CASCADE", "GOTO", "RESTRICT", "CASE", "GRANT", "RETURN", "CHECK", "GROUP", "REVOKE", "CHECKPOINT", "HAVING", "RIGHT", "CLOSE", "HOLDLOCK", "ROLLBACK", "CLUSTERED", "IDENTITY", "ROWCOUNT", "COALESCE", "IDENTITY_INSERT", "ROWGUIDCOL", "COLLATE", "IDENTITYCOL", "RULE", "COLUMN", "IF", "SAVE", "COMMIT", "IN", "SCHEMA", "COMPUTE", "INDEX", "SELECT", "CONSTRAINT", "INNER", "SESSION_USER", "CONTAINS", "INSERT", "SET", "CONTAINSTABLE", "INTERSECT", "SETUSER", "CONTINUE", "INTO", "SHUTDOWN", "CONVERT", "IS", "SOME", "CREATE", "JOIN", "STATISTICS", "CROSS", "KEY", "SYSTEM_USER", "CURRENT", "KILL", "TABLE", "CURRENT_DATE", "LEFT", "TEXTSIZE", "CURRENT_TIME", "LIKE", "THEN", "CURRENT_TIMESTAMP", "LINENO", "TO", "CURRENT_USER", "LOAD", "TOP", "CURSOR", "NATIONAL", "TRAN", "DATABASE", "NOCHECK", "TRANSACTION", "DBCC", "NONCLUSTERED", "TRIGGER", "DEALLOCATE", "NOT", "TRUNCATE", "DECLARE", "NULL", "TSEQUAL", "DEFAULT", "NULLIF", "UNION", "DELETE", "OF", "UNIQUE", "DENY", "OFF", "UPDATE", "DESC", "OFFSETS", "UPDATETEXT", "DISK", "ON", "USE", "DISTINCT", "OPEN", "USER", "DISTRIBUTED", "OPENDATASOURCE", "VALUES", "DOUBLE", "OPENQUERY", "VARYING", "DROP", "OPENROWSET", "VIEW", "DUMMY", "OPENXML", "WAITFOR", "DUMP", "OPTION", "WHEN", "ELSE", "OR", "WHERE", "END", "ORDER", "WHILE", "ERRLVL", "OUTER", "WITH", "ESCAPE", "OVER", "WRITETEXT"); } diff --git a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs index 805d5fbd..f62b550b 100644 --- a/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/SqlServer/SqlServerTransformationProvider.cs @@ -55,20 +55,7 @@ protected virtual void CreateConnection(string providerName) _connection.ConnectionString = _connectionString; _connection.Open(); - string collationString = null; - var collation = ExecuteScalar("SELECT DATABASEPROPERTYEX('" + _connection.Database + "', 'Collation')"); - if (collation != null) - { - collationString = collation.ToString(); - } - - if (string.IsNullOrWhiteSpace(collationString)) - { - collationString = "Latin1_General_CI_AS"; - } - - Dialect.RegisterProperty(ColumnProperty.CaseSensitive, "COLLATE " + collationString.Replace("_CI_", "_CS_")); } public override void CopyDataFromTableToTable(string sourceTableName, List sourceColumnNames, string targetTableName, List targetColumnNames, List orderBySourceColumns = null) @@ -277,78 +264,15 @@ public override string AddIndex(string table, Index index) return sql; } - public override void AddTable(string name, string engine, params IDbField[] fields) - { - var definitions = fields.Select(field => field is Column column ? column.CopyDefinition() : field).ToArray(); - var owned = definitions.OfType().Where(c => c.ColumnProperty.HasFlag(ColumnProperty.Unique)).ToArray(); - foreach (var column in owned) column.ColumnProperty &= ~ColumnProperty.Unique; - base.AddTable(name, engine, definitions); - foreach (var column in owned) AddOwnedColumnUnique(name, column.Name); - } - - public override void AddColumn(string table, Column column) - { - var definition = column.CopyDefinition(); - var owned = definition.ColumnProperty.HasFlag(ColumnProperty.Unique); - definition.ColumnProperty &= ~ColumnProperty.Unique; - base.AddColumn(table, definition); - if (owned) AddOwnedColumnUnique(table, column.Name); - } - - public override void AddColumn(string table, string column, MigratorDbType type, int size, ColumnProperty property, object defaultValue) - { - base.AddColumn(table, column, type, size, property & ~ColumnProperty.Unique, defaultValue); - if (property.HasFlag(ColumnProperty.Unique)) AddOwnedColumnUnique(table, column); - } - - private void AddOwnedColumnUnique(string table, string column) - { - var name = "UX_" + Guid.NewGuid().ToString("N"); - AddUniqueConstraint(name, table, column); - MarkColumnUniqueOwned(table, column, name); - } - - /// Explicitly adopt a caller-owned, single-column legacy UNIQUE constraint. - /// No ownership is inferred from its name. Future ChangeColumn calls may remove it. - public void AdoptColumnUniqueConstraint(string table, string column, string constraint) - { - using var command = CreateCommand(); - command.CommandText = "SELECT COUNT(*) FROM sys.key_constraints kc JOIN sys.index_columns ic ON ic.object_id=kc.parent_object_id AND ic.index_id=kc.unique_index_id JOIN sys.columns c ON c.object_id=ic.object_id AND c.column_id=ic.column_id WHERE kc.parent_object_id=OBJECT_ID(@table) AND kc.type='UQ' AND kc.name=@constraint AND ic.key_ordinal=1 AND c.name=@column AND NOT EXISTS (SELECT 1 FROM sys.index_columns more WHERE more.object_id=ic.object_id AND more.index_id=ic.index_id AND more.key_ordinal>1)"; - AddParameter(command, "@table", table); AddParameter(command, "@column", column); AddParameter(command, "@constraint", constraint); - if (Convert.ToInt32(command.ExecuteScalar()) != 1) throw new MigrationException("Ownership requires an existing single-column UNIQUE constraint on the specified table and column."); - MarkColumnUniqueOwned(table, column, constraint); - } - - private void MarkColumnUniqueOwned(string table, string column, string constraint) - { - using var command = CreateCommand(); - command.CommandText = "DECLARE @schema sysname=OBJECT_SCHEMA_NAME(OBJECT_ID(@table)); DECLARE @name sysname=OBJECT_NAME(OBJECT_ID(@table)); IF EXISTS (SELECT 1 FROM sys.extended_properties ep JOIN sys.key_constraints kc ON ep.class=1 AND ep.major_id=kc.object_id AND ep.minor_id=0 WHERE kc.parent_object_id=OBJECT_ID(@table) AND kc.name=@constraint AND ep.name=N'Migrator.NET.ColumnUnique') EXEC sys.sp_updateextendedproperty @name=N'Migrator.NET.ColumnUnique', @value=@column, @level0type=N'SCHEMA', @level0name=@schema, @level1type=N'TABLE', @level1name=@name, @level2type=N'CONSTRAINT', @level2name=@constraint; ELSE EXEC sys.sp_addextendedproperty @name=N'Migrator.NET.ColumnUnique', @value=@column, @level0type=N'SCHEMA', @level0name=@schema, @level1type=N'TABLE', @level1name=@name, @level2type=N'CONSTRAINT', @level2name=@constraint"; - AddParameter(command, "@table", table); AddParameter(command, "@column", column); AddParameter(command, "@constraint", constraint); - command.ExecuteNonQuery(); - } - public override void ChangeColumn(string table, Column column) { - var definition = new Column(column.Name, column.MigratorDbType, column.Size, column.ColumnProperty, column.DefaultValue) - { Precision = column.Precision, Scale = column.Scale }; - var unique = definition.ColumnProperty.IsSet(ColumnProperty.Unique); - definition.ColumnProperty = definition.ColumnProperty.Clear(ColumnProperty.Unique); - var owned = new List(); - using (var command = CreateCommand()) - { - command.CommandText = "SELECT kc.name FROM sys.key_constraints kc JOIN sys.extended_properties ep ON ep.class=1 AND ep.major_id=kc.object_id AND ep.minor_id=0 WHERE kc.parent_object_id=OBJECT_ID(@table) AND kc.type='UQ' AND ep.name=N'Migrator.NET.ColumnUnique' AND CONVERT(nvarchar(128),ep.value)=@column"; - AddParameter(command, "@table", table); AddParameter(command, "@column", column.Name); - using var reader = command.ExecuteReader(); - while (reader.Read()) owned.Add(reader.GetString(0)); - } - foreach (var constraint in owned) RemoveConstraint(table, constraint); + var definition = column.CopyDefinition(); RemoveColumnDefaultValue(table, definition.Name); var requestedDefault = definition.DefaultValue; definition.DefaultValue = null; base.ChangeColumn(table, definition); if (requestedDefault != null && requestedDefault != DBNull.Value) ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD DEFAULT {_dialect.Default(requestedDefault)[8..]} FOR {QuoteColumnNameIfRequired(column.Name)}"); - if (unique) AddOwnedColumnUnique(table, column.Name); } private static void AddParameter(IDbCommand command, string name, object value) @@ -626,15 +550,14 @@ public override Column[] GetColumns(string table) var defaultValueString = reader.IsDBNull(defaultValueOrdinal) ? null : reader.GetString(defaultValueOrdinal).Trim(); var characterMaximumLength = reader.IsDBNull(characterMaximumLengthOrdinal) ? (int?)null : reader.GetInt32(characterMaximumLengthOrdinal); - if (uniqueColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.Unique; if (pkColumns.Contains(column.Name)) { - column.ColumnProperty |= ColumnProperty.PrimaryKey; + } if (idtColumns.Contains(column.Name)) { - column.ColumnProperty |= ColumnProperty.Identity; + column.IsIdentity = true; } var nullableStr = reader.GetString(1); @@ -727,7 +650,12 @@ public override Column[] GetColumns(string table) var bracesStrippedString = defaultValueString.Replace("(", "").Replace(")", "").Trim(); var bracesAndSingleQuoteStrippedString = bracesStrippedString.Replace("'", ""); - if (column.Type == DbType.Int16 || column.Type == DbType.Int32 || column.Type == DbType.Int64) + var parsedDefault = CatalogDefaultValue.Parse(defaultValueString, column.Type); + if (column.Type is DbType.String or DbType.AnsiString or DbType.StringFixedLength or DbType.AnsiStringFixedLength + || (parsedDefault is RawSql && !System.Text.RegularExpressions.Regex.IsMatch(defaultValueString, + @"(?i)^\(*\s*(CONVERT\s*\(|0x[0-9a-f]+\)*)"))) + column.DefaultValue = parsedDefault; + else if (column.Type == DbType.Int16 || column.Type == DbType.Int32 || column.Type == DbType.Int64) { column.DefaultValue = long.Parse(bracesAndSingleQuoteStrippedString, CultureInfo.InvariantCulture); } @@ -842,7 +770,7 @@ public override Column[] GetColumns(string table) } } - column.ColumnProperty |= isNullable ? ColumnProperty.Null : ColumnProperty.NotNull; + column.IsNullable = isNullable; columns.Add(column); } diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs index 8d76f09d..54a64516 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs @@ -37,7 +37,7 @@ public SybaseDialect() RegisterColumnType(DbType.StringFixedLength, 255, "CHAR($l)"); RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); RegisterColumnType(DbType.AnsiStringFixedLength, 255, "CHAR($l)"); - RegisterProperty(ColumnProperty.Identity, "IDENTITY"); + RegisterColumnAttribute(ColumnAttribute.Identity, "IDENTITY"); } public override string Default(object value) => value is bool boolean ? (boolean ? "DEFAULT 1" : "DEFAULT 0") : base.Default(value); @@ -58,10 +58,12 @@ private sealed class NativeColumnMapper(Dialect dialect, string type) : ColumnPr public override void MapColumnProperties(Column column) { Name = column.Name; - _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); + var parts = new System.Collections.Generic.List(); AddName(parts); AddType(parts); + AddCollation(column, parts); + AddUnsigned(column, parts); AddDefaultValue(column, parts); if (column.IsIdentity) AddIdentityAgain(column, parts); else @@ -69,8 +71,7 @@ public override void MapColumnProperties(Column column) AddNotNull(column, parts); AddNull(column, parts); } - AddPrimaryKey(column, parts); - AddUnique(column, parts); + _ColumnSql = string.Join(" ", parts); } } diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 7474054d..30f94098 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -1,3 +1,4 @@ +using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; using System; using System.Collections.Generic; using System.Data; @@ -27,8 +28,6 @@ public override void AddColumn(string table, Column column) => public override void AddTable(string name, string engine, params IDbField[] fields) { base.AddTable(name, engine, fields); - foreach (var column in fields.OfType().Where(c => c.ColumnProperty.HasFlag(ColumnProperty.Indexed))) - AddIndex(name, new Index { KeyColumns = [column.Name] }); } public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( @@ -67,7 +66,7 @@ public override Column[] GetColumns(string table) var status = Convert.ToInt32(reader.GetValue(2)); var column = new Column(reader.GetString(0), type) { - ColumnProperty = (status & 8) != 0 ? ColumnProperty.Null : ColumnProperty.NotNull + IsNullable = (status & 8) != 0 }; if (type == DbType.Decimal) { @@ -75,14 +74,59 @@ public override Column[] GetColumns(string table) if (!reader.IsDBNull(5)) column.Scale = Convert.ToInt32(reader.GetValue(5)); } if (defaults.TryGetValue(column.Name, out var defaultSql)) column.DefaultValue = CatalogDefaultValue.Parse(defaultSql, type); - if ((status & 128) != 0) column.ColumnProperty |= ColumnProperty.Identity; + if ((status & 128) != 0) column.IsIdentity = true; if (type == DbType.String) column.Size = nativeType is "text" or "unitext" ? int.MaxValue : Convert.ToInt32(reader.GetValue(3)); - if (primaryColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.PrimaryKey; columns.Add(column); } return columns.ToArray(); } + public override ForeignKeyConstraint[] GetForeignKeyConstraints(string table) + { + var columns = string.Join(",", Enumerable.Range(1, 16).Select(n => $"col_name(r.tableid,r.fokey{n}),col_name(r.reftabid,r.refkey{n})")); + var result = new List(); + using var command = CreateCommand(); + using var reader = ExecuteQuery(command, $"SELECT object_name(r.constrid),object_name(r.reftabid),r.keycnt,r.frgndbname,r.pmrydbname,{columns} FROM sysreferences r WHERE r.tableid=object_id('{Literal(table)}') ORDER BY r.constrid"); + while (reader.Read()) + { + if (!reader.IsDBNull(3) || !reader.IsDBNull(4)) + throw new NotSupportedException("Cross-database ASE foreign keys require qualified metadata support."); + var count = Convert.ToInt32(reader.GetValue(2)); + if (count is < 1 or > 16) throw new NotSupportedException("Unsupported ASE foreign-key column count."); + var children = new string[count]; var parents = new string[count]; + for (var index = 0; index < count; index++) + { + children[index] = reader.GetString(5 + index * 2); + parents[index] = reader.GetString(6 + index * 2); + } + result.Add(new ForeignKeyConstraint(reader.GetString(0), reader.GetString(1), parents, table, children) + { OnDelete = "NO ACTION", OnUpdate = "NO ACTION" }); + } + return result.ToArray(); + } + + public override TableConstraint[] GetTableConstraints(string table) + { + var constraints = new List(); + foreach (var index in GetIndexes(table)) + { + if (index.PrimaryKey) constraints.Add(new PrimaryKeyConstraint(index.Name, index.KeyColumns) { NonClustered = !index.Clustered }); + else if (index.UniqueConstraint) constraints.Add(new DotNetProjects.Migrator.Framework.UniqueConstraint(index.Name, index.KeyColumns)); + } + var checks = new Dictionary(); + using (var command = CreateCommand()) + using (var reader = ExecuteQuery(command, $"SELECT o.name,c.text FROM sysconstraints con JOIN sysobjects o ON o.id=con.constrid JOIN syscomments c ON c.id=o.id WHERE con.tableid=object_id('{Literal(table)}') AND o.type='C' ORDER BY o.name,c.colid2,c.colid")) + while (reader.Read()) + { + var name = reader.GetString(0); + if (!checks.TryGetValue(name, out var text)) checks[name] = text = new System.Text.StringBuilder(); + text.Append(reader.GetString(1)); + } + constraints.AddRange(checks.Select(c => new CheckConstraint(c.Key, ConstraintMetadataReader.CheckExpression(c.Value.ToString())))); + constraints.AddRange(GetForeignKeyConstraints(table)); + return constraints.ToArray(); + } + private Dictionary GetColumnDefaults(string table) { var defaults = new Dictionary(); @@ -156,14 +200,11 @@ public override void RenameTable(string oldName, string newName) => public override void RemoveColumnDefaultValue(string table, string column) => ExecuteNonQuery($"ALTER TABLE {table} REPLACE {column} DEFAULT NULL"); public override void ChangeColumn(string table, Column column) { - var isUniqueSet = column.ColumnProperty.HasFlag(ColumnProperty.Unique); - column.ColumnProperty &= ~ColumnProperty.Unique; + var type = _dialect.GetColumnMapper(column).Type; - var nullable = column.ColumnProperty.HasFlag(ColumnProperty.NotNull) ? "NOT NULL" : "NULL"; + var nullable = !column.IsNullable ? "NOT NULL" : "NULL"; ExecuteNonQuery($"ALTER TABLE {table} MODIFY {column.Name} {type} {nullable}"); ExecuteNonQuery($"ALTER TABLE {table} REPLACE {column.Name} {(column.DefaultValue == null ? "DEFAULT NULL" : _dialect.Default(column.DefaultValue))}"); - if (isUniqueSet) - AddUniqueConstraint($"UX_{table}_{column.Name}", table, [column.Name]); } public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, diff --git a/src/Migrator/Providers/NoOpTransformationProvider.cs b/src/Migrator/Providers/NoOpTransformationProvider.cs index 145918df..b81592b2 100644 --- a/src/Migrator/Providers/NoOpTransformationProvider.cs +++ b/src/Migrator/Providers/NoOpTransformationProvider.cs @@ -3,7 +3,7 @@ using System.Data; using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Framework.Models; -using DotNetProjects.Migrator.Framework.SchemaBuilder; + using ForeignKeyConstraint = DotNetProjects.Migrator.Framework.ForeignKeyConstraint; using Index = DotNetProjects.Migrator.Framework.Index; @@ -14,6 +14,8 @@ namespace DotNetProjects.Migrator.Providers; /// public class NoOpTransformationProvider : ITransformationProvider { + public TableConstraint[] GetTableConstraints(string table) => []; + public static readonly NoOpTransformationProvider Instance = new NoOpTransformationProvider(); private NoOpTransformationProvider() @@ -163,11 +165,6 @@ public bool ViewExists(string view) return false; } - public void AddColumn(string table, string column, DbType type, int size, ColumnProperty property, object defaultValue) - { - // No Op - } - public void AddColumn(string table, string column, DbType type) { // No Op @@ -183,16 +180,6 @@ public void AddColumn(string table, string column, DbType type, int size) // No Op } - public void AddColumn(string table, string column, DbType type, ColumnProperty property) - { - // No Op - } - - public void AddColumn(string table, string column, DbType type, int size, ColumnProperty property) - { - // No Op - } - public void AddPrimaryKey(string name, string table, params string[] columns) { // No Op @@ -415,11 +402,6 @@ public IDbCommand GetCommand() return null; } - public void ExecuteSchemaBuilder(SchemaBuilder schemaBuilder) - { - // No Op - } - public void RemoveAllForeignKeys(string tableName, string columnName) { @@ -563,11 +545,6 @@ public int GetColumnContentSize(string table, string columnName) throw new NotImplementedException(); } - public void AddColumn(string table, string column, MigratorDbType type, int size, ColumnProperty property, object defaultValue) - { - throw new NotImplementedException(); - } - public void AddColumn(string table, string column, MigratorDbType type) { throw new NotImplementedException(); @@ -578,16 +555,6 @@ public void AddColumn(string table, string column, MigratorDbType type, int size throw new NotImplementedException(); } - public void AddColumn(string table, string column, MigratorDbType type, int size, ColumnProperty property) - { - throw new NotImplementedException(); - } - - public void AddColumn(string table, string column, MigratorDbType type, ColumnProperty property) - { - throw new NotImplementedException(); - } - public void AddColumn(string table, string column, MigratorDbType type, object defaultValue) { throw new NotImplementedException(); diff --git a/src/Migrator/Providers/ProviderTypes.cs b/src/Migrator/Providers/ProviderTypes.cs index 7a7dbcba..8dd2f21c 100644 --- a/src/Migrator/Providers/ProviderTypes.cs +++ b/src/Migrator/Providers/ProviderTypes.cs @@ -18,4 +18,5 @@ public enum ProviderTypes Firebird, Ingres, Sybase, + Hana, } diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index c6ee88f1..bf8a43e6 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -14,12 +14,13 @@ using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator.Framework.Loggers; using DotNetProjects.Migrator.Framework.Models; -using DotNetProjects.Migrator.Framework.SchemaBuilder; + using DotNetProjects.Migrator.Providers.Impl.SQLite; using DotNetProjects.Migrator.Providers.Models; using System; using System.Collections.Generic; using System.Data; +using UniqueConstraint = DotNetProjects.Migrator.Framework.UniqueConstraint; using System.Data.Common; using System.IO; using System.Linq; @@ -132,7 +133,7 @@ public virtual Column[] GetColumns(string table) var column = new Column(reader.GetString(0), DbType.String); var nullableStr = reader.GetString(1); var isNullable = nullableStr == "YES"; - column.ColumnProperty |= isNullable ? ColumnProperty.Null : ColumnProperty.NotNull; + column.IsNullable = isNullable; columns.Add(column); } @@ -147,77 +148,9 @@ public virtual Column[] GetColumns(string table) /// /// /// - public virtual ForeignKeyConstraint[] GetForeignKeyConstraints(string table) - { - var constraints = new List(); - var sb = new StringBuilder(); - sb.AppendLine("SELECT"); - sb.AppendLine(" tc.CONSTRAINT_NAME AS FK_KEY,"); - sb.AppendLine(" tc.TABLE_SCHEMA,"); - sb.AppendLine(" tc.TABLE_NAME AS CHILD_TABLE,"); - sb.AppendLine(" kcu.COLUMN_NAME AS CHILD_COLUMN,"); - sb.AppendLine(" ccu.TABLE_NAME AS PARENT_TABLE,"); - sb.AppendLine(" ccu.COLUMN_NAME AS PARENT_COLUMN"); - sb.AppendLine("FROM "); - sb.AppendLine(" INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc "); - sb.AppendLine("JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE as kcu"); - sb.AppendLine(" ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA"); - sb.AppendLine("JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS as rc"); - sb.AppendLine(" ON tc.CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = rc.CONSTRAINT_SCHEMA"); - sb.AppendLine("JOIN INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE AS ccu"); - sb.AppendLine(" ON rc.UNIQUE_CONSTRAINT_NAME = ccu.CONSTRAINT_NAME AND rc.UNIQUE_CONSTRAINT_SCHEMA = ccu.CONSTRAINT_SCHEMA"); - sb.AppendLine($"WHERE LOWER(tc.TABLE_NAME) = LOWER('{table}') AND tc.CONSTRAINT_TYPE = 'FOREIGN KEY'"); - sb.AppendLine("ORDER BY kcu.ORDINAL_POSITION"); - - var sql = sb.ToString(); - List foreignKeyConstraintItems = []; - - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, sql)) - { - while (reader.Read()) - { - var constraintItem = new ForeignKeyConstraintItem - { - SchemaName = reader.GetString(reader.GetOrdinal("TABLE_SCHEMA")), - ForeignKeyName = reader.GetString(reader.GetOrdinal("FK_KEY")), - ChildTableName = reader.GetString(reader.GetOrdinal("CHILD_TABLE")), - ChildColumnName = reader.GetString(reader.GetOrdinal("CHILD_COLUMN")), - ParentTableName = reader.GetString(reader.GetOrdinal("PARENT_TABLE")), - ParentColumnName = reader.GetString(reader.GetOrdinal("PARENT_COLUMN")) - }; - - foreignKeyConstraintItems.Add(constraintItem); - } - } + public virtual ForeignKeyConstraint[] GetForeignKeyConstraints(string table) => ForeignKeyMetadataReader.Read(this, table); - var schemaChildTableGroups = foreignKeyConstraintItems.GroupBy(x => new { x.SchemaName, x.ChildTableName }).Count(); - - if (schemaChildTableGroups > 1) - { - throw new MigrationException($"Duplicates found (grouping by schema name and child table name). Since we do not offer schemas in '{nameof(GetForeignKeyConstraints)}' at this moment in time we cannot filter your target schema. Your database use the same table name in different schemas."); - } - - var groups = foreignKeyConstraintItems.GroupBy(x => x.ForeignKeyName); - - foreach (var group in groups) - { - var first = group.First(); - - var foreignKeyConstraint = new ForeignKeyConstraint - { - Name = first.ForeignKeyName, - ParentTable = first.ParentTableName, - ParentColumns = [.. group.Select(x => x.ParentColumnName).Distinct()], - ChildTable = first.ChildTableName, - ChildColumns = [.. group.Select(x => x.ChildColumnName).Distinct()] - }; - - constraints.Add(foreignKeyConstraint); - } - - return [.. constraints]; - } + public virtual TableConstraint[] GetTableConstraints(string table) => ConstraintMetadataReader.Read(this, table); public virtual string[] GetConstraints(string table) { @@ -387,11 +320,6 @@ public virtual void AddView(string name, string tableName, params IViewElement[] /// Columns public virtual void AddTable(string name, params IDbField[] columns) { - if (this is not SQLiteTransformationProvider && columns.Any(x => x is CheckConstraint)) - { - throw new MigrationException($"{nameof(CheckConstraint)}s are currently only supported in SQLite."); - } - // Most databases don't have the concept of a storage engine, so default is to not use it. AddTable(name, null, columns); } @@ -404,48 +332,30 @@ public virtual void AddTable(string name, params IDbField[] columns) /// the database storage engine to use public virtual void AddTable(string name, string engine, params IDbField[] fields) { - var columns = fields.Where(x => x is Column).Cast().ToArray(); - - var pks = GetPrimaryKeys(columns); - var compoundPrimaryKey = pks.Count > 1; - - var columnProviders = new List(columns.Count()); - - foreach (var column in columns) + var columns = fields.OfType().Select(c => c.CopyDefinition()).ToArray(); + var keys = fields.OfType().ToArray(); + if (keys.Length > 1) throw new MigrationException("A table can have only one primary key."); + foreach (var key in keys) { - // Remove the primary key notation if compound primary key because we'll add it back later - if (compoundPrimaryKey && column.IsPrimaryKey) - { - column.ColumnProperty = column.ColumnProperty ^ ColumnProperty.PrimaryKey; - column.ColumnProperty = column.ColumnProperty | ColumnProperty.NotNull; // PK is always not-null - } - - var mapper = _dialect.GetAndMapColumnProperties(column); - columnProviders.Add(mapper); + ValidateKeyColumns(key.Name, key.KeyColumns, columns); + foreach (var column in columns.Where(c => key.KeyColumns.Contains(c.Name, StringComparer.OrdinalIgnoreCase))) + column.IsNullable = false; } + foreach (var unique in fields.OfType()) ValidateKeyColumns(unique.Name, unique.KeyColumns, columns); + var sql = columns.Select(c => _dialect.GetAndMapColumnProperties(c).ColumnSql) + .Concat(fields.OfType().Where(c => c is not ForeignKeyConstraint).Select(_dialect.GetTableConstraintSql)); + AddTable(name, engine, string.Join(", ", sql)); + foreach (var index in fields.OfType()) AddIndex(name, index); + foreach (var foreignKey in fields.OfType()) AddForeignKey(name, foreignKey); + } - var columnsAndIndexes = JoinColumnsAndIndexes(columnProviders); - - AddTable(name, engine, columnsAndIndexes); - - if (compoundPrimaryKey) - { - AddPrimaryKey(GetPrimaryKeyname(name), name, pks.ToArray()); - } - - var indexes = fields.Where(x => x is Index).Cast().ToArray(); - - foreach (var index in indexes) - { - AddIndex(name, index); - } - - var foreignKeys = fields.Where(x => x is ForeignKeyConstraint).Cast().ToArray(); - - foreach (var foreignKey in foreignKeys) - { - AddForeignKey(name, foreignKey); - } + protected internal static void ValidateKeyColumns(string name, string[] keys, Column[] columns) + { + if (name != null && string.IsNullOrWhiteSpace(name)) throw new MigrationException("A constraint name must not be empty."); + if (keys == null || keys.Length == 0 || keys.Any(string.IsNullOrWhiteSpace) || keys.Distinct(StringComparer.OrdinalIgnoreCase).Count() != keys.Length) + throw new MigrationException("A key needs distinct, non-empty column names."); + if (keys.Any(key => !columns.Any(c => c.Name.Equals(key, StringComparison.OrdinalIgnoreCase)))) + throw new MigrationException("A constraint references a column that is absent from the table definition."); } protected virtual string GetPrimaryKeyname(string tableName) @@ -535,18 +445,13 @@ public virtual bool ColumnExists(string table, string column, bool ignoreCase) public virtual void ChangeColumn(string table, Column column) { column = column.CopyDefinition(); - var isUniqueSet = column.ColumnProperty.IsSet(ColumnProperty.Unique); - column.ColumnProperty = column.ColumnProperty.Clear(ColumnProperty.Unique); var mapper = _dialect.GetAndMapColumnProperties(column); ChangeColumn(table, mapper.ColumnSql); - if (isUniqueSet) - { - AddUniqueConstraint(string.Format("UX_{0}_{1}", table, column.Name), table, [column.Name]); - } + } public virtual void RemoveColumnDefaultValue(string table, string column) @@ -592,77 +497,24 @@ public virtual void DropDatabases(string databaseName) ExecuteNonQuery(string.Format("DROP DATABASE {0}", databaseName)); } - /// - /// Add a new column to an existing table. - /// - /// Table to which to add the column - /// Column name - /// Date type of the column - /// Max length of the column - /// Properties of the column, see ColumnProperty, - /// Default value - public void AddColumn(string table, string column, DbType type, int size, ColumnProperty property, - object defaultValue) - { - AddColumn(table, column, (MigratorDbType)type, size, property, defaultValue); - } - - /// - /// Add a new column to an existing table. - /// - /// Table to which to add the column - /// Column name - /// Date type of the column - /// Max length of the column - /// Properties of the column, see ColumnProperty, - /// Default value - public virtual void AddColumn(string table, string column, MigratorDbType type, int size, ColumnProperty property, - object defaultValue) - { - var mapper = - _dialect.GetAndMapColumnProperties(new Column(column, type, size, property, defaultValue)); - - AddColumn(table, mapper.ColumnSql); - } - - /// - /// - /// AddColumn(string, string, Type, int, ColumnProperty, object) - /// - /// public virtual void AddColumn(string table, string column, DbType type) { - AddColumn(table, column, type, 0, ColumnProperty.Null, null); + AddColumn(table, new Column(column, type)); } - /// - /// - /// AddColumn(string, string, Type, int, ColumnProperty, object) - /// - /// public virtual void AddColumn(string table, string column, MigratorDbType type) { - AddColumn(table, column, type, 0, ColumnProperty.Null, null); + AddColumn(table, new Column(column, type)); } - /// - /// - /// AddColumn(string, string, Type, int, ColumnProperty, object) - /// - /// public virtual void AddColumn(string table, string column, DbType type, int size) { - AddColumn(table, column, type, size, ColumnProperty.Null, null); + AddColumn(table, new Column(column, type, size)); } - /// - /// - /// AddColumn(string, string, Type, int, ColumnProperty, object) - /// - /// public virtual void AddColumn(string table, string column, MigratorDbType type, int size) { - AddColumn(table, column, type, size, ColumnProperty.Null, null); + AddColumn(table, new Column(column, type, size)); } public virtual void AddColumn(string table, string column, DbType type, object defaultValue) @@ -678,46 +530,6 @@ public virtual void AddColumn(string table, string column, MigratorDbType type, AddColumn(table, mapper.ColumnSql); } - /// - /// - /// AddColumn(string, string, Type, int, ColumnProperty, object) - /// - /// - public virtual void AddColumn(string table, string column, DbType type, ColumnProperty property) - { - AddColumn(table, column, type, 0, property, null); - } - - /// - /// - /// AddColumn(string, string, Type, int, ColumnProperty, object) - /// - /// - public virtual void AddColumn(string table, string column, MigratorDbType type, ColumnProperty property) - { - AddColumn(table, column, type, 0, property, null); - } - - /// - /// - /// AddColumn(string, string, Type, int, ColumnProperty, object) - /// - /// - public virtual void AddColumn(string table, string column, DbType type, int size, ColumnProperty property) - { - AddColumn(table, column, type, size, property, null); - } - - /// - /// - /// AddColumn(string, string, Type, int, ColumnProperty, object) - /// - /// - public virtual void AddColumn(string table, string column, MigratorDbType type, int size, ColumnProperty property) - { - AddColumn(table, column, type, size, property, null); - } - /// /// Append a primary key to a table. /// @@ -1681,14 +1493,7 @@ public virtual void MigrationUnApplied(long version, string scope) public virtual void AddColumn(string table, Column column) { - if (!column.Precision.HasValue && !column.Scale.HasValue) - { - AddColumn(table, column.Name, column.Type, column.Size, column.ColumnProperty, column.DefaultValue); - return; - } - var definition = new Column(column.Name, column.MigratorDbType, column.Size, column.ColumnProperty, column.DefaultValue) - { Precision = column.Precision, Scale = column.Scale }; - AddColumn(table, _dialect.GetAndMapColumnProperties(definition).ColumnSql); + AddColumn(table, _dialect.GetAndMapColumnProperties(column.CopyDefinition()).ColumnSql); } public virtual void GenerateForeignKey(string primaryTable, string refTable) @@ -1706,14 +1511,6 @@ public virtual IDbCommand GetCommand() return BuildCommand(null); } - public virtual void ExecuteSchemaBuilder(SchemaBuilder builder) - { - foreach (var expr in builder.Expressions) - { - expr.Create(this); - } - } - public void Dispose() { try { if (_transaction != null) Rollback(); } @@ -1779,20 +1576,7 @@ public virtual void AddTable(string table, string engine, string columns) ExecuteNonQuery(sqlCreate); } - public virtual List GetPrimaryKeys(IEnumerable columns) - { - var primaryKeys = new List(); - - foreach (var col in columns) - { - if (col.IsPrimaryKey) - { - primaryKeys.Add(col.Name); - } - } - return primaryKeys; - } public virtual void AddColumnDefaultValue(string table, string column, object defaultValue) { @@ -1822,34 +1606,6 @@ public virtual void ChangeColumn(string table, string sqlColumn) ExecuteNonQuery(string.Format("ALTER TABLE {0} ALTER COLUMN {1}", table, sqlColumn)); } - protected virtual string JoinColumnsAndIndexes(IEnumerable columns) - { - var indexes = JoinIndexes(columns); - var columnsAndIndexes = JoinColumns(columns) + (indexes != null ? "," + indexes : string.Empty); - return columnsAndIndexes; - } - - protected virtual string JoinIndexes(IEnumerable columns) - { - var indexes = new List(); - foreach (var column in columns) - { - var indexSql = column.IndexSql; - - if (indexSql != null) - { - indexes.Add(indexSql); - } - } - - if (indexes.Count == 0) - { - return null; - } - - return string.Join(", ", [.. indexes]); - } - protected virtual string JoinColumns(IEnumerable columns) { var columnStrings = new List(); @@ -1912,15 +1668,15 @@ protected virtual void CreateSchemaInfoTable() if (!TableExists(_schemaInfotable)) { AddTable(_schemaInfotable, - new Column("Version", DbType.Int64, ColumnProperty.NotNull | ColumnProperty.PrimaryKey), - new Column("Scope", DbType.String, 50, ColumnProperty.NotNull | ColumnProperty.PrimaryKey, "default"), + new Column("Version",DbType.Int64){IsNullable = false}, + new Column("Scope",DbType.String,50,"default"){IsNullable = false}, new Column("TimeStamp", DbType.DateTime)); } else { if (!ColumnExists(_schemaInfotable, "Scope")) { - AddColumn(_schemaInfotable, "Scope", DbType.String, 50, ColumnProperty.NotNull, "default"); + AddColumn(_schemaInfotable, new Column("Scope", DbType.String, 50) { IsNullable = false, DefaultValue = "default" }); RemoveAllConstraints(_schemaInfotable); AddPrimaryKey("PK_SchemaInfo", _schemaInfotable, ["Version", "Scope"]); } diff --git a/src/Migrator/RunnerOptions.cs b/src/Migrator/RunnerOptions.cs new file mode 100644 index 00000000..0666f37d --- /dev/null +++ b/src/Migrator/RunnerOptions.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using DotNetProjects.Migrator.Framework; +namespace DotNetProjects.Migrator; + +public enum TagMatchMode { Any, All } +public enum MigrationTransactionMode { PerMigration, None, WholeSession } +public enum MaintenanceStage { BeforeRun, BeforeMigration, AfterMigration, AfterRun } + +[AttributeUsage(AttributeTargets.Class, Inherited = true)] +public sealed class TagsAttribute(params string[] tags) : Attribute +{ public IReadOnlyList Tags { get; } = Array.AsReadOnly((string[])tags.Clone()); } +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class ProfileAttribute(string name) : Attribute +{ public string Name { get; } = name; public int Order { get; set; } public string Scope { get; set; } } +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class MaintenanceAttribute(MaintenanceStage stage) : Attribute +{ public MaintenanceStage Stage { get; } = stage; public int Order { get; set; } public string Scope { get; set; } } + +public sealed class RunnerOptions +{ + public ISet Tags { get; } = new HashSet(StringComparer.Ordinal); + public TagMatchMode TagMatch { get; set; } = TagMatchMode.Any; + public ISet Profiles { get; } = new HashSet(StringComparer.Ordinal); + public MigrationTransactionMode TransactionMode { get; set; } = MigrationTransactionMode.PerMigration; + public Func Activator { get; set; } + public IMigrationLock Lock { get; set; } + public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(30); +} + +/// Acquire before any history read. The lease must release its lock in Dispose. +public interface IMigrationLock +{ + IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout); +} + +public sealed class UnsupportedMigrationFeatureException : NotSupportedException +{ + public UnsupportedMigrationFeatureException(string message, Exception inner = null) : base(message, inner) { } +} +public sealed class MigrationLockTimeoutException : TimeoutException +{ + public MigrationLockTimeoutException(Exception inner) : base("Timed out acquiring the migration lock.", inner) { } +}