diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfdb8b77 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh new file mode 100644 index 00000000..7bb5d15a --- /dev/null +++ b/.github/scripts/start-database.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail +database="$1" +# Registry timeouts are transient; retry downloads, never test failures. +pull() { + for attempt in 1 2 3; do + if docker pull "$1"; then return 0; fi + sleep 5 + done + return 1 +} +case "$database" in + Unit|SQLite) 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; } + ;; + MariaDB) + docker run -d --name migrator-db -p 3306:3306 -e MARIADB_ROOT_PASSWORD=rootpass -e MARIADB_DATABASE=testdb mariadb:11.4.10 + ready() { docker exec migrator-db mariadb -uroot -prootpass -e 'SELECT 1' >/dev/null 2>&1; } + ;; + PostgreSQL) + docker run -d --name migrator-db -p 5432:5432 -e POSTGRES_USER=testuser -e POSTGRES_PASSWORD=testpass postgres:13.23 + ready() { docker exec migrator-db pg_isready -U testuser >/dev/null 2>&1; } + ;; + SQLServer) + docker run -d --name migrator-db -p 1433:1433 -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD=YourStrong@Passw0rd mcr.microsoft.com/mssql/server:2019-CU32-ubuntu-20.04 + ready() { docker exec migrator-db /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'YourStrong@Passw0rd' -Q 'SELECT 1' >/dev/null 2>&1; } + ;; + Oracle) + docker run -d --name migrator-db -p 1521:1521 -e ORACLE_PASSWORD=adfkweflajdfglkj gvenzl/oracle-free:23.9-slim-faststart + ready() { docker exec migrator-db healthcheck.sh >/dev/null 2>&1; } + ;; + Firebird) + docker run -d --name migrator-db -p 3050:3050 -e FIREBIRD_ROOT_PASSWORD=masterkey -e FIREBIRD_DATABASE=test.fdb firebirdsql/firebird:5.0.3 + ready() { echo 'select 1 from rdb$database;' | docker exec -i migrator-db isql -b -u SYSDBA -p masterkey localhost:/var/lib/firebird/data/test.fdb >/dev/null 2>&1; } + ;; + Db2) + pull icr.io/db2_community/db2:11.5.9.0 + docker run -d --name migrator-db --privileged -p 50000:50000 -e LICENSE=accept -e DB2INST1_PASSWORD=testpass -e DBNAME=testdb -e ARCHIVE_LOGS=false -e AUTOCONFIG=false icr.io/db2_community/db2:11.5.9.0 + ready() { docker logs migrator-db 2>&1 | grep -q 'Setup has completed'; } + ;; + Informix) + pull icr.io/informix/informix-developer-database:15.0.1.0.3 + docker run -dt --name migrator-db --hostname ifx --privileged -p 9088:9088 -e LICENSE=accept icr.io/informix/informix-developer-database:15.0.1.0.3 + ready() { docker exec migrator-db bash -c 'source /usr/local/bin/informix_inf.env; onstat - >/dev/null; test $? -eq 5' >/dev/null 2>&1; } + ;; + Sybase) + pull datagrip/sybase:16.0 + docker run -dt --name migrator-db -p 5000:5000 datagrip/sybase:16.0 + ready() { printf 'select 12345\ngo\n' | docker exec -i migrator-db bash -c 'source /opt/sybase/SYBASE.sh; isql -Usa -PmyPassword -Slocalhost:5000' 2>/dev/null | grep -q 12345; } + ;; + *) echo "Unknown database: $database" >&2; exit 1 ;; +esac +for attempt in $(seq 1 120); do + if ready; then break; fi + if [ "$attempt" -eq 120 ]; then docker logs migrator-db; exit 1; fi + sleep 5 +done +case "$database" in + Sybase) + printf "disk init name='migrator_data', physname='/opt/sybase/migrator_data.dat', size='128M'\ngo\ndisk init name='migrator_log', physname='/opt/sybase/migrator_log.dat', size='64M'\ngo\n" | docker exec -i migrator-db bash -c 'source /opt/sybase/SYBASE.sh; isql -b -Usa -PmyPassword -Slocalhost:5000' + ;; + SQLServer) docker exec migrator-db /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'YourStrong@Passw0rd' -b -Q 'CREATE DATABASE [Whatever];' ;; + Oracle) docker exec -i migrator-db sqlplus -s / as sysdba < .github/workflows/sql/oracle.sql ;; + Informix) + # ONLINE can precede completion of the image's catalog initialization. + for attempt in $(seq 1 24); do + if echo 'create database testdb with log;' | docker exec -i migrator-db bash -c 'source /usr/local/bin/informix_inf.env; dbaccess sysmaster -'; then break; fi + if [ "$attempt" -eq 24 ]; then docker logs migrator-db; exit 1; fi + sleep 5 + done + ;; +esac diff --git a/.github/scripts/test.ps1 b/.github/scripts/test.ps1 new file mode 100644 index 00000000..01f97f7b --- /dev/null +++ b/.github/scripts/test.ps1 @@ -0,0 +1,20 @@ +param( + [ValidateSet('Unit','SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase')] + [string]$Database = 'Unit' +) +$ErrorActionPreference = 'Stop' +$databases = @('SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase') +$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" +if ($LASTEXITCODE -ne 0) { throw "Tests failed for $Database" } +[xml]$results = Get-Content "TestResults/$Database.trx" +$counters = $results.TestRun.ResultSummary.Counters +if ([int]$counters.executed -eq 0) { throw "No tests executed for $Database" } +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) { + throw "New database suites must not skip tests." +} diff --git a/.github/scripts/verify-test-coverage.py b/.github/scripts/verify-test-coverage.py new file mode 100644 index 00000000..8ef02980 --- /dev/null +++ b/.github/scripts/verify-test-coverage.py @@ -0,0 +1,40 @@ +"""Ensure database filters form a complete, disjoint partition of NUnit discovery.""" +import pathlib +import sys +import xml.etree.ElementTree as ET + +expected = {"Unit", "SQLite", "SQLServer", "PostgreSQL", "Oracle", "MySQL", + "MariaDB", "Firebird", "Db2", "Informix", "Sybase"} +seen = {} +executed = 0 +counts = set() +found = set() +for path in pathlib.Path(sys.argv[1]).rglob("Migrator.Tests.xml"): + database = path.parent.name + if database not in expected: + raise SystemExit(f"Unexpected result file: {path}") + if database in found: + raise SystemExit(f"Duplicate suite: {database}") + found.add(database) + root = ET.parse(path).getroot() + counts.add(int(root.attrib["testcasecount"])) + tests = root.findall(".//test-case") + if not tests or not any(t.get("result") == "Passed" for t in tests): + raise SystemExit(f"No passing tests: {database}") + executed += len(tests) + for test in tests: + name = test.attrib["fullname"] + # NUnit can discover inherited or repeated cases with identical full names. + # They are valid within one job, but must never appear in another job. + if name in seen and seen[name] != database: + raise SystemExit(f"Test assigned to both {seen[name]} and {database}: {name}") + seen[name] = database + if test.get("result") not in {"Passed", "Skipped"}: + raise SystemExit(f"Test did not pass: {name}") + print(f"{database}: {len(tests)} tests") + +if found != expected: + raise SystemExit(f"Missing suites: {sorted(expected - found)}") +if len(counts) != 1 or executed != next(iter(counts)): + raise SystemExit(f"Discovery reports {counts} tests but jobs covered {executed} test cases") +print(f"All {executed} discovered tests assigned exactly once.") diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index f25eef79..5e9af897 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -1,108 +1,84 @@ name: .NET Pull Request - on: push: branches: [master] pull_request: branches: [master] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: live-databases-${{ github.ref }} + cancel-in-progress: true jobs: - build: + test: + name: Test (${{ matrix.database }}) runs-on: ubuntu-22.04 - services: - sqlserver: - image: mcr.microsoft.com/mssql/server:2019-latest - ports: - - 1433:1433 - env: - SA_PASSWORD: YourStrong@Passw0rd - ACCEPT_EULA: Y - options: >- - --health-cmd "bash -c '- - --health-cmd="pg_isready -U testuser" - --health-interval=10s - --health-timeout=5s - --health-retries=5 - oracle: - image: gvenzl/oracle-free:latest - ports: - - 1521:1521 - env: - ORACLE_PASSWORD: adfkweflajdfglkj - options: >- - --health-cmd healthcheck.sh - --health-interval 10s - --health-timeout 5s - --health-retries 10 - mysql: - image: mysql:8.0 - ports: - - 3306:3306 - env: - MYSQL_ROOT_PASSWORD: rootpass - MYSQL_DATABASE: testdb - MYSQL_USER: testuser - MYSQL_PASSWORD: testpass - options: >- - --health-cmd="mysqladmin ping -h localhost -u root -prootpass" - --health-interval=10s - --health-timeout=5s - --health-retries=10 + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] steps: - uses: actions/checkout@v4 - - uses: gvenzl/setup-oracle-sqlcl@v1 - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 9.0.x - - name: Install Microsoft GPG apt-key + dotnet-version: 9.0.x + - name: Start database + shell: bash run: | - wget https://packages.microsoft.com/keys/microsoft.asc -O microsoft.asc - gpg --dearmor microsoft.asc - chmod 644 microsoft.asc.gpg - sudo mv microsoft.asc.gpg /etc/apt/trusted.gpg.d/microsoft.gpg - - name: Add Microsoft SQL Server repo - run: | - echo "deb [arch=amd64] https://packages.microsoft.com/config/ubuntu/22.04/prod jammy main" - sudo apt-get update - - name: Install SQLCMD tools - run: | - sudo ACCEPT_EULA=Y apt-get install -y mssql-tools unixodbc-dev - echo 'export PATH="$PATH:/opt/mssql-tools/bin"' >> ~/.bashrc - source ~/.bashrc - - name: Create SQLServer database - run: | - /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'YourStrong@Passw0rd' -Q "CREATE DATABASE [Whatever];" - - name: Create Oracle user - run: | - sql sys/adfkweflajdfglkj@localhost/FREEPDB1 as sysdba <&1 | tee TestResults/startup.log + timeout-minutes: 15 - name: Build + run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} + - name: Configure native IBM drivers + if: matrix.database == 'Db2' || matrix.database == 'Informix' + shell: bash run: | - dotnet build Migrator.slnx + sudo apt-get update + sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 + output="$GITHUB_WORKSPACE/src/Migrator.Tests/bin/Debug/net9.0" + if [ "${{ matrix.database }}" = Db2 ]; then + echo "DB2_CLI_DRIVER_INSTALL_PATH=$output/clidriver" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/clidriver/lib" >> "$GITHUB_ENV" + else + echo "DELIMIDENT=y" >> "$GITHUB_ENV" + echo "INFORMIXDIR=$output/native" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" >> "$GITHUB_ENV" + fi - name: Test + shell: pwsh + run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} + - name: Collect database logs + if: always() run: | - dotnet test Migrator.slnx + mkdir -p TestResults + if docker inspect migrator-db >/dev/null 2>&1; then + docker logs migrator-db > TestResults/database.log 2>&1 + docker inspect migrator-db > TestResults/container.json + fi + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.database }} + path: TestResults/ + if-no-files-found: error + - name: Remove test container + if: always() + run: | + if docker inspect migrator-db >/dev/null 2>&1; then + docker rm -fv migrator-db + fi + coverage: + name: Verify complete test coverage + needs: test + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: test-results-* + path: TestResults + - run: python3 .github/scripts/verify-test-coverage.py TestResults diff --git a/.gitignore b/.gitignore index c44a0634..c92f3f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ packages/ /src/GlobalAssemblyInfo.cs *.gpState -**/appsettings.Development.json \ No newline at end of file +**/appsettings.Development.json +TestResults/ diff --git a/README.md b/README.md index 385358b3..8955225b 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,7 @@ In this fork the main changes are: [1]: http://code.google.com/p/migratordotnet/ [2]: https://github.com/migratordotnet/Migrator.NET + +## Live database testing + +See [live database testing](docs/live-database-tests.md) for the CI matrix, pinned versions, local commands, coverage, engine limitations and excluded candidates. diff --git a/docs/live-database-tests.md b/docs/live-database-tests.md new file mode 100644 index 00000000..8ae1d72c --- /dev/null +++ b/docs/live-database-tests.md @@ -0,0 +1,91 @@ +# Live database tests + +The pull-request workflow runs independent jobs on GitHub-hosted Ubuntu 22.04 with .NET 9. No external database, private download, repository secret, or paid runner is required. Database credentials in the scripts are disposable CI credentials. + +## Tested versions + +| Job | Pinned image / embedded driver | Client | +| --- | --- | --- | +| SQLite | embedded | Microsoft.Data.Sqlite 9.0.7; System.Data.SQLite.Core 1.0.119 | +| SQLServer | `mcr.microsoft.com/mssql/server:2019-CU32-ubuntu-20.04` | Microsoft.Data.SqlClient 6.1.0 | +| PostgreSQL | `postgres:13.23` | Npgsql 9.0.3 | +| Oracle | `gvenzl/oracle-free:23.9-slim-faststart` | Oracle.ManagedDataAccess.Core 23.9.1 | +| MySQL | `mysql:8.0.44` | MySql.Data 9.4.0 | +| MariaDB | `mariadb:11.4.10` | MySql.Data 9.4.0 | +| 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 | +| 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. + +## Coverage and isolation + +`LiveDatabaseTests` adds ten scenarios each for MySQL, MariaDB, Firebird, Db2, Informix and Sybase: database/view catalogs; table/column metadata; persisted CRUD and defaults; column add/rename/type/nullability/default changes and removal; identity generation; primary-key enforcement/removal; foreign-key enforcement/removal; unique/check enforcement/removal; ordered composite index metadata/removal; and two complete migration up/down cycles with persisted version tracking. + +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. + +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. + +## Local reproduction + +Run from the repository root. For Unit and SQLite, only the .NET SDK and PowerShell are needed: + +```powershell +dotnet build Migrator.slnx +./.github/scripts/test.ps1 -Database Unit +./.github/scripts/test.ps1 -Database SQLite +``` + +For a server-backed suite, use Linux with Docker, .NET 9 and PowerShell (`pwsh`). Start one engine at a time because the script uses the name `migrator-db` and fixed host ports: + +```bash +database=MySQL # or a server job name from the table +bash .github/scripts/start-database.sh "$database" +dotnet build Migrator.slnx -p:LiveDatabase="$database" +pwsh -File .github/scripts/test.ps1 -Database "$database" +docker logs migrator-db +docker rm -fv migrator-db +``` + +For Db2/Informix, install the native prerequisites and export driver paths before testing (the workflow contains the same setup): + +```bash +sudo apt-get update +sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 +output="$PWD/src/Migrator.Tests/bin/Debug/net9.0" +# Db2: +export DB2_CLI_DRIVER_INSTALL_PATH="$output/clidriver" +export LD_LIBRARY_PATH="$output/clidriver/lib" +# Informix, instead: +export DELIMIDENT=y +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. + +To reproduce the assignment audit, download all `test-results-*` artifacts from a single completed workflow into `TestResults`, preserving their per-database directories, then run: + +```bash +python3 .github/scripts/verify-test-coverage.py TestResults +``` + +## Engine and provider limits + +- 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. +- Informix uses SERIAL/BIGSERIAL identity types and positional parameters. Declarative referential actions support restrictive behavior and cascading deletes; unsupported actions throw explicitly. +- ASE declarative foreign keys support restrictive behavior, without cascading actions. Default removal uses ASE's `REPLACE ... DEFAULT NULL` syntax. +- The new Firebird/Db2/Informix index implementations cover ordinary and unique indexes. Unsupported INCLUDE, filtered or clustered options throw rather than silently changing semantics; this is a provider limitation, not a claim that each engine lacks every such feature. + +## Excluded candidate: Ingres + +As checked on 2026-09-22, the official [Actian Ingres image](https://hub.docker.com/r/actian/ingres) (`ii12.1.0_p31093`) is distributed for commercial subscription use and its standalone startup requires a valid `license.xml` mounted at `/Secrets/license`. Accepting the license environment variable does not supply that key. Consequently we cannot validate a reproducible public server/driver pair under the no-new-secrets/no-private-downloads requirement. No Ingres tests are added or blanket-skipped; the existing provider is retained. Reconsider when a publicly runnable distribution and compatible driver are available. This exclusion is an installation/licensing blocker, not a provider implementation estimate. + +Db2, Informix and Sybase are included rather than excluded for incomplete provider implementations: their public images and drivers are exercised by the workflow, and the tested catalog/DDL operations are implemented in this change. + +Informix character metadata distinguishes fixed-length CHAR/NCHAR from variable strings. LVARCHAR is bounded at 32,739 bytes; larger String/AnsiString requests and inspected TEXT/CLOB columns recreate as TEXT with the large-text size sentinel. This preserves text capacity, but not CLOB-specific smart-LOB storage APIs. Maximum-width columns remain subject to the total row-size limit. See [IBM character types](https://www.ibm.com/docs/en/informix-servers/15.0.x?topic=types-character-data) and [catalog type identifiers](https://www.ibm.com/docs/en/informix-servers/14.10.0?topic=tables-syscolumns). diff --git a/src/Migrator.Tests/Dialects/PostgreSQLDialectTests.cs b/src/Migrator.Tests/Dialects/PostgreSQLDialectTests.cs index 11951b71..d1d1b8ab 100644 --- a/src/Migrator.Tests/Dialects/PostgreSQLDialectTests.cs +++ b/src/Migrator.Tests/Dialects/PostgreSQLDialectTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Dialects; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreDialectTests { private PostgreSQLDialect _postgreSQLDialect; diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index 456eae07..457de6da 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -5,7 +5,8 @@ false - + + @@ -30,7 +31,17 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + + + + + @@ -41,4 +52,4 @@ - \ No newline at end of file + diff --git a/src/Migrator.Tests/ProviderFactoryTest.cs b/src/Migrator.Tests/ProviderFactoryTest.cs index 8c9c3c81..3cd62545 100644 --- a/src/Migrator.Tests/ProviderFactoryTest.cs +++ b/src/Migrator.Tests/ProviderFactoryTest.cs @@ -34,7 +34,7 @@ public void SetUp() } [Test] - [Category("MySql")] + [Category("MySQL")] public void CanLoad_MySqlProvider() { var configReader = new ConfigurationReader(); @@ -56,7 +56,7 @@ public void CanLoad_OracleProvider() } [Test] - [Category("Postgre")] + [Category("PostgreSQL")] public void CanLoad_PostgreSQLProvider() { var configReader = new ConfigurationReader(); @@ -78,7 +78,7 @@ public void CanLoad_SQLiteProvider() } [Test] - [Category("SqlServer")] + [Category("SQLServer")] public void CanLoad_SqlServerProvider() { var configReader = new ConfigurationReader(); diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs new file mode 100644 index 00000000..a0aecf29 --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -0,0 +1,366 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Linq; +using System.Reflection; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using FirebirdSql.Data.FirebirdClient; +using MySql.Data.MySqlClient; +using NUnit.Framework; +using ProviderFactories = DotNetProjects.Migrator.Providers.DbProviderFactories; +using DbIndex = DotNetProjects.Migrator.Framework.Index; + +namespace Migrator.Tests.Providers.Live; + +// A fresh database/schema per test also isolates engines with auto-committing DDL. +[TestFixture("MySQL", ProviderTypes.Mysql, Category = "MySQL")] +[TestFixture("MariaDB", ProviderTypes.MariaDB, Category = "MariaDB")] +[TestFixture("Firebird", ProviderTypes.Firebird, Category = "Firebird")] +[TestFixture("Db2", ProviderTypes.IBM_DB2, Category = "Db2")] +[TestFixture("Informix", ProviderTypes.IBM_Informix, Category = "Informix")] +[TestFixture("Sybase", ProviderTypes.Sybase, Category = "Sybase")] +[NonParallelizable] +public class LiveDatabaseTests(string database, ProviderTypes providerType) +{ + private ITransformationProvider provider; + internal ITransformationProvider Provider => provider; + + internal void RunRegression(Action action) + { + try { SetUp(); action(this); } + finally { TearDown(); } + } + + internal void DropCreatedDatabase() + { + provider.DropDatabases(provider.GetDatabases().Single()); + created = false; + provider.Dispose(); + provider = null; + using var connection = new FbConnection(connectionString); + Assert.Catch(() => connection.Open()); + } + private DbConnection admin; + private string connectionString; + private string isolatedName; + private bool created; + + private static DbProviderFactory LoadFactory(string assemblyName) + { + var type = Assembly.Load(assemblyName).GetTypes() + .Single(t => !t.IsAbstract && typeof(DbProviderFactory).IsAssignableFrom(t)); + return (DbProviderFactory)(type.GetField("Instance")?.GetValue(null) + ?? type.GetProperty("Instance")?.GetValue(null) + ?? Activator.CreateInstance(type)); + } + + [SetUp] + public void SetUp() + { + isolatedName = "m" + Guid.NewGuid().ToString("N")[..12]; + string invariant; + DbProviderFactory factory; + var configured = Environment.GetEnvironmentVariable("MIGRATOR_" + database.ToUpperInvariant()); + switch (database) + { + case "MySQL": + case "MariaDB": + invariant = "MySql.Data.MySqlClient"; + factory = MySqlClientFactory.Instance; + connectionString = configured ?? "Server=127.0.0.1;Database=testdb;User ID=root;Password=rootpass;Pooling=false"; + break; + case "Firebird": + invariant = "FirebirdSql.Data.FirebirdClient"; + factory = FirebirdClientFactory.Instance; + connectionString = configured ?? "DataSource=localhost;Database=/var/lib/firebird/data/test.fdb;User=SYSDBA;Password=masterkey;Pooling=false"; + break; + case "Db2": + invariant = "IBM.Data.DB2"; + factory = LoadFactory("IBM.Data.Db2"); + connectionString = configured ?? "Server=localhost:50000;Database=testdb;UID=db2inst1;PWD=testpass;Pooling=false"; + break; + case "Informix": + invariant = "IBM.Data.Informix.Client"; + factory = LoadFactory("Informix.Net.Core"); + connectionString = configured ?? "Host=localhost;Service=9088;Server=informix;Database=testdb;User ID=informix;Password=in4mix;Protocol=onsoctcp;Pooling=false"; + break; + case "Sybase": + invariant = "Sybase.Data.AseClient"; + factory = LoadFactory("AdoNetCore.AseClient"); + connectionString = configured ?? "Data Source=localhost;Port=5000;Database=master;Uid=sa;Pwd=myPassword;Pooling=false"; + break; + default: throw new InvalidOperationException(database); + } + + ProviderFactories.RegisterFactory(invariant, () => factory); + admin = factory.CreateConnection(); + var adminBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + // Informix CREATE DATABASE needs a server-only connection, not an open database. + if (database == "Informix") adminBuilder["Database"] = ""; + admin.ConnectionString = adminBuilder.ConnectionString; + admin.Open(); + if (database == "Firebird") + { + var builder = new FbConnectionStringBuilder(connectionString); + var slash = builder.Database.LastIndexOf('/'); + builder.Database = builder.Database[..(slash + 1)] + isolatedName + ".fdb"; + connectionString = builder.ConnectionString; + FbConnection.CreateDatabase(connectionString); + } + else if (database == "Db2") + { + ExecuteAdmin("CREATE SCHEMA " + isolatedName); + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + builder["CurrentSchema"] = isolatedName.ToUpperInvariant(); + connectionString = builder.ConnectionString; + } + else + { + ExecuteAdmin("CREATE DATABASE " + isolatedName + (database == "Informix" ? " WITH LOG" : database == "Sybase" ? " ON migrator_data = 32 LOG ON migrator_log = 16" : "")); + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + builder["Database"] = isolatedName; + connectionString = builder.ConnectionString; + } + created = true; + if (database == "Informix") + { + // CREATE DATABASE implicitly selects it; release that attachment for teardown. + admin.Close(); + admin.Open(); + } + if (database == "Sybase") + { + ExecuteAdmin("EXEC sp_dboption " + isolatedName + ", 'ddl in tran', true"); + ExecuteAdmin("EXEC sp_dboption " + isolatedName + ", 'full logging for alter table', true"); + } + provider = ProviderFactory.Create(providerType, connectionString, null); + if (database == "Db2") provider.ExecuteNonQuery("SET CURRENT SCHEMA " + isolatedName); + if (database == "Sybase") provider.ExecuteNonQuery("CHECKPOINT"); + } + + private void ExecuteAdmin(string sql) + { + using var command = admin.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + [TearDown] + public void TearDown() + { + try + { + // Db2 DROP SCHEMA RESTRICT requires its objects to be removed first. + if (database == "Db2" && provider != null) + { + var tables = provider.GetTables(); + foreach (var table in tables.OrderBy(t => t.Equals("children", StringComparison.OrdinalIgnoreCase) ? 0 : 1)) + provider.RemoveTable(table); + } + } + finally + { + provider?.Dispose(); + provider = null; + try + { + if (created) + { + if (database == "Sybase") + { + // The managed ASE client closes its socket asynchronously. Wait only + // for our isolated database's sessions to detach before dropping it. + using var command = admin.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM master..sysprocesses WHERE dbid=db_id('" + isolatedName + "')"; + var deadline = DateTime.UtcNow.AddSeconds(10); + while (Convert.ToInt32(command.ExecuteScalar()) != 0) + { + if (DateTime.UtcNow >= deadline) throw new TimeoutException("ASE sessions did not detach from " + isolatedName); + System.Threading.Thread.Sleep(100); + } + } + if (database == "Firebird") FbConnection.DropDatabase(connectionString); + else ExecuteAdmin("DROP " + (database == "Db2" ? "SCHEMA " + isolatedName + " RESTRICT" : "DATABASE " + isolatedName)); + } + } + finally + { + admin?.Dispose(); + admin = null; + created = false; + } + } + } + + internal void AssertDatabaseError(TestDelegate action) + { + // AdoNetCore's ASE exception predates DbException inheritance. + var error = Assert.Catch(action); + Assert.That(error is DbException || (database == "Sybase" && error.GetType().FullName == "AdoNetCore.AseClient.AseException"), Is.True, error?.ToString()); + } + + 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)); + + [Test] + public void TableAndColumnMetadata() + { + Assert.That(provider.TableExists("items"), Is.False); + Assert.That(provider.ColumnExists("items", "id"), Is.False); + CreateItems(); + Assert.That(provider.TableExists("items"), Is.True); + Assert.That(provider.GetTables(), Has.Some.EqualTo("items").IgnoreCase); + 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); + provider.RemoveTable("items"); + Assert.That(provider.TableExists("items"), Is.False); + } + + [Test] + public void DatabaseAndViewCatalogs() + { + Assert.That(provider.GetDatabases(), Is.Not.Empty); + Assert.That(provider.ViewExists("item_view"), Is.False); + CreateItems(); + provider.ExecuteNonQuery("CREATE VIEW item_view AS SELECT id FROM items"); + Assert.That(provider.ViewExists("item_view"), Is.True); + Assert.That(provider.TableExists("item_view"), Is.False); + provider.ExecuteNonQuery("DROP VIEW item_view"); + Assert.That(provider.ViewExists("item_view"), Is.False); + } + + [Test] + public void DataDefaultsAndPersistence() + { + CreateItems(); + provider.Insert("items", ["id", "label"], [1, "O'Brien"]); + Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT amount FROM items WHERE id=1")), Is.EqualTo(7)); + provider.Dispose(); + provider = ProviderFactory.Create(providerType, connectionString, null); + if (database == "Db2") provider.ExecuteNonQuery("SET CURRENT SCHEMA " + isolatedName); + if (database == "Sybase") provider.ExecuteNonQuery("CHECKPOINT"); + Assert.That(provider.ExecuteScalar("SELECT label FROM items WHERE id=1"), Is.EqualTo("O'Brien")); + provider.Update("items", ["label"], ["changed"], "id=1"); + Assert.That(provider.ExecuteScalar("SELECT label FROM items WHERE id=1"), Is.EqualTo("changed")); + provider.Delete("items", ["id"], [1]); + Assert.That(Convert.ToInt32(provider.ExecuteScalar("SELECT COUNT(*) FROM items")), Is.Zero); + } + + [Test] + public void AddRenameChangeAndDropColumn() + { + CreateItems(); + provider.AddColumn("items", new Column("extra", DbType.String, 20, ColumnProperty.Null)); + provider.RenameColumn("items", "extra", "renamed"); + provider.ChangeColumn("items", new Column("renamed", DbType.String, 80, ColumnProperty.NotNull, "fallback")); + 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); + provider.Insert("items", ["id"], [1]); + Assert.That(provider.ExecuteScalar("SELECT renamed FROM items"), Is.EqualTo("fallback")); + provider.RemoveColumnDefaultValue("items", "renamed"); + AssertDatabaseError(() => provider.Insert("items", ["id"], [2])); + provider.RemoveColumn("items", "renamed"); + Assert.That(provider.ColumnExists("items", "renamed"), Is.False); + } + + [Test] + public void PrimaryKeyAndIdentity() + { + provider.AddTable("items", + new Column("id", DbType.Int32, ColumnProperty.PrimaryKeyWithIdentity), + new Column("label", DbType.String, 40)); + 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); + } + + [Test] + public void NamedPrimaryKey() + { + provider.AddTable("items", new Column("id", DbType.Int32, ColumnProperty.NotNull)); + provider.AddPrimaryKey("pk_items", "items", "id"); + Assert.That(provider.PrimaryKeyExists("items", "pk_items"), Is.True); + provider.Insert("items", ["id"], [1]); + AssertDatabaseError(() => provider.Insert("items", ["id"], [1])); + provider.RemovePrimaryKey("items"); + Assert.That(provider.PrimaryKeyExists("items", "pk_items"), Is.False); + provider.Insert("items", ["id"], [1]); + } + + [Test] + public void ForeignKeyIsEnforcedAndRemoved() + { + CreateItems(); + provider.AddTable("children", new Column("parentid", DbType.Int32)); + provider.AddForeignKey("fk_children", "children", "parentid", "items", "id"); + Assert.That(provider.ConstraintExists("children", "fk_children"), Is.True); + AssertDatabaseError(() => provider.Insert("children", ["parentid"], [99])); + provider.Insert("items", ["id"], [1]); + provider.Insert("children", ["parentid"], [1]); + provider.RemoveForeignKey("children", "fk_children"); + Assert.That(provider.ConstraintExists("children", "fk_children"), Is.False); + provider.Insert("children", ["parentid"], [99]); + } + + [Test] + 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.AddUniqueConstraint("uq_label", "items", "label"); + provider.AddCheckConstraint("ck_amount", "items", "amount >= 0"); + Assert.That(provider.ConstraintExists("items", "uq_label"), Is.True); + Assert.That(provider.ConstraintExists("items", "ck_amount"), Is.True); + provider.Insert("items", ["id", "label"], [1, "unique"]); + AssertDatabaseError(() => provider.Insert("items", ["id", "label"], [2, "unique"])); + AssertDatabaseError(() => provider.Insert("items", ["id", "label", "amount"], [3, "negative", -1])); + provider.RemoveConstraint("items", "ck_amount"); + provider.RemoveConstraint("items", "uq_label"); + provider.Insert("items", ["id", "label", "amount"], [2, "unique", -1]); + } + + [Test] + public void CompositeIndexMetadataAndRemoval() + { + CreateItems(); + provider.AddIndex("items", new DbIndex { Name = "ix_items", KeyColumns = ["amount", "label"], Unique = true }); + Assert.That(provider.IndexExists("items", "ix_items"), Is.True); + var index = provider.GetIndexes("items").Single(i => i.Name.Equals("ix_items", StringComparison.OrdinalIgnoreCase)); + Assert.That(index.KeyColumns.Select(c => c.ToLowerInvariant()), Is.EqualTo(new[] { "amount", "label" })); + Assert.That(index.Unique, Is.True); + provider.RemoveIndex("items", "ix_items"); + Assert.That(provider.IndexExists("items", "ix_items"), Is.False); + } + + [Test] + public void MigrateUpDownAndRepeat() + { + for (var cycle = 0; cycle < 2; cycle++) + { + var migrator = new DotNetProjects.Migrator.Migrator(provider, false, typeof(LiveMigration)); + migrator.MigrateToLastVersion(); + Assert.That(provider.TableExists("migration_items"), Is.True); + Assert.That(provider.AppliedMigrations, Does.Contain(987654L)); + migrator.MigrateTo(0); + Assert.That(provider.TableExists("migration_items"), Is.False); + Assert.That(provider.AppliedMigrations, Is.Empty); + } + } + + [Migration(987654)] + public class LiveMigration : Migration + { + public override void Up() => Database.AddTable("migration_items", new Column("id", DbType.Int32)); + public override void Down() => Database.RemoveTable("migration_items"); + } +} diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs new file mode 100644 index 00000000..18158f3b --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -0,0 +1,338 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Framework; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.Live; + +[NonParallelizable] +public class LiveMetadataRegressionTests +{ + [Test, Category("Sybase")] + public void SybaseLargeTextMetadataPreservesCapacity() => new LiveDatabaseTests("Sybase", ProviderTypes.Sybase).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE source_values (text_value TEXT NULL, unicode_value UNITEXT NULL)"); + var columns = f.Provider.GetColumns("source_values"); + Assert.That(columns.Select(c => c.Type), Is.EqualTo(new[] { DbType.String, DbType.String })); + Assert.That(columns.Select(c => c.Size), Is.EqualTo(new[] { int.MaxValue, int.MaxValue })); + f.Provider.AddTable("copied_values", columns); + Assert.That(f.Provider.GetColumns("copied_values").Select(c => c.Size), Is.EqualTo(new[] { int.MaxValue, int.MaxValue })); + var content = new string('x', 5000); + f.Provider.Insert("copied_values", ["text_value", "unicode_value"], [content, content]); + Assert.That(f.Provider.ExecuteScalar("SELECT text_value FROM copied_values"), Is.EqualTo(content)); + Assert.That(f.Provider.ExecuteScalar("SELECT unicode_value FROM copied_values"), Is.EqualTo(content)); + }); + + [Test, Category("Informix")] + public void InformixTypedDefaultsSurviveMetadataCopy() => new LiveDatabaseTests("Informix", ProviderTypes.IBM_Informix).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE source_values (id INTEGER, amount INTEGER DEFAULT 7, price DECIMAL(12,3) DEFAULT 12.345, enabled BOOLEAN DEFAULT 't', disabled BOOLEAN DEFAULT 'f', label VARCHAR(40) DEFAULT ' O''Brien ', stamp DATETIME YEAR TO FRACTION(5) DEFAULT CURRENT YEAR TO FRACTION(5), today_value DATE DEFAULT TODAY, null_value INTEGER DEFAULT NULL)"); + var columns = f.Provider.GetColumns("source_values"); + foreach (var column in columns) + TestContext.WriteLine($"Default {column.Name}: [{column.DefaultValue}] ({column.DefaultValue?.GetType().Name})"); + Assert.That(columns.Single(c => c.Name == "amount").DefaultValue, Is.TypeOf().And.EqualTo(7)); + Assert.That(columns.Single(c => c.Name == "price").DefaultValue, Is.TypeOf().And.EqualTo(12.345m)); + Assert.That(columns.Single(c => c.Name == "enabled").DefaultValue, Is.TypeOf().And.EqualTo(true)); + Assert.That(columns.Single(c => c.Name == "disabled").DefaultValue, Is.TypeOf().And.EqualTo(false)); + Assert.That(columns.Single(c => c.Name == "label").DefaultValue, Is.EqualTo(" O'Brien ")); + Assert.That(columns.Single(c => c.Name == "null_value").DefaultValue, Is.Null); + f.Provider.AddTable("copied_values", columns); + f.Provider.Insert("copied_values", ["id"], [1]); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT amount FROM copied_values")), Is.EqualTo(7)); + Assert.That(Convert.ToDecimal(f.Provider.ExecuteScalar("SELECT price FROM copied_values")), Is.EqualTo(12.345m)); + Assert.That(Convert.ToBoolean(f.Provider.ExecuteScalar("SELECT enabled FROM copied_values")), Is.True); + Assert.That(Convert.ToBoolean(f.Provider.ExecuteScalar("SELECT disabled FROM copied_values")), Is.False); + // Append a marker on the server so driver trailing-space trimming cannot affect the assertion. + Assert.That(f.Provider.ExecuteScalar("SELECT label || '!' FROM copied_values"), Is.EqualTo(" O'Brien !")); + Assert.That(f.Provider.ExecuteScalar("SELECT stamp FROM copied_values"), Is.Not.Null.And.Not.EqualTo(DBNull.Value)); + Assert.That(f.Provider.ExecuteScalar("SELECT today_value FROM copied_values"), Is.Not.Null.And.Not.EqualTo(DBNull.Value)); + Assert.That(f.Provider.ExecuteScalar("SELECT null_value FROM copied_values"), Is.EqualTo(DBNull.Value)); + }); + + [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 => + { + f.Provider.AddTable("unique_values", new Column("amount", DbType.Int32, ColumnProperty.NotNull)); + f.Provider.Insert("unique_values", ["amount"], [7]); + f.Provider.ChangeColumn("unique_values", new Column("amount", DbType.Int64, ColumnProperty.NotNull | ColumnProperty.Unique)); + 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])); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT COUNT(*) FROM unique_values")), Is.EqualTo(1)); + f.Provider.RemoveConstraint("unique_values", "UX_unique_values_amount"); + f.Provider.Insert("unique_values", ["amount"], [7L]); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT COUNT(*) FROM unique_values")), Is.EqualTo(2)); + }); + + [Test, Category("Informix")] + public void InformixLargeTextMetadataCopiesAsLargeObjects() => new LiveDatabaseTests("Informix", ProviderTypes.IBM_Informix).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE source_values (text_value TEXT, clob_value CLOB)"); + var columns = f.Provider.GetColumns("source_values"); + Assert.That(columns.Select(c => c.Type), Is.EqualTo(new[] { DbType.String, DbType.String })); + Assert.That(columns.Select(c => c.Size), Is.EqualTo(new[] { int.MaxValue, int.MaxValue })); + f.Provider.AddTable("copied_values", columns); + Assert.That(f.Provider.GetColumns("copied_values").Select(c => c.Size), Is.EqualTo(new[] { int.MaxValue, int.MaxValue })); + // A maximum-width LVARCHAR leaves insufficient row space for an additional LOB locator. + f.Provider.AddTable("bounded_values", new Column("bounded_value", DbType.String, 32739)); + f.Provider.AddTable("large_values", new Column("large_value", DbType.AnsiString, int.MaxValue)); + Assert.That(f.Provider.GetColumns("bounded_values").Single().Size, Is.EqualTo(32739)); + Assert.That(f.Provider.GetColumns("large_values").Single().Size, Is.EqualTo(int.MaxValue)); + var content = new string('z', 40000); + f.Provider.Insert("copied_values", ["text_value", "clob_value"], [content, content]); + Assert.That(f.Provider.ExecuteScalar("SELECT text_value FROM copied_values"), Is.EqualTo(content)); + Assert.That(f.Provider.ExecuteScalar("SELECT clob_value FROM copied_values"), Is.EqualTo(content)); + }); + + [Test, Category("Informix")] + public void InformixCharacterLengthsSurviveMetadataCopy() => new LiveDatabaseTests("Informix", ProviderTypes.IBM_Informix).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE source_values (long_text LVARCHAR(3000), short_text VARCHAR(40,10), fixed_text CHAR(300))"); + var columns = f.Provider.GetColumns("source_values"); + Assert.That(columns.Select(c => c.Size), Is.EqualTo(new[] { 3000, 40, 300 })); + Assert.That(columns.Select(c => c.Type), Is.EqualTo(new[] { DbType.String, DbType.String, DbType.StringFixedLength })); + f.Provider.AddTable("copied_values", columns); + Assert.That(f.Provider.GetColumns("copied_values").Select(c => c.Size), Is.EqualTo(new[] { 3000, 40, 300 })); + Assert.That(f.Provider.GetColumns("copied_values").Last().Type, Is.EqualTo(DbType.StringFixedLength)); + var content = new string('x', 2500); + f.Provider.Insert("copied_values", ["long_text", "short_text", "fixed_text"], [content, "short", new string('y', 300)]); + Assert.That(f.Provider.ExecuteScalar("SELECT long_text FROM copied_values"), Is.EqualTo(content)); + Assert.That(f.Provider.ExecuteScalar("SELECT fixed_text FROM copied_values"), Is.EqualTo(new string('y', 300))); + }); + + [Test, Category("Firebird")] + public void FirebirdNativeDateTimeAndBooleanSurviveMetadataCopy() => new LiveDatabaseTests("Firebird", ProviderTypes.Firebird).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE source_values (date_value DATE, time_value TIME, enabled BOOLEAN)"); + var columns = f.Provider.GetColumns("source_values"); + Assert.That(columns.Select(c => c.Type), Is.EqualTo(new[] { DbType.Date, DbType.Time, DbType.Boolean })); + f.Provider.AddTable("copied_values", columns); + Assert.That(f.Provider.GetColumns("copied_values").Select(c => c.Type), Is.EqualTo(new[] { DbType.Date, DbType.Time, DbType.Boolean })); + f.Provider.ExecuteNonQuery("INSERT INTO copied_values VALUES (DATE '2026-09-22', TIME '12:34:56', TRUE)"); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT COUNT(*) FROM copied_values WHERE date_value = DATE '2026-09-22' AND time_value = TIME '12:34:56' AND enabled IS TRUE")), Is.EqualTo(1)); + }); + + [TestCase("Firebird", ProviderTypes.Firebird, Category = "Firebird")] + [TestCase("Db2", ProviderTypes.IBM_DB2, Category = "Db2")] + [TestCase("Informix", ProviderTypes.IBM_Informix, Category = "Informix")] + [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 }); + 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 }); + var changed = f.Provider.GetColumns("numbers").Single(); + Assert.That(changed.Precision, Is.EqualTo(15)); + Assert.That(changed.Scale, Is.EqualTo(3)); + Assert.That(Convert.ToDecimal(f.Provider.ExecuteScalar("SELECT amount FROM numbers")), Is.EqualTo(123.456m)); + f.Provider.AddTable("copied_numbers", changed); + 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 }); + 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)); + }); + + [TestCase("Firebird", ProviderTypes.Firebird, Category = "Firebird")] + [TestCase("Informix", ProviderTypes.IBM_Informix, Category = "Informix")] + [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)); + 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); + }); + + [Test, Category("Db2")] + public void Db2DecfloatPrecisionRoundTrips() => new LiveDatabaseTests("Db2", ProviderTypes.IBM_DB2).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE floats (small_value DECFLOAT(16), large_value DECFLOAT(34))"); + var columns = f.Provider.GetColumns("floats"); + Assert.That(columns.Select(c => c.Type), Is.All.EqualTo(DbType.VarNumeric)); + Assert.That(columns.Select(c => c.Precision), Is.EqualTo(new int?[] { 16, 34 })); + f.Provider.AddTable("copied_floats", columns); + Assert.That(f.Provider.GetColumns("copied_floats").Select(c => c.Precision), Is.EqualTo(new int?[] { 16, 34 })); + }); + + [TestCase("Db2", ProviderTypes.IBM_DB2, Category = "Db2")] + [TestCase("Sybase", ProviderTypes.Sybase, Category = "Sybase")] + public void TypedCatalogDefaultsRoundTrip(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => + { + var boolean = database == "Db2" ? "BOOLEAN DEFAULT TRUE" : "BIT DEFAULT 1"; + var timestamp = database == "Db2" ? "TIMESTAMP DEFAULT CURRENT TIMESTAMP" : "DATETIME DEFAULT GETDATE()"; + f.Provider.ExecuteNonQuery($"CREATE TABLE source_values (id INTEGER, amount INTEGER DEFAULT 7, enabled {boolean}, label VARCHAR(40) DEFAULT 'O''Brien', stamp {timestamp})"); + var columns = f.Provider.GetColumns("source_values"); + Assert.That(columns.Single(c => c.Name.Equals("amount", StringComparison.OrdinalIgnoreCase)).DefaultValue, Is.TypeOf().And.EqualTo(7)); + Assert.That(columns.Single(c => c.Name.Equals("enabled", StringComparison.OrdinalIgnoreCase)).DefaultValue, Is.TypeOf().And.EqualTo(true)); + Assert.That(columns.Single(c => c.Name.Equals("label", StringComparison.OrdinalIgnoreCase)).DefaultValue, Is.EqualTo("O'Brien")); + f.Provider.AddTable("copied_values", columns); + f.Provider.Insert("copied_values", ["id"], [1]); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT amount FROM copied_values")), Is.EqualTo(7)); + Assert.That(f.Provider.ExecuteScalar("SELECT label FROM copied_values"), Is.EqualTo("O'Brien")); + Assert.That(f.Provider.ExecuteScalar("SELECT stamp FROM copied_values"), Is.Not.Null.And.Not.EqualTo(DBNull.Value)); + }); + + [Test, Category("Firebird")] + public void FirebirdTextAndBinaryBlobsRoundTrip() => new LiveDatabaseTests("Firebird", ProviderTypes.Firebird).RunRegression(f => + { + f.Provider.AddTable("large_values", new Column("contents", DbType.String, int.MaxValue), new Column("binary_value", DbType.Binary)); + var columns = f.Provider.GetColumns("large_values"); + Assert.That(columns[0].Type, Is.EqualTo(DbType.String)); + Assert.That(columns[0].Size, Is.EqualTo(int.MaxValue)); + Assert.That(columns[1].Type, Is.EqualTo(DbType.Binary)); + f.Provider.AddTable("copied_values", columns); + var content = new string('x', 5000); + f.Provider.Insert("copied_values", ["contents", "binary_value"], [content, new byte[] { 0, 1, 255 }]); + Assert.That(f.Provider.ExecuteScalar("SELECT contents FROM copied_values"), Is.EqualTo(content)); + Assert.That(f.Provider.ExecuteScalar("SELECT binary_value FROM copied_values"), Is.EqualTo(new byte[] { 0, 1, 255 })); + }); + + [TestCase("MySQL", ProviderTypes.Mysql, Category = "MySQL")] + [TestCase("MariaDB", ProviderTypes.MariaDB, Category = "MariaDB")] + public void MySqlBlobVariantsRemainBinary(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE blobs (tiny_value TINYBLOB, medium_value MEDIUMBLOB, ordinary_value BLOB, large_value LONGBLOB)"); + var columns = f.Provider.GetColumns("blobs"); + Assert.That(columns.Select(c => c.Type), Is.All.EqualTo(DbType.Binary)); + f.Provider.AddTable("copied_blobs", columns); + f.Provider.Insert("copied_blobs", ["tiny_value", "medium_value"], [new byte[] { 0, 255 }, new byte[] { 1, 255 }]); + Assert.That(f.Provider.ExecuteScalar("SELECT tiny_value FROM copied_blobs"), Is.EqualTo(new byte[] { 0, 255 })); + Assert.That(f.Provider.ExecuteScalar("SELECT medium_value FROM copied_blobs"), Is.EqualTo(new byte[] { 1, 255 })); + }); + + [Test, Category("Informix")] + public void InformixTimeMetadataRoundTrips() => new LiveDatabaseTests("Informix", ProviderTypes.IBM_Informix).RunRegression(f => + { + f.Provider.AddTable("times", new Column("time_value", DbType.Time)); + var column = f.Provider.GetColumns("times").Single(); + Assert.That(column.Type, Is.EqualTo(DbType.Time)); + f.Provider.AddTable("copied_times", column); + Assert.That(f.Provider.GetColumns("copied_times").Single().Type, Is.EqualTo(DbType.Time)); + f.Provider.ExecuteNonQuery("INSERT INTO copied_times VALUES (INTERVAL(12:34:56) HOUR TO SECOND)"); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT COUNT(*) FROM copied_times WHERE time_value=INTERVAL(12:34:56) HOUR TO SECOND")), Is.EqualTo(1)); + }); + + [Test, Category("Sybase")] + public void SybaseByteMetadataRoundTrips() => new LiveDatabaseTests("Sybase", ProviderTypes.Sybase).RunRegression(f => + { + f.Provider.AddTable("bytes", new Column("byte_value", DbType.Byte)); + var column = f.Provider.GetColumns("bytes").Single(); + Assert.That(column.Type, Is.EqualTo(DbType.Byte)); + f.Provider.AddTable("copied_bytes", column); + f.Provider.Insert("copied_bytes", ["byte_value"], [(byte)255]); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT byte_value FROM copied_bytes")), Is.EqualTo(255)); + }); + + [TestCase("Firebird", ProviderTypes.Firebird, Category = "Firebird")] + [TestCase("Db2", ProviderTypes.IBM_DB2, Category = "Db2")] + [TestCase("Informix", ProviderTypes.IBM_Informix, Category = "Informix")] + [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)); + Assert.That(f.Provider.GetIndexes("indexed_values").Any(i => i.KeyColumns.Select(c => c.ToLowerInvariant()).SequenceEqual(new[] { "amount" })), Is.True); + }); + + [Test, Category("Sybase")] + public void SybaseRemovesConstraintBackedIndexes() => new LiveDatabaseTests("Sybase", ProviderTypes.Sybase).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE numbers (id INTEGER NOT NULL PRIMARY KEY, amount INTEGER NOT NULL, CONSTRAINT uq_amount UNIQUE(amount))"); + Assert.That(f.Provider.GetIndexes("numbers").Count(i => i.UniqueConstraint), Is.EqualTo(1)); + f.Provider.RemoveAllIndexes("numbers"); + Assert.That(f.Provider.GetIndexes("numbers"), Is.Empty); + Assert.That(f.Provider.ConstraintExists("numbers", "uq_amount"), Is.False); + }); + + [Test, Category("Firebird")] + public void FirebirdDefaultsRoundTrip() => new LiveDatabaseTests("Firebird", ProviderTypes.Firebird).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE source_values (amount INTEGER DEFAULT 7, label VARCHAR(40) DEFAULT 'O''Brien', stamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"); + var columns = f.Provider.GetColumns("source_values"); + Assert.That(columns.Single(c => c.Name == "AMOUNT").DefaultValue, Is.TypeOf().And.EqualTo(7)); + Assert.That(columns.Single(c => c.Name == "LABEL").DefaultValue, Is.EqualTo("O'Brien")); + f.Provider.AddTable("copied_values", columns); + f.Provider.ExecuteNonQuery("INSERT INTO copied_values DEFAULT VALUES"); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT amount FROM copied_values")), Is.EqualTo(7)); + Assert.That(f.Provider.ExecuteScalar("SELECT label FROM copied_values"), Is.EqualTo("O'Brien")); + Assert.That(f.Provider.ExecuteScalar("SELECT stamp FROM copied_values"), Is.Not.Null.And.Not.EqualTo(DBNull.Value)); + }); + + [Test, Category("Firebird")] + public void FirebirdDecimalPrecisionAndScale() => new LiveDatabaseTests("Firebird", ProviderTypes.Firebird).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE numbers (amount DECIMAL(12,3))"); + var column = f.Provider.GetColumns("numbers").Single(); + Assert.That(column.Type, Is.EqualTo(DbType.Decimal)); + Assert.That(column.Precision, Is.EqualTo(12)); + Assert.That(column.Scale, Is.EqualTo(3)); + }); + + [Test, Category("Firebird")] + public void FirebirdDropsOnlyAttachedDatabase() => new LiveDatabaseTests("Firebird", ProviderTypes.Firebird).RunRegression(f => + { + Assert.Throws(() => f.Provider.DropDatabases("another_database")); + f.DropCreatedDatabase(); + }); + + [Test, Category("Db2")] + public void Db2RemovesConstraintBackedIndexes() => new LiveDatabaseTests("Db2", ProviderTypes.IBM_DB2).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE numbers (id INTEGER NOT NULL PRIMARY KEY, amount INTEGER NOT NULL, CONSTRAINT uq_amount UNIQUE(amount))"); + Assert.That(f.Provider.GetIndexes("numbers").Count(i => i.UniqueConstraint), Is.EqualTo(1)); + f.Provider.RemoveAllIndexes("numbers"); + Assert.That(f.Provider.GetIndexes("numbers"), Is.Empty); + Assert.That(f.Provider.ConstraintExists("numbers", "uq_amount"), Is.False); + }); + + [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.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)); + Assert.That(f.Provider.GetIndexes("numbers").Count(i => i.UniqueConstraint), Is.EqualTo(1)); + f.Provider.RemoveAllIndexes("numbers"); + Assert.That(f.Provider.GetIndexes("numbers"), Is.Empty); + Assert.That(f.Provider.ConstraintExists("numbers", "uq_amount"), Is.False); + }); + + [Test, Category("Informix")] + public void InformixPreservesQuotedCatalogNames() => new LiveDatabaseTests("Informix", ProviderTypes.IBM_Informix).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE \"MixedCase\" (id INTEGER)"); + Assert.That(f.Provider.TableExists("\"MixedCase\""), Is.True); + Assert.That(f.Provider.GetColumns("\"MixedCase\"").Single().Name, Is.EqualTo("id")); + }); + + [TestCase("MySQL", ProviderTypes.Mysql, Category = "MySQL")] + [TestCase("MariaDB", ProviderTypes.MariaDB, Category = "MariaDB")] + public void MySqlDefaultsAndBooleanMetadataRoundTrip(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => + { + f.Provider.ExecuteNonQuery("CREATE TABLE source_values (enabled TINYINT(1) DEFAULT 1, amount INTEGER DEFAULT 7, label VARCHAR(40) DEFAULT 'O''Brien', stamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"); + var columns = f.Provider.GetColumns("source_values"); + Assert.That(columns.Single(c => c.Name == "enabled").Type, Is.EqualTo(DbType.Boolean)); + Assert.That(columns.Single(c => c.Name == "enabled").DefaultValue, Is.EqualTo(true)); + Assert.That(columns.Single(c => c.Name == "amount").DefaultValue, Is.TypeOf().And.EqualTo(7)); + Assert.That(columns.Single(c => c.Name == "label").DefaultValue, Is.EqualTo("O'Brien")); + f.Provider.AddTable("copied_values", columns); + f.Provider.AddUniqueConstraint("uq_label", "copied_values", "label"); + Assert.That(f.Provider.GetIndexes("copied_values").Single(i => i.Name == "uq_label").UniqueConstraint, Is.True); + f.Provider.ExecuteNonQuery("INSERT INTO copied_values () VALUES ()"); + Assert.That(Convert.ToInt32(f.Provider.ExecuteScalar("SELECT amount FROM copied_values")), Is.EqualTo(7)); + Assert.That(f.Provider.ExecuteScalar("SELECT label FROM copied_values"), Is.EqualTo("O'Brien")); + Assert.That(f.Provider.ExecuteScalar("SELECT stamp FROM copied_values"), Is.Not.Null.And.Not.EqualTo(DBNull.Value)); + }); +} diff --git a/src/Migrator.Tests/Providers/MySQL/MySqlTransformationProviderTest.cs b/src/Migrator.Tests/Providers/MySQL/MySqlTransformationProviderTest.cs index a8ee884a..e38837f9 100644 --- a/src/Migrator.Tests/Providers/MySQL/MySqlTransformationProviderTest.cs +++ b/src/Migrator.Tests/Providers/MySQL/MySqlTransformationProviderTest.cs @@ -10,7 +10,7 @@ // namespace Migrator.Tests.Providers.MySQL; // [TestFixture] -// [Category("MySql")] +// [Category("MySQL")] // public class MySqlTransformationProviderTest : TransformationProviderConstraintBase // { // [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/Base/PostgreSQLTransformationProviderTestBase.cs b/src/Migrator.Tests/Providers/PostgreSQL/Base/PostgreSQLTransformationProviderTestBase.cs index 9e8de2ac..a605806b 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/Base/PostgreSQLTransformationProviderTestBase.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/Base/PostgreSQLTransformationProviderTestBase.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.PostgreSQL.Base; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public abstract class PostgreSQLTransformationProviderTestBase : TransformationProviderSimpleBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProviderGenericTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProviderGenericTests.cs index 5fce7dfd..463a0e0d 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProviderGenericTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProviderGenericTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProviderGenericTests : TransformationProviderGenericMiscConstraintBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs index 0f981653..d271bf58 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddIndexTests.cs @@ -15,7 +15,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_AddIndexTests : Generic_AddIndexTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddPrimaryKeyTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddPrimaryKeyTests.cs index 9649a621..9df50876 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddPrimaryKeyTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddPrimaryKeyTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_AddPrimaryKeyTests : Generic_AddPrimaryTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddTableTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddTableTests.cs index d09187b1..7428ffbf 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddTableTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_AddTableTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_AddTableTests : Generic_AddTableTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ChangeColumnTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ChangeColumnTests.cs index 7185210c..2d60120d 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ChangeColumnTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ChangeColumnTests.cs @@ -8,7 +8,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_ChangeColumnTests : Generic_ChangeColumnTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ConstraintExists.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ConstraintExists.cs index 358859be..22ab1bfd 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ConstraintExists.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ConstraintExists.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_ConstraintExistsTests : Generic_ConstraintExistsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_CopyDataFromTableToTableTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_CopyDataFromTableToTableTests.cs index 4bf485d8..bbb269ca 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_CopyDataFromTableToTableTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_CopyDataFromTableToTableTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_CopyDataFromTableToTableTests : Generic_CopyDataFromTableToTableBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_DefaultValueTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_DefaultValueTests.cs index a985d3ab..bb19b962 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_DefaultValueTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_DefaultValueTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_DefaultValueTests : Generic_DefaultValueTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumnContent_SizeTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumnContent_SizeTests.cs index 16c9deda..ae9a24a4 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumnContent_SizeTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumnContent_SizeTests.cs @@ -7,7 +7,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_GetColumnContentSize_Tests : PostgreSQLTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumnsTypeTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumnsTypeTests.cs index dd9e1e53..fac5efa9 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumnsTypeTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumnsTypeTests.cs @@ -7,7 +7,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_GetColumnTypeTests : PostgreSQLTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_DefaultValueTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_DefaultValueTests.cs index 20b1e2e1..c6510aec 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_DefaultValueTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_DefaultValueTests.cs @@ -9,7 +9,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_GetColumns_DefaultValuesTests : TransformationProviderBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_Tests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_Tests.cs index fe18719f..26613b31 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_Tests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_GetColumns_Tests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_GetColumns_Tests : Generic_GetColumnsTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyExistsTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyExistsTests.cs index 48462699..d44069c7 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyExistsTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyExistsTests.cs @@ -4,7 +4,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_PrimaryKeyExistsTests : PostgreSQLTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyWithIdentityTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyWithIdentityTests.cs index 4b17cb02..3eaaddf3 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyWithIdentityTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_PrimaryKeyWithIdentityTests.cs @@ -7,7 +7,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_PrimaryKeyWithIdentityTests : PostgreSQLTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ReservedWordsTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ReservedWordsTests.cs index efa156c5..58b068c7 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ReservedWordsTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ReservedWordsTests.cs @@ -6,7 +6,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_ReservedWordsTests : PostgreSQLTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_TableExistsTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_TableExistsTests.cs index fa53a604..7ed97103 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_TableExistsTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_TableExistsTests.cs @@ -6,7 +6,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_TableExistsTests : PostgreSQLTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ViewExistsTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ViewExistsTests.cs index b34dbcc2..e04be1c2 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ViewExistsTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgreSQLTransformationProvider_ViewExistsTests.cs @@ -6,7 +6,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_ViewExistsTests : PostgreSQLTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/PostgreSQL/PostgresSQLTransformationProvider_UpdateFromTableToTableTests.cs b/src/Migrator.Tests/Providers/PostgreSQL/PostgresSQLTransformationProvider_UpdateFromTableToTableTests.cs index 2b39e27f..1fecebcc 100644 --- a/src/Migrator.Tests/Providers/PostgreSQL/PostgresSQLTransformationProvider_UpdateFromTableToTableTests.cs +++ b/src/Migrator.Tests/Providers/PostgreSQL/PostgresSQLTransformationProvider_UpdateFromTableToTableTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.PostgreSQL; [TestFixture] -[Category("Postgre")] +[Category("PostgreSQL")] public class PostgreSQLTransformationProvider_UpdateFromTableToTableTests : Generic_UpdateFromTableToTableTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs index 91fb83f2..38b1f87d 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddIndexTests.cs @@ -14,7 +14,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_AddIndexTests : Generic_AddIndexTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddPrimaryKeyTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddPrimaryKeyTests.cs index 32f4b308..1a1b0498 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddPrimaryKeyTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddPrimaryKeyTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_AddPrimaryKeyTests : Generic_AddPrimaryTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddTableTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddTableTests.cs index 1632e892..8272fcfa 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddTableTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_AddTableTests.cs @@ -7,7 +7,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_AddTableTests : Generic_AddTableTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs index 754fad52..e31ceaff 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ChangeColumnTests.cs @@ -7,7 +7,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_ChangeColumnTests : Generic_ChangeColumnTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs index 5be60184..d694f22c 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ConstraintExistsTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_ConstraintExistsTests : Generic_ConstraintExistsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_CopyDataFromTableToTableTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_CopyDataFromTableToTableTests.cs index dcab7bf4..4a2fbb84 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_CopyDataFromTableToTableTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_CopyDataFromTableToTableTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_CopyDataFromTableToTableTests : Generic_CopyDataFromTableToTableBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_DefaultValueTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_DefaultValueTests.cs index 73caec5d..b6ee94b2 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_DefaultValueTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_DefaultValueTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_DefaultValueTests : Generic_DefaultValueTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumnsTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumnsTests.cs index 19204913..5d8ccc0e 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumnsTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumnsTests.cs @@ -7,7 +7,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_GetColumnsTests : Generic_GetColumnsTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumns_DefaultValues_Tests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumns_DefaultValues_Tests.cs index 03c60f2b..b138b562 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumns_DefaultValues_Tests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_GetColumns_DefaultValues_Tests.cs @@ -9,7 +9,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_GetColumns_DefaultValues_Tests : TransformationProviderBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_NVARCHARnTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_NVARCHARnTests.cs index 0cce1e4f..3d9443c4 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_NVARCHARnTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_NVARCHARnTests.cs @@ -7,7 +7,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SqlServerTransformationProvider_NVARCHARnTests : SQLServerTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_TableExists.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_TableExists.cs index b9b59e27..d89a3e45 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_TableExists.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_TableExists.cs @@ -6,7 +6,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_TableExistsTests : SQLServerTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_UpdateFromTableToTableTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_UpdateFromTableToTableTests.cs index 7015e781..c58cd0c1 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_UpdateFromTableToTableTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_UpdateFromTableToTableTests.cs @@ -5,7 +5,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_UpdateFromTableToTableTests : Generic_UpdateFromTableToTableTestsBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ViewExistsTests.cs b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ViewExistsTests.cs index de0784e0..ba89f740 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ViewExistsTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SQLServerTransformationProvider_ViewExistsTests.cs @@ -6,7 +6,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SQLServerTransformationProvider_ViewExistsTests : SQLServerTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs index f89967d6..eece559e 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderGenericTests.cs @@ -8,7 +8,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SqlServerTransformationProviderGenericTests : TransformationProviderGenericMiscConstraintBase { [SetUp] diff --git a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs index b9b03fd9..9c0d0641 100644 --- a/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs +++ b/src/Migrator.Tests/Providers/SQLServer/SqlServerTransformationProviderTests.cs @@ -7,7 +7,7 @@ namespace Migrator.Tests.Providers.SQLServer; [TestFixture] -[Category("SqlServer")] +[Category("SQLServer")] public class SqlServerTransformationProviderTests : SQLServerTransformationProviderTestBase { [Test] diff --git a/src/Migrator.Tests/Settings/ConfigurationReader.cs b/src/Migrator.Tests/Settings/ConfigurationReader.cs index 0767fff8..b1214e61 100644 --- a/src/Migrator.Tests/Settings/ConfigurationReader.cs +++ b/src/Migrator.Tests/Settings/ConfigurationReader.cs @@ -21,6 +21,10 @@ public class ConfigurationReader() : IConfigurationReader /// 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(); diff --git a/src/Migrator.Tests/Tools/SchemaDumperTest.cs b/src/Migrator.Tests/Tools/SchemaDumperTest.cs index aa6e13d1..9062f7b2 100644 --- a/src/Migrator.Tests/Tools/SchemaDumperTest.cs +++ b/src/Migrator.Tests/Tools/SchemaDumperTest.cs @@ -7,7 +7,7 @@ //namespace Migrator.Tests.Tools; //[TestFixture] -//[Category("MySql")] +//[Category("MySQL")] //public class SchemaDumperTest //{ // [Test] diff --git a/src/Migrator/Providers/CatalogDefaultValue.cs b/src/Migrator/Providers/CatalogDefaultValue.cs new file mode 100644 index 00000000..d8ada5be --- /dev/null +++ b/src/Migrator/Providers/CatalogDefaultValue.cs @@ -0,0 +1,61 @@ +using System; +using System.Data; +using System.Globalization; + +namespace DotNetProjects.Migrator.Providers; + +// Catalogs contain SQL, whereas Column.DefaultValue distinguishes CLR literals +// 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("'")) + { + 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); + return literal; + } + if (type == DbType.Boolean) + { + if (bool.TryParse(value, out var boolean)) return boolean; + if (value is "0" or "1") return value == "1"; + } + 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; + if (type == DbType.Int64 && long.TryParse(value, CultureInfo.InvariantCulture, out var large)) return large; + 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); + } + + private static bool HasOuterParentheses(string value) + { + if (!value.StartsWith('(') || !value.EndsWith(')')) return false; + var depth = 0; + var quoted = false; + for (var i = 0; i < value.Length; i++) + { + if (value[i] == '\'') + { + if (quoted && i + 1 < value.Length && value[i + 1] == '\'') { i++; continue; } + quoted = !quoted; + } + if (quoted) continue; + if (value[i] == '(') depth++; + if (value[i] == ')' && --depth == 0) return i == value.Length - 1; + } + return false; + } +} diff --git a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs index 2c0f6923..aeb19070 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs @@ -5,58 +5,68 @@ namespace DotNetProjects.Migrator.Providers.Impl.DB2; public class DB2Dialect : Dialect { + // This flag controls MySQL-style inline INDEX syntax, not CREATE INDEX support. + public override bool SupportsIndex => false; + public DB2Dialect() { - this.RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); - this.RegisterColumnType(DbType.AnsiStringFixedLength, 255, "CHAR($l)"); - this.RegisterColumnType(DbType.AnsiStringFixedLength, 65535, "TEXT"); - this.RegisterColumnType(DbType.AnsiStringFixedLength, 16777215, "MEDIUMTEXT"); - this.RegisterColumnType(DbType.AnsiString, "VARCHAR(255)"); - this.RegisterColumnType(DbType.AnsiString, 255, "VARCHAR($l)"); - this.RegisterColumnType(DbType.AnsiString, 256, "VARCHAR(255)"); - this.RegisterColumnType(DbType.AnsiString, 65535, "TEXT"); - this.RegisterColumnType(DbType.AnsiString, 16777215, "MEDIUMTEXT"); - this.RegisterColumnType(DbType.Binary, "LONGBLOB"); - this.RegisterColumnType(DbType.Binary, 127, "TINYBLOB"); - this.RegisterColumnType(DbType.Binary, 65535, "BLOB"); - this.RegisterColumnType(DbType.Binary, 16777215, "MEDIUMBLOB"); - this.RegisterColumnType(DbType.Boolean, "TINYINT(1)"); - this.RegisterColumnType(DbType.Byte, "TINYINT UNSIGNED"); - this.RegisterColumnType(DbType.Currency, "MONEY"); - this.RegisterColumnType(DbType.Date, "DATE"); - this.RegisterColumnType(DbType.DateTime, "DATETIME"); - this.RegisterColumnType(DbType.DateTimeOffset, "DATETIME"); - this.RegisterColumnType(DbType.Decimal, "NUMERIC(19,5)"); - this.RegisterColumnType(DbType.Decimal, 19, "NUMERIC(19, $l)"); - this.RegisterColumnType(DbType.Double, "DOUBLE"); - this.RegisterColumnType(DbType.Guid, "VARCHAR(40)"); - this.RegisterColumnType(DbType.Int16, "SMALLINT"); - this.RegisterColumnType(DbType.Int32, "INTEGER"); - this.RegisterColumnType(DbType.Int64, "BIGINT"); - this.RegisterColumnType(DbType.Single, "FLOAT"); - this.RegisterColumnType(DbType.StringFixedLength, "CHAR(255)"); - this.RegisterColumnType(DbType.StringFixedLength, 255, "CHAR($l)"); - this.RegisterColumnType(DbType.StringFixedLength, 65535, "TEXT"); - this.RegisterColumnType(DbType.StringFixedLength, 16777215, "MEDIUMTEXT"); - this.RegisterColumnType(DbType.String, "VARCHAR(255)"); - this.RegisterColumnType(DbType.String, 255, "VARCHAR($l)"); - this.RegisterColumnType(DbType.String, 256, "VARCHAR(255)"); - this.RegisterColumnType(DbType.String, 65535, "TEXT"); - this.RegisterColumnType(DbType.String, 16777215, "MEDIUMTEXT"); - this.RegisterColumnType(DbType.String, 1073741823, "LONGTEXT"); - this.RegisterColumnType(DbType.Time, "TIME"); - - this.RegisterProperty(ColumnProperty.Unsigned, "UNSIGNED"); - this.RegisterProperty(ColumnProperty.Identity, "AUTO_INCREMENT"); + RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); + RegisterColumnType(DbType.AnsiString, "VARCHAR(255)"); + RegisterColumnType(DbType.StringFixedLength, "CHAR(255)"); + RegisterColumnType(DbType.String, "VARCHAR(255)"); + RegisterColumnType(DbType.Binary, "BLOB"); + RegisterColumnType(DbType.Boolean, "BOOLEAN"); + RegisterColumnType(DbType.Byte, "SMALLINT"); + RegisterColumnType(DbType.Currency, "DECIMAL(18,4)"); + RegisterColumnType(DbType.Date, "DATE"); + RegisterColumnType(DbType.DateTime, "TIMESTAMP"); + RegisterColumnType(DbType.DateTime2, "TIMESTAMP"); + RegisterColumnType(DbType.DateTimeOffset, "TIMESTAMP"); + RegisterColumnType(DbType.Decimal, "DECIMAL(18,5)"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); + RegisterColumnType(DbType.VarNumeric, "DECFLOAT(34)"); + RegisterColumnTypeWithParameters(DbType.VarNumeric, "DECFLOAT({precision})"); + RegisterColumnType(DbType.Double, "DOUBLE PRECISION"); + RegisterColumnType(DbType.Guid, "CHAR(36)"); + RegisterColumnType(DbType.Int16, "SMALLINT"); + RegisterColumnType(DbType.Int32, "INTEGER"); + RegisterColumnType(DbType.Int64, "BIGINT"); + RegisterColumnType(DbType.Single, "REAL"); + RegisterColumnType(DbType.Time, "TIME"); + RegisterColumnType(DbType.String, 32672, "VARCHAR($l)"); + RegisterColumnType(DbType.String, int.MaxValue, "CLOB"); + RegisterColumnType(DbType.AnsiString, 32672, "VARCHAR($l)"); + 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"); + } - this.RegisterUnsignedCompatible(DbType.Int16); - this.RegisterUnsignedCompatible(DbType.Int32); - this.RegisterUnsignedCompatible(DbType.Int64); - this.RegisterUnsignedCompatible(DbType.Decimal); - this.RegisterUnsignedCompatible(DbType.Double); - this.RegisterUnsignedCompatible(DbType.Single); + public override ColumnPropertiesMapper GetColumnMapper(Column column) + { + var type = column.Size > 0 ? GetTypeName(column.Type, column.Size) : GetTypeName(column.Type); + if (column.Precision.HasValue || column.Scale.HasValue) + type = GetTypeNameParametrized(column.Type, column.Size, column.Precision ?? 18, column.Scale ?? 0); + return new NativeColumnMapper(this, type); + } - this.AddReservedWords("KEY"); + private sealed class NativeColumnMapper(Dialect dialect, string type) : ColumnPropertiesMapper(dialect, type) + { + 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); + AddIdentityAgain(column, parts); + AddDefaultValue(column, parts); + if (column.IsPrimaryKey || column.ColumnProperty.HasFlag(ColumnProperty.NotNull)) + parts.Add("NOT NULL"); + AddPrimaryKey(column, parts); + AddUnique(column, parts); + _ColumnSql = string.Join(" ", parts); + } } public override ITransformationProvider GetTransformationProvider(Dialect dialect, string connectionString, diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index 81be053f..1fb5ef08 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -1,45 +1,170 @@ using System; using System.Collections.Generic; using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; namespace DotNetProjects.Migrator.Providers.Impl.DB2; -/// -/// DB2 transformation provider -/// public class DB2TransformationProvider : TransformationProvider { public DB2TransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) : base(dialect, connectionString, null, scope) { - if (string.IsNullOrEmpty(providerName)) + var factory = DbProviderFactoriesHelper.GetFactory(string.IsNullOrEmpty(providerName) ? "IBM.Data.DB2" : providerName, null, null); + _connection = factory.CreateConnection(); + _connection.ConnectionString = connectionString; + _connection.Open(); + } + + public DB2TransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) + : base(dialect, connection, null, scope) { } + + private static string Name(string name) => (name.StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); + private static string Identifier(string name) => name.StartsWith('"') ? name : "\"" + name.ToUpperInvariant().Replace("\"", "\"\"") + "\""; + + public override void AddColumn(string table, Column column) => + AddColumn(table, _dialect.GetAndMapColumnProperties(column).ColumnSql); + + 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( + $"SELECT COUNT(*) FROM SYSCAT.TABLES WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' AND TYPE='T'")) > 0; + public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( + $"SELECT COUNT(*) FROM SYSCAT.VIEWS WHERE VIEWSCHEMA=CURRENT SCHEMA AND VIEWNAME='{Name(view)}'")) > 0; + public override string[] GetTables() => ExecuteStringQuery( + "SELECT TABNAME FROM SYSCAT.TABLES WHERE TABSCHEMA=CURRENT SCHEMA AND TYPE='T'").ToArray(); + // SQL exposes the current database, not the client's local database directory. + public override List GetDatabases() => [Convert.ToString(ExecuteScalar("VALUES CURRENT SERVER")).Trim()]; + public override string[] GetConstraints(string table) => ExecuteStringQuery( + $"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}'").ToArray(); + public override bool ConstraintExists(string table, string name) => GetConstraints(table).Contains(Name(name)); + protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( + $"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' AND TYPE='P'").FirstOrDefault(); + + public override Column[] GetColumns(string table) + { + var columns = new List(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT COLNAME, TYPENAME, NULLS, DEFAULT, LENGTH, IDENTITY, KEYSEQ, SCALE + FROM SYSCAT.COLUMNS WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' ORDER BY COLNO + """); + while (reader.Read()) { - providerName = "IBM.Data.DB2"; + var type = reader.GetString(1).Trim() switch + { + "SMALLINT" => DbType.Int16, "INTEGER" => DbType.Int32, "BIGINT" => DbType.Int64, + "DECIMAL" => DbType.Decimal, "DECFLOAT" => DbType.VarNumeric, "DOUBLE" => DbType.Double, "REAL" => DbType.Single, + "DATE" => DbType.Date, "TIME" => DbType.Time, "TIMESTAMP" => DbType.DateTime, + "BLOB" or "BINARY" or "VARBINARY" => DbType.Binary, "BOOLEAN" => DbType.Boolean, _ => DbType.String + }; + var column = new Column(reader.GetString(0).Trim(), type) + { + ColumnProperty = reader.GetString(2) == "Y" ? ColumnProperty.Null : ColumnProperty.NotNull + }; + if (!reader.IsDBNull(3)) column.DefaultValue = CatalogDefaultValue.Parse(reader.GetString(3), type); + if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(4)); + if (type == DbType.Decimal) + { + column.Precision = Convert.ToInt32(reader.GetValue(4)); + 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; + columns.Add(column); } + return columns.ToArray(); + } - var fac = DbProviderFactoriesHelper.GetFactory(providerName, null, null); - _connection = fac.CreateConnection(); - _connection.ConnectionString = _connectionString; - this._connection.Open(); + public override Index[] GetIndexes(string table) + { + var indexes = new Dictionary(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT i.INDNAME, i.UNIQUERULE, c.COLNAME, d.CONSTNAME FROM SYSCAT.INDEXES i + JOIN SYSCAT.INDEXCOLUSE c ON c.INDSCHEMA=i.INDSCHEMA AND c.INDNAME=i.INDNAME + LEFT JOIN SYSCAT.CONSTDEP d ON d.BSCHEMA=i.INDSCHEMA AND d.BNAME=i.INDNAME AND d.BTYPE='I' AND d.TABSCHEMA=i.TABSCHEMA AND d.TABNAME=i.TABNAME + WHERE i.TABSCHEMA=CURRENT SCHEMA AND i.TABNAME='{Name(table)}' + ORDER BY i.INDNAME, c.COLSEQ + """); + while (reader.Read()) + { + var name = reader.GetString(0).Trim(); + if (!indexes.TryGetValue(name, out var index)) + { + index = new Index { Name = name, Unique = reader.GetString(1) != "D", PrimaryKey = reader.GetString(1) == "P", UniqueConstraint = reader.GetString(1) == "U" && !reader.IsDBNull(3) }; + indexes.Add(name, index); + } + index.KeyColumns = [..index.KeyColumns, reader.GetString(2).Trim()]; + } + return indexes.Values.ToArray(); } - public DB2TransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) + public override void RemoveAllIndexes(string table) { + // Constraint and backing-index names need not match in Db2. + var constraints = ExecuteStringQuery($"SELECT CONSTNAME FROM SYSCAT.TABCONST WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' AND TYPE IN ('P','U')"); + foreach (var name in constraints) RemoveConstraint(table, name); + foreach (var index in GetIndexes(table)) RemoveIndex(table, index.Name); } - public override List GetDatabases() + public override bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == Name(name)); + public override string AddIndex(string table, Index index) { - throw new NotImplementedException(); + if (index.KeyColumns.Length == 0) throw new ArgumentException("An index needs key columns.", nameof(index)); + if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0 || index.Clustered) + throw new NotSupportedException("This Db2 provider supports ordinary and unique indexes without INCLUDE, filters or clustering."); + var name = index.Name ?? $"IX_{table}_{string.Join("_", index.KeyColumns)}"; + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {Identifier(name)} ON {Identifier(table)} ({string.Join(", ", index.KeyColumns.Select(Identifier))})"); + return name; } - public override bool ConstraintExists(string table, string name) + public override void ChangeColumn(string table, Column column) { - throw new NotImplementedException(); + 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"); + Reorganize(table); + if (isUniqueSet) + AddUniqueConstraint($"UX_{table}_{column.Name}", table, [column.Name]); } - public override bool IndexExists(string table, string name) + public override void RemoveColumn(string tableName, string column) { - throw new NotImplementedException(); + base.RemoveColumn(tableName, column); + Reorganize(tableName); + } + + private void Reorganize(string table) + { + var schema = Convert.ToString(ExecuteScalar("VALUES CURRENT SCHEMA")).Trim(); + ExecuteNonQuery($"CALL SYSPROC.ADMIN_CMD('REORG TABLE {schema}.{Identifier(table).Replace("'", "''")}')"); + } + + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) + { + // Db2 supports only NO ACTION/RESTRICT for ON UPDATE. + var delete = constraint switch + { + ForeignKeyConstraintType.Cascade => "CASCADE", + ForeignKeyConstraintType.SetNull => "SET NULL", + ForeignKeyConstraintType.NoAction => "NO ACTION", + ForeignKeyConstraintType.Restrict => "RESTRICT", + _ => throw new NotSupportedException("This referential action is not supported by Db2.") + }; + ExecuteNonQuery($"ALTER TABLE {Identifier(childTable)} ADD CONSTRAINT {Identifier(name)} FOREIGN KEY ({string.Join(", ", childColumns.Select(Identifier))}) REFERENCES {Identifier(parentTable)} ({string.Join(", ", parentColumns.Select(Identifier))}) ON DELETE {delete} ON UPDATE NO ACTION"); } } diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdColumnPropertiesMapper.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdColumnPropertiesMapper.cs index 38ea3a26..a9a4d833 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdColumnPropertiesMapper.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdColumnPropertiesMapper.cs @@ -24,10 +24,10 @@ public override void MapColumnProperties(Column column) AddIdentity(column, vals); - AddPrimaryKey(column, vals); - AddIdentityAgain(column, vals); + AddPrimaryKey(column, vals); + AddUnique(column, vals); AddForeignKey(column, vals); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs index e00c9ff5..3d9f85d8 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs @@ -5,19 +5,23 @@ namespace DotNetProjects.Migrator.Providers.Impl.Firebird; public class FirebirdDialect : Dialect { + // This flag controls MySQL-style inline INDEX syntax, not CREATE INDEX support. + public override bool SupportsIndex => false; + public FirebirdDialect() { RegisterColumnType(DbType.AnsiStringFixedLength, 8000, "CHAR($l)"); RegisterColumnType(DbType.AnsiString, 8000, "CHAR($l)"); RegisterColumnType(DbType.Binary, "BLOB"); RegisterColumnType(DbType.Binary, 8000, "CHAR"); - RegisterColumnType(DbType.Boolean, "SMALLINT"); - RegisterColumnType(DbType.Byte, "TINYINT"); - RegisterColumnType(DbType.Currency, "MONEY"); - RegisterColumnType(DbType.Date, "TIMESTAMP"); + RegisterColumnType(DbType.Boolean, "BOOLEAN"); + RegisterColumnType(DbType.Byte, "SMALLINT"); + RegisterColumnType(DbType.Currency, "DECIMAL(18,4)"); + RegisterColumnType(DbType.Date, "DATE"); RegisterColumnType(DbType.DateTime, "TIMESTAMP"); RegisterColumnType(DbType.DateTimeOffset, "TIMESTAMP"); RegisterColumnType(DbType.Decimal, "DECIMAL"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); RegisterColumnType(DbType.Double, "DOUBLE PRECISION"); //synonym for FLOAT(53) RegisterColumnType(DbType.Guid, "CHAR(38)"); RegisterColumnType(DbType.Int16, "SMALLINT"); @@ -28,9 +32,9 @@ public FirebirdDialect() RegisterColumnType(DbType.String, "VARCHAR(255) CHARACTER SET UNICODE_FSS"); RegisterColumnType(DbType.String, 4000, "VARCHAR($l) CHARACTER SET UNICODE_FSS"); RegisterColumnType(DbType.String, int.MaxValue, "BLOB SUB_TYPE TEXT"); - RegisterColumnType(DbType.Time, "INTEGER"); + RegisterColumnType(DbType.Time, "TIME"); - this.RegisterProperty(ColumnProperty.Unsigned, "UNSIGNED"); + RegisterProperty(ColumnProperty.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 878667fb..062643c8 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -1,153 +1,214 @@ using System; using System.Collections.Generic; using System.Data; +using System.Globalization; using System.Linq; using DotNetProjects.Migrator.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; namespace DotNetProjects.Migrator.Providers.Impl.Firebird; -/// -/// Firebird transformation provider -/// public class FirebirdTransformationProvider : TransformationProvider { public FirebirdTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) : base(dialect, connectionString, null, scope) { - if (string.IsNullOrEmpty(providerName)) - { - providerName = "FirebirdSql.Data.FirebirdClient"; - } - - var fac = DbProviderFactoriesHelper.GetFactory(providerName, "FirebirdSql.Data.FirebirdClient", "FirebirdSql.Data.FirebirdClient.FirebirdClientFactory"); - _connection = fac.CreateConnection(); - _connection.ConnectionString = _connectionString; - this._connection.Open(); + var factory = DbProviderFactoriesHelper.GetFactory(string.IsNullOrEmpty(providerName) ? "FirebirdSql.Data.FirebirdClient" : providerName, + "FirebirdSql.Data.FirebirdClient", "FirebirdSql.Data.FirebirdClient.FirebirdClientFactory"); + _connection = factory.CreateConnection(); + _connection.ConnectionString = connectionString; + _connection.Open(); } public FirebirdTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) - { - } + : base(dialect, connection, null, scope) { } + + private static string CatalogName(string name) => + (name.StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); + + public override void AddColumn(string table, Column column) => + AddColumn(table, _dialect.GetAndMapColumnProperties(column).ColumnSql); - public override void AddColumn(string table, string sqlColumn) + public override void AddTable(string name, string engine, params IDbField[] fields) { - table = QuoteTableNameIfRequired(table); - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD {1}", table, sqlColumn)); + 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( + $"SELECT COUNT(*) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME='{CatalogName(table)}' AND RDB$VIEW_BLR IS NULL")) > 0; + + public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( + $"SELECT COUNT(*) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME='{CatalogName(view)}' AND RDB$VIEW_BLR IS NOT NULL")) > 0; + + public override string[] GetTables() => ExecuteStringQuery( + "SELECT TRIM(RDB$RELATION_NAME) FROM RDB$RELATIONS WHERE COALESCE(RDB$SYSTEM_FLAG,0)=0 AND RDB$VIEW_BLR IS NULL").ToArray(); + + // Firebird has no server-wide SQL database catalog; only the attached database is visible. + public override List GetDatabases() => [_connection.Database]; + public override void DropDatabases(string databaseName) { - if (string.IsNullOrEmpty(databaseName)) - { - ExecuteNonQuery(string.Format("DROP DATABASE")); - } + if (!string.Equals(databaseName, _connection.Database, StringComparison.Ordinal)) + throw new ArgumentException("Firebird can only drop the currently attached database.", nameof(databaseName)); + // DROP DATABASE is an attachment API operation, not a DSQL statement. + // Resolve the registered driver's API without adding a driver dependency. + var method = _connection.GetType().GetMethod("DropDatabase", [typeof(string)]) + ?? throw new NotSupportedException("The registered Firebird driver does not expose DropDatabase(string)."); + var drop = method.CreateDelegate>(); + var connectionString = _connection.ConnectionString; + _connection.Close(); + drop(connectionString); } - /// - /// Execute an SQL query returning results. - /// - /// The SQL command. - /// A data iterator, IDataReader. - public override IDataReader ExecuteQuery(IDbCommand cmd, string sql) + public override string[] GetConstraints(string table) => ExecuteStringQuery( + $"SELECT TRIM(RDB$CONSTRAINT_NAME) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME='{CatalogName(table)}'").ToArray(); + + public override bool ConstraintExists(string table, string name) => + GetConstraints(table).Any(n => n == CatalogName(name).Replace("''", "'")); + + protected override string GetPrimaryKeyConstraintName(string table) => + ExecuteStringQuery($"SELECT TRIM(RDB$CONSTRAINT_NAME) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME='{CatalogName(table)}' AND RDB$CONSTRAINT_TYPE='PRIMARY KEY'").FirstOrDefault(); + + public override bool PrimaryKeyExists(string table, string name) => + string.Equals(GetPrimaryKeyConstraintName(table), CatalogName(name), StringComparison.Ordinal); + + public override Column[] GetColumns(string table) { - Logger.Trace(sql); - //IDbCommand cmd = BuildCommand(sql); + var primaryColumns = GetIndexes(table).Where(i => i.PrimaryKey).SelectMany(i => i.KeyColumns).ToHashSet(StringComparer.Ordinal); + var result = new List(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, + r.RDB$DEFAULT_SOURCE, f.RDB$CHARACTER_LENGTH, r.RDB$IDENTITY_TYPE, + f.RDB$FIELD_SUB_TYPE, f.RDB$FIELD_PRECISION, f.RDB$FIELD_SCALE + FROM RDB$RELATION_FIELDS r JOIN RDB$FIELDS f ON f.RDB$FIELD_NAME=r.RDB$FIELD_SOURCE + WHERE r.RDB$RELATION_NAME='{CatalogName(table)}' ORDER BY r.RDB$FIELD_POSITION + """); + while (reader.Read()) { - try + var type = Convert.ToInt32(reader.GetValue(1)) switch { - return cmd.ExecuteReader(); - } - catch (Exception ex) + 7 => DbType.Int16, 8 => DbType.Int32, 16 => DbType.Int64, 10 => DbType.Single, + 27 => DbType.Double, 12 => DbType.Date, 13 => DbType.Time, 35 => DbType.DateTime, + 23 => DbType.Boolean, 261 => !reader.IsDBNull(6) && Convert.ToInt32(reader.GetValue(6)) == 1 ? DbType.String : DbType.Binary, _ => DbType.String + }; + if (!reader.IsDBNull(6) && Convert.ToInt32(reader.GetValue(6)) is 1 or 2 && type is DbType.Int16 or DbType.Int32 or DbType.Int64) + 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 + }; + if (type == DbType.Decimal) { - Logger.Warn("query failed: {0}", cmd.CommandText); - throw new Exception("Failed to execute sql statement: " + sql, ex); + if (!reader.IsDBNull(7)) column.Precision = Convert.ToInt32(reader.GetValue(7)); + if (!reader.IsDBNull(8)) column.Scale = -Convert.ToInt32(reader.GetValue(8)); } + 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; + result.Add(column); } + return result.ToArray(); } - public override Column[] GetColumns(string table) + private sealed record DatabaseDefault(string Sql) { - var columns = new List(); - using (var cmd = CreateCommand()) - using ( - var reader = - ExecuteQuery(cmd, - string.Format("select RDB$FIELD_NAME, RDB$NULL_FLAG from RDB$RELATION_FIELDS where RDB$RELATION_NAME = '{0}'", table.ToUpper()))) - { - while (reader.Read()) - { - var column = new Column(reader.GetString(0).Trim(), DbType.String); - var nullableStr = reader.GetString(1); - var isNullable = nullableStr == "1"; - column.ColumnProperty |= isNullable ? ColumnProperty.Null : ColumnProperty.NotNull; - - columns.Add(column); - } - } - - return columns.ToArray(); + public override string ToString() => Sql; } - public override void AddTable(string name, params IDbField[] fields) + private static object ReadDefault(string source, DbType type) { - var columns = fields.Where(x => x is Column).Cast().ToArray(); - - base.AddTable(name, fields); - - if (columns.Any(c => c.ColumnProperty == ColumnProperty.PrimaryKeyWithIdentity)) + var value = source.Trim(); + if (value.StartsWith("DEFAULT ", StringComparison.OrdinalIgnoreCase)) value = value[8..].Trim(); + if (value.Equals("NULL", StringComparison.OrdinalIgnoreCase)) return null; + if (value.StartsWith("'") && value.EndsWith("'")) { - var identityColumn = columns.First(c => c.ColumnProperty == ColumnProperty.PrimaryKeyWithIdentity); - - var seqTName = name.Length > 21 ? name.Substring(0, 21) : name; - if (seqTName.EndsWith("_")) - { - seqTName = seqTName.Substring(0, seqTName.Length - 1); - } - - // Create a sequence for the table - using (var cmd = CreateCommand()) - { - ExecuteQuery(cmd, string.Format("CREATE GENERATOR {0}_SEQUENCE", seqTName)); - } - - using (var cmd = CreateCommand()) - { - ExecuteQuery(cmd, string.Format("SET GENERATOR {0}_SEQUENCE TO 0", seqTName)); - } - - var sql = ""; // "set term !! ;"; - sql += "CREATE TRIGGER {1}_TRIGGER FOR {0}\n"; - sql += "ACTIVE BEFORE INSERT POSITION 0\n"; - sql += "AS\n"; - sql += "BEGIN\n"; - sql += "if (NEW.{2} is NULL) then NEW.{2} = GEN_ID({1}_SEQUENCE, 1);\n"; - sql += "END\n"; - - using (var cmd = CreateCommand()) - { - ExecuteQuery(cmd, string.Format(sql, name, seqTName, identityColumn.Name)); - } + var literal = value[1..^1].Replace("''", "'"); + if (type is DbType.Date or DbType.DateTime && DateTime.TryParse(literal, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + return DateTime.SpecifyKind(date, DateTimeKind.Utc); + return literal; } + 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; + if (type == DbType.Int64 && long.TryParse(value, CultureInfo.InvariantCulture, out var large)) return large; + if (type == DbType.Decimal && decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var number)) return number; + if (type == DbType.Boolean && bool.TryParse(value, out var boolean)) return boolean; + return new DatabaseDefault(value); } - public override List GetDatabases() + public override void AddColumn(string table, string sqlColumn) => + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); + + public override void RemoveColumn(string tableName, string column) { - throw new NotImplementedException(); + if (!ColumnExists(tableName, column)) + throw new MigrationException($"Column '{column}' does not exist in '{tableName}'."); + var existing = GetColumns(tableName).Single(c => c.Name.Equals(column, StringComparison.OrdinalIgnoreCase)); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} DROP {_dialect.Quote(existing.Name)}"); } - public override bool ConstraintExists(string table, string name) + public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) { - //todo, implement this!!! + if (!ColumnExists(tableName, oldColumnName) || ColumnExists(tableName, newColumnName)) + throw new MigrationException("Source column must exist and destination column must not exist."); + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} ALTER {QuoteColumnNameIfRequired(oldColumnName)} TO {QuoteColumnNameIfRequired(newColumnName)}"); + } - //http://edn.embarcadero.com/article/25259 field infos in FB - //http://www.felix-colibri.com/papers/db/interbase/using_interbase_system_tables/using_interbase_system_tables.html + 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]); + } - return false; + public override string AddIndex(string table, Index index) + { + if (index.KeyColumns.Length == 0) throw new ArgumentException("An index needs key columns.", nameof(index)); + if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0 || index.Clustered) + throw new NotSupportedException("This Firebird provider supports ordinary and unique indexes without INCLUDE or filters."); + var name = index.Name ?? $"IX_{table}_{string.Join("_", index.KeyColumns)}"; + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {QuoteConstraintNameIfRequired(name)} ON {QuoteTableNameIfRequired(table)} ({string.Join(", ", index.KeyColumns.Select(QuoteColumnNameIfRequired))})"); + return name; } - public override bool IndexExists(string table, string name) + public override Index[] GetIndexes(string table) { - return false; + var result = new Dictionary(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT TRIM(i.RDB$INDEX_NAME), COALESCE(i.RDB$UNIQUE_FLAG,0), TRIM(s.RDB$FIELD_NAME), + TRIM(c.RDB$CONSTRAINT_TYPE) + FROM RDB$INDICES i JOIN RDB$INDEX_SEGMENTS s ON s.RDB$INDEX_NAME=i.RDB$INDEX_NAME + LEFT JOIN RDB$RELATION_CONSTRAINTS c ON c.RDB$INDEX_NAME=i.RDB$INDEX_NAME + WHERE i.RDB$RELATION_NAME='{CatalogName(table)}' + ORDER BY i.RDB$INDEX_NAME, s.RDB$FIELD_POSITION + """); + while (reader.Read()) + { + var name = reader.GetString(0); + if (!result.TryGetValue(name, out var index)) + { + var constraint = reader.IsDBNull(3) ? "" : reader.GetString(3); + index = new Index { Name = name, Unique = Convert.ToInt32(reader.GetValue(1)) == 1, + PrimaryKey = constraint == "PRIMARY KEY", UniqueConstraint = constraint == "UNIQUE" }; + result.Add(name, index); + } + index.KeyColumns = [..index.KeyColumns, reader.GetString(2)]; + } + return result.Values.ToArray(); } + + public override bool IndexExists(string table, string name) => + GetIndexes(table).Any(i => i.Name == CatalogName(name)); } diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 9a93fff3..18f376d3 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -5,60 +5,71 @@ namespace DotNetProjects.Migrator.Providers.Impl.Informix; public class InformixDialect : Dialect { + // This flag controls MySQL-style inline INDEX syntax, not CREATE INDEX support. + public override bool SupportsIndex => false; + public InformixDialect() { - this.RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); - this.RegisterColumnType(DbType.AnsiStringFixedLength, 255, "CHAR($l)"); - this.RegisterColumnType(DbType.AnsiStringFixedLength, 65535, "TEXT"); - this.RegisterColumnType(DbType.AnsiStringFixedLength, 16777215, "MEDIUMTEXT"); - this.RegisterColumnType(DbType.AnsiString, "VARCHAR(255)"); - this.RegisterColumnType(DbType.AnsiString, 255, "VARCHAR($l)"); - this.RegisterColumnType(DbType.AnsiString, 256, "VARCHAR(255)"); - this.RegisterColumnType(DbType.AnsiString, 65535, "TEXT"); - this.RegisterColumnType(DbType.AnsiString, 16777215, "MEDIUMTEXT"); - this.RegisterColumnType(DbType.Binary, "LONGBLOB"); - this.RegisterColumnType(DbType.Binary, 127, "TINYBLOB"); - this.RegisterColumnType(DbType.Binary, 65535, "BLOB"); - this.RegisterColumnType(DbType.Binary, 16777215, "MEDIUMBLOB"); - this.RegisterColumnType(DbType.Boolean, "TINYINT(1)"); - this.RegisterColumnType(DbType.Byte, "TINYINT UNSIGNED"); - this.RegisterColumnType(DbType.Currency, "MONEY"); - this.RegisterColumnType(DbType.Date, "DATE"); - this.RegisterColumnType(DbType.DateTime, "DATETIME"); - this.RegisterColumnType(DbType.DateTimeOffset, "DATETIME"); - this.RegisterColumnType(DbType.Decimal, "NUMERIC(19,5)"); - this.RegisterColumnType(DbType.Decimal, 19, "NUMERIC(19, $l)"); - this.RegisterColumnType(DbType.Double, "DOUBLE"); - this.RegisterColumnType(DbType.Guid, "VARCHAR(40)"); - this.RegisterColumnType(DbType.Int16, "SMALLINT"); - this.RegisterColumnType(DbType.Int32, "INTEGER"); - this.RegisterColumnType(DbType.Int64, "BIGINT"); - this.RegisterColumnType(DbType.Single, "FLOAT"); - this.RegisterColumnType(DbType.StringFixedLength, "CHAR(255)"); - this.RegisterColumnType(DbType.StringFixedLength, 255, "CHAR($l)"); - this.RegisterColumnType(DbType.StringFixedLength, 65535, "TEXT"); - this.RegisterColumnType(DbType.StringFixedLength, 16777215, "MEDIUMTEXT"); - this.RegisterColumnType(DbType.String, "VARCHAR(255)"); - this.RegisterColumnType(DbType.String, 255, "VARCHAR($l)"); - this.RegisterColumnType(DbType.String, 256, "VARCHAR(255)"); - this.RegisterColumnType(DbType.String, 65535, "TEXT"); - this.RegisterColumnType(DbType.String, 16777215, "MEDIUMTEXT"); - this.RegisterColumnType(DbType.String, 1073741823, "LONGTEXT"); - this.RegisterColumnType(DbType.Time, "TIME"); - - this.RegisterProperty(ColumnProperty.Unsigned, "UNSIGNED"); - this.RegisterProperty(ColumnProperty.Identity, "AUTO_INCREMENT"); + RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); + RegisterColumnType(DbType.AnsiString, "VARCHAR(255)"); + RegisterColumnType(DbType.StringFixedLength, "CHAR(255)"); + RegisterColumnType(DbType.String, "VARCHAR(255)"); + RegisterColumnType(DbType.Binary, "BYTE"); + RegisterColumnType(DbType.Boolean, "BOOLEAN"); + RegisterColumnType(DbType.Byte, "SMALLINT"); + RegisterColumnType(DbType.Currency, "DECIMAL(18,4)"); + RegisterColumnType(DbType.Date, "DATE"); + RegisterColumnType(DbType.DateTime, "DATETIME YEAR TO FRACTION(5)"); + RegisterColumnType(DbType.DateTime2, "DATETIME YEAR TO FRACTION(5)"); + RegisterColumnType(DbType.DateTimeOffset, "DATETIME YEAR TO FRACTION(5)"); + RegisterColumnType(DbType.Decimal, "DECIMAL(18,5)"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); + RegisterColumnType(DbType.Double, "DOUBLE PRECISION"); + RegisterColumnType(DbType.Guid, "CHAR(36)"); + RegisterColumnType(DbType.Int16, "SMALLINT"); + RegisterColumnType(DbType.Int32, "INTEGER"); + RegisterColumnType(DbType.Int64, "BIGINT"); + RegisterColumnType(DbType.Single, "SMALLFLOAT"); + RegisterColumnType(DbType.Time, "INTERVAL HOUR TO SECOND"); + RegisterColumnType(DbType.String, 255, "VARCHAR($l)"); + RegisterColumnType(DbType.String, 32739, "LVARCHAR($l)"); + RegisterColumnType(DbType.AnsiString, 255, "VARCHAR($l)"); + RegisterColumnType(DbType.AnsiString, 32739, "LVARCHAR($l)"); + RegisterColumnType(DbType.StringFixedLength, 32767, "CHAR($l)"); + RegisterColumnType(DbType.AnsiStringFixedLength, 32767, "CHAR($l)"); + RegisterColumnType(DbType.String, int.MaxValue, "TEXT"); + RegisterColumnType(DbType.AnsiString, int.MaxValue, "TEXT"); + RegisterProperty(ColumnProperty.Identity, ""); + } - this.RegisterUnsignedCompatible(DbType.Int16); - this.RegisterUnsignedCompatible(DbType.Int32); - this.RegisterUnsignedCompatible(DbType.Int64); - this.RegisterUnsignedCompatible(DbType.Decimal); - this.RegisterUnsignedCompatible(DbType.Double); - this.RegisterUnsignedCompatible(DbType.Single); + public override string Default(object value) => value is bool boolean ? (boolean ? "DEFAULT 't'" : "DEFAULT 'f'") : base.Default(value); - this.AddReservedWords("KEY"); + public override ColumnPropertiesMapper GetColumnMapper(Column column) + { + var type = column.Size > 0 ? GetTypeName(column.Type, column.Size) : GetTypeName(column.Type); + if (column.IsIdentity) type = column.Type == DbType.Int64 ? "BIGSERIAL" : "SERIAL"; + if (column.Precision.HasValue || column.Scale.HasValue) + type = GetTypeNameParametrized(column.Type, column.Size, column.Precision ?? 18, column.Scale ?? 0); + return new NativeColumnMapper(this, type); } + private sealed class NativeColumnMapper(Dialect dialect, string type) : ColumnPropertiesMapper(dialect, type) + { + 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); + AddIdentityAgain(column, parts); + AddDefaultValue(column, parts); + AddNotNull(column, parts); + AddPrimaryKey(column, parts); + AddUnique(column, parts); + _ColumnSql = string.Join(" ", parts); + } + } public override ITransformationProvider GetTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 4ab49fa7..2711e38f 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -1,45 +1,223 @@ using System; using System.Collections.Generic; using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; namespace DotNetProjects.Migrator.Providers.Impl.Informix; -/// -/// DB2 transformation provider -/// public class InformixTransformationProvider : TransformationProvider { public InformixTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) : base(dialect, connectionString, null, scope) { - if (string.IsNullOrEmpty(providerName)) + var factory = DbProviderFactoriesHelper.GetFactory(string.IsNullOrEmpty(providerName) ? "IBM.Data.Informix.Client" : providerName, null, null); + _connection = factory.CreateConnection(); + _connection.ConnectionString = connectionString; + _connection.Open(); + } + + public InformixTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) + : base(dialect, connection, null, scope) { } + + private static string Name(string name) => (name.StartsWith('"') ? name[1..^1].Replace("\"\"", "\"") : name.ToLowerInvariant()).Replace("'", "''"); + public override string GenerateParameterName(int index) => "?"; + public override void AddColumn(string table, Column column) => + AddColumn(table, _dialect.GetAndMapColumnProperties(column).ColumnSql); + + 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( + $"SELECT COUNT(*) FROM systables WHERE tabname='{Name(table)}' AND owner=USER AND tabtype='T'")) > 0; + public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( + $"SELECT COUNT(*) FROM systables WHERE tabname='{Name(view)}' AND owner=USER AND tabtype='V'")) > 0; + public override string[] GetTables() => ExecuteStringQuery( + "SELECT tabname FROM systables WHERE owner=USER AND tabid>=100 AND tabtype='T'").Select(n => n.Trim()).ToArray(); + public override List GetDatabases() => ExecuteStringQuery("SELECT name FROM sysmaster:sysdatabases"); + public override string[] GetConstraints(string table) => ExecuteStringQuery( + $"SELECT c.constrname FROM sysconstraints c JOIN systables t ON c.tabid=t.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}'").Select(n => n.Trim()).ToArray(); + public override bool ConstraintExists(string table, string name) => GetConstraints(table).Contains(Name(name)); + protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( + $"SELECT c.constrname FROM sysconstraints c JOIN systables t ON c.tabid=t.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}' AND c.constrtype='P'").FirstOrDefault()?.Trim(); + + public override Column[] GetColumns(string table) + { + var primaryColumns = GetIndexes(table).Where(i => i.PrimaryKey).SelectMany(i => i.KeyColumns).ToHashSet(StringComparer.Ordinal); + var columns = new List(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT c.colname, c.coltype, c.collength, d.default, x.name, d.type + FROM syscolumns c JOIN systables t ON c.tabid=t.tabid + LEFT JOIN sysdefaults d ON d.tabid=c.tabid AND d.colno=c.colno AND d.class='T' + LEFT JOIN sysxtdtypes x ON x.extended_id=c.extended_id + WHERE t.owner=USER AND t.tabname='{Name(table)}' ORDER BY c.colno + """); + while (reader.Read()) { - providerName = "IBM.Data.Informix.Client"; + var code = Convert.ToInt32(reader.GetValue(1)); + var extendedType = reader.IsDBNull(4) ? "" : reader.GetString(4).Trim().ToLowerInvariant(); + var type = (code & 255) switch + { + 1 => DbType.Int16, 2 or 6 => DbType.Int32, 17 or 18 or 52 or 53 => DbType.Int64, + 3 => DbType.Double, 4 => DbType.Single, 5 or 8 => DbType.Decimal, + 7 => DbType.Date, 10 => DbType.DateTime, 11 => DbType.Binary, 14 => DbType.Time, + 0 or 15 => DbType.StringFixedLength, + 45 => DbType.Boolean, _ => DbType.String + }; + if (extendedType == "blob") type = DbType.Binary; + if (extendedType == "boolean") type = DbType.Boolean; + var column = new Column(reader.GetString(0).Trim(), type) + { + ColumnProperty = (code & 256) != 0 ? ColumnProperty.NotNull : ColumnProperty.Null + }; + if (type is DbType.String or DbType.StringFixedLength) + { + var length = Convert.ToInt32(reader.GetValue(2)); + // VARCHAR/NVARCHAR pack reserved space into the high byte; CHAR/LVARCHAR store the full length. + column.Size = (code & 255) == 12 || extendedType == "clob" + ? int.MaxValue + : (code & 255) is 13 or 16 ? length & 255 : length; + } + if (type == DbType.Decimal) + { + var length = Convert.ToInt32(reader.GetValue(2)); + 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 (!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(); + } - var fac = DbProviderFactoriesHelper.GetFactory(providerName, null, null); - _connection = fac.CreateConnection(); - _connection.ConnectionString = _connectionString; - this._connection.Open(); + private static object ReadDefault(string catalogValue, string kind, DbType type) + { + // SYSDEFAULTS stores literal text without SQL quotes, and prefixes non-character + // literals with a six-bit encoding separated from the readable value by a space. + if (kind != "L") + return CatalogDefaultValue.Parse(kind switch + { + // Match the DATETIME precision emitted by InformixDialect. + "N" => "NULL", "C" => "CURRENT YEAR TO FRACTION(5)", "T" => "TODAY", + "U" => "USER", "S" => "DBSERVERNAME", + _ => throw new NotSupportedException($"Unsupported Informix default kind: {kind}") + }, type); + // Character literals are null-terminated before the CHAR(256) padding. + // Stop at the terminator so meaningful trailing spaces remain part of the literal. + var terminator = catalogValue.IndexOf('\0'); + var value = terminator >= 0 ? catalogValue[..terminator] : catalogValue.TrimEnd(); + if (type is DbType.String or DbType.AnsiString or DbType.StringFixedLength or DbType.AnsiStringFixedLength) + return value; + if (type == DbType.Boolean) + { + var literal = value.Trim(); + var suffix = literal.LastIndexOf(' '); + if (suffix >= 0) literal = literal[(suffix + 1)..]; + literal = literal.Trim('\''); + return CatalogDefaultValue.Parse(literal.Equals("t", StringComparison.OrdinalIgnoreCase) ? "true" : + literal.Equals("f", StringComparison.OrdinalIgnoreCase) ? "false" : literal, type); + } + var separator = value.IndexOf(' '); + if (separator >= 0) value = value[(separator + 1)..].Trim(); + if (type is DbType.Date or DbType.DateTime or DbType.Time) value = "'" + value.Replace("'", "''") + "'"; + return CatalogDefaultValue.Parse(value, type); } - public InformixTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) + public override Index[] GetIndexes(string table) { + var result = new List(); + var columns = GetColumnsForIndex(table); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT i.*, c.constrtype FROM sysindexes i JOIN systables t ON t.tabid=i.tabid + LEFT JOIN sysconstraints c ON c.tabid=i.tabid AND c.idxname=i.idxname AND c.constrtype IN ('P','U') + WHERE t.owner=USER AND t.tabname='{Name(table)}' + """); + while (reader.Read()) + { + var index = new Index { Name = Convert.ToString(reader["idxname"]).Trim(), Unique = Convert.ToString(reader["idxtype"]).Trim() == "U", + PrimaryKey = Convert.ToString(reader["constrtype"]).Trim() == "P", + UniqueConstraint = Convert.ToString(reader["constrtype"]).Trim() == "U" }; + var keys = new List(); + for (var part = 1; part <= 16; part++) + { + var number = Math.Abs(Convert.ToInt32(reader["part" + part])); + if (number == 0) break; + keys.Add(columns[number]); + } + index.KeyColumns = keys.ToArray(); + result.Add(index); + } + return result.ToArray(); } - public override List GetDatabases() + private Dictionary GetColumnsForIndex(string table) { - throw new NotImplementedException(); + var columns = new Dictionary(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $"SELECT c.colno,c.colname FROM syscolumns c JOIN systables t ON t.tabid=c.tabid WHERE t.owner=USER AND t.tabname='{Name(table)}'"); + while (reader.Read()) columns[Convert.ToInt32(reader.GetValue(0))] = reader.GetString(1).Trim(); + return columns; } - public override bool ConstraintExists(string table, string name) + public override void RemoveAllIndexes(string table) { - throw new NotImplementedException(); + var constraints = ExecuteStringQuery($"SELECT c.constrname 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')"); + foreach (var name in constraints) RemoveConstraint(table, name.Trim()); + foreach (var index in GetIndexes(table)) RemoveIndex(table, index.Name); } - public override bool IndexExists(string table, string name) + public override bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == Name(name)); + public override string AddIndex(string table, Index index) { - throw new NotImplementedException(); + if (index.KeyColumns.Length == 0) throw new ArgumentException("An index needs key columns.", nameof(index)); + if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0 || index.Clustered) + throw new NotSupportedException("This Informix provider supports ordinary and unique indexes without INCLUDE, filters or clustering."); + var name = index.Name ?? $"ix_{table}_{string.Join("_", index.KeyColumns)}"; + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {name} ON {table} ({string.Join(", ", index.KeyColumns)})"); + return name; + } + + public override void AddColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {table} ADD ({sqlColumn})"); + public override void ChangeColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {table} MODIFY ({sqlColumn})"); + public override void RemoveColumn(string tableName, string column) => ExecuteNonQuery($"ALTER TABLE {tableName} DROP ({column})"); + public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) + { + if (!ColumnExists(tableName, oldColumnName) || ColumnExists(tableName, newColumnName)) + throw new MigrationException("Source column must exist and destination column must not exist."); + ExecuteNonQuery($"RENAME COLUMN {tableName}.{oldColumnName} TO {newColumnName}"); + } + public override void RenameTable(string oldName, string newName) => ExecuteNonQuery($"RENAME TABLE {oldName} TO {newName}"); + public override void RemoveColumnDefaultValue(string table, string column) + { + var existing = GetColumns(table).Single(c => c.Name.Equals(column, StringComparison.OrdinalIgnoreCase)); + existing.DefaultValue = null; + ChangeColumn(table, existing); + } + + public override void AddPrimaryKey(string name, string table, params string[] columns) => + ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT PRIMARY KEY ({string.Join(", ", columns)}) CONSTRAINT {name}"); + public override void AddUniqueConstraint(string name, string table, params string[] columns) => + ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT UNIQUE ({string.Join(", ", columns)}) CONSTRAINT {name}"); + public override void AddCheckConstraint(string name, string table, string checkSql) => + ExecuteNonQuery($"ALTER TABLE {table} ADD CONSTRAINT CHECK ({checkSql}) CONSTRAINT {name}"); + + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) + { + var action = constraint switch + { + ForeignKeyConstraintType.Cascade => " ON DELETE CASCADE", + ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict => "", + _ => throw new NotSupportedException("Informix supports cascading deletes or its default restrictive referential action.") + }; + ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT FOREIGN KEY ({string.Join(", ", childColumns)}) REFERENCES {parentTable} ({string.Join(", ", parentColumns)}){action} CONSTRAINT {name}"); } } diff --git a/src/Migrator/Providers/Impl/Mysql/MariaDBTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MariaDBTransformationProvider.cs index a8ef9397..6ff3ec94 100644 --- a/src/Migrator/Providers/Impl/Mysql/MariaDBTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MariaDBTransformationProvider.cs @@ -10,15 +10,6 @@ public class MariaDBTransformationProvider : MySqlTransformationProvider public MariaDBTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) : base(dialect, connectionString, scope, providerName) { - if (string.IsNullOrEmpty(providerName)) - { - providerName = "MySql.Data.MySqlClient"; - } - - var fac = DbProviderFactoriesHelper.GetFactory(providerName, "MySql.Data", "MySql.Data.MySqlClient.MySqlClientFactory"); - _connection = fac.CreateConnection(); - _connection.ConnectionString = _connectionString; - _connection.Open(); } public MariaDBTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) diff --git a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs index 7491d2d8..5ad933d7 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Data; using System.Globalization; +using System.Globalization; +using System.Linq; using Index = DotNetProjects.Migrator.Framework.Index; namespace DotNetProjects.Migrator.Providers.Impl.Mysql; @@ -116,33 +118,21 @@ INNER JOIN information_schema.TABLE_CONSTRAINTS i public override void RemoveConstraint(string table, string name) { - if (ConstraintExists(table, name)) + var type = Convert.ToString(ExecuteScalar($"SELECT CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{table.Replace("'", "''")}' AND CONSTRAINT_NAME='{name.Replace("'", "''")}'")); + var action = type switch { - ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP KEY {1}", table, _dialect.Quote(name))); - } + "PRIMARY KEY" => "DROP PRIMARY KEY", + "FOREIGN KEY" => "DROP FOREIGN KEY " + _dialect.Quote(name), + "UNIQUE" => "DROP INDEX " + _dialect.Quote(name), + "CHECK" => (_dialect is MariaDBDialect ? "DROP CONSTRAINT " : "DROP CHECK ") + _dialect.Quote(name), + _ => throw new MigrationException($"Constraint '{name}' does not exist") + }; + ExecuteNonQuery($"ALTER TABLE {_dialect.Quote(table)} {action}"); } public override bool ConstraintExists(string table, string name) { - if (!TableExists(table)) - { - return false; - } - - var sqlConstraint = string.Format("SHOW KEYS FROM {0}", table); - - using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, sqlConstraint); - - while (reader.Read()) - { - if (reader["Key_name"].ToString().ToLower() == name.ToLower()) - { - return true; - } - } - - return false; + return Convert.ToInt32(ExecuteScalar($"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{table.Replace("'", "''")}' AND CONSTRAINT_NAME='{name.Replace("'", "''")}'")) > 0; } public bool ForeignKeyExists(string table, string name) @@ -176,32 +166,24 @@ INNER JOIN information_schema.KEY_COLUMN_USAGE k public override Index[] GetIndexes(string table) { - var retVal = new List(); - - var sql = @"SHOW INDEX FROM {0}"; - - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, string.Format(sql, table))) + if (!TableExists(table)) return []; + var constraints = ExecuteStringQuery($"SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{table.Replace("'", "''")}' AND CONSTRAINT_TYPE='UNIQUE'").ToHashSet(StringComparer.OrdinalIgnoreCase); + var indexes = new Dictionary(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $"SHOW INDEX FROM {_dialect.Quote(table)}"); + var columns = new Dictionary>(); + while (reader.Read()) { - while (reader.Read()) + var name = Convert.ToString(reader["Key_name"]); + if (!indexes.ContainsKey(name)) { - if (!reader.IsDBNull(1)) - { - var idx = new Index - { - Name = reader.GetString(2), - PrimaryKey = reader.GetString(2) == "PRIMARY", - Unique = !reader.GetBoolean(1), - }; - //var cols = reader.GetString(7); - //cols = cols.Substring(1, cols.Length - 2); - //idx.KeyColumns = cols.Split(','); - retVal.Add(idx); - } + indexes[name] = new Index { Name = name, PrimaryKey = name == "PRIMARY", UniqueConstraint = constraints.Contains(name), Unique = Convert.ToInt32(reader["Non_unique"]) == 0 }; + columns[name] = new SortedDictionary(); } + columns[name][Convert.ToInt32(reader["Seq_in_index"])] = Convert.ToString(reader["Column_name"]); } - - return retVal.ToArray(); + foreach (var item in indexes) item.Value.KeyColumns = columns[item.Key].Values.ToArray(); + return indexes.Values.ToArray(); } public override bool PrimaryKeyExists(string table, string name) @@ -212,80 +194,67 @@ public override bool PrimaryKeyExists(string table, string name) public override Column[] GetColumns(string table) { var columns = new List(); - using (var cmd = CreateCommand()) - using ( - var reader = - ExecuteQuery(cmd, - string.Format("SHOW COLUMNS FROM {0}", table))) + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, CHARACTER_MAXIMUM_LENGTH, COLUMN_KEY, COLUMN_TYPE, NUMERIC_PRECISION, NUMERIC_SCALE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{table.Replace("'", "''")}' ORDER BY ORDINAL_POSITION"); + while (reader.Read()) { - while (reader.Read()) + var type = reader.GetString(1) switch { - var column = new Column(reader.GetString(0), DbType.String); - var nullableStr = reader.GetString(2); - var isNullable = nullableStr == "YES"; - var defaultValue = reader.GetValue(4); - column.ColumnProperty |= isNullable ? ColumnProperty.Null : ColumnProperty.NotNull; - - if (defaultValue != null && defaultValue != DBNull.Value) - { - column.DefaultValue = defaultValue; - } - - 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" || column.DefaultValue.ToString().Trim() == "YES"; - } - 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); - 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; - } - } - } - - columns.Add(column); + "smallint" => DbType.Int16, "int" or "integer" or "mediumint" => DbType.Int32, + "bigint" => DbType.Int64, "tinyint" => reader.GetString(7).StartsWith("tinyint(1)", StringComparison.OrdinalIgnoreCase) ? DbType.Boolean : DbType.Byte, + "decimal" or "numeric" => DbType.Decimal, "double" => DbType.Double, "float" => DbType.Single, + "date" => DbType.Date, "datetime" or "timestamp" => DbType.DateTime, "time" => DbType.Time, + "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; + if (!reader.IsDBNull(3)) column.DefaultValue = ReadDefault(reader.GetString(3), type, reader.GetString(4)); + if (type == DbType.Decimal) + { + if (!reader.IsDBNull(8)) column.Precision = Convert.ToInt32(reader.GetValue(8)); + if (!reader.IsDBNull(9)) column.Scale = Convert.ToInt32(reader.GetValue(9)); } + if (!reader.IsDBNull(5)) column.Size = (int)Math.Min(int.MaxValue, Convert.ToInt64(reader.GetValue(5))); + columns.Add(column); } - return columns.ToArray(); } + // Non-string objects retain SQL expression semantics in Dialect.Default. + private sealed record DatabaseDefault(string Sql) + { + public override string ToString() => Sql; + } + + private object ReadDefault(string value, DbType type, string extra) + { + if (_dialect is MariaDBDialect) + { + if (value.Equals("NULL", StringComparison.OrdinalIgnoreCase)) return null; + if (value.StartsWith("'") && value.EndsWith("'")) + value = value[1..^1].Replace("''", "'").Replace("\\'", "'").Replace("\\\\", "\\"); + else if (type == DbType.String) return new DatabaseDefault(value); + } + if (extra.Contains("DEFAULT_GENERATED", StringComparison.OrdinalIgnoreCase) || + (type == DbType.DateTime && value.StartsWith("current_timestamp", StringComparison.OrdinalIgnoreCase))) + return new DatabaseDefault(value); + return type switch + { + DbType.Boolean => value != "0", + DbType.Byte => byte.Parse(value, CultureInfo.InvariantCulture), + DbType.Int16 => short.Parse(value, CultureInfo.InvariantCulture), + DbType.Int32 => int.Parse(value, CultureInfo.InvariantCulture), + DbType.Int64 => long.Parse(value, CultureInfo.InvariantCulture), + DbType.Decimal => decimal.Parse(value, CultureInfo.InvariantCulture), + DbType.Double => double.Parse(value, CultureInfo.InvariantCulture), + DbType.Single => float.Parse(value, CultureInfo.InvariantCulture), + DbType.Date or DbType.DateTime => DateTime.SpecifyKind(DateTime.Parse(value, CultureInfo.InvariantCulture), DateTimeKind.Utc), + _ => value + }; + } + public override string[] GetTables() { var tables = new List(); @@ -319,65 +288,9 @@ public override void AddTable(string name, string engine, string columns) public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) { - if (ColumnExists(tableName, newColumnName)) - { - throw new MigrationException(string.Format("Table '{0}' has column named '{1}' already", tableName, newColumnName)); - } - - if (!ColumnExists(tableName, oldColumnName)) - { - throw new MigrationException(string.Format("The table '{0}' does not have a column named '{1}'", tableName, oldColumnName)); - } - - string definition = null; - - var dropPrimary = false; - using (var cmd = CreateCommand()) - using (var reader = ExecuteQuery(cmd, string.Format("SHOW COLUMNS FROM {0} WHERE Field='{1}'", tableName, oldColumnName))) - { - if (reader.Read()) - { - // TODO: Could use something similar to construct the columns in GetColumns - definition = reader["Type"].ToString(); - if ("NO" == reader["Null"].ToString()) - { - definition += " " + "NOT NULL"; - } - - if (!reader.IsDBNull(reader.GetOrdinal("Key"))) - { - var key = reader["Key"].ToString(); - if ("PRI" == key) - { - //definition += " " + "PRIMARY KEY"; - dropPrimary = true; - } - else if ("UNI" == key) - { - definition += " " + "UNIQUE"; - } - } - - if (!reader.IsDBNull(reader.GetOrdinal("Extra"))) - { - definition += " " + reader["Extra"]; - } - } - } - - if (!string.IsNullOrEmpty(definition)) - { - if (dropPrimary) - { - ExecuteNonQuery(string.Format("ALTER TABLE {0} DROP PRIMARY KEY", tableName)); - } - - ExecuteNonQuery(string.Format("ALTER TABLE {0} CHANGE {1} {2} {3}", tableName, QuoteColumnNameIfRequired(oldColumnName), QuoteColumnNameIfRequired(newColumnName), definition)); - if (dropPrimary) - { - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD PRIMARY KEY({1});", tableName, QuoteColumnNameIfRequired(newColumnName))); - } - } + if (!ColumnExists(tableName, oldColumnName) || ColumnExists(tableName, newColumnName)) + throw new MigrationException("Source column must exist and destination column must not exist."); + ExecuteNonQuery($"ALTER TABLE {_dialect.Quote(tableName)} RENAME COLUMN {_dialect.Quote(oldColumnName)} TO {_dialect.Quote(newColumnName)}"); } public string GetDatabase() @@ -400,11 +313,30 @@ public override List GetDatabases() public override bool IndexExists(string table, string name) { - return ConstraintExists(table, name); + return GetIndexes(table).Any(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } public override string Concatenate(params string[] strings) { return "CONCAT(" + string.Join(", ", strings) + ")"; } + public override bool TableExists(string table) => + Convert.ToInt32(ExecuteScalar($"SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE' AND TABLE_NAME='{table.Replace("'", "''")}'")) > 0; + + public override bool ViewExists(string view) => + Convert.ToInt32(ExecuteScalar($"SELECT COUNT(*) FROM information_schema.VIEWS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{view.Replace("'", "''")}'")) > 0; + + public override string AddIndex(string table, Index index) + { + if (index.KeyColumns.Length == 0) throw new ArgumentException("An index needs key columns.", nameof(index)); + if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0 || index.Clustered) + throw new NotSupportedException("MySQL and MariaDB do not support included columns, filtered indexes or explicit clustered indexes."); + var name = index.Name ?? $"IX_{table}_{string.Join("_", index.KeyColumns)}"; + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}INDEX {_dialect.Quote(name)} ON {_dialect.Quote(table)} ({string.Join(", ", index.KeyColumns.Select(_dialect.Quote))})"); + return name; + } + + protected override string GetPrimaryKeyConstraintName(string table) => + ConstraintExists(table, "PRIMARY") ? "PRIMARY" : null; + } diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs index a8bbdf13..8d76f09d 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs @@ -5,8 +5,74 @@ namespace DotNetProjects.Migrator.Providers.Impl.Sybase; public class SybaseDialect : Dialect { + // This flag controls MySQL-style inline INDEX syntax, not CREATE INDEX support. + public override bool SupportsIndex => false; + public SybaseDialect() { + RegisterColumnType(DbType.Int16, "SMALLINT"); + RegisterColumnType(DbType.Int32, "INT"); + RegisterColumnType(DbType.Int64, "BIGINT"); + RegisterColumnType(DbType.Byte, "TINYINT"); + RegisterColumnType(DbType.Boolean, "BIT"); + RegisterColumnType(DbType.Decimal, "DECIMAL(18,5)"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); + RegisterColumnType(DbType.Currency, "MONEY"); + RegisterColumnType(DbType.Double, "FLOAT"); + RegisterColumnType(DbType.Single, "REAL"); + RegisterColumnType(DbType.Date, "DATE"); + RegisterColumnType(DbType.Time, "TIME"); + RegisterColumnType(DbType.DateTime, "DATETIME"); + RegisterColumnType(DbType.DateTime2, "BIGDATETIME"); + RegisterColumnType(DbType.DateTimeOffset, "BIGDATETIME"); + RegisterColumnType(DbType.Guid, "CHAR(36)"); + RegisterColumnType(DbType.Binary, "IMAGE"); + RegisterColumnType(DbType.String, "VARCHAR(255)"); + RegisterColumnType(DbType.String, 16384, "VARCHAR($l)"); + RegisterColumnType(DbType.String, int.MaxValue, "TEXT"); + RegisterColumnType(DbType.AnsiString, "VARCHAR(255)"); + RegisterColumnType(DbType.AnsiString, 16384, "VARCHAR($l)"); + RegisterColumnType(DbType.AnsiString, int.MaxValue, "TEXT"); + RegisterColumnType(DbType.StringFixedLength, "CHAR(255)"); + RegisterColumnType(DbType.StringFixedLength, 255, "CHAR($l)"); + RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); + RegisterColumnType(DbType.AnsiStringFixedLength, 255, "CHAR($l)"); + RegisterProperty(ColumnProperty.Identity, "IDENTITY"); + } + + public override string Default(object value) => value is bool boolean ? (boolean ? "DEFAULT 1" : "DEFAULT 0") : base.Default(value); + + public override string QuoteTemplate => "[{0}]"; + public override bool NeedsNullForNullableWhenAlteringTable => true; + + public override ColumnPropertiesMapper GetColumnMapper(Column column) + { + var type = column.Size > 0 ? GetTypeName(column.Type, column.Size) : GetTypeName(column.Type); + if (column.Precision.HasValue || column.Scale.HasValue) + type = GetTypeNameParametrized(column.Type, column.Size, column.Precision ?? 18, column.Scale ?? 0); + return new NativeColumnMapper(this, type); + } + + private sealed class NativeColumnMapper(Dialect dialect, string type) : ColumnPropertiesMapper(dialect, type) + { + 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); + AddDefaultValue(column, parts); + if (column.IsIdentity) AddIdentityAgain(column, parts); + else + { + AddNotNull(column, parts); + AddNull(column, parts); + } + AddPrimaryKey(column, parts); + AddUnique(column, parts); + _ColumnSql = string.Join(" ", parts); + } } public override ITransformationProvider GetTransformationProvider(Dialect dialect, string connectionString, string defaultSchema, string scope, string providerName) diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 46464db2..71f82029 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using Index = DotNetProjects.Migrator.Framework.Index; namespace DotNetProjects.Migrator.Providers.Impl.Sybase; @@ -9,34 +12,164 @@ public class SybaseTransformationProvider : TransformationProvider public SybaseTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) : base(dialect, connectionString, null, scope) { - if (string.IsNullOrEmpty(providerName)) + var factory = DbProviderFactoriesHelper.GetFactory(string.IsNullOrEmpty(providerName) ? "Sybase.Data.AseClient" : providerName, null, null); + _connection = factory.CreateConnection(); + _connection.ConnectionString = connectionString; + _connection.Open(); + } + public SybaseTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) + : base(dialect, connection, null, scope) { } + + private static string Literal(string name) => name.Replace("'", "''"); + public override void AddColumn(string table, Column column) => + AddColumn(table, _dialect.GetAndMapColumnProperties(column).ColumnSql); + + 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( + $"SELECT COUNT(*) FROM sysobjects WHERE id=object_id('{Literal(table)}') AND type='U'")) > 0; + public override bool ViewExists(string view) => Convert.ToInt32(ExecuteScalar( + $"SELECT COUNT(*) FROM sysobjects WHERE id=object_id('{Literal(view)}') AND type='V'")) > 0; + public override string[] GetTables() => ExecuteStringQuery("SELECT name FROM sysobjects WHERE type='U' AND uid=user_id()").ToArray(); + public override List GetDatabases() => ExecuteStringQuery("SELECT name FROM master..sysdatabases"); + public override string[] GetConstraints(string table) => ExecuteStringQuery( + $"SELECT o.name FROM sysconstraints c JOIN sysobjects o ON o.id=c.constrid WHERE c.tableid=object_id('{Literal(table)}') UNION SELECT name FROM sysindexes WHERE id=object_id('{Literal(table)}') AND (status2 & 2)=2").ToArray(); + public override bool ConstraintExists(string table, string name) => GetConstraints(table).Contains(name); + protected override string GetPrimaryKeyConstraintName(string table) => ExecuteStringQuery( + $"SELECT name FROM sysindexes WHERE id=object_id('{Literal(table)}') AND (status & 2048)=2048 AND (status & 2)=2").FirstOrDefault(); + + public override Column[] GetColumns(string table) + { + var primaryColumns = GetIndexes(table).Where(i => i.PrimaryKey).SelectMany(i => i.KeyColumns).ToHashSet(StringComparer.Ordinal); + var defaults = GetColumnDefaults(table); + var columns = new List(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT c.name,t.name,c.status,c.length,c.prec,c.scale FROM syscolumns c JOIN systypes t ON t.usertype=c.usertype + WHERE c.id=object_id('{Literal(table)}') ORDER BY c.colid + """); + while (reader.Read()) { - providerName = "Sybase.Data.AseClient"; + var nativeType = reader.GetString(1).Trim(); + var type = nativeType switch + { + "tinyint" => DbType.Byte, "smallint" => DbType.Int16, "int" => DbType.Int32, "bigint" => DbType.Int64, + "numeric" or "decimal" or "money" => DbType.Decimal, "float" => DbType.Double, + "real" => DbType.Single, "date" => DbType.Date, "time" => DbType.Time, + "datetime" or "bigdatetime" => DbType.DateTime, "bit" => DbType.Boolean, + "image" or "binary" or "varbinary" => DbType.Binary, _ => DbType.String + }; + var status = Convert.ToInt32(reader.GetValue(2)); + var column = new Column(reader.GetString(0), type) + { + ColumnProperty = (status & 8) != 0 ? ColumnProperty.Null : ColumnProperty.NotNull + }; + if (type == DbType.Decimal) + { + if (!reader.IsDBNull(4)) column.Precision = Convert.ToInt32(reader.GetValue(4)); + 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 (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(); + } - var fac = DbProviderFactoriesHelper.GetFactory(providerName, null, null); - _connection = fac.CreateConnection(); - _connection.ConnectionString = _connectionString; - this._connection.Open(); + private Dictionary GetColumnDefaults(string table) + { + var defaults = new Dictionary(); + using var command = CreateCommand(); + using var reader = ExecuteQuery(command, $"SELECT c.name,d.text FROM syscolumns c JOIN syscomments d ON d.id=c.cdefault WHERE c.id=object_id('{Literal(table)}') ORDER BY c.colid,d.colid2,d.colid"); + while (reader.Read()) + { + var name = reader.GetString(0); + defaults.TryGetValue(name, out var text); + defaults[name] = text + reader.GetString(1); + } + foreach (var name in defaults.Keys.ToArray()) + { + var sql = defaults[name].Trim(); + if (sql.StartsWith("CREATE DEFAULT", StringComparison.OrdinalIgnoreCase)) + sql = System.Text.RegularExpressions.Regex.Replace(sql, @"^CREATE\s+DEFAULT\s+.+?\s+AS\s+", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + if (sql.StartsWith("DEFAULT", StringComparison.OrdinalIgnoreCase)) sql = sql[7..].Trim(); + defaults[name] = sql; + } + return defaults; } - public SybaseTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) + public override Index[] GetIndexes(string table) { + var indexes = new List(); + using var cmd = CreateCommand(); + using (var reader = ExecuteQuery(cmd, $"SELECT name,indid,status,status2 FROM sysindexes WHERE id=object_id('{Literal(table)}') AND indid BETWEEN 1 AND 254")) + { + while (reader.Read()) + { + var status = Convert.ToInt32(reader.GetValue(2)); + indexes.Add(new Index { Name = reader.GetString(0), Unique = (status & 2) != 0, + PrimaryKey = (status & 2048) != 0, UniqueConstraint = (status & 2048) == 0 && (Convert.ToInt32(reader.GetValue(3)) & 2) != 0, + Clustered = Convert.ToInt32(reader.GetValue(1)) == 1 || (Convert.ToInt32(reader.GetValue(3)) & 512) != 0 }); + } + } + foreach (var index in indexes) + { + var id = Convert.ToInt32(ExecuteScalar($"SELECT indid FROM sysindexes WHERE id=object_id('{Literal(table)}') AND name='{Literal(index.Name)}'")); + var keys = new List(); + for (var position = 1; position <= 31; position++) + { + var key = Convert.ToString(ExecuteScalar($"SELECT index_col('{Literal(table)}', {id}, {position})")); + if (string.IsNullOrEmpty(key)) break; + keys.Add(key); + } + index.KeyColumns = keys.ToArray(); + } + return indexes.ToArray(); } - public override List GetDatabases() + public override bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == name); + public override string AddIndex(string table, Index index) { - throw new NotImplementedException(); + if (index.KeyColumns.Length == 0) throw new ArgumentException("An index needs key columns.", nameof(index)); + if (index.IncludeColumns.Length != 0 || index.FilterItems.Count != 0) + throw new NotSupportedException("ASE does not support this index's INCLUDE or filter options."); + var name = index.Name ?? $"ix_{table}_{string.Join("_", index.KeyColumns)}"; + ExecuteNonQuery($"CREATE {(index.Unique ? "UNIQUE " : "")}{(index.Clustered ? "CLUSTERED " : "NONCLUSTERED ")}INDEX {name} ON {table} ({string.Join(", ", index.KeyColumns)})"); + return name; } - public override bool ConstraintExists(string table, string name) + public override void AddColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {table} ADD {sqlColumn}"); + + public override void RemoveIndex(string table, string name) => ExecuteNonQuery($"DROP INDEX {table}.{name}"); + public override void RenameColumn(string tableName, string oldColumnName, string newColumnName) => + ExecuteNonQuery($"EXEC sp_rename '{Literal(tableName)}.{Literal(oldColumnName)}', '{Literal(newColumnName)}'"); + public override void RenameTable(string oldName, string newName) => + ExecuteNonQuery($"EXEC sp_rename '{Literal(oldName)}', '{Literal(newName)}'"); + public override void RemoveColumn(string tableName, string column) => ExecuteNonQuery($"ALTER TABLE {tableName} DROP {column}"); + public override void RemoveColumnDefaultValue(string table, string column) => ExecuteNonQuery($"ALTER TABLE {table} REPLACE {column} DEFAULT NULL"); + public override void ChangeColumn(string table, Column column) { - throw new NotImplementedException(); + 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"; + 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 bool IndexExists(string table, string name) + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) { - throw new NotImplementedException(); + if (constraint is not (ForeignKeyConstraintType.NoAction or ForeignKeyConstraintType.Restrict)) + throw new NotSupportedException("ASE declarative foreign keys do not support cascading referential actions."); + ExecuteNonQuery($"ALTER TABLE {childTable} ADD CONSTRAINT {name} FOREIGN KEY ({string.Join(", ", childColumns)}) REFERENCES {parentTable} ({string.Join(", ", parentColumns)})"); } } diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 7a945c5f..7b766915 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -1668,7 +1668,7 @@ public virtual void MigrationApplied(long version, string scope) public virtual void MigrationUnApplied(long version, string scope) { CreateSchemaInfoTable(); - Delete(_schemaInfotable, ["Scope", "Version"], [scope ?? _scope, version.ToString()]); + Delete(_schemaInfotable, ["Scope", "Version"], [scope ?? _scope, version]); _appliedMigrations.Remove(version); } @@ -1986,6 +1986,16 @@ protected virtual void ConfigureParameterWithValue(IDbDataParameter parameter, i parameter.DbType = DbType.Guid; parameter.Value = (Guid)value; } + else if (value is byte[] bytes) + { + parameter.DbType = DbType.Binary; + parameter.Value = bytes; + } + else if (value is byte) + { + parameter.DbType = DbType.Byte; + parameter.Value = value; + } else if (value is short) { parameter.DbType = DbType.Int16;