diff --git a/.github/coverage.runsettings b/.github/coverage.runsettings new file mode 100644 index 00000000..80653297 --- /dev/null +++ b/.github/coverage.runsettings @@ -0,0 +1,16 @@ + + + + + + + cobertura + [DotNetProjects.Migrator*]* + false + GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute + **/obj/** + + + + + diff --git a/.github/scripts/normalize-code-coverage.py b/.github/scripts/normalize-code-coverage.py new file mode 100644 index 00000000..620ff5d9 --- /dev/null +++ b/.github/scripts/normalize-code-coverage.py @@ -0,0 +1,29 @@ +"""Select one validated report, ignoring byte-identical VSTest attachment copies.""" +import hashlib +from pathlib import Path +import sys +import xml.etree.ElementTree as ET + + +def normalize(directory): + directory = Path(directory) + destination = directory / "coverage" / "coverage.cobertura.xml" + reports = {} + for path in directory.rglob("coverage.cobertura.xml"): + if path == destination: + continue + data = path.read_bytes() + reports[hashlib.sha256(data).hexdigest()] = data + if len(reports) != 1: + raise ValueError(f"Expected one distinct Coverlet report, found {len(reports)}") + data = next(iter(reports.values())) + root = ET.fromstring(data) + if root.tag != "coverage" or int(root.get("lines-valid", "0")) <= 0: + raise ValueError("Coverlet report contains no instrumented lines") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + print(f"Validated Coverlet report: {destination}") + + +if __name__ == "__main__": + normalize(sys.argv[1]) diff --git a/.github/scripts/summarize-code-coverage.py b/.github/scripts/summarize-code-coverage.py new file mode 100644 index 00000000..ec692caa --- /dev/null +++ b/.github/scripts/summarize-code-coverage.py @@ -0,0 +1,70 @@ +"""Compare merged Cobertura reports; missing baselines must never imply 0%.""" + +import os +from pathlib import Path +import xml.etree.ElementTree as ET + + +def read_coverage(path): + root = ET.parse(path).getroot() + counts = {} + for metric in ("lines", "branches"): + covered = int(root.attrib[f"{metric}-covered"]) + total = int(root.attrib[f"{metric}-valid"]) + if not 0 <= covered <= total: + raise ValueError(f"Invalid {metric} coverage in {path}") + counts[metric] = (covered, total) + if counts["lines"][1] == 0: + raise ValueError(f"No instrumented source lines in {path}") + return counts + + +def percentage(count): + covered, total = count + return 100 * covered / total if total else None + + +def display(count): + rate = percentage(count) + return f"{rate:.2f}% ({count[0]}/{count[1]})" if rate is not None else "N/A" + + +def summary(current, baseline=None): + rows = [ + "## Code coverage (Coverlet)", + "", + "Combined Unit and all database suites; production assemblies only.", + "", + "| Metric | Current | PR base | Change (percentage points) |", + "| --- | ---: | ---: | ---: |", + ] + for metric in ("lines", "branches"): + previous = display(baseline[metric]) if baseline else "Unavailable" + before = percentage(baseline[metric]) if baseline else None + after = percentage(current[metric]) + delta = f"{after - before:+.2f}" if before is not None and after is not None else "N/A" + rows.append(f"| {metric.title()} | {display(current[metric])} | {previous} | {delta} |") + if baseline is None: + rows.extend(["", "No baseline available. A successful push or manual run on the exact PR target commit must first publish a code-coverage artifact (retained for 90 days). No change is inferred."]) + rows.extend(["", "Download the **code-coverage** artifact and open **index.html** for coverage by assembly, class and source line. Coverage changes are informational; no minimum threshold is enforced."]) + return "\n".join(rows) + "\n" + + +def main(): + current = read_coverage(Path("artifacts/coverage/Cobertura.xml")) + baseline_path = Path("artifacts/baseline/Cobertura.xml") + baseline = read_coverage(baseline_path) if baseline_path.exists() else None + report = summary(current, baseline) + for label, variable in (("Measured commit (PR merge result)", "HEAD_SHA"), ("PR target commit", "BASE_SHA")): + value = os.environ.get(variable) + if value: + report += f"\n{label}: `{value}`\n" + Path("artifacts/coverage/summary.md").write_text(report, encoding="utf-8") + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as output: + output.write(report) + print(report) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test-code-coverage.py b/.github/scripts/test-code-coverage.py new file mode 100644 index 00000000..8b9cc7bb --- /dev/null +++ b/.github/scripts/test-code-coverage.py @@ -0,0 +1,74 @@ +"""Regression checks for coverage comparison edge cases.""" + +import importlib.util +from pathlib import Path +import tempfile +import unittest +import sys + +sys.dont_write_bytecode = True + +spec = importlib.util.spec_from_file_location("coverage_summary", Path(__file__).with_name("summarize-code-coverage.py")) +coverage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(coverage) + +normalizer_spec = importlib.util.spec_from_file_location("normalize_coverage", Path(__file__).with_name("normalize-code-coverage.py")) +normalizer = importlib.util.module_from_spec(normalizer_spec) +normalizer_spec.loader.exec_module(normalizer) + + +class CoverageSummaryTests(unittest.TestCase): + def test_vstest_attachment_copies_are_deduplicated(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + data = b'' + for relative in ("guid/coverage.cobertura.xml", "runner/In/host/coverage.cobertura.xml"): + path = root / relative + path.parent.mkdir(parents=True) + path.write_bytes(data) + normalizer.normalize(root) + self.assertEqual((root / "coverage/coverage.cobertura.xml").read_bytes(), data) + normalizer.normalize(root) # Idempotent; the canonical copy is ignored. + + def test_missing_empty_and_conflicting_reports_fail(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with self.assertRaises(ValueError): + normalizer.normalize(root) + first = root / "one/coverage.cobertura.xml" + first.parent.mkdir() + first.write_text('') + with self.assertRaises(ValueError): + normalizer.normalize(root) + first.write_text('') + second = root / "two/coverage.cobertura.xml" + second.parent.mkdir() + second.write_text('') + with self.assertRaises(ValueError): + normalizer.normalize(root) + + def test_delta_uses_percentage_points_and_different_denominators(self): + current = {"lines": (90, 120), "branches": (1, 4)} + base = {"lines": (80, 100), "branches": (1, 8)} + report = coverage.summary(current, base) + self.assertIn("75.00% (90/120) | 80.00% (80/100) | -5.00", report) + self.assertIn("25.00% (1/4) | 12.50% (1/8) | +12.50", report) + + def test_missing_baseline_and_zero_branches(self): + report = coverage.summary({"lines": (1, 2), "branches": (0, 0)}) + self.assertIn("50.00% (1/2) | Unavailable | N/A", report) + self.assertIn("Branches | N/A | Unavailable | N/A", report) + self.assertIn("No baseline available", report) + + def test_cobertura_counts_and_empty_report_rejection(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "coverage.xml" + path.write_text('') + self.assertEqual(coverage.read_coverage(path)["lines"], (3, 4)) + path.write_text('') + with self.assertRaises(ValueError): + coverage.read_coverage(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test.ps1 b/.github/scripts/test.ps1 index 66a36a4b..aa4bc4a9 100644 --- a/.github/scripts/test.ps1 +++ b/.github/scripts/test.ps1 @@ -1,12 +1,14 @@ param( [ValidateSet('Unit','SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase','Hana')] - [string]$Database = 'Unit' + [string]$Database = 'Unit', + [switch]$Coverage ) $ErrorActionPreference = 'Stop' $databases = @('SQLite','SQLServer','PostgreSQL','Oracle','MySQL','MariaDB','Firebird','Db2','Informix','Sybase','Hana') $filter = if ($Database -eq 'Unit') { ($databases | ForEach-Object { "TestCategory!=$_" }) -join '&' } else { "TestCategory=$Database" } $xmlDirectory = Join-Path (Get-Location) "TestResults/$Database" -dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --results-directory TestResults -- NUnit.NumberOfTestWorkers=0 "NUnit.TestOutputXml=$xmlDirectory" +$coverageArguments = if ($Coverage) { @('--collect', 'XPlat Code Coverage', '--settings', "$PSScriptRoot/../coverage.runsettings") } else { @() } +dotnet test Migrator.slnx --no-build --filter $filter --logger "trx;LogFileName=$Database.trx" --results-directory TestResults @coverageArguments -- 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 diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index 75969306..0aafafde 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -49,7 +49,9 @@ jobs: fi - name: Test shell: pwsh - run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} + run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} -Coverage + - name: Verify coverage was collected + run: python3 .github/scripts/normalize-code-coverage.py TestResults - name: Collect database logs if: always() run: | @@ -72,10 +74,13 @@ jobs: docker rm -fv migrator-db fi coverage: - name: Verify complete test coverage + name: Code coverage and PR comparison needs: test runs-on: ubuntu-22.04 - timeout-minutes: 5 + timeout-minutes: 10 + permissions: + contents: read + actions: read steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 @@ -83,3 +88,52 @@ jobs: pattern: test-results-* path: TestResults - run: python3 .github/scripts/verify-test-coverage.py TestResults + - run: python3 .github/scripts/test-code-coverage.py + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Merge coverage and generate HTML report + run: | + dotnet tool install dotnet-reportgenerator-globaltool --version 5.4.18 --tool-path "$RUNNER_TEMP/reportgenerator" + "$RUNNER_TEMP/reportgenerator/reportgenerator" '-reports:TestResults/*/coverage/coverage.cobertura.xml' '-targetdir:artifacts/coverage' '-reporttypes:Html;Cobertura' + - name: Find coverage for the exact PR base commit + if: github.event_name == 'pull_request' + id: baseline + uses: actions/github-script@v7 + with: + script: | + const base = context.payload.pull_request.base; + // Only trust reports from successful push/manual runs in this repository. + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + ...context.repo, workflow_id: 'dotnetpull.yml', head_sha: base.sha, + status: 'success', per_page: 100 + }); + for (const run of runs) { + if (!['push', 'workflow_dispatch'].includes(run.event) || run.head_branch !== base.ref) continue; + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, run_id: run.id, per_page: 100 + }); + if (artifacts.some(a => a.name === 'code-coverage' && !a.expired)) { + core.setOutput('run-id', String(run.id)); + break; + } + } + - name: Download baseline coverage + if: steps.baseline.outputs.run-id != '' + uses: actions/download-artifact@v4 + with: + name: code-coverage + path: artifacts/baseline + github-token: ${{ github.token }} + run-id: ${{ steps.baseline.outputs.run-id }} + - name: Write coverage and delta to the PR check summary + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.sha }} + run: python3 .github/scripts/summarize-code-coverage.py + - uses: actions/upload-artifact@v4 + with: + name: code-coverage + path: artifacts/coverage/ + if-no-files-found: error + retention-days: 90 diff --git a/docs/data-type-boundary-tests.md b/docs/data-type-boundary-tests.md new file mode 100644 index 00000000..680a0276 --- /dev/null +++ b/docs/data-type-boundary-tests.md @@ -0,0 +1,92 @@ +# Data-type and boundary tests + +`DataBoundaryTests` runs the same parameterized scenarios in the SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase and HANA CI jobs. Each case uses the existing isolated database/schema or transaction setup. Server failures are failures, not skips or automatic evidence that a feature is unsupported. + +## What the suite verifies + +| Area | Assertions against stored data / the database | +| --- | --- | +| Every `MigratorDbType` enum value | Explicit supported/unsupported contract; supported types create a column, expose metadata and accept NULL (ASE BIT instead tests a non-null false value); unsupported types fail before creating a table | +| Signed integers and Byte | Minimum, maximum, negative values where applicable, zero, insert and update | +| SByte and unsigned integers | Supported ranges, including values above signed 16-/32-/64-bit maxima; explicit rejection for unsupported mappings; SQLite rejects UInt64 above Int64.MaxValue | +| Decimal and Currency | Positive/negative fractions down to 0.0001 and exact readback; decimal(12,4) limits, catalog precision/scale and overflow | +| Single and Double | Positive/negative fractional values and zero, with a specified tolerance | +| Variable strings | Requested capacities 1, 32, 255, 256, 2000 and 4000 for String; 1, 255, 256 and 2000 for AnsiString; exact content, NULL and update to a shorter value | +| Fixed strings | Both StringFixedLength and AnsiStringFixedLength at 1, 32 and 255 characters, exact full-length content and catalog length | +| Large text | `int.MaxValue` selects large-object storage; insert and read back more than 70,000 characters, including a distinctive suffix | +| String edge cases | Exact limit and limit+1, NULL, empty string, whitespace, trailing spaces, quotes, SQL punctuation, backslash/newline and accented Latin-1 text | +| Binary | Default, bounded, 8001 and maximum size mappings; zeros, high bytes, insert/update, NULL and a 70,000-byte payload | +| Boolean | False, true and update back to false | +| Dates and timestamps | Leap day and year boundary; timestamps include 23:59:59 | +| Time | Midnight, ordinary time and 23:59:59 | +| Schema changes | Widen a populated string, retain NULL and existing content, write at the new limit, rename and verify content again | +| Defaults and nullability | Omitted value vs explicit NULL vs zero, change a default without rewriting existing data, reject NULL in a required column | + +The separate `DataTypeBoundaryTests` and `DialectCapacityRegressionTests` run without servers. They cover every enum value for all eleven dialects, decimal rendering, large-text fallback and storage transitions such as SQL Server binary 8000/8001, HANA text 5000/5001, Db2 32672/32673 and Informix 32739/32740. These rendering checks complement live tests; they do not prove that a server accepted the SQL. + +## Explicit type support + +The test contract is maintained independently of the production mapping in `DataTypeContract`. A new enum value fails until the contract is updated deliberately. + +| Types | Supported mappings in this matrix | +| --- | --- | +| AnsiString, Binary, Byte, Boolean, Currency, Date, DateTime, Decimal, Double, Int16, Int32, Int64, Single, String, Time, AnsiStringFixedLength, StringFixedLength | All eleven | +| Guid, DateTimeOffset | All except HANA | +| DateTime2 | All except Firebird | +| SByte | SQLite | +| UInt16, UInt32, UInt64 | SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB | +| VarNumeric | SQLite, SQL Server, Db2 | +| Interval | SQLite, SQL Server, PostgreSQL, Oracle, MySQL, MariaDB | +| Object, Xml, Json | Rejected by the eleven selected dialects; historical SqlServer2005 XML support is outside this matrix | + +This table describes schema mapping support, not identical native storage or full value fidelity. In particular, the new per-type schema test does not by itself establish Guid, DateTimeOffset, Interval or VarNumeric value round trips. Existing interval tests remain in place. Exhaustive offset/precision, GUID-format and variable-numeric boundaries need additional provider-specific qualification. + +## Engine semantics and remaining limits + +- SQLite ignores string length and decimal precision constraints. The tests assert preservation of over-length values instead of inventing server enforcement. Its INTEGER storage is signed 64-bit; binding an out-of-range UInt64 now throws rather than allowing driver conversion to corrupt the value. +- Oracle treats empty character strings as NULL. ASE represents an empty varchar as a single space and disallows nullable BIT columns. The tests assert those explicit behaviors. +- Informix reserves the lowest signed integer value for NULL, truncates over-length VARCHAR assignments, and trims trailing spaces on readback. ASE also trims trailing spaces. Tests verify these native contracts explicitly. +- Firebird decimal precision describes a minimum capacity: DECIMAL(12,4) uses a scaled 64-bit integer. The suite checks its actual storage boundary rather than expecting overflow at twelve digits. +- MySQL/MariaDB tests set `STRICT_ALL_TABLES`; ASE tests enable `STRING_RTRUNCATION` and raise `TEXTSIZE` for large-object readback. Length enforcement depends on these session settings. +- Large-column declarations are tested with bounded allocations, not multi-gigabyte payloads. Engine row-size limits and every native length transition are not exhaustively live-tested. +- Accented Latin-1 text is covered across the legacy CI encodings. Supplementary Unicode characters, combining-sequence normalization, collations, embedded NUL in text, DST/timezone offsets, NaN/infinity and concurrent transactions remain separate qualification work. +- Ingres and historical provider aliases are outside the eleven-engine CI matrix; see [live database tests](live-database-tests.md). + +## Reproduction and validation + +After building, the existing commands include these tests automatically: + +```powershell +./.github/scripts/test.ps1 -Database Unit -Coverage +./.github/scripts/test.ps1 -Database SQLite -Coverage +``` + +For one new live suite on a configured server: + +```powershell +dotnet test src/Migrator.Tests/Migrator.Tests.csproj --no-build --filter "FullyQualifiedName~DataBoundaryTests&TestCategory=MySQL" +``` + +The implementation was exercised locally against SQLite and the unit suite. The ten server engines require the GitHub Actions run; the presence of a test here is not a claim that those runs have already passed. Coverage comparisons must use the same selected suites: a Unit+SQLite number is not whole-matrix coverage. + +Local validation on 2026-09-23: 823 Unit+SQLite cases passed together, followed by the additional PostgreSQL driver-binding regression (824 passing cases in total). The new live fixture contributes 75 cases per engine; the separate dialect/driver suite contributes 371 cases. Comparing Unit+SQLite with the earlier local Coverlet report, line coverage rose from 4,356/10,049 (43.35%) to 4,529/10,078 (44.94%); branch coverage rose from 1,864/4,934 (37.78%) to 1,946/4,958 (39.25%). The latter coverage run precedes the additional driver-binding test. The denominator changes include the provider fixes. + +## Corrections exposed by these tests + +- MySQL/MariaDB: ANSI length 256 is retained; maximum ANSI text maps to LONGTEXT; Currency uses DECIMAL(19,4); explicit decimal precision and scale are honored. [MySQL numeric types](https://dev.mysql.com/doc/refman/8.0/en/precision-math-numbers.html). +- Oracle: new Currency columns use NUMBER(19,4) instead of rounding to one fractional digit. +- SQL Server: binary lengths above 8000 use VARBINARY(max). +- Firebird: bounded binary columns use VARCHAR(n) CHARACTER SET OCTETS; metadata recognizes OCTETS as binary. ANSI variable strings remain variable-length, maximum ANSI text uses a text BLOB, and fixed Unicode strings retain the requested length. [Firebird character and binary types](https://firebirdsql.org/file/documentation/chunk/en/refdocs/fblangref50/fblangref50-datatypes-chartypes.html). +- Shared parameter binding accepts Single and SByte. PostgreSQL binds UInt64 as Decimal, matching its NUMERIC(20,0) mapping; SQLite checks its signed integer limit. + +Mapping changes affect newly generated DDL; they do not alter existing tables automatically. Applications relying on previous implicit truncation or rounding should review their migrations. + +## CI regression fixes + +The first full matrix exposed additional regressions: PostgreSQL fixed-character metadata and non-UTC timestamp binding, Oracle character metadata and Single storage/binding, SQL Server numeric/fixed-character metadata, Db2 Byte binding, Informix large-text transfers, and untyped NULL parameters for binary columns. The fixes include NULL updates through both update overloads and native Oracle BINARY_FLOAT storage and parameters. Local Unit+SQLite validation after these changes passes 862 tests. + +Informix TEXT scalar reads use raw bytes and the database's GL_CTYPE codeset to bypass a native Unicode conversion that corrupts the final character. The large-text test includes an accented suffix; unit regressions cover Latin-1, UTF-8, Windows-1252, and invalid UTF-8 rejection. + +Shared test teardown now disposes provider-owned connections even when cleanup fails. Oracle's unique per-test users disable connection pooling to avoid accumulating dedicated server processes that cannot be reused by subsequent tests. + +VSTest can publish byte-identical coverage attachments at multiple paths. CI now validates and normalizes these into one report per job, while still rejecting missing, empty, or conflicting reports. Five Python regression tests cover report handling and coverage comparison. diff --git a/docs/live-database-tests.md b/docs/live-database-tests.md index a1e7f9db..f37b75c8 100644 --- a/docs/live-database-tests.md +++ b/docs/live-database-tests.md @@ -20,7 +20,9 @@ The pull-request workflow runs independent jobs on GitHub-hosted Ubuntu 22.04 wi The IBM Linux packages and ASE client are conditional test-project dependencies selected by `-p:LiveDatabase=Db2`, `Informix`, or `Sybase`. They do not become library dependencies. The SAP client is also a test-only dependency; the core loads its factory dynamically. Db2 and Informix containers need privileged mode. Image tags are fixed versions; container inspection artifacts record the actual downloaded image IDs. -## Coverage and isolation +## Coverage and isolation + +The shared [data-type and boundary suite](data-type-boundary-tests.md) extends all eleven engines with explicit contracts for every `MigratorDbType`, stored numeric/string/binary boundaries, NULL/default behavior and populated-column changes. It documents engine differences and the remaining qualification gaps separately from code coverage. `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. @@ -31,6 +33,16 @@ The Hana suite creates a disposable schema per test and covers imperative/fluent Existing SQL Server, PostgreSQL, Oracle and SQLite suites continue to run in full. The Unit job uses the complement of all database categories. An audit compares NUnit's discovery count against the union of all job results and rejects missing or duplicate test assignments. Each job rejects zero executed tests; new suites also reject skips. Previously ignored default-removal and SQL Server uniqueness cases have behavioral replacements. TRX and NUnit XML expose any remaining ignored case and its reason; a skipped case is never evidence of support. Readiness and startup are bounded; database jobs time out after 35 minutes. Startup logs, container logs/inspection, TRX and NUnit XML are uploaded on success or failure. Registry downloads may retry; test failures never do. A new commit cancels an obsolete run. + +## Code coverage in pull requests + +The `.NET Pull Request` workflow collects [Coverlet](https://github.com/coverlet-coverage/coverlet/blob/v6.0.4/Documentation/VSTestIntegration.md) Cobertura reports in every test group. ReportGenerator merges covered lines and branches across all groups, rather than averaging their percentages. The measured assemblies are `DotNetProjects.Migrator*` (core, tool and dependency injection); test assemblies, dependencies and generated `obj` files are excluded. + +In a pull request, open **Checks → Code coverage and PR comparison → Summary** to see the combined line and branch percentages and their changes in percentage points. The current report measures GitHub's PR merge result. The baseline comes only from a successful push/manual run of this workflow on the exact target commit and target branch. Missing or expired baselines are explicitly marked unavailable, never treated as zero coverage. After initially merging this configuration, let the master push workflow complete to establish the first baseline. For another target branch, run the workflow manually on that branch's target commit. Artifacts are retained for 90 days. + +Download **code-coverage** from the workflow run and open `index.html` for the detailed HTML report. `Cobertura.xml` and `summary.md` are included. The comparison is informational and does not enforce a coverage threshold. It uses read-only GitHub permissions, works for fork PRs, and requires no external service or additional secret; it does not post a PR conversation comment. + +To collect coverage locally after building, run `./.github/scripts/test.ps1 -Database Unit -Coverage` (or select a database group). Raw reports are written to `TestResults//coverage.cobertura.xml`. Collector configuration lives in `.github/coverage.runsettings`. ## Local reproduction diff --git a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs index 6d41ace4..227df82b 100644 --- a/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs +++ b/src/Migrator.Tests/Database/DerivedDatabaseIntegrationTestServices/OracleDatabaseIntegrationTestService.cs @@ -133,6 +133,9 @@ await Parallel.ForEachAsync( connectionStringBuilder.Add(UserStringKey, ReplaceString); connectionStringBuilder.Add(PasswordStringKey, ReplaceString); + // Every test gets a unique user; its pool would retain dedicated server + // processes with no opportunity for reuse by another test. + connectionStringBuilder.Pooling = false; tempDatabaseConnectionConfig.ConnectionString = connectionStringBuilder.ConnectionString; tempDatabaseConnectionConfig.ConnectionString = tempDatabaseConnectionConfig.ConnectionString.Replace(ReplaceString, $"\"{tempUserName}\""); diff --git a/src/Migrator.Tests/Dialects/DataTypeBoundaryTests.cs b/src/Migrator.Tests/Dialects/DataTypeBoundaryTests.cs new file mode 100644 index 00000000..ec524665 --- /dev/null +++ b/src/Migrator.Tests/Dialects/DataTypeBoundaryTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Data; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; +using DotNetProjects.Migrator.Providers.Impl.Oracle; +using DotNetProjects.Migrator.Providers.Impl.Informix; +using Oracle.ManagedDataAccess.Client; +using Migrator.Tests.Providers.Live; +using Npgsql; +using NUnit.Framework; + +namespace Migrator.Tests.Dialects; + +[TestFixture(ProviderTypes.SQLite)] +[TestFixture(ProviderTypes.SqlServer)] +[TestFixture(ProviderTypes.PostgreSQL)] +[TestFixture(ProviderTypes.Oracle)] +[TestFixture(ProviderTypes.Mysql)] +[TestFixture(ProviderTypes.MariaDB)] +[TestFixture(ProviderTypes.Firebird)] +[TestFixture(ProviderTypes.IBM_DB2)] +[TestFixture(ProviderTypes.IBM_Informix)] +[TestFixture(ProviderTypes.Sybase)] +[TestFixture(ProviderTypes.Hana)] +public class DataTypeBoundaryTests(ProviderTypes provider) +{ + [Test] + public void EveryTypeHasAnExplicitMappingOrRejection([Values] MigratorDbType type) + { + var dialect = ProviderFactory.DialectForProvider(provider); + var column = new Column("payload", type); + if (type is MigratorDbType.String or MigratorDbType.AnsiString or MigratorDbType.StringFixedLength or MigratorDbType.AnsiStringFixedLength) + column.Size = 32; + if (!DataTypeContract.Supports(provider, type)) + { + Assert.Throws(() => dialect.GetAndMapColumnProperties(column)); + return; + } + var sql = dialect.GetAndMapColumnProperties(column).ColumnSql; + Assert.That(sql, Does.Contain("payload").IgnoreCase.And.Not.Contain("$l").And.Not.Contain("{precision}")); + } + + [TestCase(DbType.String)] + [TestCase(DbType.AnsiString)] + public void UnlimitedTextNeverFallsBackToDefaultLength(DbType type) + { + var sql = ProviderFactory.DialectForProvider(provider).GetTypeName(type, int.MaxValue).ToUpperInvariant(); + Assert.That(sql.Contains("TEXT") || sql.Contains("CLOB") || sql.Contains("MAX") || sql.Contains("BLOB"), Is.True, sql); + Assert.That(sql, Does.Not.Contain("255").And.Not.Contain("2147483647")); + } + + [Test] + public void DecimalPrecisionAndScaleAreRendered() + { + if (provider == ProviderTypes.SQLite) return; // SQLite uses numeric affinity, not a precision constraint. + var sql = ProviderFactory.DialectForProvider(provider) + .GetAndMapColumnProperties(new Column("payload", DbType.Decimal) { Precision = 12, Scale = 4 }).ColumnSql; + Assert.That(sql.Replace(" ", ""), Does.Contain("(12,4)")); + } +} + +public class DialectCapacityRegressionTests +{ + [TestCase("en_US.819", 28591)] + [TestCase("en_us.8859-1", 28591)] + [TestCase("en_US.57372", 65001)] + [TestCase("en_US.utf8@modifier", 65001)] + [TestCase("en_US.1252", 1252)] + public void InformixTextDecodingUsesDatabaseCodeset(string locale, int codePage) + { + var encoding = InformixTransformationProvider.TextEncodingForLocale(locale); + Assert.That(encoding.CodePage, Is.EqualTo(codePage)); + Assert.That(encoding.GetString(encoding.GetBytes("café'tail")), Is.EqualTo("café'tail")); + } + + [Test] + public void InformixTextDecodingRejectsInvalidUtf8() + => Assert.Throws(() => + InformixTransformationProvider.TextEncodingForLocale("en_US.57372").GetString(new byte[] { 0xff })); + + private sealed class OracleParameterProbe() + : OracleTransformationProvider(new OracleDialect(), (IDbConnection)null, null, "test", null) + { + public void Bind(IDbDataParameter parameter, object value) => ConfigureParameterWithValue(parameter, 0, value); + } + + [Test] + public void OracleSingleParameterUsesNativeBinaryFloat() + { + using var provider = new OracleParameterProbe(); + using var parameter = new OracleParameter(); + provider.Bind(parameter, -12345.125f); + Assert.That(parameter.OracleDbType, Is.EqualTo(OracleDbType.BinaryFloat)); + Assert.That(parameter.Value, Is.TypeOf().And.EqualTo(-12345.125f)); + } + + private sealed class PostgreSqlParameterProbe() + : PostgreSQLTransformationProvider(new PostgreSQLDialect(), (IDbConnection)null, "public", "test", "Npgsql") + { + public void Bind(IDbDataParameter parameter, object value) => ConfigureParameterWithValue(parameter, 0, value); + } + + [Test] + public void PostgreSqlDriverAcceptsUInt64WithoutLosingPrecision() + { + using var provider = new PostgreSqlParameterProbe(); + foreach (var value in new[] { 0UL, (ulong)long.MaxValue + 1, ulong.MaxValue }) + { + var parameter = new NpgsqlParameter(); + provider.Bind(parameter, value); + Assert.That(parameter.NpgsqlDbType, Is.EqualTo(NpgsqlTypes.NpgsqlDbType.Numeric)); + Assert.That(parameter.Value, Is.TypeOf().And.EqualTo((decimal)value)); + } + } + + [TestCase(DateTimeKind.Unspecified, NpgsqlTypes.NpgsqlDbType.Timestamp)] + [TestCase(DateTimeKind.Local, NpgsqlTypes.NpgsqlDbType.Timestamp)] + [TestCase(DateTimeKind.Utc, NpgsqlTypes.NpgsqlDbType.TimestampTz)] + public void PostgreSqlTimestampBindingPreservesDateTimeKind(DateTimeKind kind, NpgsqlTypes.NpgsqlDbType expected) + { + using var provider = new PostgreSqlParameterProbe(); + var value = new DateTime(2024, 2, 29, 23, 59, 59, kind); + var parameter = new NpgsqlParameter(); + provider.Bind(parameter, value); + Assert.That(parameter.NpgsqlDbType, Is.EqualTo(expected)); + Assert.That(parameter.Value, Is.EqualTo(value)); + Assert.That(((DateTime)parameter.Value).Kind, Is.EqualTo(kind)); + } + + [Test] + public void OracleSingleUsesIeeeBinaryStorage() + => Assert.That(ProviderFactory.DialectForProvider(ProviderTypes.Oracle).GetTypeName(DbType.Single), Is.EqualTo("BINARY_FLOAT")); + + [TestCase(ProviderTypes.Mysql, DbType.AnsiString, 256, "VARCHAR(256)")] + [TestCase(ProviderTypes.MariaDB, DbType.AnsiString, 256, "VARCHAR(256)")] + [TestCase(ProviderTypes.Firebird, DbType.Binary, 32, "VARCHAR(32) CHARACTER SET OCTETS")] + [TestCase(ProviderTypes.Firebird, DbType.StringFixedLength, 32, "CHAR(32) CHARACTER SET UTF8")] + [TestCase(ProviderTypes.Firebird, DbType.AnsiString, 32, "VARCHAR(32)")] + [TestCase(ProviderTypes.SqlServer, DbType.Binary, 8000, "VARBINARY(8000)")] + [TestCase(ProviderTypes.SqlServer, DbType.Binary, 8001, "VARBINARY(max)")] + [TestCase(ProviderTypes.SqlServer, DbType.String, 4000, "NVARCHAR(4000)")] + [TestCase(ProviderTypes.SqlServer, DbType.String, 4001, "NVARCHAR(max)")] + [TestCase(ProviderTypes.Hana, DbType.String, 5000, "NVARCHAR(5000)")] + [TestCase(ProviderTypes.Hana, DbType.String, 5001, "NCLOB")] + [TestCase(ProviderTypes.IBM_Informix, DbType.String, 32739, "LVARCHAR(32739)")] + [TestCase(ProviderTypes.IBM_Informix, DbType.String, 32740, "TEXT")] + [TestCase(ProviderTypes.IBM_DB2, DbType.String, 32672, "VARCHAR(32672)")] + [TestCase(ProviderTypes.IBM_DB2, DbType.String, 32673, "CLOB")] + public void CapacityTransitionsHaveExpectedStorage(ProviderTypes provider, DbType type, int size, string expected) + => Assert.That(ProviderFactory.DialectForProvider(provider).GetTypeName(type, size), Is.EqualTo(expected).IgnoreCase); + + [TestCase(ProviderTypes.Mysql, "DECIMAL(19,4)")] + [TestCase(ProviderTypes.MariaDB, "DECIMAL(19,4)")] + [TestCase(ProviderTypes.Oracle, "NUMBER(19,4)")] + public void CurrencyHasFourFractionalDigits(ProviderTypes provider, string expected) + => Assert.That(ProviderFactory.DialectForProvider(provider).GetTypeName(DbType.Currency), Is.EqualTo(expected)); +} diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index 912afe26..d39d419d 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -6,6 +6,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Migrator.Tests/Providers/Base/TransformationProviderBase.cs b/src/Migrator.Tests/Providers/Base/TransformationProviderBase.cs index f12b7f27..7cf7cc53 100644 --- a/src/Migrator.Tests/Providers/Base/TransformationProviderBase.cs +++ b/src/Migrator.Tests/Providers/Base/TransformationProviderBase.cs @@ -30,11 +30,21 @@ public abstract class TransformationProviderBase [TearDown] public virtual void TearDown() { - DropTestTables(); - - Provider?.Rollback(); - - _dbConnection?.Dispose(); + try + { + DropTestTables(); + Provider?.Rollback(); + } + finally + { + try { Provider?.Dispose(); } + finally + { + _dbConnection?.Dispose(); + _dbConnection = null; + Provider = null; + } + } } protected void DropTestTables() diff --git a/src/Migrator.Tests/Providers/Live/DataBoundaryTests.cs b/src/Migrator.Tests/Providers/Live/DataBoundaryTests.cs new file mode 100644 index 00000000..c127427b --- /dev/null +++ b/src/Migrator.Tests/Providers/Live/DataBoundaryTests.cs @@ -0,0 +1,518 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using Migrator.Tests.Providers.Base; +using NUnit.Framework; +using Sap.Data.Hana; + +namespace Migrator.Tests.Providers.Live; + +// Every case is assigned to exactly one existing CI database job. No catch-and-skip. +[TestFixture("SQLite", ProviderTypes.SQLite, Category = "SQLite")] +[TestFixture("SQLServer", ProviderTypes.SqlServer, Category = "SQLServer")] +[TestFixture("PostgreSQL", ProviderTypes.PostgreSQL, Category = "PostgreSQL")] +[TestFixture("Oracle", ProviderTypes.Oracle, Category = "Oracle")] +[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")] +[TestFixture("Hana", ProviderTypes.Hana, Category = "Hana")] +[NonParallelizable] +public class DataBoundaryTests(string database, ProviderTypes providerType) : TransformationProviderBase +{ + private LiveDatabaseTests live; + private HanaConnection hana; + private string schema; + + [SetUp] + public async Task SetUp() + { + switch (database) + { + case "SQLite": await BeginSQLiteTransactionAsync(); break; + case "SQLServer": await BeginSQLServerTransactionAsync(); break; + case "PostgreSQL": await BeginPostgreSQLTransactionAsync(); break; + case "Oracle": await BeginOracleTransactionAsync(); break; + case "Hana": + hana = new HanaConnection(Environment.GetEnvironmentVariable("MIGRATOR_HANA") + ?? "Server=localhost:39041;UserID=SYSTEM;Password=MgT9ci7Q4xZ2"); + hana.Open(); + var name = "BOUNDARY_" + Guid.NewGuid().ToString("N").ToUpperInvariant(); + using (var command = hana.CreateCommand()) + { + command.CommandText = "CREATE SCHEMA " + name; + command.ExecuteNonQuery(); + schema = name; + command.CommandText = "SET SCHEMA " + schema; + command.ExecuteNonQuery(); + } + Provider = ProviderFactory.Create(providerType, hana, schema, "boundary-tests"); + break; + default: + live = new LiveDatabaseTests(database, providerType); + live.SetUp(); + Provider = live.Provider; + break; + } + // A too-long value must not silently truncate on engines with configurable modes. + if (database is "MySQL" or "MariaDB") Provider.ExecuteNonQuery("SET SESSION sql_mode='STRICT_ALL_TABLES'"); + if (database == "Sybase") + { + Provider.ExecuteNonQuery("SET STRING_RTRUNCATION ON"); + Provider.ExecuteNonQuery("SET TEXTSIZE 2147483647"); + } + } + + [TearDown] + public override void TearDown() + { + try + { + if (live != null) live.TearDown(); + else if (hana != null) + { + Provider?.Dispose(); + if (schema != null && hana.State == ConnectionState.Open) + { + using var command = hana.CreateCommand(); + command.CommandText = "DROP SCHEMA " + schema + " CASCADE"; + command.ExecuteNonQuery(); + } + } + else base.TearDown(); + } + finally + { + if (live == null) Provider?.Dispose(); + hana?.Dispose(); + Provider = null; + live = null; + hana = null; + schema = null; + } + } + + private string Table => Provider.QuoteTableNameIfRequired("Test"); + private string ValueColumn => Provider.QuoteColumnNameIfRequired("payload"); + private string IdColumn => Provider.QuoteColumnNameIfRequired("id"); + private object Read(int id) => Provider.ExecuteScalar($"SELECT {ValueColumn} FROM {Table} WHERE {IdColumn}={id}"); + private void Create(Column column) => Provider.AddTable("Test", new Column("id", DbType.Int32), column); + private void Insert(int id, object value) => Provider.Insert("Test", ["id", "payload"], [id, value]); + private void AssertDatabaseError(TestDelegate action) + { + if (live != null) live.AssertDatabaseError(action); + else Assert.Catch(action); + } + + [Test] + public void EveryDeclaredTypeHasAnExplicitSchemaContract([Values] MigratorDbType type) + { + var column = new Column("payload", type); + if (type is MigratorDbType.AnsiString or MigratorDbType.String or MigratorDbType.AnsiStringFixedLength or MigratorDbType.StringFixedLength) + column.Size = 32; + if (!DataTypeContract.Supports(providerType, type)) + { + Assert.Throws(() => Create(column), $"{database}: {type} must be rejected before executing DDL"); + Assert.That(Provider.TableExists("Test"), Is.False); + return; + } + // ASE BIT columns cannot be nullable. Test the engine's explicit contract. + if (database == "Sybase" && type == MigratorDbType.Boolean) column.IsNullable = false; + Create(column); + Assert.That(Provider.ColumnExists("Test", "payload"), Is.True); + Assert.That(Provider.ReadLegacyColumns("Test").Select(c => c.Name.ToLowerInvariant()), Is.EquivalentTo(new[] { "id", "payload" })); + if (database == "Sybase" && type == MigratorDbType.Boolean) + { + Insert(1, false); + Assert.That(Convert.ToBoolean(Read(1)), Is.False); + } + else + { + Insert(1, DBNull.Value); + Assert.That(Read(1), Is.EqualTo(DBNull.Value)); + } + } + + [TestCase(DbType.Int16)] + [TestCase(DbType.Int32)] + [TestCase(DbType.Int64)] + [TestCase(DbType.Byte)] + public void IntegerExtremesSurviveInsertAndUpdate(DbType type) + { + Create(new Column("payload", type)); + object[] values = type switch + { + DbType.Int16 => [short.MinValue, (short)-1, (short)0, short.MaxValue], + DbType.Int32 => [int.MinValue, -1, 0, int.MaxValue], + DbType.Int64 => [long.MinValue, -1L, 0L, long.MaxValue], + _ => [(byte)0, (byte)1, (byte)254, byte.MaxValue] + }; + // Informix reserves the most-negative signed value for its NULL encoding. + object reservedMinimum = null; + if (database == "Informix" && type != DbType.Byte) + { + reservedMinimum = values[0]; + values[0] = type switch + { + DbType.Int16 => (object)(short)(short.MinValue + 1), + DbType.Int32 => int.MinValue + 1, + _ => long.MinValue + 1 + }; + } + for (var i = 0; i < values.Length; i++) + { + Insert(i, values[i]); + Assert.That(Convert.ToDecimal(Read(i)), Is.EqualTo(Convert.ToDecimal(values[i]))); + } + Provider.Update("Test", ["payload"], [values[^1]], $"{IdColumn}=0"); + Assert.That(Convert.ToDecimal(Read(0)), Is.EqualTo(Convert.ToDecimal(values[^1]))); + if (reservedMinimum != null) AssertDatabaseError(() => Insert(99, reservedMinimum)); + } + + [TestCase(DbType.Decimal)] + [TestCase(DbType.Currency)] + public void DecimalFractionsAreNotRoundedToIntegers(DbType type) + { + var column = new Column("payload", type); + if (type == DbType.Decimal) { column.Precision = 12; column.Scale = 4; } + Create(column); + decimal[] values = [-1234567.8901m, -0.0001m, 0m, 0.0001m, 1234567.8901m]; + for (var i = 0; i < values.Length; i++) + { + Insert(i, values[i]); + Assert.That(Convert.ToDecimal(Read(i), CultureInfo.InvariantCulture), Is.EqualTo(values[i])); + } + } + + [TestCase(DbType.Single)] + [TestCase(DbType.Double)] + public void FloatingPointSignsAndFractionsRoundTrip(DbType type) + { + Create(new Column("payload", type)); + double[] values = [-12345.125, -0.125, 0, 0.125, 12345.125]; + for (var i = 0; i < values.Length; i++) + { + object value = type == DbType.Single ? (object)(float)values[i] : values[i]; + Insert(i, value); + // MySQL's text protocol formats FLOAT with limited significant digits. + // Read its stored value as DOUBLE to distinguish formatting from data loss. + var stored = database is "MySQL" or "MariaDB" && type == DbType.Single + ? Provider.ExecuteScalar($"SELECT {ValueColumn} + 0e0 FROM {Table} WHERE {IdColumn}={i}") + : Read(i); + Assert.That(Convert.ToDouble(stored), Is.EqualTo(values[i]).Within(0.000001)); + } + } + + [TestCase(DbType.String, 1)] + [TestCase(DbType.String, 32)] + [TestCase(DbType.String, 255)] + [TestCase(DbType.String, 256)] + [TestCase(DbType.String, 2000)] + [TestCase(DbType.String, 4000)] + [TestCase(DbType.AnsiString, 1)] + [TestCase(DbType.AnsiString, 255)] + [TestCase(DbType.AnsiString, 256)] + [TestCase(DbType.AnsiString, 2000)] + public void RequestedStringCapacityPreservesEntireValue(DbType type, int length) + { + Create(new Column("payload", type, length)); + var value = new string('x', length - 1) + "!"; + Insert(1, value); + Assert.That(Read(1), Is.EqualTo(value)); + Insert(2, DBNull.Value); + Assert.That(Read(2), Is.EqualTo(DBNull.Value)); + Provider.Update("Test", ["payload"], ["z"], $"{IdColumn}=1"); + Assert.That(Read(1), Is.EqualTo("z")); + } + + [TestCase(DbType.String)] + [TestCase(DbType.AnsiString)] + public void MaxLengthSentinelStoresLargeValue(DbType type) + { + Create(new Column("payload", type, int.MaxValue)); + // Exceeds varchar(8000), nvarchar(4000), and typical driver text-size defaults. + var value = new string('x', 70000) + "'tailé"; + Insert(1, value); + Assert.That(Read(1), Is.EqualTo(value)); + } + + [Test] + public void BoundedStringOverflowIsExplicit() + { + Create(new Column("payload", DbType.String, 8)); + Insert(1, "12345678"); + Assert.That(Read(1), Is.EqualTo("12345678")); + if (database == "SQLite") + { + // SQLite type affinity does not enforce declared string lengths. + Insert(2, "123456789"); + Assert.That(Read(2), Is.EqualTo("123456789")); + } + else if (database == "Informix") + { + // Informix accepts this assignment and truncates to the declared width. + Insert(2, "123456789"); + Assert.That(Read(2), Is.EqualTo("12345678")); + } + else AssertDatabaseError(() => Insert(2, "123456789")); + } + + [Test] + public void NullEmptyWhitespaceAndSqlPunctuationRemainDistinct() + { + Create(new Column("payload", DbType.String, 80)); + object[] values = [DBNull.Value, "", " ", " x ", "O'Brien; -- %_\\\r\n"]; + for (var i = 0; i < values.Length; i++) + { + Insert(i, values[i]); + var expected = i == 1 && database == "Oracle" ? DBNull.Value + : i == 1 && database == "Sybase" ? " " : values[i]; + if (expected is string text && database is "Sybase" or "Informix") + expected = database == "Sybase" && text.TrimEnd(' ').Length == 0 ? " " : text.TrimEnd(' '); + Assert.That(Read(i), Is.EqualTo(expected), $"Value {i}"); + } + Assert.That(Convert.ToInt32(Provider.ExecuteScalar($"SELECT COUNT(*) FROM {Table}")), Is.EqualTo(values.Length)); + } + + [TestCase(0)] + [TestCase(32)] + [TestCase(8001)] + [TestCase(int.MaxValue)] + public void BinaryZerosAndHighBytesRoundTrip(int size) + { + Create(new Column("payload", DbType.Binary, size)); + var bytes = size == int.MaxValue + ? Enumerable.Range(0, 70000).Select(i => (byte)(i % 256)).ToArray() + : new byte[] { 0, 1, 39, 127, 128, 254, 255, 0 }; + Insert(1, bytes); + Assert.That(Read(1), Is.EqualTo(bytes)); + Provider.Update("Test", ["payload"], [new byte[] { 255, 0 }], $"{IdColumn}=1"); + Assert.That(Read(1), Is.EqualTo(new byte[] { 255, 0 })); + Insert(2, DBNull.Value); + Assert.That(Read(2), Is.EqualTo(DBNull.Value)); + Provider.Update("Test", ["payload"], [DBNull.Value], $"{IdColumn}=1"); + Assert.That(Read(1), Is.EqualTo(DBNull.Value)); + Insert(3, bytes); + Provider.Update("Test", ["payload"], [null], ["id"], [3]); + Assert.That(Read(3), Is.EqualTo(DBNull.Value)); + } + + [Test] + public void WideningAndRenamingPreserveExistingDataAndNulls() + { + Create(new Column("payload", DbType.String, 8)); + Insert(1, "O'Brien"); + Insert(2, DBNull.Value); + Provider.ChangeColumn("Test", new Column("payload", DbType.String, 256)); + Assert.That(Read(1), Is.EqualTo("O'Brien")); + Assert.That(Read(2), Is.EqualTo(DBNull.Value)); + var expanded = new string('w', 256); + Provider.Update("Test", ["payload"], [expanded], $"{IdColumn}=1"); + Provider.RenameColumn("Test", "payload", "renamed_payload"); + Assert.That(Provider.ColumnExists("Test", "payload"), Is.False); + var renamed = Provider.QuoteColumnNameIfRequired("renamed_payload"); + Assert.That(Provider.ExecuteScalar($"SELECT {renamed} FROM {Table} WHERE {IdColumn}=1"), Is.EqualTo(expanded)); + Assert.That(Provider.ExecuteScalar($"SELECT {renamed} FROM {Table} WHERE {IdColumn}=2"), Is.EqualTo(DBNull.Value)); + } + + [Test] + public void NotNullIsEnforcedByTheDatabase() + { + Create(new Column("payload", DbType.Int32) { IsNullable = false }); + Insert(1, 0); + Assert.That(Convert.ToInt32(Read(1)), Is.Zero); + AssertDatabaseError(() => Insert(2, DBNull.Value)); + } + + [TestCase(DbType.AnsiStringFixedLength, 1)] + [TestCase(DbType.AnsiStringFixedLength, 32)] + [TestCase(DbType.AnsiStringFixedLength, 255)] + [TestCase(DbType.StringFixedLength, 1)] + [TestCase(DbType.StringFixedLength, 32)] + [TestCase(DbType.StringFixedLength, 255)] + public void FixedLengthStringsHonorRequestedCapacity(DbType type, int size) + { + Create(new Column("payload", type, size)); + var value = new string('f', size - 1) + "!"; + Insert(1, value); + Assert.That(Read(1), Is.EqualTo(value)); + if (database != "SQLite") + Assert.That(Provider.ReadLegacyColumns("Test").Single(c => c.Name.Equals("payload", StringComparison.OrdinalIgnoreCase)).Size, Is.EqualTo(size)); + } + + [Test] + public void AccentedTextIsNotLost() + { + Create(new Column("payload", DbType.String, 80)); + // Latin-1 repertoire also works with the legacy ASE/Informix CI encodings. + var value = "Grüße, déjà vu, mañana"; + Insert(1, value); + Assert.That(Read(1), Is.EqualTo(value)); + } + + [Test] + public void BooleanFalseAndTrueRemainDifferent() + { + Create(new Column("payload", DbType.Boolean) { IsNullable = false }); + Insert(1, false); + Insert(2, true); + Assert.That(Convert.ToBoolean(Read(1)), Is.False); + Assert.That(Convert.ToBoolean(Read(2)), Is.True); + Provider.Update("Test", ["payload"], [false], $"{IdColumn}=2"); + Assert.That(Convert.ToBoolean(Read(2)), Is.False); + } + + [TestCase(DbType.Date)] + [TestCase(DbType.DateTime)] + [TestCase(DbType.DateTime2)] + public void LeapDayAndYearBoundaryRoundTrip(DbType type) + { + var column = new Column("payload", type); + if (!DataTypeContract.Supports(providerType, (MigratorDbType)type)) + { + Assert.Throws(() => Create(column)); + return; + } + Create(column); + var values = new[] { new DateTime(2000, 2, 29), new DateTime(2024, 12, 31), new DateTime(2025, 1, 1) }; + for (var i = 0; i < values.Length; i++) + { + if (type != DbType.Date) values[i] = values[i].AddHours(23).AddMinutes(59).AddSeconds(59); + Insert(i, values[i]); + Assert.That(Convert.ToDateTime(Read(i), CultureInfo.InvariantCulture), Is.EqualTo(values[i])); + } + } + + [Test] + public void TimeOfDayMidnightAndLastSecondRoundTrip() + { + Create(new Column("payload", DbType.Time)); + var values = new[] { TimeOnly.MinValue, new TimeOnly(12, 34, 56), new TimeOnly(23, 59, 59) }; + for (var i = 0; i < values.Length; i++) + { + Insert(i, values[i]); + var actual = Read(i) switch + { + DateTime date => TimeOnly.FromDateTime(date), + TimeSpan span => TimeOnly.FromTimeSpan(span), + TimeOnly time => time, + var text => TimeOnly.Parse(Convert.ToString(text, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture) + }; + Assert.That(actual, Is.EqualTo(values[i])); + } + } + + [Test] + public void DefaultDoesNotReplaceExplicitNullOrExistingValues() + { + Create(new Column("payload", DbType.Int32) { DefaultValue = -17 }); + Provider.Insert("Test", ["id"], [1]); + Insert(2, DBNull.Value); + Insert(3, 0); + Assert.That(Convert.ToInt32(Read(1)), Is.EqualTo(-17)); + Assert.That(Read(2), Is.EqualTo(DBNull.Value)); + Assert.That(Convert.ToInt32(Read(3)), Is.Zero); + Provider.ChangeColumn("Test", new Column("payload", DbType.Int32) { DefaultValue = 29 }); + Provider.Insert("Test", ["id"], [4]); + Assert.That(Convert.ToInt32(Read(4)), Is.EqualTo(29)); + Assert.That(Convert.ToInt32(Read(1)), Is.EqualTo(-17)); + Assert.That(Read(2), Is.EqualTo(DBNull.Value)); + Assert.That(Convert.ToInt32(Read(3)), Is.Zero); + } + + [TestCase(MigratorDbType.SByte)] + [TestCase(MigratorDbType.UInt16)] + [TestCase(MigratorDbType.UInt32)] + [TestCase(MigratorDbType.UInt64)] + public void UnsignedAndSignedByteRangesAreExplicit(MigratorDbType type) + { + var column = new Column("payload", type); + if (!DataTypeContract.Supports(providerType, type)) + { + Assert.Throws(() => Create(column)); + Assert.That(Provider.TableExists("Test"), Is.False); + return; + } + Create(column); + object[] values = type switch + { + MigratorDbType.SByte => [sbyte.MinValue, (sbyte)0, sbyte.MaxValue], + MigratorDbType.UInt16 => [(ushort)0, (ushort)32768, ushort.MaxValue], + MigratorDbType.UInt32 => [0U, 2147483648U, uint.MaxValue], + _ when database == "SQLite" => [0UL, (ulong)long.MaxValue], + _ => [0UL, (ulong)long.MaxValue + 1, ulong.MaxValue] + }; + for (var i = 0; i < values.Length; i++) + { + Insert(i, values[i]); + Assert.That(Convert.ToDecimal(Read(i)), Is.EqualTo(Convert.ToDecimal(values[i]))); + } + if (database == "SQLite" && type == MigratorDbType.UInt64) + Assert.Throws(() => Insert(99, ulong.MaxValue), "SQLite INTEGER is signed 64-bit; never wrap or round an out-of-range UInt64."); + } + + [Test] + public void DecimalPrecisionBoundaryIsExplicit() + { + Create(new Column("payload", DbType.Decimal) { Precision = 12, Scale = 4 }); + Insert(1, 99999999.9999m); + Insert(2, -99999999.9999m); + Assert.That(Convert.ToDecimal(Read(1)), Is.EqualTo(99999999.9999m)); + Assert.That(Convert.ToDecimal(Read(2)), Is.EqualTo(-99999999.9999m)); + if (database == "SQLite") + { + Insert(3, 100000000m); + Assert.That(Convert.ToDecimal(Read(3)), Is.EqualTo(100000000m)); + } + else + { + var metadata = Provider.ReadLegacyColumns("Test").Single(c => c.Name.Equals("payload", StringComparison.OrdinalIgnoreCase)); + Assert.That(metadata.Precision, Is.EqualTo(12)); + Assert.That(metadata.Scale, Is.EqualTo(4)); + if (database == "Firebird") + { + // DECIMAL precision is a minimum; dialect 3 uses a scaled BIGINT. + Insert(3, 100000000m); + Assert.That(Convert.ToDecimal(Read(3)), Is.EqualTo(100000000m)); + Insert(4, 922337203685477.5807m); + Assert.That(Convert.ToDecimal(Read(4)), Is.EqualTo(922337203685477.5807m)); + // The driver encodes the scaled Int64 and rejects overflow before sending SQL. + Assert.Throws(() => Insert(5, 922337203685477.5808m)); + } + else AssertDatabaseError(() => Insert(3, 100000000m)); + } + } +} + +internal static class DataTypeContract +{ + // Explicit contract, independent of the dialect under test. New enum values fail + // until deliberately supported or recorded as unsupported here. + internal static bool Supports(ProviderTypes provider, MigratorDbType type) => type switch + { + MigratorDbType.AnsiString or MigratorDbType.Binary or MigratorDbType.Byte or + MigratorDbType.Boolean or MigratorDbType.Currency or MigratorDbType.Date or + MigratorDbType.DateTime or MigratorDbType.Decimal or MigratorDbType.Double or + MigratorDbType.Int16 or MigratorDbType.Int32 or MigratorDbType.Int64 or + MigratorDbType.Single or MigratorDbType.String or MigratorDbType.Time or + MigratorDbType.AnsiStringFixedLength or MigratorDbType.StringFixedLength => true, + MigratorDbType.Guid or MigratorDbType.DateTimeOffset => provider != ProviderTypes.Hana, + MigratorDbType.DateTime2 => provider != ProviderTypes.Firebird, + MigratorDbType.SByte => provider == ProviderTypes.SQLite, + MigratorDbType.UInt16 or MigratorDbType.UInt32 or MigratorDbType.UInt64 => + provider is ProviderTypes.SQLite or ProviderTypes.SqlServer or ProviderTypes.PostgreSQL or ProviderTypes.Oracle or ProviderTypes.Mysql or ProviderTypes.MariaDB, + MigratorDbType.VarNumeric => provider is ProviderTypes.SQLite or ProviderTypes.SqlServer or ProviderTypes.IBM_DB2, + MigratorDbType.Interval => provider is ProviderTypes.SQLite or ProviderTypes.SqlServer or ProviderTypes.PostgreSQL or ProviderTypes.Oracle or ProviderTypes.Mysql or ProviderTypes.MariaDB, + MigratorDbType.Json or MigratorDbType.Xml or MigratorDbType.Object => false, + _ => throw new AssertionException($"Add an explicit data-type contract for {type}") + }; +} diff --git a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs index 07d0f9c5..c657fd41 100644 --- a/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs +++ b/src/Migrator/Providers/Impl/DB2/DB2TransformationProvider.cs @@ -18,8 +18,19 @@ public DB2TransformationProvider(Dialect dialect, string connectionString, strin _connection.Open(); } - public DB2TransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) { } + public DB2TransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) + : base(dialect, connection, null, scope) { } + + protected override void ConfigureParameterWithValue(IDbDataParameter parameter, int index, object value) + { + if (value is byte number) + { + // Db2 stores Byte as SMALLINT; DbType.Byte selects binary in its driver. + parameter.DbType = DbType.Int16; + parameter.Value = (short)number; + } + else base.ConfigureParameterWithValue(parameter, index, value); + } 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("\"", "\"\"") + "\""; diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs index 75061921..9431e05c 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdDialect.cs @@ -12,10 +12,13 @@ public class FirebirdDialect : Dialect public FirebirdDialect() { - RegisterColumnType(DbType.AnsiStringFixedLength, 8000, "CHAR($l)"); - RegisterColumnType(DbType.AnsiString, 8000, "CHAR($l)"); - RegisterColumnType(DbType.Binary, "BLOB"); - RegisterColumnType(DbType.Binary, 8000, "CHAR"); + RegisterColumnType(DbType.AnsiStringFixedLength, "CHAR(255)"); + RegisterColumnType(DbType.AnsiStringFixedLength, 8000, "CHAR($l)"); + RegisterColumnType(DbType.AnsiString, "VARCHAR(255)"); + RegisterColumnType(DbType.AnsiString, 8000, "VARCHAR($l)"); + RegisterColumnType(DbType.AnsiString, int.MaxValue, "BLOB SUB_TYPE TEXT"); + RegisterColumnType(DbType.Binary, "BLOB"); + RegisterColumnType(DbType.Binary, 8000, "VARCHAR($l) CHARACTER SET OCTETS"); RegisterColumnType(DbType.Boolean, "BOOLEAN"); RegisterColumnType(DbType.Byte, "SMALLINT"); RegisterColumnType(DbType.Currency, "DECIMAL(18,4)"); @@ -30,7 +33,8 @@ public FirebirdDialect() RegisterColumnType(DbType.Int32, "INT"); RegisterColumnType(DbType.Int64, "BIGINT"); RegisterColumnType(DbType.Single, "REAL"); //synonym for FLOAT(24) - RegisterColumnType(DbType.StringFixedLength, "NCHAR(255)"); + RegisterColumnType(DbType.StringFixedLength, "CHAR(255) CHARACTER SET UTF8"); + RegisterColumnType(DbType.StringFixedLength, 4000, "CHAR($l) CHARACTER SET UTF8"); 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"); diff --git a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs index 7d75446d..ac1ddbb5 100644 --- a/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Firebird/FirebirdTransformationProvider.cs @@ -80,7 +80,8 @@ public override Column[] GetColumns(string table) 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 + f.RDB$FIELD_SUB_TYPE, f.RDB$FIELD_PRECISION, f.RDB$FIELD_SCALE, + f.RDB$CHARACTER_SET_ID 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 """); @@ -89,7 +90,9 @@ SELECT TRIM(r.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, r.RDB$NULL_FLAG, 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, + 27 => DbType.Double, 12 => DbType.Date, 13 => DbType.Time, 35 => DbType.DateTime, + 14 or 37 when !reader.IsDBNull(9) && Convert.ToInt32(reader.GetValue(9)) == 1 => DbType.Binary, + 14 => DbType.StringFixedLength, 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) diff --git a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs index c09e7844..4e8f471d 100644 --- a/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Informix/InformixTransformationProvider.cs @@ -2,7 +2,8 @@ using System; using System.Collections.Generic; using System.Data; -using System.Linq; +using System.Linq; +using System.Text; using DotNetProjects.Migrator.Framework; using Index = DotNetProjects.Migrator.Framework.Index; @@ -19,8 +20,51 @@ public InformixTransformationProvider(Dialect dialect, string connectionString, _connection.Open(); } - public InformixTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) - : base(dialect, connection, null, scope) { } + public InformixTransformationProvider(Dialect dialect, IDbConnection connection, string scope, string providerName) + : base(dialect, connection, null, scope) { } + + public override object ExecuteScalar(string sql) + { + Logger.Trace(sql); + using var command = BuildCommand(sql); + byte[] bytes; + using (var reader = command.ExecuteReader(CommandBehavior.SingleRow | CommandBehavior.SequentialAccess)) + { + if (!reader.Read()) return null; + var typeName = reader.GetDataTypeName(0).Replace(" ", "").ToUpperInvariant(); + if (typeName is not ("TEXT" or "LONGVARCHAR")) return reader.GetValue(0); + // The native Unicode TEXT conversion can replace the final character + // with NUL. SQL_C_BINARY preserves the bytes stored by the database. + var length = reader.GetBytes(0, 0, null, 0, 0); + if (length < 0) return DBNull.Value; + bytes = new byte[checked((int)length)]; + var offset = 0; + while (offset < bytes.Length) + { + var count = (int)reader.GetBytes(0, offset, bytes, offset, Math.Min(8192, bytes.Length - offset)); + if (count == 0) throw new System.IO.EndOfStreamException("Incomplete Informix TEXT value."); + offset += count; + } + } + // GL_CTYPE records the actual database codeset; do not assume UTF-8 or + // the client's locale when decoding raw database bytes. + var locale = Convert.ToString(base.ExecuteScalar("SELECT site FROM systables WHERE tabid=91")); + return TextEncodingForLocale(locale).GetString(bytes); + } + + internal static Encoding TextEncodingForLocale(string locale) + { + var codeSet = locale?.Trim().Split('@')[0].Split('.').Last().ToLowerInvariant(); + if (string.IsNullOrEmpty(codeSet)) throw new NotSupportedException("Missing Informix database locale."); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return codeSet switch + { + "819" or "8859-1" => Encoding.GetEncoding(28591, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback), + "57372" or "utf8" or "utf-8" => new UTF8Encoding(false, true), + _ when int.TryParse(codeSet, out var codePage) => Encoding.GetEncoding(codePage, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback), + _ => Encoding.GetEncoding(codeSet, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback) + }; + } private static string Name(string name) => (name.StartsWith('"') ? name[1..^1].Replace("\"\"", "\"") : name.ToLowerInvariant()).Replace("'", "''"); public override string GenerateParameterName(int index) => "?"; diff --git a/src/Migrator/Providers/Impl/Mysql/MysqlDialect.cs b/src/Migrator/Providers/Impl/Mysql/MysqlDialect.cs index d60d348f..0fde0868 100644 --- a/src/Migrator/Providers/Impl/Mysql/MysqlDialect.cs +++ b/src/Migrator/Providers/Impl/Mysql/MysqlDialect.cs @@ -24,22 +24,24 @@ public MysqlDialect() RegisterColumnType(DbType.AnsiStringFixedLength, 16777215, "MEDIUMTEXT"); RegisterColumnType(DbType.AnsiString, "VARCHAR(255)"); RegisterColumnType(DbType.AnsiString, 255, "VARCHAR($l)"); - RegisterColumnType(DbType.AnsiString, 256, "VARCHAR(255)"); + RegisterColumnType(DbType.AnsiString, 256, "VARCHAR($l)"); RegisterColumnType(DbType.AnsiString, 65535, "TEXT"); - RegisterColumnType(DbType.AnsiString, 16777215, "MEDIUMTEXT"); + RegisterColumnType(DbType.AnsiString, 16777215, "MEDIUMTEXT"); + RegisterColumnType(DbType.AnsiString, int.MaxValue, "LONGTEXT"); RegisterColumnType(DbType.Binary, "LONGBLOB"); RegisterColumnType(DbType.Binary, 127, "TINYBLOB"); RegisterColumnType(DbType.Binary, 65535, "BLOB"); RegisterColumnType(DbType.Binary, 16777215, "MEDIUMBLOB"); RegisterColumnType(DbType.Boolean, "TINYINT(1)"); RegisterColumnType(DbType.Byte, "TINYINT UNSIGNED"); - RegisterColumnType(DbType.Currency, "MONEY"); + RegisterColumnType(DbType.Currency, "DECIMAL(19,4)"); RegisterColumnType(DbType.Date, "DATE"); RegisterColumnType(DbType.DateTime, "DATETIME"); RegisterColumnType(DbType.DateTime2, "DATETIME"); RegisterColumnType(DbType.DateTimeOffset, "DATETIME"); RegisterColumnType(DbType.Decimal, "NUMERIC(19,5)"); - RegisterColumnType(DbType.Decimal, 19, "NUMERIC(19, $l)"); + RegisterColumnType(DbType.Decimal, 19, "NUMERIC(19, $l)"); + RegisterColumnTypeWithParameters(DbType.Decimal, "DECIMAL({precision},{scale})"); RegisterColumnType(DbType.Double, "DOUBLE"); RegisterColumnType(DbType.Guid, "VARCHAR(40)"); RegisterColumnType(DbType.Int16, "SMALLINT"); diff --git a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs index d355a80a..6f541968 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleDialect.cs @@ -23,7 +23,7 @@ public OracleDialect() // 23ai now has a native boolean data type but for backwards compatibility we keep using NUMBER(1,0) RegisterColumnType(DbType.Boolean, "NUMBER(1,0)"); RegisterColumnType(DbType.Byte, "NUMBER(3,0)"); - RegisterColumnType(DbType.Currency, "NUMBER(19,1)"); + RegisterColumnType(DbType.Currency, "NUMBER(19,4)"); RegisterColumnType(DbType.Date, "DATE"); RegisterColumnType(DbType.DateTime, "TIMESTAMP(4)"); RegisterColumnType(DbType.DateTime2, "TIMESTAMP(7)"); @@ -41,7 +41,7 @@ public OracleDialect() RegisterColumnType(DbType.UInt16, "NUMBER(5,0)"); RegisterColumnType(DbType.UInt32, "NUMBER(10,0)"); RegisterColumnType(DbType.UInt64, "NUMBER(20,0)"); - RegisterColumnType(DbType.Single, "FLOAT(24)"); + RegisterColumnType(DbType.Single, "BINARY_FLOAT"); RegisterColumnType(DbType.Double, "BINARY_DOUBLE"); RegisterColumnType(DbType.StringFixedLength, "NCHAR(255)"); RegisterColumnType(DbType.StringFixedLength, 2000, "NCHAR($l)"); diff --git a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs index e2277924..9e163db1 100644 --- a/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/Oracle/OracleTransformationProvider.cs @@ -463,10 +463,22 @@ public override Column[] GetColumns(string table) { column.MigratorDbType = MigratorDbType.Binary; } - else if (dataTypeString == "NVARCHAR2") - { - column.MigratorDbType = MigratorDbType.String; - } + else if (dataTypeString == "NVARCHAR2") + { + column.MigratorDbType = MigratorDbType.String; + } + else if (dataTypeString == "VARCHAR2" || dataTypeString == "CLOB") + { + column.MigratorDbType = MigratorDbType.AnsiString; + } + else if (dataTypeString == "CHAR") + { + column.MigratorDbType = MigratorDbType.AnsiStringFixedLength; + } + else if (dataTypeString == "NCHAR") + { + column.MigratorDbType = MigratorDbType.StringFixedLength; + } else if (dataTypeString == "BINARY_FLOAT") { column.MigratorDbType = MigratorDbType.Single; @@ -492,7 +504,12 @@ public override Column[] GetColumns(string table) throw new NotImplementedException($"The data type '{dataTypeString}' is not implemented yet. Please file an issue."); } - OracleColumnDefault.Apply(column, dataDefaultString); + if (dataTypeString is "CLOB" or "NCLOB" or "BLOB") column.Size = int.MaxValue; + else if (dataTypeString is "VARCHAR2" or "NVARCHAR2" or "CHAR" or "NCHAR") + column.Size = charColDeclLength ?? dataLength ?? 0; + else if (dataTypeString == "RAW") column.Size = dataLength ?? 0; + + OracleColumnDefault.Apply(column, dataDefaultString); columns.Add(column); } @@ -513,7 +530,23 @@ public override string GenerateParameterName(int index) protected override void ConfigureParameterWithValue(IDbDataParameter parameter, int index, object value) { - if (value is TimeOnly time) + if (value is float single) + { + base.ConfigureParameterWithValue(parameter, index, value); + // ODP.NET maps DbType.Single to decimal FLOAT, rounding to seven + // decimal digits. Select its native IEEE type without coupling the + // provider assembly to either managed or unmanaged ODP.NET. + var oracleType = parameter.GetType().GetProperty("OracleDbType"); + if (oracleType?.CanWrite == true && oracleType.PropertyType.IsEnum && + Enum.IsDefined(oracleType.PropertyType, "BinaryFloat")) + oracleType.SetValue(parameter, Enum.Parse(oracleType.PropertyType, "BinaryFloat")); + else + { + parameter.DbType = DbType.Double; + parameter.Value = (double)single; + } + } + else if (value is TimeOnly time) { parameter.DbType = DbType.Date; parameter.Value = OracleDialect.TimeValue(time); diff --git a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs index 01a91f3a..687e7aa9 100644 --- a/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs +++ b/src/Migrator/Providers/Impl/PostgreSQL/PostgreSQLTransformationProvider.cs @@ -588,7 +588,8 @@ public override Column[] GetColumns(string table) } else if (columnInfo.DataType == "character" || columnInfo.DataType.StartsWith("character(")) { - throw new NotSupportedException("Data type 'character' detected. 'character' is not supported. Use 'text' or 'character varying' instead."); + dbType = MigratorDbType.StringFixedLength; + size = columnInfo.CharacterMaximumLength; } else { @@ -739,9 +740,16 @@ public override void CopyDataFromTableToTable(string sourceTableName, List QuoteColumnNameIfRequired(col)).ToArray()); + var columnNames = string.Join(", ", columns.Select(col => QuoteColumnNameIfRequired(col)).ToArray()); var builder = new StringBuilder(); @@ -1181,7 +1193,7 @@ public virtual int Insert(string table, string[] columns, object[] values) builder.Append(", "); } - builder.Append(GenerateParameterName(i)); + builder.Append(values[i] == null || values[i] == DBNull.Value ? "NULL" : GenerateParameterName(i)); } var parameterNames = builder.ToString(); @@ -1194,14 +1206,19 @@ public virtual int Insert(string table, string[] columns, object[] values) command.Transaction = _transaction; - command.CommandText = string.Format("INSERT INTO {0} ({1}) VALUES ({2})", table, columnNames, parameterNames); + command.CommandText = string.Format("INSERT INTO {0} ({1}) VALUES ({2})", table, columnNames, parameterNames); command.CommandType = CommandType.Text; var paramCount = 0; - foreach (var value in values) - { - var parameter = command.CreateParameter(); + foreach (var value in values) + { + if (value == null || value == DBNull.Value) + { + paramCount++; + continue; + } + var parameter = command.CreateParameter(); ConfigureParameterWithValue(parameter, paramCount, value); @@ -1752,7 +1769,12 @@ protected virtual void ConfigureParameterWithValue(IDbDataParameter parameter, i parameter.DbType = DbType.Byte; parameter.Value = value; } - else if (value is short) + else if (value is sbyte signedByte) + { + parameter.DbType = DbType.Int16; + parameter.Value = (short)signedByte; + } + else if (value is short) { parameter.DbType = DbType.Int16; parameter.Value = value; @@ -1782,7 +1804,12 @@ protected virtual void ConfigureParameterWithValue(IDbDataParameter parameter, i parameter.DbType = DbType.UInt64; parameter.Value = value; } - else if (value is double) + else if (value is float) + { + parameter.DbType = DbType.Single; + parameter.Value = value; + } + else if (value is double) { parameter.DbType = DbType.Double; parameter.Value = value;