From 9bfbe09134d51621d4a14843be7096455cbaef22 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 09:43:35 +0200 Subject: [PATCH 01/27] Add isolated live database CI jobs and migration contract tests Why: The former combined workflow did not exercise most existing providers and MySQL only had a connection smoke test. Independent pinned containers make failures attributable to one engine and avoid requiring private infrastructure. Changes: Add isolated contract tests for persisted CRUD, DDL, constraints, indexes and repeatable migration/version tracking. Preserve existing suites while adding public-container startup, bounded jobs and diagnostic artifacts. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .github/scripts/start-database.sh | 51 +++ .github/scripts/test.ps1 | 14 + .github/workflows/dotnetpull.yml | 133 +++----- .../Dialects/PostgreSQLDialectTests.cs | 2 +- src/Migrator.Tests/Migrator.Tests.csproj | 14 +- src/Migrator.Tests/ProviderFactoryTest.cs | 6 +- .../Providers/Live/LiveDatabaseTests.cs | 287 +++++++++++++++++ ...ostgreSQLTransformationProviderTestBase.cs | 2 +- ...reSQLTransformationProviderGenericTests.cs | 2 +- ...SQLTransformationProvider_AddIndexTests.cs | 2 +- ...ansformationProvider_AddPrimaryKeyTests.cs | 2 +- ...SQLTransformationProvider_AddTableTests.cs | 2 +- ...ransformationProvider_ChangeColumnTests.cs | 2 +- ...TransformationProvider_ConstraintExists.cs | 2 +- ...nProvider_CopyDataFromTableToTableTests.cs | 2 +- ...ransformationProvider_DefaultValueTests.cs | 2 +- ...tionProvider_GetColumnContent_SizeTests.cs | 2 +- ...nsformationProvider_GetColumnsTypeTests.cs | 2 +- ...onProvider_GetColumns_DefaultValueTests.cs | 2 +- ...TransformationProvider_GetColumns_Tests.cs | 2 +- ...formationProvider_PrimaryKeyExistsTests.cs | 2 +- ...ionProvider_PrimaryKeyWithIdentityTests.cs | 2 +- ...ansformationProvider_ReservedWordsTests.cs | 2 +- ...TransformationProvider_TableExistsTests.cs | 2 +- ...LTransformationProvider_ViewExistsTests.cs | 2 +- ...ionProvider_UpdateFromTableToTableTests.cs | 2 +- .../Settings/ConfigurationReader.cs | 4 + .../FirebirdColumnPropertiesMapper.cs | 4 +- .../Impl/Firebird/FirebirdDialect.cs | 6 +- .../FirebirdTransformationProvider.cs | 297 +++++++++--------- .../Mysql/MariaDBTransformationProvider.cs | 9 - .../Impl/Mysql/MySqlTransformationProvider.cs | 236 ++++---------- 32 files changed, 641 insertions(+), 460 deletions(-) create mode 100644 .github/scripts/start-database.sh create mode 100644 .github/scripts/test.ps1 create mode 100644 src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh new file mode 100644 index 00000000..826937ab --- /dev/null +++ b/.github/scripts/start-database.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail +database="$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) + 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) + docker run -dt --name migrator-db --hostname ifx --privileged -p 9088:9088 -e LICENSE=accept icr.io/informix/informix-developer-database:15.0.0.0 + ready() { docker exec migrator-db bash -lc 'onstat -' 2>/dev/null | grep -q 'On-Line'; } + ;; + *) 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 + 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) + echo 'create database testdb with log;' | docker exec -i migrator-db bash -lc 'dbaccess sysmaster -' + ;; +esac diff --git a/.github/scripts/test.ps1 b/.github/scripts/test.ps1 new file mode 100644 index 00000000..e26fce79 --- /dev/null +++ b/.github/scripts/test.ps1 @@ -0,0 +1,14 @@ +param( + [ValidateSet('Unit','SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix')] + [string]$Database = 'Unit' +) +$ErrorActionPreference = 'Stop' +$databases = @('SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix') +$filter = if ($Database -eq 'Unit') { ($databases | ForEach-Object { "TestCategory!=$_" }) -join '&' } else { "TestCategory=$Database" } +dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --results-directory TestResults -- NUnit.NumberOfTestWorkers=0 +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" } +Write-Host "$Database : $($counters.passed) passed, $($counters.notExecuted) not executed" diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index f25eef79..3c2478d5 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -1,108 +1,51 @@ name: .NET Pull Request - on: push: branches: [master] pull_request: branches: [master] + workflow_dispatch: +permissions: + contents: read 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] 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 - 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 </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 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..768926b5 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -5,7 +5,8 @@ false - + + @@ -30,7 +31,14 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + + @@ -41,4 +49,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..d9e06688 --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -0,0 +1,287 @@ +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")] +[NonParallelizable] +public class LiveDatabaseTests(string database, ProviderTypes providerType) +{ + private ITransformationProvider provider; + 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; + default: throw new InvalidOperationException(database); + } + + ProviderFactories.RegisterFactory(invariant, () => factory); + admin = factory.CreateConnection(); + admin.ConnectionString = 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); + } + else + { + ExecuteAdmin("CREATE DATABASE " + isolatedName + (database == "Informix" ? " WITH LOG" : "")); + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + builder["Database"] = isolatedName; + connectionString = builder.ConnectionString; + } + created = true; + provider = ProviderFactory.Create(providerType, connectionString, null); + if (database == "Db2") provider.ExecuteNonQuery("SET CURRENT SCHEMA " + isolatedName); + } + + 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 == "Firebird") FbConnection.DropDatabase(connectionString); + else ExecuteAdmin("DROP " + (database == "Db2" ? "SCHEMA " + isolatedName + " RESTRICT" : "DATABASE " + isolatedName)); + } + } + finally + { + admin?.Dispose(); + admin = null; + created = false; + } + } + } + + 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 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); + 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")); + provider.Insert("items", ["id"], [1]); + Assert.That(provider.ExecuteScalar("SELECT renamed FROM items"), Is.EqualTo("fallback")); + provider.RemoveColumnDefaultValue("items", "renamed"); + Assert.Catch(() => 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]); + Assert.Catch(() => 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); + Assert.Catch(() => 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(); + 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"]); + Assert.Catch(() => provider.Insert("items", ["id", "label"], [2, "unique"])); + Assert.Catch(() => provider.Insert("items", ["id", "amount"], [3, -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/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/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/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..97ca6c02 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs @@ -12,8 +12,8 @@ public FirebirdDialect() RegisterColumnType(DbType.Binary, "BLOB"); RegisterColumnType(DbType.Binary, 8000, "CHAR"); RegisterColumnType(DbType.Boolean, "SMALLINT"); - RegisterColumnType(DbType.Byte, "TINYINT"); - RegisterColumnType(DbType.Currency, "MONEY"); + RegisterColumnType(DbType.Byte, "SMALLINT"); + RegisterColumnType(DbType.Currency, "DECIMAL(18,4)"); RegisterColumnType(DbType.Date, "TIMESTAMP"); RegisterColumnType(DbType.DateTime, "TIMESTAMP"); RegisterColumnType(DbType.DateTimeOffset, "TIMESTAMP"); @@ -30,7 +30,7 @@ public FirebirdDialect() RegisterColumnType(DbType.String, int.MaxValue, "BLOB SUB_TYPE TEXT"); RegisterColumnType(DbType.Time, "INTEGER"); - 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..9c803c76 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -1,153 +1,144 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Linq; -using DotNetProjects.Migrator.Framework; - -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(); - } - - public FirebirdTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) - { - } - - public override void AddColumn(string table, string sqlColumn) - { - table = QuoteTableNameIfRequired(table); - ExecuteNonQuery(string.Format("ALTER TABLE {0} ADD {1}", table, sqlColumn)); - } - - public override void DropDatabases(string databaseName) - { - if (string.IsNullOrEmpty(databaseName)) - { - ExecuteNonQuery(string.Format("DROP DATABASE")); - } - } - - /// - /// Execute an SQL query returning results. - /// - /// The SQL command. - /// A data iterator, IDataReader. - public override IDataReader ExecuteQuery(IDbCommand cmd, string sql) - { - Logger.Trace(sql); - //IDbCommand cmd = BuildCommand(sql); - { - try - { - return cmd.ExecuteReader(); - } - catch (Exception ex) - { - Logger.Warn("query failed: {0}", cmd.CommandText); - throw new Exception("Failed to execute sql statement: " + sql, ex); - } - } - } - - public override Column[] GetColumns(string table) - { - 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 void AddTable(string name, params IDbField[] fields) - { - var columns = fields.Where(x => x is Column).Cast().ToArray(); - - base.AddTable(name, fields); - - if (columns.Any(c => c.ColumnProperty == ColumnProperty.PrimaryKeyWithIdentity)) - { - 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)); - } - } - } - - public override List GetDatabases() - { - throw new NotImplementedException(); - } - - public override bool ConstraintExists(string table, string name) - { - //todo, implement this!!! - - //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 - - return false; - } - - public override bool IndexExists(string table, string name) - { - return false; - } -} +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.Firebird; + +public class FirebirdTransformationProvider : TransformationProvider +{ + public FirebirdTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) + : base(dialect, connectionString, null, scope) + { + var factory = DbProviderFactoriesHelper.GetFactory(providerName ?? "FirebirdSql.Data.FirebirdClient", + "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) { } + + private static string CatalogName(string name) => + (name.StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); + + 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 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) + { + 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()) + { + var type = Convert.ToInt32(reader.GetValue(1)) switch + { + 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 => 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 (!reader.IsDBNull(3)) column.DefaultValue = reader.GetString(3).Trim(); + if (!reader.IsDBNull(4)) column.Size = Convert.ToInt32(reader.GetValue(4)); + if (!reader.IsDBNull(5)) column.ColumnProperty |= ColumnProperty.Identity; + result.Add(column); + } + return result.ToArray(); + } + + public override void AddColumn(string table, string sqlColumn) => + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); + + 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($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} ALTER {QuoteColumnNameIfRequired(oldColumnName)} TO {QuoteColumnNameIfRequired(newColumnName)}"); + } + + public override void ChangeColumn(string table, Column column) + { + var prefix = $"ALTER TABLE {QuoteTableNameIfRequired(table)} ALTER {QuoteColumnNameIfRequired(column.Name)}"; + var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(column.Type); + ExecuteNonQuery($"{prefix} TYPE {type}"); + ExecuteNonQuery($"{prefix} {(column.DefaultValue == null ? "DROP DEFAULT" : "SET " + _dialect.Default(column.DefaultValue))}"); + ExecuteNonQuery($"{prefix} {(column.ColumnProperty.HasFlag(ColumnProperty.NotNull) ? "SET" : "DROP")} NOT NULL"); + } + + 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 Index[] GetIndexes(string table) + { + 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/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..314190c6 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Data; using System.Globalization; +using System.Linq; using Index = DotNetProjects.Migrator.Framework.Index; namespace DotNetProjects.Migrator.Providers.Impl.Mysql; @@ -116,33 +117,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 +165,23 @@ 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 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", 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,77 +192,26 @@ 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 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" => DbType.Byte, + "decimal" or "numeric" => DbType.Decimal, "double" => DbType.Double, "float" => DbType.Single, + "date" => DbType.Date, "datetime" or "timestamp" => DbType.DateTime, "time" => DbType.Time, + "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 = reader.GetValue(3); + if (!reader.IsDBNull(5)) column.Size = (int)Math.Min(int.MaxValue, Convert.ToInt64(reader.GetValue(5))); + columns.Add(column); } - return columns.ToArray(); } @@ -319,65 +248,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 +273,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; + } From 19e904dae2a5391c9909e35c0a5d0c982cb875dc Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 09:51:38 +0200 Subject: [PATCH 02/27] Complete Db2 and Informix migration operations and correct database test routing Why: Enabling Db2 and Informix exposed placeholder catalog and DDL implementations. Excluding these engines for incomplete providers would leave the requested migration coverage unfulfilled. Changes: Implement native catalog inspection, type/identity mapping and schema operations. Normalize database categories so each matrix job runs the complete existing suite, including provider-factory tests. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .github/scripts/start-database.sh | 102 +++---- .github/workflows/dotnetpull.yml | 4 +- .../Providers/Live/LiveDatabaseTests.cs | 10 +- .../MySQL/MySqlTransformationProviderTest.cs | 2 +- ...verTransformationProvider_AddIndexTests.cs | 2 +- ...ansformationProvider_AddPrimaryKeyTests.cs | 2 +- ...verTransformationProvider_AddTableTests.cs | 2 +- ...ransformationProvider_ChangeColumnTests.cs | 2 +- ...formationProvider_ConstraintExistsTests.cs | 2 +- ...nProvider_CopyDataFromTableToTableTests.cs | 2 +- ...ransformationProvider_DefaultValueTests.cs | 2 +- ...rTransformationProvider_GetColumnsTests.cs | 2 +- ...Provider_GetColumns_DefaultValues_Tests.cs | 2 +- ...erTransformationProvider_NVARCHARnTests.cs | 2 +- ...erverTransformationProvider_TableExists.cs | 2 +- ...ionProvider_UpdateFromTableToTableTests.cs | 2 +- ...rTransformationProvider_ViewExistsTests.cs | 2 +- ...erverTransformationProviderGenericTests.cs | 2 +- .../SqlServerTransformationProviderTests.cs | 2 +- src/Migrator.Tests/Tools/SchemaDumperTest.cs | 2 +- src/Migrator/Providers/Impl/DB2/DB2Dialect.cs | 96 +++--- .../Impl/DB2/DB2TransformationProvider.cs | 186 ++++++++--- .../FirebirdTransformationProvider.cs | 288 +++++++++--------- .../Impl/Informix/InformixDialect.cs | 98 +++--- .../InformixTransformationProvider.cs | 196 +++++++++--- 25 files changed, 609 insertions(+), 405 deletions(-) diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh index 826937ab..0f0a7a11 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -1,51 +1,51 @@ -#!/usr/bin/env bash -set -euo pipefail -database="$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) - 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) - docker run -dt --name migrator-db --hostname ifx --privileged -p 9088:9088 -e LICENSE=accept icr.io/informix/informix-developer-database:15.0.0.0 - ready() { docker exec migrator-db bash -lc 'onstat -' 2>/dev/null | grep -q 'On-Line'; } - ;; - *) 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 - 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) - echo 'create database testdb with log;' | docker exec -i migrator-db bash -lc 'dbaccess sysmaster -' - ;; -esac +#!/usr/bin/env bash +set -euo pipefail +database="$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) + 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) + 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 -lc 'onstat -' 2>/dev/null | grep -q 'On-Line'; } + ;; + *) 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 + 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) + echo 'create database testdb with log;' | docker exec -i migrator-db bash -lc 'dbaccess sysmaster -' + ;; +esac diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index 3c2478d5..f335854c 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -22,7 +22,9 @@ jobs: with: dotnet-version: 9.0.x - name: Start database - run: bash .github/scripts/start-database.sh "${{ matrix.database }}" + run: | + mkdir -p TestResults + bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&1 | tee TestResults/startup.log timeout-minutes: 15 - name: Build run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index d9e06688..d66d4239 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -188,7 +188,7 @@ public void AddRenameChangeAndDropColumn() provider.Insert("items", ["id"], [1]); Assert.That(provider.ExecuteScalar("SELECT renamed FROM items"), Is.EqualTo("fallback")); provider.RemoveColumnDefaultValue("items", "renamed"); - Assert.Catch(() => provider.Insert("items", ["id"], [2])); + Assert.Catch(() => provider.Insert("items", ["id"], [2])); provider.RemoveColumn("items", "renamed"); Assert.That(provider.ColumnExists("items", "renamed"), Is.False); } @@ -212,7 +212,7 @@ public void NamedPrimaryKey() provider.AddPrimaryKey("pk_items", "items", "id"); Assert.That(provider.PrimaryKeyExists("items", "pk_items"), Is.True); provider.Insert("items", ["id"], [1]); - Assert.Catch(() => provider.Insert("items", ["id"], [1])); + Assert.Catch(() => provider.Insert("items", ["id"], [1])); provider.RemovePrimaryKey("items"); Assert.That(provider.PrimaryKeyExists("items", "pk_items"), Is.False); provider.Insert("items", ["id"], [1]); @@ -225,7 +225,7 @@ public void ForeignKeyIsEnforcedAndRemoved() 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); - Assert.Catch(() => provider.Insert("children", ["parentid"], [99])); + Assert.Catch(() => provider.Insert("children", ["parentid"], [99])); provider.Insert("items", ["id"], [1]); provider.Insert("children", ["parentid"], [1]); provider.RemoveForeignKey("children", "fk_children"); @@ -242,8 +242,8 @@ public void UniqueAndCheckConstraints() 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"]); - Assert.Catch(() => provider.Insert("items", ["id", "label"], [2, "unique"])); - Assert.Catch(() => provider.Insert("items", ["id", "amount"], [3, -1])); + Assert.Catch(() => provider.Insert("items", ["id", "label"], [2, "unique"])); + Assert.Catch(() => provider.Insert("items", ["id", "amount"], [3, -1])); provider.RemoveConstraint("items", "ck_amount"); provider.RemoveConstraint("items", "uq_label"); provider.Insert("items", ["id", "label", "amount"], [2, "unique", -1]); 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/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/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/Impl/DB2/DB2Dialect.cs b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs index 2c0f6923..bb0510dc 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs @@ -7,56 +7,56 @@ public class DB2Dialect : Dialect { 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)"); + 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); + 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; + 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, diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index 81be053f..7b4e06fe 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -1,45 +1,141 @@ -using System; -using System.Collections.Generic; -using System.Data; - -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)) - { - providerName = "IBM.Data.DB2"; - } - - var fac = DbProviderFactoriesHelper.GetFactory(providerName, null, null); - _connection = fac.CreateConnection(); - _connection.ConnectionString = _connectionString; - this._connection.Open(); - } - - public DB2TransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) - { - } - - public override List GetDatabases() - { - throw new NotImplementedException(); - } - - public override bool ConstraintExists(string table, string name) - { - throw new NotImplementedException(); - } - - public override bool IndexExists(string table, string name) - { - throw new NotImplementedException(); - } -} +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; + +public class DB2TransformationProvider : TransformationProvider +{ + public DB2TransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) + : base(dialect, connectionString, null, scope) + { + 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 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 + FROM SYSCAT.COLUMNS WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' ORDER BY COLNO + """); + while (reader.Read()) + { + var type = reader.GetString(1).Trim() switch + { + "SMALLINT" => DbType.Int16, "INTEGER" => DbType.Int32, "BIGINT" => DbType.Int64, + "DECIMAL" or "DECFLOAT" => DbType.Decimal, "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 = reader.GetValue(3); + if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(4)); + if (reader.GetString(5) == "Y") column.ColumnProperty |= ColumnProperty.Identity; + if (!reader.IsDBNull(6)) column.ColumnProperty |= ColumnProperty.PrimaryKey; + columns.Add(column); + } + return columns.ToArray(); + } + + 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 FROM SYSCAT.INDEXES i + JOIN SYSCAT.INDEXCOLUSE c ON c.INDSCHEMA=i.INDSCHEMA AND c.INDNAME=i.INDNAME + 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" }; + indexes.Add(name, index); + } + index.KeyColumns = [..index.KeyColumns, reader.GetString(2).Trim()]; + } + return indexes.Values.ToArray(); + } + + public override bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == Name(name)); + 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 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 void ChangeColumn(string table, Column column) + { + var prefix = $"ALTER TABLE {Identifier(table)} ALTER COLUMN {Identifier(column.Name)}"; + var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(column.Type); + ExecuteNonQuery($"{prefix} SET DATA TYPE {type}"); + 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); + } + + public override void RemoveColumn(string tableName, string column) + { + 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/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index 9c803c76..e1f4e117 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -1,144 +1,144 @@ -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.Firebird; - -public class FirebirdTransformationProvider : TransformationProvider -{ - public FirebirdTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) - : base(dialect, connectionString, null, scope) - { - var factory = DbProviderFactoriesHelper.GetFactory(providerName ?? "FirebirdSql.Data.FirebirdClient", - "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) { } - - private static string CatalogName(string name) => - (name.StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); - - 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 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) - { - 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()) - { - var type = Convert.ToInt32(reader.GetValue(1)) switch - { - 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 => 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 (!reader.IsDBNull(3)) column.DefaultValue = reader.GetString(3).Trim(); - if (!reader.IsDBNull(4)) column.Size = Convert.ToInt32(reader.GetValue(4)); - if (!reader.IsDBNull(5)) column.ColumnProperty |= ColumnProperty.Identity; - result.Add(column); - } - return result.ToArray(); - } - - public override void AddColumn(string table, string sqlColumn) => - ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); - - 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($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} ALTER {QuoteColumnNameIfRequired(oldColumnName)} TO {QuoteColumnNameIfRequired(newColumnName)}"); - } - - public override void ChangeColumn(string table, Column column) - { - var prefix = $"ALTER TABLE {QuoteTableNameIfRequired(table)} ALTER {QuoteColumnNameIfRequired(column.Name)}"; - var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(column.Type); - ExecuteNonQuery($"{prefix} TYPE {type}"); - ExecuteNonQuery($"{prefix} {(column.DefaultValue == null ? "DROP DEFAULT" : "SET " + _dialect.Default(column.DefaultValue))}"); - ExecuteNonQuery($"{prefix} {(column.ColumnProperty.HasFlag(ColumnProperty.NotNull) ? "SET" : "DROP")} NOT NULL"); - } - - 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 Index[] GetIndexes(string table) - { - 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)); -} - +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.Firebird; + +public class FirebirdTransformationProvider : TransformationProvider +{ + public FirebirdTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) + : base(dialect, connectionString, null, scope) + { + 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) { } + + private static string CatalogName(string name) => + (name.StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); + + 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 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) + { + 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()) + { + var type = Convert.ToInt32(reader.GetValue(1)) switch + { + 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 => 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 (!reader.IsDBNull(3)) column.DefaultValue = reader.GetString(3).Trim(); + if (!reader.IsDBNull(4)) column.Size = Convert.ToInt32(reader.GetValue(4)); + if (!reader.IsDBNull(5)) column.ColumnProperty |= ColumnProperty.Identity; + result.Add(column); + } + return result.ToArray(); + } + + public override void AddColumn(string table, string sqlColumn) => + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); + + 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($"ALTER TABLE {QuoteTableNameIfRequired(tableName)} ALTER {QuoteColumnNameIfRequired(oldColumnName)} TO {QuoteColumnNameIfRequired(newColumnName)}"); + } + + public override void ChangeColumn(string table, Column column) + { + var prefix = $"ALTER TABLE {QuoteTableNameIfRequired(table)} ALTER {QuoteColumnNameIfRequired(column.Name)}"; + var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(column.Type); + ExecuteNonQuery($"{prefix} TYPE {type}"); + ExecuteNonQuery($"{prefix} {(column.DefaultValue == null ? "DROP DEFAULT" : "SET " + _dialect.Default(column.DefaultValue))}"); + ExecuteNonQuery($"{prefix} {(column.ColumnProperty.HasFlag(ColumnProperty.NotNull) ? "SET" : "DROP")} NOT NULL"); + } + + 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 Index[] GetIndexes(string table) + { + 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..5e1b73c3 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -7,58 +7,58 @@ public class InformixDialect : Dialect { 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"); - - this.RegisterUnsignedCompatible(DbType.Int16); - this.RegisterUnsignedCompatible(DbType.Int32); - this.RegisterUnsignedCompatible(DbType.Int64); - this.RegisterUnsignedCompatible(DbType.Decimal); - this.RegisterUnsignedCompatible(DbType.Double); - this.RegisterUnsignedCompatible(DbType.Single); + 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)"); + 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, int.MaxValue, "LVARCHAR"); + RegisterColumnType(DbType.AnsiString, 255, "VARCHAR($l)"); + RegisterColumnType(DbType.AnsiString, int.MaxValue, "LVARCHAR"); + RegisterColumnType(DbType.StringFixedLength, 255, "CHAR($l)"); + RegisterColumnType(DbType.AnsiStringFixedLength, 255, "CHAR($l)"); + RegisterProperty(ColumnProperty.Identity, ""); + } - 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"; + 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; + 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..ea96f492 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -1,45 +1,151 @@ -using System; -using System.Collections.Generic; -using System.Data; - -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)) - { - providerName = "IBM.Data.Informix.Client"; - } - - var fac = DbProviderFactoriesHelper.GetFactory(providerName, null, null); - _connection = fac.CreateConnection(); - _connection.ConnectionString = _connectionString; - this._connection.Open(); - } - - public InformixTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) - { - } - - public override List GetDatabases() - { - throw new NotImplementedException(); - } - - public override bool ConstraintExists(string table, string name) - { - throw new NotImplementedException(); - } - - public override bool IndexExists(string table, string name) - { - throw new NotImplementedException(); - } -} +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; + +public class InformixTransformationProvider : TransformationProvider +{ + public InformixTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) + : base(dialect, connectionString, null, scope) + { + 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.Trim('"').ToLowerInvariant().Replace("'", "''"); + public override string GenerateParameterName(int index) => "?"; + 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 columns = new List(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT c.colname, c.coltype, c.collength, d.default + 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 + WHERE t.owner=USER AND t.tabname='{Name(table)}' ORDER BY c.colno + """); + while (reader.Read()) + { + var code = Convert.ToInt32(reader.GetValue(1)); + 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, + 45 => DbType.Boolean, _ => DbType.String + }; + var column = new Column(reader.GetString(0).Trim(), type) + { + ColumnProperty = (code & 256) != 0 ? ColumnProperty.NotNull : ColumnProperty.Null + }; + if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(2)) & 255; + if ((code & 255) is 6 or 18 or 53) column.ColumnProperty |= ColumnProperty.Identity; + if (!reader.IsDBNull(3)) column.DefaultValue = reader.GetString(3).Trim(); + columns.Add(column); + } + return columns.ToArray(); + } + + public override Index[] GetIndexes(string table) + { + var result = new List(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT i.* FROM sysindexes i JOIN systables t ON t.tabid=i.tabid + WHERE t.owner=USER AND t.tabname='{Name(table)}' + """); + var columns = GetColumnsForIndex(table); + while (reader.Read()) + { + var index = new Index { Name = Convert.ToString(reader["idxname"]).Trim(), Unique = Convert.ToString(reader["idxtype"]).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(); + } + + private Dictionary GetColumnsForIndex(string table) + { + 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 IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == Name(name)); + 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 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}"); + } +} + From c07fbb71a373b23947047feaf5ee26d0a2a080e6 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 09:53:58 +0200 Subject: [PATCH 03/27] Configure native Linux drivers and enforce shell startup failures Why: The IBM Linux clients require their packaged native libraries to be discoverable. Startup failures were also masked by a logging pipeline, and Windows line endings broke bash execution. Changes: Select Linux driver packages conditionally, configure library paths, and enforce bash pipefail. Normalize shell script line endings so container failures stop the job before misleading connection failures. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .gitattributes | 1 + .github/scripts/start-database.sh | 102 +++++++++++------------ .github/workflows/dotnetpull.yml | 15 ++++ src/Migrator.Tests/Migrator.Tests.csproj | 2 +- 4 files changed, 68 insertions(+), 52 deletions(-) create mode 100644 .gitattributes 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 index 0f0a7a11..9ec8f4a8 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -1,51 +1,51 @@ -#!/usr/bin/env bash -set -euo pipefail -database="$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) - 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) - 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 -lc 'onstat -' 2>/dev/null | grep -q 'On-Line'; } - ;; - *) 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 - 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) - echo 'create database testdb with log;' | docker exec -i migrator-db bash -lc 'dbaccess sysmaster -' - ;; -esac +#!/usr/bin/env bash +set -euo pipefail +database="$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) + 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) + 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 -lc 'onstat -' 2>/dev/null | grep -q 'On-Line'; } + ;; + *) 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 + 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) + echo 'create database testdb with log;' | docker exec -i migrator-db bash -lc 'dbaccess sysmaster -' + ;; +esac diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index f335854c..730f9d21 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -22,12 +22,27 @@ jobs: with: dotnet-version: 9.0.x - name: Start database + shell: bash run: | mkdir -p TestResults bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&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: | + 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 "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 }} diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index 768926b5..d8cc7467 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -37,7 +37,7 @@ - + From a53512f56637d990c5f6df7b5e76587c491ebe97 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 09:58:01 +0200 Subject: [PATCH 04/27] Fix Firebird column removal and retry transient IBM image downloads Why: Firebird live tests rejected the generic DROP COLUMN syntax. Public IBM registry downloads also intermittently timed out before a server could start. Changes: Use Firebird's native DROP field syntax and retry image downloads only. Test failures remain fatal and are never retried or ignored. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .github/scripts/start-database.sh | 10 ++++++++++ .gitignore | 3 ++- .../Impl/Firebird/FirebirdTransformationProvider.cs | 12 ++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh index 9ec8f4a8..89bc261d 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -1,6 +1,14 @@ #!/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) @@ -28,10 +36,12 @@ case "$database" in 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 -lc 'onstat -' 2>/dev/null | grep -q 'On-Line'; } ;; 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/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index e1f4e117..0d09af35 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -82,8 +82,16 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, return result.ToArray(); } - public override void AddColumn(string table, string sqlColumn) => - ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); + public override void AddColumn(string table, string sqlColumn) => + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); + + public override void RemoveColumn(string tableName, string column) + { + 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 void RenameColumn(string tableName, string oldColumnName, string newColumnName) { From c913b89ed39503b8b8f48b13a5730052e0334f67 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:01:49 +0200 Subject: [PATCH 05/27] Add public Sybase ASE live coverage and native provider operations Why: The public ASE developer image and managed client connect successfully, so Sybase can be included without secrets or private vendor downloads. Its previous provider methods were insufficient for migration tests. Changes: Add the isolated ASE suite, pinned startup and conditional managed driver. Implement native catalogs, constraints, identity/type mapping and schema operations while rejecting unsupported referential actions explicitly. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .github/scripts/start-database.sh | 9 +- .github/scripts/test.ps1 | 4 +- .github/workflows/dotnetpull.yml | 2 +- src/Migrator.Tests/Migrator.Tests.csproj | 3 + .../Providers/Live/LiveDatabaseTests.cs | 12 +- .../Providers/Impl/Sybase/SybaseDialect.cs | 57 ++++++ .../Sybase/SybaseTransformationProvider.cs | 168 +++++++++++++----- 7 files changed, 205 insertions(+), 50 deletions(-) diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh index 89bc261d..66e7fa9d 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -43,7 +43,12 @@ case "$database" in 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 -lc 'onstat -' 2>/dev/null | grep -q 'On-Line'; } + ready() { docker exec migrator-db bash -ic 'onstat -' 2>/dev/null | grep -q 'On-Line'; } + ;; + 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 @@ -56,6 +61,6 @@ case "$database" in 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) - echo 'create database testdb with log;' | docker exec -i migrator-db bash -lc 'dbaccess sysmaster -' + echo 'create database testdb with log;' | docker exec -i migrator-db bash -ic 'dbaccess sysmaster -' ;; esac diff --git a/.github/scripts/test.ps1 b/.github/scripts/test.ps1 index e26fce79..e0ca61d8 100644 --- a/.github/scripts/test.ps1 +++ b/.github/scripts/test.ps1 @@ -1,9 +1,9 @@ param( - [ValidateSet('Unit','SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix')] + [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') +$databases = @('SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase') $filter = if ($Database -eq 'Unit') { ($databases | ForEach-Object { "TestCategory!=$_" }) -join '&' } else { "TestCategory=$Database" } dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --results-directory TestResults -- NUnit.NumberOfTestWorkers=0 if ($LASTEXITCODE -ne 0) { throw "Tests failed for $Database" } diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index 730f9d21..46e31e8d 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix] + database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index d8cc7467..457de6da 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -39,6 +39,9 @@ + + + diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index d66d4239..d34d6180 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -19,7 +19,8 @@ namespace Migrator.Tests.Providers.Live; [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("Informix", ProviderTypes.IBM_Informix, Category = "Informix")] +[TestFixture("Sybase", ProviderTypes.Sybase, Category = "Sybase")] [NonParallelizable] public class LiveDatabaseTests(string database, ProviderTypes providerType) { @@ -63,11 +64,16 @@ public void SetUp() factory = LoadFactory("IBM.Data.Db2"); connectionString = configured ?? "Server=localhost:50000;Database=testdb;UID=db2inst1;PWD=testpass;Pooling=false"; break; - case "Informix": + 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; + 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); } diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs index a8bbdf13..434ea4bc 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs @@ -7,6 +7,63 @@ public class SybaseDialect : Dialect { 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)"); + 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 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); + 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; + 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..56767560 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -1,42 +1,126 @@ -using System; -using System.Collections.Generic; -using System.Data; - -namespace DotNetProjects.Migrator.Providers.Impl.Sybase; - -public class SybaseTransformationProvider : TransformationProvider -{ - public SybaseTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) - : base(dialect, connectionString, null, scope) - { - if (string.IsNullOrEmpty(providerName)) - { - providerName = "Sybase.Data.AseClient"; - } - - var fac = DbProviderFactoriesHelper.GetFactory(providerName, null, null); - _connection = fac.CreateConnection(); - _connection.ConnectionString = _connectionString; - this._connection.Open(); - } - - public SybaseTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) - { - } - - public override List GetDatabases() - { - throw new NotImplementedException(); - } - - public override bool ConstraintExists(string table, string name) - { - throw new NotImplementedException(); - } - - public override bool IndexExists(string table, string name) - { - throw new NotImplementedException(); - } -} +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; + +public class SybaseTransformationProvider : TransformationProvider +{ + public SybaseTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) + : base(dialect, connectionString, null, scope) + { + 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 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 columns = new List(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT c.name,t.name,c.status,c.length 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()) + { + var type = reader.GetString(1).Trim() switch + { + "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 ((status & 128) != 0) column.ColumnProperty |= ColumnProperty.Identity; + if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(3)); + columns.Add(column); + } + return columns.ToArray(); + } + + public override Index[] GetIndexes(string table) + { + var indexes = new List(); + using var cmd = CreateCommand(); + using (var reader = ExecuteQuery(cmd, $"SELECT name,indid,status,keycnt 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, Clustered = Convert.ToInt32(reader.GetValue(1)) == 1 }); + } + } + 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 bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == name); + 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) + 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 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) + { + var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(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))}"); + } + + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) + { + 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)})"); + } +} From d12e0a0f4cfcd709dd45a5bd2f26dc6257a463f3 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:03:20 +0200 Subject: [PATCH 06/27] Preserve Db2 schema across migration transactions and enforce key nullability Why: Db2 live tests showed nullable primary-key declarations are rejected and migration commits reopen the connection, losing a session-only current-schema setting. Changes: Emit explicit NOT NULL for key columns and persist the isolated schema in the connection string. This keeps migration version tracking and cleanup in the intended schema after reconnects. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .../Providers/Live/LiveDatabaseTests.cs | 19 ++++++++++++------- src/Migrator/Providers/Impl/DB2/DB2Dialect.cs | 3 ++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index d34d6180..02147824 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -89,9 +89,12 @@ public void SetUp() connectionString = builder.ConnectionString; FbConnection.CreateDatabase(connectionString); } - else if (database == "Db2") - { - ExecuteAdmin("CREATE SCHEMA " + isolatedName); + else if (database == "Db2") + { + ExecuteAdmin("CREATE SCHEMA " + isolatedName); + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + builder["CurrentSchema"] = isolatedName.ToUpperInvariant(); + connectionString = builder.ConnectionString; } else { @@ -240,16 +243,18 @@ public void ForeignKeyIsEnforcedAndRemoved() } [Test] - public void UniqueAndCheckConstraints() - { - CreateItems(); + 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"]); Assert.Catch(() => provider.Insert("items", ["id", "label"], [2, "unique"])); - Assert.Catch(() => provider.Insert("items", ["id", "amount"], [3, -1])); + Assert.Catch(() => 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]); diff --git a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs index bb0510dc..cb287ab2 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs @@ -52,7 +52,8 @@ public override void MapColumnProperties(Column column) AddType(parts); AddIdentityAgain(column, parts); AddDefaultValue(column, parts); - AddNotNull(column, parts); + if (column.IsPrimaryKey || column.ColumnProperty.HasFlag(ColumnProperty.NotNull)) + parts.Add("NOT NULL"); AddPrimaryKey(column, parts); AddUnique(column, parts); _ColumnSql = string.Join(" ", parts); From 692710dd4f204b82f88dbab54486dc2066a16206 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:09:59 +0200 Subject: [PATCH 07/27] Fix native readiness and default changes; audit complete test assignment Why: Informix onstat reports ONLINE using exit code 5, which the original readiness pipeline treated as failure. Firebird and Db2 reject removing an absent default. Category filtering also needs evidence that no tests are orphaned. Changes: Handle the native readiness status, avoid redundant default removal, and audit NUnit discovery against all job results. Require nonzero execution and disallow skips in the new suites. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .github/scripts/start-database.sh | 4 +- .github/scripts/test.ps1 | 10 +- .github/scripts/verify-test-coverage.py | 36 +++ .github/workflows/dotnetpull.yml | 15 + .../Providers/Live/LiveDatabaseTests.cs | 45 +-- .../Impl/DB2/DB2TransformationProvider.cs | 283 ++++++++-------- .../FirebirdTransformationProvider.cs | 23 +- .../InformixTransformationProvider.cs | 302 +++++++++--------- 8 files changed, 389 insertions(+), 329 deletions(-) create mode 100644 .github/scripts/verify-test-coverage.py diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh index 66e7fa9d..2d1664fa 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -43,7 +43,7 @@ case "$database" in 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 -ic 'onstat -' 2>/dev/null | grep -q 'On-Line'; } + 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 @@ -61,6 +61,6 @@ case "$database" in 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) - echo 'create database testdb with log;' | docker exec -i migrator-db bash -ic 'dbaccess sysmaster -' + echo 'create database testdb with log;' | docker exec -i migrator-db bash -c 'source /usr/local/bin/informix_inf.env; dbaccess sysmaster -' ;; esac diff --git a/.github/scripts/test.ps1 b/.github/scripts/test.ps1 index e0ca61d8..01f97f7b 100644 --- a/.github/scripts/test.ps1 +++ b/.github/scripts/test.ps1 @@ -5,10 +5,16 @@ param( $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" } -dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --results-directory TestResults -- NUnit.NumberOfTestWorkers=0 +$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" } -Write-Host "$Database : $($counters.passed) passed, $($counters.notExecuted) not executed" +$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..db19a8e7 --- /dev/null +++ b/.github/scripts/verify-test-coverage.py @@ -0,0 +1,36 @@ +"""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 = {} +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}") + for test in tests: + name = test.attrib["fullname"] + if name in seen: + 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 len(seen) != next(iter(counts)): + raise SystemExit(f"Discovery reports {counts} tests but jobs covered {len(seen)} unique tests") +print(f"All {len(seen)} discovered tests assigned exactly once.") diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index 46e31e8d..d178630e 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -7,6 +7,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: live-databases-${{ github.ref }} + cancel-in-progress: true jobs: test: name: Test (${{ matrix.database }}) @@ -66,3 +69,15 @@ jobs: 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/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index 02147824..5bb97329 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -19,8 +19,8 @@ namespace Migrator.Tests.Providers.Live; [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")] +[TestFixture("Informix", ProviderTypes.IBM_Informix, Category = "Informix")] +[TestFixture("Sybase", ProviderTypes.Sybase, Category = "Sybase")] [NonParallelizable] public class LiveDatabaseTests(string database, ProviderTypes providerType) { @@ -64,16 +64,16 @@ public void SetUp() factory = LoadFactory("IBM.Data.Db2"); connectionString = configured ?? "Server=localhost:50000;Database=testdb;UID=db2inst1;PWD=testpass;Pooling=false"; break; - case "Informix": + 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; + 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); } @@ -89,21 +89,22 @@ public void SetUp() 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 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" : "")); + ExecuteAdmin("CREATE DATABASE " + isolatedName + (database == "Informix" ? " WITH LOG" : database == "Sybase" ? " ON default = 32" : "")); var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; builder["Database"] = isolatedName; connectionString = builder.ConnectionString; } created = true; + if (database == "Sybase") ExecuteAdmin("EXEC sp_dboption " + isolatedName + ", 'ddl in tran', true"); provider = ProviderFactory.Create(providerType, connectionString, null); if (database == "Db2") provider.ExecuteNonQuery("SET CURRENT SCHEMA " + isolatedName); } @@ -243,18 +244,18 @@ public void ForeignKeyIsEnforcedAndRemoved() } [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)); + 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"]); Assert.Catch(() => provider.Insert("items", ["id", "label"], [2, "unique"])); - Assert.Catch(() => provider.Insert("items", ["id", "label", "amount"], [3, "negative", -1])); + Assert.Catch(() => 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]); diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index 7b4e06fe..8f1f8254 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -1,141 +1,142 @@ -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; - -public class DB2TransformationProvider : TransformationProvider -{ - public DB2TransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) - : base(dialect, connectionString, null, scope) - { - 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 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 - FROM SYSCAT.COLUMNS WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' ORDER BY COLNO - """); - while (reader.Read()) - { - var type = reader.GetString(1).Trim() switch - { - "SMALLINT" => DbType.Int16, "INTEGER" => DbType.Int32, "BIGINT" => DbType.Int64, - "DECIMAL" or "DECFLOAT" => DbType.Decimal, "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 = reader.GetValue(3); - if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(4)); - if (reader.GetString(5) == "Y") column.ColumnProperty |= ColumnProperty.Identity; - if (!reader.IsDBNull(6)) column.ColumnProperty |= ColumnProperty.PrimaryKey; - columns.Add(column); - } - return columns.ToArray(); - } - - 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 FROM SYSCAT.INDEXES i - JOIN SYSCAT.INDEXCOLUSE c ON c.INDSCHEMA=i.INDSCHEMA AND c.INDNAME=i.INDNAME - 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" }; - indexes.Add(name, index); - } - index.KeyColumns = [..index.KeyColumns, reader.GetString(2).Trim()]; - } - return indexes.Values.ToArray(); - } - - public override bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == Name(name)); - 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 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 void ChangeColumn(string table, Column column) - { - var prefix = $"ALTER TABLE {Identifier(table)} ALTER COLUMN {Identifier(column.Name)}"; - var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(column.Type); - ExecuteNonQuery($"{prefix} SET DATA TYPE {type}"); - 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); - } - - public override void RemoveColumn(string tableName, string column) - { - 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"); - } -} - +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; + +public class DB2TransformationProvider : TransformationProvider +{ + public DB2TransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) + : base(dialect, connectionString, null, scope) + { + 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 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 + FROM SYSCAT.COLUMNS WHERE TABSCHEMA=CURRENT SCHEMA AND TABNAME='{Name(table)}' ORDER BY COLNO + """); + while (reader.Read()) + { + var type = reader.GetString(1).Trim() switch + { + "SMALLINT" => DbType.Int16, "INTEGER" => DbType.Int32, "BIGINT" => DbType.Int64, + "DECIMAL" or "DECFLOAT" => DbType.Decimal, "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 = reader.GetValue(3); + if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(4)); + if (reader.GetString(5) == "Y") column.ColumnProperty |= ColumnProperty.Identity; + if (!reader.IsDBNull(6)) column.ColumnProperty |= ColumnProperty.PrimaryKey; + columns.Add(column); + } + return columns.ToArray(); + } + + 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 FROM SYSCAT.INDEXES i + JOIN SYSCAT.INDEXCOLUSE c ON c.INDSCHEMA=i.INDSCHEMA AND c.INDNAME=i.INDNAME + 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" }; + indexes.Add(name, index); + } + index.KeyColumns = [..index.KeyColumns, reader.GetString(2).Trim()]; + } + return indexes.Values.ToArray(); + } + + public override bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == Name(name)); + 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 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 void ChangeColumn(string table, Column column) + { + var prefix = $"ALTER TABLE {Identifier(table)} ALTER COLUMN {Identifier(column.Name)}"; + var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(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); + } + + public override void RemoveColumn(string tableName, string column) + { + 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/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index 0d09af35..67f88e76 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -82,16 +82,16 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, return result.ToArray(); } - public override void AddColumn(string table, string sqlColumn) => - ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); - - public override void RemoveColumn(string tableName, string column) - { - 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 void AddColumn(string table, string sqlColumn) => + ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); + + public override void RemoveColumn(string tableName, string column) + { + 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 void RenameColumn(string tableName, string oldColumnName, string newColumnName) { @@ -105,7 +105,8 @@ public override void ChangeColumn(string table, Column column) var prefix = $"ALTER TABLE {QuoteTableNameIfRequired(table)} ALTER {QuoteColumnNameIfRequired(column.Name)}"; var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(column.Type); ExecuteNonQuery($"{prefix} TYPE {type}"); - ExecuteNonQuery($"{prefix} {(column.DefaultValue == null ? "DROP DEFAULT" : "SET " + _dialect.Default(column.DefaultValue))}"); + 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"); } diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index ea96f492..ba438e9f 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -1,151 +1,151 @@ -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; - -public class InformixTransformationProvider : TransformationProvider -{ - public InformixTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) - : base(dialect, connectionString, null, scope) - { - 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.Trim('"').ToLowerInvariant().Replace("'", "''"); - public override string GenerateParameterName(int index) => "?"; - 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 columns = new List(); - using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, $""" - SELECT c.colname, c.coltype, c.collength, d.default - 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 - WHERE t.owner=USER AND t.tabname='{Name(table)}' ORDER BY c.colno - """); - while (reader.Read()) - { - var code = Convert.ToInt32(reader.GetValue(1)); - 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, - 45 => DbType.Boolean, _ => DbType.String - }; - var column = new Column(reader.GetString(0).Trim(), type) - { - ColumnProperty = (code & 256) != 0 ? ColumnProperty.NotNull : ColumnProperty.Null - }; - if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(2)) & 255; - if ((code & 255) is 6 or 18 or 53) column.ColumnProperty |= ColumnProperty.Identity; - if (!reader.IsDBNull(3)) column.DefaultValue = reader.GetString(3).Trim(); - columns.Add(column); - } - return columns.ToArray(); - } - - public override Index[] GetIndexes(string table) - { - var result = new List(); - using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, $""" - SELECT i.* FROM sysindexes i JOIN systables t ON t.tabid=i.tabid - WHERE t.owner=USER AND t.tabname='{Name(table)}' - """); - var columns = GetColumnsForIndex(table); - while (reader.Read()) - { - var index = new Index { Name = Convert.ToString(reader["idxname"]).Trim(), Unique = Convert.ToString(reader["idxtype"]).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(); - } - - private Dictionary GetColumnsForIndex(string table) - { - 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 IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == Name(name)); - 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 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}"); - } -} - +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; + +public class InformixTransformationProvider : TransformationProvider +{ + public InformixTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) + : base(dialect, connectionString, null, scope) + { + 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.Trim('"').ToLowerInvariant().Replace("'", "''"); + public override string GenerateParameterName(int index) => "?"; + 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 columns = new List(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT c.colname, c.coltype, c.collength, d.default + 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 + WHERE t.owner=USER AND t.tabname='{Name(table)}' ORDER BY c.colno + """); + while (reader.Read()) + { + var code = Convert.ToInt32(reader.GetValue(1)); + 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, + 45 => DbType.Boolean, _ => DbType.String + }; + var column = new Column(reader.GetString(0).Trim(), type) + { + ColumnProperty = (code & 256) != 0 ? ColumnProperty.NotNull : ColumnProperty.Null + }; + if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(2)) & 255; + if ((code & 255) is 6 or 18 or 53) column.ColumnProperty |= ColumnProperty.Identity; + if (!reader.IsDBNull(3)) column.DefaultValue = reader.GetString(3).Trim(); + columns.Add(column); + } + return columns.ToArray(); + } + + 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.* FROM sysindexes i JOIN systables t ON t.tabid=i.tabid + 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" }; + 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(); + } + + private Dictionary GetColumnsForIndex(string table) + { + 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 IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == Name(name)); + 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 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}"); + } +} + From dcdc854b752463cb1b3fefe9a4a0da645813b64f Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:13:34 +0200 Subject: [PATCH 08/27] Allocate ASE test storage and use Informix server-only administration Why: ASE's default device lacks enough space for databases copied from its model, and Informix disallows CREATE DATABASE through a connection already attached to a database. Changes: Allocate disposable ASE storage and use a server-only Informix administration connection. Document pinned versions, reproduction steps, engine limits and the concrete Ingres license-key blocker. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .github/scripts/start-database.sh | 3 + README.md | 4 + docs/live-database-tests.md | 88 +++++++++++++++++++ .../Providers/Live/LiveDatabaseTests.cs | 11 ++- 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 docs/live-database-tests.md diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh index 2d1664fa..78c857cc 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -58,6 +58,9 @@ for attempt in $(seq 1 120); do sleep 5 done case "$database" in + Sybase) + printf "disk init name='migrator_data', physname='/opt/sybase/migrator_data.dat', size='128M'\ngo\nsp_diskdefault migrator_data, defaulton\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) 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..72fba476 --- /dev/null +++ b/docs/live-database-tests.md @@ -0,0 +1,88 @@ +# 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 nine scenarios each for MySQL, MariaDB, Firebird, Db2, Informix and Sybase: 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 allow DDL in transactions and allocate 32 MB 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 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. 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. diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index 5bb97329..0d17af76 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -79,7 +79,10 @@ public void SetUp() ProviderFactories.RegisterFactory(invariant, () => factory); admin = factory.CreateConnection(); - admin.ConnectionString = connectionString; + 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") { @@ -104,6 +107,12 @@ public void SetUp() 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"); provider = ProviderFactory.Create(providerType, connectionString, null); if (database == "Db2") provider.ExecuteNonQuery("SET CURRENT SCHEMA " + isolatedName); From 7f7ad2495a7261ff722b3fc1b127effa85d58cee Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:19:44 +0200 Subject: [PATCH 09/27] Address metadata review findings with live regression tests and fix ASE operations Why: Copilot identified lost decimal precision, boolean/default metadata, constraint ownership and quoted identifier case. Live ASE tests additionally exposed incorrect ADD COLUMN syntax, asynchronous connection release and a string migration-version parameter. Changes: Add focused live regressions and repair metadata round-trips, native DDL, bounded teardown synchronization and typed version deletion. Audit inherited/repeated NUnit cases without mistaking identical names within one job for cross-job duplication. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .github/scripts/verify-test-coverage.py | 12 +- .github/workflows/dotnetpull.yml | 1 + docs/live-database-tests.md | 176 ++--- .../Providers/Live/LiveDatabaseTests.cs | 668 ++++++++++-------- .../Live/LiveMetadataRegressionTests.cs | 63 ++ .../Impl/DB2/DB2TransformationProvider.cs | 14 +- .../FirebirdTransformationProvider.cs | 13 +- .../InformixTransformationProvider.cs | 3 +- .../Impl/Mysql/MySqlTransformationProvider.cs | 45 +- .../Sybase/SybaseTransformationProvider.cs | 254 +++---- .../Providers/TransformationProvider.cs | 2 +- 11 files changed, 715 insertions(+), 536 deletions(-) create mode 100644 src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs diff --git a/.github/scripts/verify-test-coverage.py b/.github/scripts/verify-test-coverage.py index db19a8e7..8ef02980 100644 --- a/.github/scripts/verify-test-coverage.py +++ b/.github/scripts/verify-test-coverage.py @@ -6,6 +6,7 @@ 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"): @@ -20,9 +21,12 @@ 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"] - if name in seen: + # 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"}: @@ -31,6 +35,6 @@ if found != expected: raise SystemExit(f"Missing suites: {sorted(expected - found)}") -if len(counts) != 1 or len(seen) != next(iter(counts)): - raise SystemExit(f"Discovery reports {counts} tests but jobs covered {len(seen)} unique tests") -print(f"All {len(seen)} discovered tests assigned exactly once.") +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 d178630e..5e9af897 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -43,6 +43,7 @@ jobs: 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 diff --git a/docs/live-database-tests.md b/docs/live-database-tests.md index 72fba476..5235b18e 100644 --- a/docs/live-database-tests.md +++ b/docs/live-database-tests.md @@ -1,88 +1,88 @@ -# 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 nine scenarios each for MySQL, MariaDB, Firebird, Db2, Informix and Sybase: 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 allow DDL in transactions and allocate 32 MB 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 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. 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. +# 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 allow DDL in transactions and allocate 32 MB 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 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. 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. diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index 0d17af76..d0740fa6 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -1,308 +1,360 @@ -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; - 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 default = 32" : "")); - 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"); - provider = ProviderFactory.Create(providerType, connectionString, null); - if (database == "Db2") provider.ExecuteNonQuery("SET CURRENT SCHEMA " + isolatedName); - } - - 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 == "Firebird") FbConnection.DropDatabase(connectionString); - else ExecuteAdmin("DROP " + (database == "Db2" ? "SCHEMA " + isolatedName + " RESTRICT" : "DATABASE " + isolatedName)); - } - } - finally - { - admin?.Dispose(); - admin = null; - created = false; - } - } - } - - 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 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); - 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")); - provider.Insert("items", ["id"], [1]); - Assert.That(provider.ExecuteScalar("SELECT renamed FROM items"), Is.EqualTo("fallback")); - provider.RemoveColumnDefaultValue("items", "renamed"); - Assert.Catch(() => 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]); - Assert.Catch(() => 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); - Assert.Catch(() => 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"]); - Assert.Catch(() => provider.Insert("items", ["id", "label"], [2, "unique"])); - Assert.Catch(() => 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"); - } -} - +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 default = 32" : "")); + 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"); + provider = ProviderFactory.Create(providerType, connectionString, null); + if (database == "Db2") provider.ExecuteNonQuery("SET CURRENT SCHEMA " + isolatedName); + } + + 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; + } + } + } + + private 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); + 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..f7d06d51 --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -0,0 +1,63 @@ +using System; +using System.Data; +using System.Linq; +using DotNetProjects.Migrator.Providers; +using NUnit.Framework; + +namespace Migrator.Tests.Providers.Live; + +[NonParallelizable] +public class LiveMetadataRegressionTests +{ + [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 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.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/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index 8f1f8254..4da22445 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -73,8 +73,9 @@ 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 FROM SYSCAT.INDEXES i + 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 """); @@ -83,7 +84,7 @@ public override Index[] GetIndexes(string table) 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" }; + 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()]; @@ -91,6 +92,14 @@ public override Index[] GetIndexes(string table) return indexes.Values.ToArray(); } + 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 bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == Name(name)); public override string AddIndex(string table, Index index) { @@ -139,4 +148,3 @@ public override void AddForeignKey(string name, string childTable, string[] chil 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/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index 67f88e76..aefadd32 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -37,6 +37,13 @@ public override string[] GetTables() => ExecuteStringQuery( // 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.Equals(databaseName, _connection.Database, StringComparison.Ordinal)) + throw new ArgumentException("Firebird can only drop the currently attached database.", nameof(databaseName)); + ExecuteNonQuery("DROP DATABASE"); + } + public override string[] GetConstraints(string table) => ExecuteStringQuery( $"SELECT TRIM(RDB$CONSTRAINT_NAME) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME='{CatalogName(table)}'").ToArray(); @@ -74,6 +81,11 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, { ColumnProperty = !reader.IsDBNull(2) && Convert.ToInt32(reader.GetValue(2)) == 1 ? ColumnProperty.NotNull : ColumnProperty.Null }; + if (type == DbType.Decimal) + { + 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 = reader.GetString(3).Trim(); if (!reader.IsDBNull(4)) column.Size = Convert.ToInt32(reader.GetValue(4)); if (!reader.IsDBNull(5)) column.ColumnProperty |= ColumnProperty.Identity; @@ -150,4 +162,3 @@ SELECT TRIM(i.RDB$INDEX_NAME), COALESCE(i.RDB$UNIQUE_FLAG,0), TRIM(s.RDB$FIELD_N public override bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == CatalogName(name)); } - diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index ba438e9f..4cb41c10 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -21,7 +21,7 @@ public InformixTransformationProvider(Dialect dialect, string connectionString, public InformixTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) : base(dialect, connection, null, scope) { } - private static string Name(string name) => name.Trim('"').ToLowerInvariant().Replace("'", "''"); + private static string Name(string name) => (name.StartsWith('"') ? name[1..^1].Replace("\"\"", "\"") : name.ToLowerInvariant()).Replace("'", "''"); public override string GenerateParameterName(int index) => "?"; public override bool TableExists(string table) => Convert.ToInt32(ExecuteScalar( $"SELECT COUNT(*) FROM systables WHERE tabname='{Name(table)}' AND owner=USER AND tabtype='T'")) > 0; @@ -148,4 +148,3 @@ public override void AddForeignKey(string name, string childTable, string[] chil 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/MySqlTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs index 314190c6..336f13de 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Data; using System.Globalization; +using System.Globalization; using System.Linq; using Index = DotNetProjects.Migrator.Framework.Index; @@ -193,13 +194,13 @@ public override Column[] GetColumns(string table) { var columns = new List(); using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, $"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, CHARACTER_MAXIMUM_LENGTH, COLUMN_KEY FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{table.Replace("'", "''")}' ORDER BY ORDINAL_POSITION"); + 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()) { var type = reader.GetString(1) switch { "smallint" => DbType.Int16, "int" or "integer" or "mediumint" => DbType.Int32, - "bigint" => DbType.Int64, "tinyint" => DbType.Byte, + "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, "blob" or "binary" or "varbinary" or "longblob" => DbType.Binary, _ => DbType.String @@ -208,13 +209,51 @@ public override Column[] GetColumns(string table) 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 = reader.GetValue(3); + 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(); diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 56767560..05b3f4a9 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -1,126 +1,128 @@ -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; - -public class SybaseTransformationProvider : TransformationProvider -{ - public SybaseTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) - : base(dialect, connectionString, null, scope) - { - 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 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 columns = new List(); - using var cmd = CreateCommand(); - using var reader = ExecuteQuery(cmd, $""" - SELECT c.name,t.name,c.status,c.length 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()) - { - var type = reader.GetString(1).Trim() switch - { - "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 ((status & 128) != 0) column.ColumnProperty |= ColumnProperty.Identity; - if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(3)); - columns.Add(column); - } - return columns.ToArray(); - } - - public override Index[] GetIndexes(string table) - { - var indexes = new List(); - using var cmd = CreateCommand(); - using (var reader = ExecuteQuery(cmd, $"SELECT name,indid,status,keycnt 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, Clustered = Convert.ToInt32(reader.GetValue(1)) == 1 }); - } - } - 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 bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == name); - 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) - 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 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) - { - var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(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))}"); - } - - public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) - { - 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)})"); - } -} +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; + +public class SybaseTransformationProvider : TransformationProvider +{ + public SybaseTransformationProvider(Dialect dialect, string connectionString, string scope, string providerName) + : base(dialect, connectionString, null, scope) + { + 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 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 columns = new List(); + using var cmd = CreateCommand(); + using var reader = ExecuteQuery(cmd, $""" + SELECT c.name,t.name,c.status,c.length 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()) + { + var type = reader.GetString(1).Trim() switch + { + "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 ((status & 128) != 0) column.ColumnProperty |= ColumnProperty.Identity; + if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(3)); + columns.Add(column); + } + return columns.ToArray(); + } + + public override Index[] GetIndexes(string table) + { + var indexes = new List(); + using var cmd = CreateCommand(); + using (var reader = ExecuteQuery(cmd, $"SELECT name,indid,status,keycnt 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, Clustered = Convert.ToInt32(reader.GetValue(1)) == 1 }); + } + } + 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 bool IndexExists(string table, string name) => GetIndexes(table).Any(i => i.Name == name); + 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) + 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 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) + { + var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(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))}"); + } + + public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) + { + 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..9fafab60 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); } From 90bdad46d1d6391dcf3d57a39594d5eee0ecf42d Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:22:19 +0200 Subject: [PATCH 10/27] Use Firebird database API and enable logged ASE column alterations Why: The Firebird regression proved DROP DATABASE is not accepted through the driver's DSQL command path, even without a database name. ASE column changes requiring a data copy need an enabled logging option. Changes: Invoke the registered Firebird driver's database API after validating the attached database, keeping the library driver-independent. Enable full ALTER TABLE logging in isolated ASE test databases. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- docs/live-database-tests.md | 3 ++- src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs | 6 +++++- .../Impl/Firebird/FirebirdTransformationProvider.cs | 9 ++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/live-database-tests.md b/docs/live-database-tests.md index 5235b18e..f7b40e9b 100644 --- a/docs/live-database-tests.md +++ b/docs/live-database-tests.md @@ -23,7 +23,7 @@ The IBM Linux packages and ASE client are conditional test-project dependencies `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 allow DDL in transactions and allocate 32 MB to accommodate the image's model database. +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 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. @@ -60,6 +60,7 @@ output="$PWD/src/Migrator.Tests/bin/Debug/net9.0" 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" ``` diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index d0740fa6..896144e2 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -130,7 +130,11 @@ public void SetUp() admin.Close(); admin.Open(); } - if (database == "Sybase") ExecuteAdmin("EXEC sp_dboption " + isolatedName + ", 'ddl in tran', true"); + 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); } diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index aefadd32..613ab445 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -41,7 +41,14 @@ public override void DropDatabases(string databaseName) { if (!string.Equals(databaseName, _connection.Database, StringComparison.Ordinal)) throw new ArgumentException("Firebird can only drop the currently attached database.", nameof(databaseName)); - ExecuteNonQuery("DROP DATABASE"); + // 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); } public override string[] GetConstraints(string table) => ExecuteStringQuery( From 8dd90acc90f48fc146c798606b0a5b17da83a573 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:23:51 +0200 Subject: [PATCH 11/27] Preserve inline index flags and ASE constraint-owned index metadata Why: Custom column mappers discarded ColumnProperty.Indexed, and ASE constraint-owned indexes were indistinguishable from ordinary indexes. Removing them through DROP INDEX is incorrect. Changes: Preserve index intent in the native mappers and read ASE constraint/clustered status bits. Add live tests for flagged column indexes and removal of primary/unique constraint-backed indexes. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .../Live/LiveMetadataRegressionTests.cs | 20 +++++++++++++++++++ src/Migrator/Providers/Impl/DB2/DB2Dialect.cs | 1 + .../Impl/Informix/InformixDialect.cs | 1 + .../Providers/Impl/Sybase/SybaseDialect.cs | 1 + .../Sybase/SybaseTransformationProvider.cs | 5 +++-- 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index f7d06d51..ab2d5b1f 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -2,6 +2,7 @@ using System.Data; using System.Linq; using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Framework; using NUnit.Framework; namespace Migrator.Tests.Providers.Live; @@ -9,6 +10,25 @@ namespace Migrator.Tests.Providers.Live; [NonParallelizable] public class LiveMetadataRegressionTests { + [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 FirebirdDecimalPrecisionAndScale() => new LiveDatabaseTests("Firebird", ProviderTypes.Firebird).RunRegression(f => { diff --git a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs index cb287ab2..a464b694 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs @@ -47,6 +47,7 @@ private sealed class NativeColumnMapper(Dialect dialect, string type) : ColumnPr public override void MapColumnProperties(Column column) { Name = column.Name; + _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); var parts = new System.Collections.Generic.List(); AddName(parts); AddType(parts); diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 5e1b73c3..c845bb24 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -48,6 +48,7 @@ private sealed class NativeColumnMapper(Dialect dialect, string type) : ColumnPr public override void MapColumnProperties(Column column) { Name = column.Name; + _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); var parts = new System.Collections.Generic.List(); AddName(parts); AddType(parts); diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs index 434ea4bc..3561e544 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs @@ -50,6 +50,7 @@ private sealed class NativeColumnMapper(Dialect dialect, string type) : ColumnPr public override void MapColumnProperties(Column column) { Name = column.Name; + _Indexed = PropertySelected(column.ColumnProperty, ColumnProperty.Indexed); var parts = new System.Collections.Generic.List(); AddName(parts); AddType(parts); diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 05b3f4a9..056351a8 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -67,13 +67,14 @@ public override Index[] GetIndexes(string table) { var indexes = new List(); using var cmd = CreateCommand(); - using (var reader = ExecuteQuery(cmd, $"SELECT name,indid,status,keycnt FROM sysindexes WHERE id=object_id('{Literal(table)}') AND indid BETWEEN 1 AND 254")) + 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, Clustered = Convert.ToInt32(reader.GetValue(1)) == 1 }); + 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) From 2eca98def7a39057fc01c6fd089fbd87c8745c5b Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:25:16 +0200 Subject: [PATCH 12/27] Create flagged column indexes using native standalone statements Why: Preserving the indexed flag alone would emit the base provider's MySQL-specific inline INDEX syntax on other engines. Native engines require separate CREATE INDEX statements. Changes: Disable only the inline syntax for Db2, Informix, ASE and Firebird, then create flagged column indexes after table creation. Validate this behavior against all four live engines. Validation: local builds and targeted live GitHub Actions runs informed this change; the complete matrix is required on the final PR commit. --- .../Providers/Live/LiveMetadataRegressionTests.cs | 1 + src/Migrator/Providers/Impl/DB2/DB2Dialect.cs | 3 +++ .../Providers/Impl/DB2/DB2TransformationProvider.cs | 7 +++++++ src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs | 3 +++ .../Impl/Firebird/FirebirdTransformationProvider.cs | 7 +++++++ src/Migrator/Providers/Impl/Informix/InformixDialect.cs | 3 +++ .../Impl/Informix/InformixTransformationProvider.cs | 7 +++++++ src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs | 3 +++ .../Providers/Impl/Sybase/SybaseTransformationProvider.cs | 7 +++++++ 9 files changed, 41 insertions(+) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index ab2d5b1f..0d42345d 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -10,6 +10,7 @@ namespace Migrator.Tests.Providers.Live; [NonParallelizable] public class LiveMetadataRegressionTests { + [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")] diff --git a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs index a464b694..48c07eb5 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs @@ -5,6 +5,9 @@ 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() { RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index 4da22445..e3c559c3 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -24,6 +24,13 @@ public DB2TransformationProvider(Dialect dialect, IDbConnection connection, stri 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 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( diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs index 97ca6c02..9007d879 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs @@ -5,6 +5,9 @@ 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)"); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index 613ab445..479333cd 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -25,6 +25,13 @@ public FirebirdTransformationProvider(Dialect dialect, IDbConnection connection, private static string CatalogName(string name) => (name.StartsWith('"') ? name.Trim('"').Replace("\"\"", "\"") : name.ToUpperInvariant()).Replace("'", "''"); + 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 RDB$RELATIONS WHERE RDB$RELATION_NAME='{CatalogName(table)}' AND RDB$VIEW_BLR IS NULL")) > 0; diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index c845bb24..5d920d9f 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -5,6 +5,9 @@ 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() { RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 4cb41c10..958a440f 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -23,6 +23,13 @@ public InformixTransformationProvider(Dialect dialect, IDbConnection connection, private static string Name(string name) => (name.StartsWith('"') ? name[1..^1].Replace("\"\"", "\"") : name.ToLowerInvariant()).Replace("'", "''"); public override string GenerateParameterName(int index) => "?"; + 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( diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs index 3561e544..7b44cbe4 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs @@ -5,6 +5,9 @@ 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"); diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 056351a8..b3c99131 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -21,6 +21,13 @@ public SybaseTransformationProvider(Dialect dialect, IDbConnection connection, s : base(dialect, connection, null, scope) { } private static string Literal(string name) => name.Replace("'", "''"); + 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( From 848800d9376dede2f46b13e7b186fe04bcb4df22 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:29:36 +0200 Subject: [PATCH 13/27] Separate ASE data and log allocations for logged schema changes Why: ASE's full-logging option did not take effect on the mixed data/log allocation, so both live column-change scenarios still rejected ALTER TABLE operations that copy data. The disposable databases need a distinct log allocation as well as the option setting. Changes: create each ASE test database with 32 MB of data and a separate 16 MB log allocation. Issue CHECKPOINT before tests to apply database options, and document these setup requirements. Validation: based on the two failures in GitHub Actions run 35704716588; the full matrix will validate the correction on the rebased PR commit. --- docs/live-database-tests.md | 2 +- src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/live-database-tests.md b/docs/live-database-tests.md index f7b40e9b..30f8c963 100644 --- a/docs/live-database-tests.md +++ b/docs/live-database-tests.md @@ -23,7 +23,7 @@ The IBM Linux packages and ASE client are conditional test-project dependencies `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 to accommodate the image's model database. +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. diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index 896144e2..77a821b5 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -118,7 +118,7 @@ public void SetUp() } else { - ExecuteAdmin("CREATE DATABASE " + isolatedName + (database == "Informix" ? " WITH LOG" : database == "Sybase" ? " ON default = 32" : "")); + ExecuteAdmin("CREATE DATABASE " + isolatedName + (database == "Informix" ? " WITH LOG" : database == "Sybase" ? " ON default = 32 LOG ON default = 16" : "")); var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; builder["Database"] = isolatedName; connectionString = builder.ConnectionString; @@ -137,6 +137,7 @@ public void SetUp() } 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) @@ -244,6 +245,7 @@ public void DataDefaultsAndPersistence() 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")); From cebced2833fc8fba93ad99ae8fa3f32b80271710 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:31:44 +0200 Subject: [PATCH 14/27] Identify and remove Informix constraint-owned indexes correctly Why: Copilot's follow-up review found that Informix index inspection omitted primary/unique constraint ownership. RemoveAllIndexes would therefore issue DROP INDEX for objects owned by constraints, which the engine rejects. Changes: join the constraint catalog to populate index flags, and remove constraints by their actual names before ordinary indexes. Constraint and backing-index names are not interchangeable in Informix. Validation: add a live regression that creates primary and unique constraints, checks both metadata flags, removes all indexes, and verifies constraint removal. Local build passes; the full CI matrix runs on this commit. --- .../Providers/Live/LiveMetadataRegressionTests.cs | 13 +++++++++++++ .../Informix/InformixTransformationProvider.cs | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 0d42345d..7f3e08e7 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -57,6 +57,19 @@ public class LiveMetadataRegressionTests 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 => { diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 958a440f..5d7520fd 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -81,12 +81,15 @@ public override Index[] GetIndexes(string table) var columns = GetColumnsForIndex(table); using var cmd = CreateCommand(); using var reader = ExecuteQuery(cmd, $""" - SELECT i.* FROM sysindexes i JOIN systables t ON t.tabid=i.tabid + 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" }; + 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++) { @@ -109,6 +112,13 @@ private Dictionary GetColumnsForIndex(string table) return columns; } + public override void RemoveAllIndexes(string table) + { + 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) => GetIndexes(table).Any(i => i.Name == Name(name)); public override string AddIndex(string table, Index index) { From bfef305975b7caa9a128c0cf419458cbf4120a92 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:35:41 +0200 Subject: [PATCH 15/27] Place ASE test data and logs on dedicated disposable devices Why: LOG ON default can select ASE's master device, which rejects log allocation because it contains only data. The test harness must select its own devices explicitly to make isolated database creation reproducible. Changes: initialize a separate 64 MB migrator_log device alongside the 128 MB migrator_data device, and name both in CREATE DATABASE. Document the same device requirement for local connection-string overrides. Validation: the preceding CI run identified the master-device allocation error before tests started. The complete public matrix validates this setup; no engine tests are skipped or allowed to fail. --- .github/scripts/start-database.sh | 2 +- docs/live-database-tests.md | 2 +- src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh index 78c857cc..1762852c 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -59,7 +59,7 @@ for attempt in $(seq 1 120); do done case "$database" in Sybase) - printf "disk init name='migrator_data', physname='/opt/sybase/migrator_data.dat', size='128M'\ngo\nsp_diskdefault migrator_data, defaulton\ngo\n" | docker exec -i migrator-db bash -c 'source /opt/sybase/SYBASE.sh; isql -b -Usa -PmyPassword -Slocalhost:5000' + 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 ;; diff --git a/docs/live-database-tests.md b/docs/live-database-tests.md index 30f8c963..cc0ef978 100644 --- a/docs/live-database-tests.md +++ b/docs/live-database-tests.md @@ -65,7 +65,7 @@ export INFORMIXDIR="$output/native" export LD_LIBRARY_PATH="$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" ``` -New suites accept `MIGRATOR_MYSQL`, `MIGRATOR_MARIADB`, `MIGRATOR_FIREBIRD`, `MIGRATOR_DB2`, `MIGRATOR_INFORMIX`, or `MIGRATOR_SYBASE` connection-string overrides. Use disposable servers with administrative database/schema creation permissions. Defaults match the startup script. Existing suites read `appsettings.json` through ConfigurationReader, with a `MIGRATOR_` plus uppercased configuration-key override. +New suites accept `MIGRATOR_MYSQL`, `MIGRATOR_MARIADB`, `MIGRATOR_FIREBIRD`, `MIGRATOR_DB2`, `MIGRATOR_INFORMIX`, 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: diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index 77a821b5..8a92c42f 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -118,7 +118,7 @@ public void SetUp() } else { - ExecuteAdmin("CREATE DATABASE " + isolatedName + (database == "Informix" ? " WITH LOG" : database == "Sybase" ? " ON default = 32 LOG ON default = 16" : "")); + 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; From d38670f6989f461d62fad90147812ef853d5f258 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:42:16 +0200 Subject: [PATCH 16/27] Round-trip Firebird defaults and expose MySQL unique constraint metadata Why: Copilot's follow-up review found two metadata gaps: Firebird returned the entire DEFAULT clause as a string, and MySQL omitted UniqueConstraint flags. Recreating a table from those columns could quote an SQL expression or default clause as literal text; consumers also lost constraint ownership information. Changes: parse Firebird catalog defaults into typed literals or SQL-expression objects, and join MySQL's constraint catalog when inspecting indexes. Add a Firebird table-recreation regression and assert MySQL/MariaDB unique ownership in the existing default round-trip tests. Validation: local build passes. Live regressions check numeric/string defaults, CURRENT_TIMESTAMP persistence and unique constraint flags in the full matrix. --- .../Live/LiveMetadataRegressionTests.cs | 16 +++++++++++ .../FirebirdTransformationProvider.cs | 28 ++++++++++++++++++- .../Impl/Mysql/MySqlTransformationProvider.cs | 3 +- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 7f3e08e7..0548fcdc 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -30,6 +30,20 @@ public class LiveMetadataRegressionTests 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 => { @@ -89,6 +103,8 @@ public class LiveMetadataRegressionTests 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")); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index 479333cd..e0271181 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -1,6 +1,7 @@ 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; @@ -100,7 +101,7 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, 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 = reader.GetString(3).Trim(); + if (!reader.IsDBNull(3)) column.DefaultValue = ReadDefault(reader.GetString(3), type); if (!reader.IsDBNull(4)) column.Size = Convert.ToInt32(reader.GetValue(4)); if (!reader.IsDBNull(5)) column.ColumnProperty |= ColumnProperty.Identity; result.Add(column); @@ -108,6 +109,31 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, return result.ToArray(); } + private sealed record DatabaseDefault(string Sql) + { + public override string ToString() => Sql; + } + + private static object ReadDefault(string source, DbType type) + { + 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 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 void AddColumn(string table, string sqlColumn) => ExecuteNonQuery($"ALTER TABLE {QuoteTableNameIfRequired(table)} ADD {sqlColumn}"); diff --git a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs index 336f13de..4e298725 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -167,6 +167,7 @@ INNER JOIN information_schema.KEY_COLUMN_USAGE k public override Index[] GetIndexes(string 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)}"); @@ -176,7 +177,7 @@ public override Index[] GetIndexes(string table) var name = Convert.ToString(reader["Key_name"]); if (!indexes.ContainsKey(name)) { - indexes[name] = new Index { Name = name, PrimaryKey = name == "PRIMARY", Unique = Convert.ToInt32(reader["Non_unique"]) == 0 }; + 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"]); From 3aa4678570223c4f7cf7b34ccd4c05f330d33755 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 10:59:48 +0200 Subject: [PATCH 17/27] Preserve numeric shapes, key membership and typed catalog values Why: the follow-up Copilot review found that native mappers and alteration paths discarded explicit decimal precision/scale. Catalog readers also lost key membership, several native data types and default semantics, so inspecting and recreating a working schema could silently change it. Changes: use parameterized decimal mappings in Db2, Informix, Firebird and ASE; retain numeric precision/scale and Db2 DECFLOAT precision; preserve primary-key membership and identity combinations. Distinguish Firebird text BLOBs, MySQL blob variants, Informix intervals and ASE bytes. Parse Db2/ASE defaults into CLR literals or unquoted SQL expressions, including concatenated ASE catalog fragments. Preserve full Column metadata when adding a column. Validation: local build passes. Add live regressions for create/alter/copy precision, identity/composite keys, default persistence and native type round-trips. All database jobs and the complete-discovery audit must pass on this commit before the new review threads are resolved. --- .../Live/LiveMetadataRegressionTests.cs | 119 ++++++++++++++++++ src/Migrator/Providers/CatalogDefaultValue.cs | 61 +++++++++ src/Migrator/Providers/Impl/DB2/DB2Dialect.cs | 9 +- .../Impl/DB2/DB2TransformationProvider.cs | 17 ++- .../Impl/Firebird/FirebirdDialect.cs | 1 + .../FirebirdTransformationProvider.cs | 10 +- .../Impl/Informix/InformixDialect.cs | 3 + .../InformixTransformationProvider.cs | 13 +- .../Impl/Mysql/MySqlTransformationProvider.cs | 2 +- .../Providers/Impl/Sybase/SybaseDialect.cs | 5 + .../Sybase/SybaseTransformationProvider.cs | 40 +++++- 11 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 src/Migrator/Providers/CatalogDefaultValue.cs diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 0548fcdc..c1ddcec3 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -10,6 +10,125 @@ namespace Migrator.Tests.Providers.Live; [NonParallelizable] public class LiveMetadataRegressionTests { + [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"], [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")] 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 48c07eb5..11cbd14c 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs @@ -23,6 +23,9 @@ public DB2Dialect() RegisterColumnType(DbType.DateTime2, "TIMESTAMP"); RegisterColumnType(DbType.DateTimeOffset, "TIMESTAMP"); RegisterColumnType(DbType.Decimal, "DECIMAL(18,5)"); + RegisterColumnTypeWithPrecision(DbType.Decimal, "DECIMAL({precision},{scale})"); + RegisterColumnType(DbType.VarNumeric, "DECFLOAT(34)"); + RegisterColumnTypeWithPrecision(DbType.VarNumeric, "DECFLOAT({precision})"); RegisterColumnType(DbType.Double, "DOUBLE PRECISION"); RegisterColumnType(DbType.Guid, "CHAR(36)"); RegisterColumnType(DbType.Int16, "SMALLINT"); @@ -42,6 +45,8 @@ public DB2Dialect() 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); } @@ -56,8 +61,8 @@ public override void MapColumnProperties(Column column) AddType(parts); AddIdentityAgain(column, parts); AddDefaultValue(column, parts); - if (column.IsPrimaryKey || column.ColumnProperty.HasFlag(ColumnProperty.NotNull)) - parts.Add("NOT NULL"); + if (column.IsPrimaryKey || column.ColumnProperty.HasFlag(ColumnProperty.NotNull)) + parts.Add("NOT NULL"); AddPrimaryKey(column, parts); AddUnique(column, parts); _ColumnSql = string.Join(" ", parts); diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index e3c559c3..be8e363c 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -24,6 +24,9 @@ public DB2TransformationProvider(Dialect dialect, IDbConnection connection, stri 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); @@ -50,7 +53,7 @@ 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 + 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()) @@ -58,7 +61,7 @@ public override Column[] GetColumns(string table) var type = reader.GetString(1).Trim() switch { "SMALLINT" => DbType.Int16, "INTEGER" => DbType.Int32, "BIGINT" => DbType.Int64, - "DECIMAL" or "DECFLOAT" => DbType.Decimal, "DOUBLE" => DbType.Double, "REAL" => DbType.Single, + "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 }; @@ -66,8 +69,14 @@ public override Column[] GetColumns(string table) { ColumnProperty = reader.GetString(2) == "Y" ? ColumnProperty.Null : ColumnProperty.NotNull }; - if (!reader.IsDBNull(3)) column.DefaultValue = reader.GetValue(3); + 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); @@ -121,7 +130,7 @@ public override string AddIndex(string table, Index index) public override void ChangeColumn(string table, Column column) { var prefix = $"ALTER TABLE {Identifier(table)} ALTER COLUMN {Identifier(column.Name)}"; - var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(column.Type); + 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))}"); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs index 9007d879..7a91209d 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs @@ -21,6 +21,7 @@ public FirebirdDialect() RegisterColumnType(DbType.DateTime, "TIMESTAMP"); RegisterColumnType(DbType.DateTimeOffset, "TIMESTAMP"); RegisterColumnType(DbType.Decimal, "DECIMAL"); + RegisterColumnTypeWithPrecision(DbType.Decimal, "DECIMAL({precision},{scale})"); RegisterColumnType(DbType.Double, "DOUBLE PRECISION"); //synonym for FLOAT(53) RegisterColumnType(DbType.Guid, "CHAR(38)"); RegisterColumnType(DbType.Int16, "SMALLINT"); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index e0271181..f5c4cc6e 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -26,6 +26,9 @@ public FirebirdTransformationProvider(Dialect dialect, IDbConnection connection, 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 AddTable(string name, string engine, params IDbField[] fields) { base.AddTable(name, engine, fields); @@ -73,6 +76,7 @@ public override bool PrimaryKeyExists(string table, string name) => public override Column[] GetColumns(string table) { + 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, $""" @@ -88,7 +92,7 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, { 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 => DbType.Binary, _ => DbType.String + 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; @@ -103,7 +107,9 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, } if (!reader.IsDBNull(3)) column.DefaultValue = ReadDefault(reader.GetString(3), type); if (!reader.IsDBNull(4)) column.Size = Convert.ToInt32(reader.GetValue(4)); + if (Convert.ToInt32(reader.GetValue(1)) == 261 && type == DbType.String) column.Size = int.MaxValue; if (!reader.IsDBNull(5)) column.ColumnProperty |= ColumnProperty.Identity; + if (primaryColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.PrimaryKey; result.Add(column); } return result.ToArray(); @@ -155,7 +161,7 @@ public override void RenameColumn(string tableName, string oldColumnName, string public override void ChangeColumn(string table, Column column) { var prefix = $"ALTER TABLE {QuoteTableNameIfRequired(table)} ALTER {QuoteColumnNameIfRequired(column.Name)}"; - var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(column.Type); + 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))}"); diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 5d920d9f..6bfe9b52 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -23,6 +23,7 @@ public InformixDialect() RegisterColumnType(DbType.DateTime2, "DATETIME YEAR TO FRACTION(5)"); RegisterColumnType(DbType.DateTimeOffset, "DATETIME YEAR TO FRACTION(5)"); RegisterColumnType(DbType.Decimal, "DECIMAL(18,5)"); + RegisterColumnTypeWithPrecision(DbType.Decimal, "DECIMAL({precision},{scale})"); RegisterColumnType(DbType.Double, "DOUBLE PRECISION"); RegisterColumnType(DbType.Guid, "CHAR(36)"); RegisterColumnType(DbType.Int16, "SMALLINT"); @@ -43,6 +44,8 @@ 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); } diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 5d7520fd..a350bd80 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -23,6 +23,9 @@ public InformixTransformationProvider(Dialect dialect, IDbConnection connection, 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); @@ -45,6 +48,7 @@ protected override string GetPrimaryKeyConstraintName(string table) => ExecuteSt 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, $""" @@ -60,7 +64,7 @@ public override Column[] GetColumns(string table) { 1 => DbType.Int16, 2 or 6 => DbType.Int32, 17 or 18 or 52 or 53 => DbType.Int64, 3 => DbType.Double, 4 => DbType.Single, 5 or 8 => DbType.Decimal, - 7 => DbType.Date, 10 => DbType.DateTime, 11 => DbType.Binary, + 7 => DbType.Date, 10 => DbType.DateTime, 11 => DbType.Binary, 14 => DbType.Time, 45 => DbType.Boolean, _ => DbType.String }; var column = new Column(reader.GetString(0).Trim(), type) @@ -68,8 +72,15 @@ public override Column[] GetColumns(string table) ColumnProperty = (code & 256) != 0 ? ColumnProperty.NotNull : ColumnProperty.Null }; if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(2)) & 255; + 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(3)) column.DefaultValue = reader.GetString(3).Trim(); + if (primaryColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.PrimaryKey; columns.Add(column); } return columns.ToArray(); diff --git a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs index 4e298725..5ad933d7 100644 --- a/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Mysql/MySqlTransformationProvider.cs @@ -204,7 +204,7 @@ public override Column[] GetColumns(string table) "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, - "blob" or "binary" or "varbinary" or "longblob" => DbType.Binary, _ => DbType.String + "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; diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs index 7b44cbe4..83da790a 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs @@ -16,6 +16,7 @@ public SybaseDialect() RegisterColumnType(DbType.Byte, "TINYINT"); RegisterColumnType(DbType.Boolean, "BIT"); RegisterColumnType(DbType.Decimal, "DECIMAL(18,5)"); + RegisterColumnTypeWithPrecision(DbType.Decimal, "DECIMAL({precision},{scale})"); RegisterColumnType(DbType.Currency, "MONEY"); RegisterColumnType(DbType.Double, "FLOAT"); RegisterColumnType(DbType.Single, "REAL"); @@ -39,12 +40,16 @@ public SybaseDialect() 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); } diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index b3c99131..7c85a7ad 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -21,6 +21,9 @@ public SybaseTransformationProvider(Dialect dialect, IDbConnection connection, s : 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); @@ -42,17 +45,19 @@ protected override string GetPrimaryKeyConstraintName(string table) => ExecuteSt 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 FROM syscolumns c JOIN systypes t ON t.usertype=c.usertype + 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()) { var type = reader.GetString(1).Trim() switch { - "smallint" => DbType.Int16, "int" => DbType.Int32, "bigint" => DbType.Int64, + "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, @@ -63,13 +68,42 @@ public override Column[] GetColumns(string table) { 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 = Convert.ToInt32(reader.GetValue(3)); + if (primaryColumns.Contains(column.Name)) column.ColumnProperty |= ColumnProperty.PrimaryKey; columns.Add(column); } return columns.ToArray(); } + 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 override Index[] GetIndexes(string table) { var indexes = new List(); @@ -121,7 +155,7 @@ public override void RenameTable(string oldName, string newName) => public override void RemoveColumnDefaultValue(string table, string column) => ExecuteNonQuery($"ALTER TABLE {table} REPLACE {column} DEFAULT NULL"); public override void ChangeColumn(string table, Column column) { - var type = column.Size > 0 ? _dialect.GetTypeName(column.Type, column.Size) : _dialect.GetTypeName(column.Type); + 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))}"); From bd511f50a0bbb79234daf90c4de75f2cb2cf8f49 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 11:03:10 +0200 Subject: [PATCH 18/27] Register numeric parameters correctly and bind binary data values Why: live regressions showed the legacy WithPrecision registration populates length-based mappings rather than the parameterized map, leaving DECIMAL at its default shape. Binary round-trip tests also exposed a shared Insert path that rejected byte arrays before the database driver could receive them. Changes: register explicit precision/scale through WithParameters, matching existing SQL Server/PostgreSQL dialects. Bind byte arrays as DbType.Binary and bytes as DbType.Byte without changing parameter values or public signatures. Validation: local build passes. Existing live regressions verify requested numeric shapes and persisted binary bytes; the ASE byte test now also binds an actual CLR byte. Full database CI runs on this commit. --- .../Providers/Live/LiveMetadataRegressionTests.cs | 2 +- src/Migrator/Providers/Impl/DB2/DB2Dialect.cs | 4 ++-- .../Providers/Impl/Firebird/FirebirdDialect.cs | 2 +- .../Providers/Impl/Informix/InformixDialect.cs | 2 +- src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs | 2 +- src/Migrator/Providers/TransformationProvider.cs | 10 ++++++++++ 6 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index c1ddcec3..00211c38 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -125,7 +125,7 @@ public class LiveMetadataRegressionTests 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"], [255]); + 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)); }); diff --git a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs index 11cbd14c..aeb19070 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2Dialect.cs @@ -23,9 +23,9 @@ public DB2Dialect() RegisterColumnType(DbType.DateTime2, "TIMESTAMP"); RegisterColumnType(DbType.DateTimeOffset, "TIMESTAMP"); RegisterColumnType(DbType.Decimal, "DECIMAL(18,5)"); - RegisterColumnTypeWithPrecision(DbType.Decimal, "DECIMAL({precision},{scale})"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); RegisterColumnType(DbType.VarNumeric, "DECFLOAT(34)"); - RegisterColumnTypeWithPrecision(DbType.VarNumeric, "DECFLOAT({precision})"); + RegisterColumnTypeWithParameters(DbType.VarNumeric, "DECFLOAT({precision})"); RegisterColumnType(DbType.Double, "DOUBLE PRECISION"); RegisterColumnType(DbType.Guid, "CHAR(36)"); RegisterColumnType(DbType.Int16, "SMALLINT"); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs index 7a91209d..8baebf0b 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs @@ -21,7 +21,7 @@ public FirebirdDialect() RegisterColumnType(DbType.DateTime, "TIMESTAMP"); RegisterColumnType(DbType.DateTimeOffset, "TIMESTAMP"); RegisterColumnType(DbType.Decimal, "DECIMAL"); - RegisterColumnTypeWithPrecision(DbType.Decimal, "DECIMAL({precision},{scale})"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); RegisterColumnType(DbType.Double, "DOUBLE PRECISION"); //synonym for FLOAT(53) RegisterColumnType(DbType.Guid, "CHAR(38)"); RegisterColumnType(DbType.Int16, "SMALLINT"); diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 6bfe9b52..013bd441 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -23,7 +23,7 @@ public InformixDialect() RegisterColumnType(DbType.DateTime2, "DATETIME YEAR TO FRACTION(5)"); RegisterColumnType(DbType.DateTimeOffset, "DATETIME YEAR TO FRACTION(5)"); RegisterColumnType(DbType.Decimal, "DECIMAL(18,5)"); - RegisterColumnTypeWithPrecision(DbType.Decimal, "DECIMAL({precision},{scale})"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); RegisterColumnType(DbType.Double, "DOUBLE PRECISION"); RegisterColumnType(DbType.Guid, "CHAR(36)"); RegisterColumnType(DbType.Int16, "SMALLINT"); diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs index 83da790a..8d76f09d 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseDialect.cs @@ -16,7 +16,7 @@ public SybaseDialect() RegisterColumnType(DbType.Byte, "TINYINT"); RegisterColumnType(DbType.Boolean, "BIT"); RegisterColumnType(DbType.Decimal, "DECIMAL(18,5)"); - RegisterColumnTypeWithPrecision(DbType.Decimal, "DECIMAL({precision},{scale})"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); RegisterColumnType(DbType.Currency, "MONEY"); RegisterColumnType(DbType.Double, "FLOAT"); RegisterColumnType(DbType.Single, "REAL"); diff --git a/src/Migrator/Providers/TransformationProvider.cs b/src/Migrator/Providers/TransformationProvider.cs index 9fafab60..7b766915 100644 --- a/src/Migrator/Providers/TransformationProvider.cs +++ b/src/Migrator/Providers/TransformationProvider.cs @@ -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; From a9cdb7ea8b327705baefa0da6d2b195778ab3922 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 11:24:01 +0200 Subject: [PATCH 19/27] Fix Informix length and Firebird native type metadata round trips Why: Copilot identified that Informix masked long character lengths to one byte and Firebird recreated DATE, TIME, and BOOLEAN columns using different native types. Both could change a schema reconstructed from GetColumns. Decode packed VARCHAR/NVARCHAR lengths separately from full CHAR/LVARCHAR lengths and emit explicit LVARCHAR sizes. Map Firebird native date, time, and boolean types consistently. Add live metadata-copy regressions that verify recreated sizes/types and persisted values, including a 2500-character value and packed VARCHAR reserved space. Validation: local solution build passes; Unit 64 passed; SQLite 139 passed with one existing issue-139 skip. Latest origin/master is already an ancestor. Container-backed regressions will be verified by the full GitHub Actions matrix after push. --- .../Live/LiveMetadataRegressionTests.cs | 26 +++++++++++++++++++ .../Impl/Firebird/FirebirdDialect.cs | 6 ++--- .../Impl/Informix/InformixDialect.cs | 4 +-- .../InformixTransformationProvider.cs | 7 ++++- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 00211c38..526adae0 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -10,6 +10,32 @@ namespace Migrator.Tests.Providers.Live; [NonParallelizable] public class LiveMetadataRegressionTests { + [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 })); + f.Provider.AddTable("copied_values", columns); + Assert.That(f.Provider.GetColumns("copied_values").Select(c => c.Size), Is.EqualTo(new[] { 3000, 40, 300 })); + 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")] diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs index 8baebf0b..3d9f85d8 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs @@ -14,10 +14,10 @@ public FirebirdDialect() RegisterColumnType(DbType.AnsiString, 8000, "CHAR($l)"); RegisterColumnType(DbType.Binary, "BLOB"); RegisterColumnType(DbType.Binary, 8000, "CHAR"); - RegisterColumnType(DbType.Boolean, "SMALLINT"); + RegisterColumnType(DbType.Boolean, "BOOLEAN"); RegisterColumnType(DbType.Byte, "SMALLINT"); RegisterColumnType(DbType.Currency, "DECIMAL(18,4)"); - RegisterColumnType(DbType.Date, "TIMESTAMP"); + RegisterColumnType(DbType.Date, "DATE"); RegisterColumnType(DbType.DateTime, "TIMESTAMP"); RegisterColumnType(DbType.DateTimeOffset, "TIMESTAMP"); RegisterColumnType(DbType.Decimal, "DECIMAL"); @@ -32,7 +32,7 @@ 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"); RegisterProperty(ColumnProperty.Identity, "GENERATED BY DEFAULT AS IDENTITY"); diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 013bd441..0549bcd8 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -32,9 +32,9 @@ public InformixDialect() RegisterColumnType(DbType.Single, "SMALLFLOAT"); RegisterColumnType(DbType.Time, "INTERVAL HOUR TO SECOND"); RegisterColumnType(DbType.String, 255, "VARCHAR($l)"); - RegisterColumnType(DbType.String, int.MaxValue, "LVARCHAR"); + RegisterColumnType(DbType.String, int.MaxValue, "LVARCHAR($l)"); RegisterColumnType(DbType.AnsiString, 255, "VARCHAR($l)"); - RegisterColumnType(DbType.AnsiString, int.MaxValue, "LVARCHAR"); + RegisterColumnType(DbType.AnsiString, int.MaxValue, "LVARCHAR($l)"); RegisterColumnType(DbType.StringFixedLength, 255, "CHAR($l)"); RegisterColumnType(DbType.AnsiStringFixedLength, 255, "CHAR($l)"); RegisterProperty(ColumnProperty.Identity, ""); diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index a350bd80..21e06b5f 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -71,7 +71,12 @@ public override Column[] GetColumns(string table) { ColumnProperty = (code & 256) != 0 ? ColumnProperty.NotNull : ColumnProperty.Null }; - if (type == DbType.String) column.Size = Convert.ToInt32(reader.GetValue(2)) & 255; + if (type == DbType.String) + { + 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) is 13 or 16 ? length & 255 : length; + } if (type == DbType.Decimal) { var length = Convert.ToInt32(reader.GetValue(2)); From d622aa90852a0af677fed8bfb5726043fed62943 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 11:38:45 +0200 Subject: [PATCH 20/27] Honor unique column changes and preserve Informix character storage Why: five Copilot findings exposed missing Unique handling in Db2, Firebird and ASE ChangeColumn overrides, invalid oversized LVARCHAR DDL, and loss of fixed-length/large-text metadata in Informix schema copies. Match the base Unique contract by clearing the flag during alteration and adding the named constraint afterward. Bound Informix LVARCHAR at 32739 bytes, use TEXT for larger string requests, support CHAR lengths through 32767, and distinguish fixed-length, TEXT and extended CLOB catalog metadata. Resolve extended types through sysxtdtypes instead of relying on ambiguous opaque type codes. Add live regressions for uniqueness metadata, duplicate rejection and constraint removal in all three providers, plus Informix large-object recreation/persistence and fixed-length type preservation. Validation: local build succeeds; Unit 64 passed and SQLite 139 passed with one existing issue-139 skip. Latest master is already included. Full container-backed GitHub Actions validation follows this push. --- .../Live/LiveMetadataRegressionTests.cs | 38 +++++++++++++++++++ .../Impl/DB2/DB2TransformationProvider.cs | 4 ++ .../FirebirdTransformationProvider.cs | 4 ++ .../Impl/Informix/InformixDialect.cs | 10 +++-- .../InformixTransformationProvider.cs | 13 +++++-- .../Sybase/SybaseTransformationProvider.cs | 4 ++ 6 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 526adae0..e2fc1289 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -10,14 +10,52 @@ namespace Migrator.Tests.Providers.Live; [NonParallelizable] public class LiveMetadataRegressionTests { + [TestCase("Db2", ProviderTypes.IBM_DB2, Category = "Db2")] + [TestCase("Firebird", ProviderTypes.Firebird, Category = "Firebird")] + [TestCase("Sybase", ProviderTypes.Sybase, Category = "Sybase")] + public void ChangeColumnCreatesRequestedUniqueConstraint(string database, ProviderTypes type) => new LiveDatabaseTests(database, type).RunRegression(f => + { + 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); + Assert.That(() => f.Provider.Insert("unique_values", ["amount"], [7L]), Throws.InstanceOf()); + 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 })); + f.Provider.AddTable("sized_values", + new Column("bounded_value", DbType.String, 32739), + new Column("large_value", DbType.AnsiString, int.MaxValue)); + Assert.That(f.Provider.GetColumns("sized_values").Select(c => c.Size), Is.EqualTo(new[] { 32739, 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)); diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index be8e363c..1fb5ef08 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -129,6 +129,8 @@ public override string AddIndex(string table, Index index) public override void ChangeColumn(string table, Column column) { + var isUniqueSet = column.ColumnProperty.HasFlag(ColumnProperty.Unique); + column.ColumnProperty &= ~ColumnProperty.Unique; var prefix = $"ALTER TABLE {Identifier(table)} ALTER COLUMN {Identifier(column.Name)}"; var type = _dialect.GetColumnMapper(column).Type; ExecuteNonQuery($"{prefix} SET DATA TYPE {type}"); @@ -136,6 +138,8 @@ public override void ChangeColumn(string table, Column column) 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 void RemoveColumn(string tableName, string column) diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index f5c4cc6e..062643c8 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -160,12 +160,16 @@ public override void RenameColumn(string tableName, string oldColumnName, string public override void ChangeColumn(string table, Column column) { + var isUniqueSet = column.ColumnProperty.HasFlag(ColumnProperty.Unique); + column.ColumnProperty &= ~ColumnProperty.Unique; var prefix = $"ALTER TABLE {QuoteTableNameIfRequired(table)} ALTER {QuoteColumnNameIfRequired(column.Name)}"; var type = _dialect.GetColumnMapper(column).Type; ExecuteNonQuery($"{prefix} TYPE {type}"); if (column.DefaultValue != null || GetColumns(table).Single(c => c.Name.Equals(column.Name, StringComparison.OrdinalIgnoreCase)).DefaultValue != null) ExecuteNonQuery($"{prefix} {(column.DefaultValue == null ? "DROP DEFAULT" : "SET " + _dialect.Default(column.DefaultValue))}"); ExecuteNonQuery($"{prefix} {(column.ColumnProperty.HasFlag(ColumnProperty.NotNull) ? "SET" : "DROP")} NOT NULL"); + if (isUniqueSet) + AddUniqueConstraint($"UX_{table}_{column.Name}", table, [column.Name]); } public override string AddIndex(string table, Index index) diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 0549bcd8..47a20c27 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -32,11 +32,13 @@ public InformixDialect() RegisterColumnType(DbType.Single, "SMALLFLOAT"); RegisterColumnType(DbType.Time, "INTERVAL HOUR TO SECOND"); RegisterColumnType(DbType.String, 255, "VARCHAR($l)"); - RegisterColumnType(DbType.String, int.MaxValue, "LVARCHAR($l)"); + RegisterColumnType(DbType.String, 32739, "LVARCHAR($l)"); RegisterColumnType(DbType.AnsiString, 255, "VARCHAR($l)"); - RegisterColumnType(DbType.AnsiString, int.MaxValue, "LVARCHAR($l)"); - RegisterColumnType(DbType.StringFixedLength, 255, "CHAR($l)"); - RegisterColumnType(DbType.AnsiStringFixedLength, 255, "CHAR($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, ""); } diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 21e06b5f..ac254637 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -52,30 +52,37 @@ public override Column[] GetColumns(string table) var columns = new List(); using var cmd = CreateCommand(); using var reader = ExecuteQuery(cmd, $""" - SELECT c.colname, c.coltype, c.collength, d.default + SELECT c.colname, c.coltype, c.collength, d.default, x.name 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 + 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()) { 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 == DbType.String) + 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) is 13 or 16 ? length & 255 : length; + column.Size = (code & 255) == 12 || extendedType == "clob" + ? int.MaxValue + : (code & 255) is 13 or 16 ? length & 255 : length; } if (type == DbType.Decimal) { diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 7c85a7ad..2d213106 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -155,10 +155,14 @@ public override void RenameTable(string oldName, string newName) => public override void RemoveColumnDefaultValue(string table, string column) => ExecuteNonQuery($"ALTER TABLE {table} REPLACE {column} DEFAULT NULL"); public override void ChangeColumn(string table, Column column) { + var isUniqueSet = column.ColumnProperty.HasFlag(ColumnProperty.Unique); + column.ColumnProperty &= ~ColumnProperty.Unique; var type = _dialect.GetColumnMapper(column).Type; var nullable = column.ColumnProperty.HasFlag(ColumnProperty.NotNull) ? "NOT NULL" : "NULL"; ExecuteNonQuery($"ALTER TABLE {table} MODIFY {column.Name} {type} {nullable}"); ExecuteNonQuery($"ALTER TABLE {table} REPLACE {column.Name} {(column.DefaultValue == null ? "DEFAULT NULL" : _dialect.Default(column.DefaultValue))}"); + if (isUniqueSet) + AddUniqueConstraint($"UX_{table}_{column.Name}", table, [column.Name]); } public override void AddForeignKey(string name, string childTable, string[] childColumns, string parentTable, string[] parentColumns, ForeignKeyConstraintType constraint) From f384c79ad2c4c03b41c23236f08fca91d53d6e44 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 11:42:15 +0200 Subject: [PATCH 21/27] Correct live regression fixtures for Informix row limits and ASE exceptions Why: CI confirms ASE enforces the new unique constraint, but its legacy driver exception does not inherit DbException. Informix rejects a maximum-width LVARCHAR sharing a row with a TEXT locator because the combined row exceeds 32767 bytes. Reuse the existing provider-aware database-error assertion and put maximum-width LVARCHAR and TEXT boundary checks in separate tables. Keep duplicate rejection, exact boundary metadata, and large-text persistence assertions intact. Validation: local solution build passes. The first CI run verified Firebird uniqueness and exposed these two fixture assumptions; the full matrix will rerun on this commit. --- .../Providers/Live/LiveDatabaseTests.cs | 2 +- .../Providers/Live/LiveMetadataRegressionTests.cs | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs index 8a92c42f..a0aecf29 100644 --- a/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveDatabaseTests.cs @@ -194,7 +194,7 @@ public void TearDown() } } - private void AssertDatabaseError(TestDelegate action) + internal void AssertDatabaseError(TestDelegate action) { // AdoNetCore's ASE exception predates DbException inheritance. var error = Assert.Catch(action); diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index e2fc1289..07279a20 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -20,7 +20,7 @@ public class LiveMetadataRegressionTests 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); - Assert.That(() => f.Provider.Insert("unique_values", ["amount"], [7L]), Throws.InstanceOf()); + 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]); @@ -36,10 +36,11 @@ public class LiveMetadataRegressionTests 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 })); - f.Provider.AddTable("sized_values", - new Column("bounded_value", DbType.String, 32739), - new Column("large_value", DbType.AnsiString, int.MaxValue)); - Assert.That(f.Provider.GetColumns("sized_values").Select(c => c.Size), Is.EqualTo(new[] { 32739, 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)); From 5eb07ee23c2bb5e7609ad0ea4e6635c1bcff82bb Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 11:45:23 +0200 Subject: [PATCH 22/27] Wait for Informix catalog initialization before live tests Why: CI reached the server ONLINE state while the image initializer still held a sysmaster lock, causing test-database creation to fail before any tests ran. Bound bootstrap database creation to 24 attempts with five-second delays and retain errors/container logs on exhaustion. Test failures remain fatal and are never retried by this loop. Document Informix string boundaries and the TEXT representation used when recreating large-text metadata. Validation: inspected the failed startup log (425/107 catalog lock); shell diff passes whitespace checks. Rerun the complete live matrix on this commit. --- .github/scripts/start-database.sh | 7 +- docs/live-database-tests.md | 180 +++++++++++++++--------------- 2 files changed, 97 insertions(+), 90 deletions(-) diff --git a/.github/scripts/start-database.sh b/.github/scripts/start-database.sh index 1762852c..7bb5d15a 100644 --- a/.github/scripts/start-database.sh +++ b/.github/scripts/start-database.sh @@ -64,6 +64,11 @@ case "$database" in 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) - echo 'create database testdb with log;' | docker exec -i migrator-db bash -c 'source /usr/local/bin/informix_inf.env; dbaccess sysmaster -' + # 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/docs/live-database-tests.md b/docs/live-database-tests.md index cc0ef978..8ae1d72c 100644 --- a/docs/live-database-tests.md +++ b/docs/live-database-tests.md @@ -1,89 +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. +# 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). From 1cf71f1899bd95ce12c42a14116d4bf9932e8699 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 12:05:53 +0200 Subject: [PATCH 23/27] Preserve ASE large-text capacity and decode Informix catalog defaults Why: Copilot identified that ASE TEXT/UNITEXT pointer lengths were being mistaken for column capacity, and Informix defaults were returned as raw catalog strings. Recreated schemas could truncate large-text capacity or misquote defaults. Use the large-text size sentinel for ASE TEXT/UNITEXT. Decode Informix literal prefixes and default-kind codes before using the shared typed parser; preserve unquoted string literals and distinguish CURRENT/TODAY expressions from literals. Render typed boolean defaults using Informix's quoted t/f syntax. Add live metadata-copy regressions for both ASE large-text types with 5000-character persistence and Informix integer, decimal, true/false, quoted string, current timestamp/date, and null defaults. Validation: local solution build passes; Unit 64 passed and SQLite 139 passed with its existing issue-139 skip. Latest origin/master is included. Full database CI will verify the new regressions after push. --- .../Live/LiveMetadataRegressionTests.cs | 38 +++++++++++++++++++ .../Impl/Informix/InformixDialect.cs | 2 + .../InformixTransformationProvider.cs | 29 ++++++++++++-- .../Sybase/SybaseTransformationProvider.cs | 5 ++- 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 07279a20..85d4ed9d 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -10,6 +10,44 @@ 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"); + 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); + 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")] diff --git a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs index 47a20c27..18f376d3 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixDialect.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixDialect.cs @@ -42,6 +42,8 @@ public InformixDialect() RegisterProperty(ColumnProperty.Identity, ""); } + public override string Default(object value) => value is bool boolean ? (boolean ? "DEFAULT 't'" : "DEFAULT 'f'") : base.Default(value); + public override ColumnPropertiesMapper GetColumnMapper(Column column) { var type = column.Size > 0 ? GetTypeName(column.Type, column.Size) : GetTypeName(column.Type); diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index ac254637..9149de7b 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -52,9 +52,9 @@ public override Column[] GetColumns(string table) var columns = new List(); using var cmd = CreateCommand(); using var reader = ExecuteQuery(cmd, $""" - SELECT c.colname, c.coltype, c.collength, d.default, x.name + 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 + 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 """); @@ -91,13 +91,36 @@ public override Column[] GetColumns(string table) column.Scale = (length & 255) == 255 ? null : length & 255; } if ((code & 255) is 6 or 18 or 53) column.ColumnProperty |= ColumnProperty.Identity; - if (!reader.IsDBNull(3)) column.DefaultValue = reader.GetString(3).Trim(); + 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(); } + 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 + { + "N" => "NULL", "C" => "CURRENT", "T" => "TODAY", + "U" => "USER", "S" => "DBSERVERNAME", + _ => throw new NotSupportedException($"Unsupported Informix default kind: {kind}") + }, type); + var value = catalogValue.TrimEnd(); + if (type is DbType.String or DbType.AnsiString or DbType.StringFixedLength or DbType.AnsiStringFixedLength) + return value; + if (type == DbType.Boolean) + return CatalogDefaultValue.Parse(value.Trim().Equals("t", StringComparison.OrdinalIgnoreCase) ? "true" : + value.Trim().Equals("f", StringComparison.OrdinalIgnoreCase) ? "false" : value, 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 override Index[] GetIndexes(string table) { var result = new List(); diff --git a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs index 2d213106..71f82029 100644 --- a/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Sybase/SybaseTransformationProvider.cs @@ -55,7 +55,8 @@ public override Column[] GetColumns(string table) """); while (reader.Read()) { - var type = reader.GetString(1).Trim() switch + 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, @@ -75,7 +76,7 @@ public override Column[] GetColumns(string table) } 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 = Convert.ToInt32(reader.GetValue(3)); + 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); } From ca2fa5840c514af06eeefce83f792100408a5fad Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 12:10:05 +0200 Subject: [PATCH 24/27] Normalize Informix boolean literal wrappers before typed parsing Why: the live metadata-copy regression passed integer and decimal defaults but exposed a boolean literal representation that the initial t/f decoder returned as an expression. Normalize quoted literal text and any catalog prefix before interpreting t/f. Add default-value/type diagnostics to the regression output so any remaining engine-specific representation is visible in CI artifacts. Validation: local solution build passes. Full live CI reruns on this commit; no assertions or skip policies were weakened. --- .../Providers/Live/LiveMetadataRegressionTests.cs | 2 ++ .../Impl/Informix/InformixTransformationProvider.cs | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 85d4ed9d..63807f2c 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -30,6 +30,8 @@ public class LiveMetadataRegressionTests { 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)); diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 9149de7b..8f8764d8 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -113,8 +113,14 @@ private static object ReadDefault(string catalogValue, string kind, DbType type) if (type is DbType.String or DbType.AnsiString or DbType.StringFixedLength or DbType.AnsiStringFixedLength) return value; if (type == DbType.Boolean) - return CatalogDefaultValue.Parse(value.Trim().Equals("t", StringComparison.OrdinalIgnoreCase) ? "true" : - value.Trim().Equals("f", StringComparison.OrdinalIgnoreCase) ? "false" : value, type); + { + 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("'", "''") + "'"; From f7e77fba82f7d38627565ed4f5f002d6955effb8 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 12:12:58 +0200 Subject: [PATCH 25/27] Remove Informix catalog literal terminators without trimming user text Why: live diagnostics show SYSDEFAULTS character/boolean literals contain a NUL terminator before catalog padding. It made t/f fail typed parsing and leaked into inspected string defaults. Read literal content only up to its terminator, preserving meaningful leading and trailing spaces. Strengthen the live regression to verify both spaces survive metadata inspection and insertion into a recreated table. Validation: local build passes. CI confirmed numeric defaults already parse correctly and isolated the t-NUL/f-NUL/string-NUL representations. Full live matrix reruns on this commit. --- .../Providers/Live/LiveMetadataRegressionTests.cs | 6 +++--- .../Impl/Informix/InformixTransformationProvider.cs | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 63807f2c..1d695b7a 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -28,7 +28,7 @@ public class LiveMetadataRegressionTests [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)"); + 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})"); @@ -36,7 +36,7 @@ public class LiveMetadataRegressionTests 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 == "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]); @@ -44,7 +44,7 @@ public class LiveMetadataRegressionTests 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); - Assert.That(f.Provider.ExecuteScalar("SELECT label FROM copied_values"), Is.EqualTo(" O'Brien")); + 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)); diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index 8f8764d8..ec4d0607 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -109,7 +109,10 @@ private static object ReadDefault(string catalogValue, string kind, DbType type) "U" => "USER", "S" => "DBSERVERNAME", _ => throw new NotSupportedException($"Unsupported Informix default kind: {kind}") }, type); - var value = catalogValue.TrimEnd(); + // 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) From fafe3d4d7fd1d64f997c085203b5ea88f8fe5a46 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 12:17:35 +0200 Subject: [PATCH 26/27] Match Informix CURRENT defaults to the recreated timestamp precision Why: the live round-trip now verifies all typed literal defaults, but Informix rejects bare CURRENT as a default for DATETIME YEAR TO FRACTION(5). Decode the catalog's CURRENT default kind with an explicit YEAR TO FRACTION(5) qualifier, matching the timestamp declaration produced by InformixDialect. Keep the live insertion check for timestamp persistence. Validation: local build passes. CI verified integer, decimal, both boolean values, whitespace-preserving string literals, and null metadata before exposing this default-expression precision mismatch. Full CI reruns on the corrected commit. --- .../Providers/Impl/Informix/InformixTransformationProvider.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index ec4d0607..2711e38f 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -105,7 +105,8 @@ private static object ReadDefault(string catalogValue, string kind, DbType type) if (kind != "L") return CatalogDefaultValue.Parse(kind switch { - "N" => "NULL", "C" => "CURRENT", "T" => "TODAY", + // 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); From 8c0743bbb205d516cabe2f1fc991e0361ebc4519 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 12:21:20 +0200 Subject: [PATCH 27/27] Verify persisted Informix whitespace independently of driver trimming Why: the live regression now recreates the table and verifies numeric/boolean persistence, but a raw VARCHAR read removes the trailing blank from the returned value. Append a nonblank marker in SQL before reading the value. This keeps the trailing-space persistence assertion exact while distinguishing server data from client trimming. Catalog metadata still must preserve both leading and trailing whitespace. Validation: local build passes. Full live CI reruns without removing any default-value assertions. --- .../Providers/Live/LiveMetadataRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs index 1d695b7a..18158f3b 100644 --- a/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs +++ b/src/Migrator.Tests/Providers/Live/LiveMetadataRegressionTests.cs @@ -44,7 +44,8 @@ public class LiveMetadataRegressionTests 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); - Assert.That(f.Provider.ExecuteScalar("SELECT label FROM copied_values"), Is.EqualTo(" O'Brien ")); + // 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));