From 3c9ab4b668cb3f576fed642c33b819fec7cee5ce Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 17:13:47 -0500 Subject: [PATCH] =?UTF-8?q?feat(sql):=20Odbc=20provider=20=E2=80=94=20ANSI?= =?UTF-8?q?=20SQL:2008=20profile=20over=20System.Data.Odbc=20(lmxopcua#753?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqlProvider.Odbc is now constructed; every enum member is a working backend. - OdbcDialect: System.Data.Odbc 10.0.14 (no vulnerable packages), ANSI double- quote quoting, positional `?` markers, INFORMATION_SCHEMA catalog (system schemas of the common backends excluded by name — the live gate caught psqlODBC listing pg_catalog), FETCH FIRST n ROWS ONLY limits, a conservative 1000-parameter chunk. Positional binding is safe because every caller adds parameters in marker order (planner IN list, catalog schema-then-table). - Named queries on a positional provider are checked by `?` count against the declared params (literals/comments ignored), since there is no name to match. - Supported backends are those whose SQL accepts that profile (PostgreSQL via psqlODBC proven, Snowflake-like); SQL Server/MySQL/DB2 should use their native provider. The host needs the ODBC driver manager + vendor driver. - Reserved-provider controls in factory/probe/browser tests now use an undeclared ordinal (99); the AdminUI form offers Odbc and a test pins the dropdown theory to SqlDialects.Supported. - Tests: OdbcDialectTests (unit, any host). OdbcReadTests + Docker/odbc/ run-odbc-gate.sh run the built suite in a throwaway Linux SDK container with unixODBC + psqlODBC against a throwaway seeded PostgreSQL: KV IN-list positional binding, where-pair, topByTimestamp via FETCH FIRST, a two- parameter named query, and the browse walk. macOS has no vendor ODBC driver, so there the suite skips. (lmxopcua#753) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- Directory.Packages.props | 5 + docs/drivers/Sql.md | 24 ++- .../SqlNamedQuery.cs | 12 ++ .../SqlProvider.cs | 13 +- .../OdbcDialect.cs | 173 ++++++++++++++++++ .../SqlDialects.cs | 1 + ...B.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj | 3 + .../SqlDriverFactoryExtensions.cs | 17 +- .../Shared/Drivers/Forms/SqlDriverForm.razor | 9 +- .../SqlDriverBrowserTests.cs | 18 +- .../Docker/odbc/run-odbc-gate.sh | 43 +++++ .../OdbcReadTests.cs | 161 ++++++++++++++++ .../OdbcDialectTests.cs | 130 +++++++++++++ .../SqlDriverFactoryTests.cs | 14 +- .../SqlDriverProbeTests.cs | 3 +- .../Uns/SqlDriverFormContractTests.cs | 19 +- 16 files changed, 606 insertions(+), 39 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OdbcDialect.cs create mode 100755 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/odbc/run-odbc-gate.sh create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/OdbcReadTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OdbcDialectTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 16645d60..b0280ed0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -118,6 +118,11 @@ (MySqlConnectorFactory.Instance, lmxopcua#753). MIT-licensed and truly async, unlike Oracle's GPL MySql.Data, which is why the design names it. No native client install. --> + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs index e86d387a..a0bfc63c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs @@ -284,7 +284,8 @@ public static class SqlDriverFactoryExtensions /// The shape rules are — the same call the AdminUI driver /// form makes — so the form refuses what this refuses. The one rule added here needs the dialect: every /// declared parameter's marker () must appear in the statement. - /// A provider that binds positionally (?) is exempt, since there is no name to look for. + /// A provider that binds positionally (?) is checked by count instead, since there is no name to + /// look for. /// private static IReadOnlyDictionary BuildQueries( SqlDriverConfigDto dto, string driverInstanceId, ISqlDialect? dialect) @@ -306,7 +307,7 @@ public static class SqlDriverFactoryExtensions if (dialect is not null) { var marker = dialect.ParameterMarker(bare); - if (marker != "?" && !SqlReadOnlyStatement.UsesMarker(sql, marker)) + if (marker != SqlReadOnlyStatement.PositionalMarker && !SqlReadOnlyStatement.UsesMarker(sql, marker)) { throw new InvalidOperationException( $"Sql driver config for '{driverInstanceId}': named query '{name}' declares parameter " + @@ -318,6 +319,18 @@ public static class SqlDriverFactoryExtensions values.Add(ToParameterValue(value)); } + // A positional provider binds in authored order, so the only check available is the count — and it + // matters: one '?' too few binds every later value to the wrong marker. + if (dialect is not null + && dialect.ParameterMarker("p") == SqlReadOnlyStatement.PositionalMarker + && SqlReadOnlyStatement.CountPositionalMarkers(sql) != names.Count) + { + throw new InvalidOperationException( + $"Sql driver config for '{driverInstanceId}': named query '{name}' declares {names.Count} " + + $"parameter(s), but its statement has {SqlReadOnlyStatement.CountPositionalMarkers(sql)} " + + $"positional '?' marker(s); {dialect.Provider} binds them in authored order, so the two must match."); + } + queries[name] = new SqlNamedQuery( name, sql, names, values, query.MaxRows ?? SqlNamedQueryRules.DefaultMaxRows); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor index 8057ad6b..9151782f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor @@ -18,14 +18,17 @@
- @* Every provider SqlDialects constructs; Odbc stays a reserved SqlProvider member and is - deliberately not offered (authoring one fails the driver). *@ + @* Every provider SqlDialects constructs. *@ + -
SQL Server, PostgreSQL, Oracle and MySQL/MariaDB supported; Odbc reserved.
+
+ SQL Server, PostgreSQL, Oracle and MySQL/MariaDB use managed providers. Odbc speaks ANSI SQL + (INFORMATION_SCHEMA, FETCH FIRST, positional ?) and needs the ODBC driver manager + vendor driver on every host. +
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs index 48785663..f84e70db 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs @@ -58,10 +58,12 @@ public sealed class SqlDriverBrowserTests SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType(); SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType(); SqlDriverBrowser.DefaultDialectSelector(SqlProvider.MySql).ShouldBeOfType(); + SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Odbc).ShouldBeOfType(); - SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle, SqlProvider.MySql]; - foreach (var provider in Enum.GetValues().Where(p => !carried.Contains(p))) - SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull(); + // Every declared member is carried now; an undeclared ordinal is the control that must stay null. + foreach (var provider in Enum.GetValues()) + SqlDriverBrowser.DefaultDialectSelector(provider).ShouldNotBeNull(); + SqlDriverBrowser.DefaultDialectSelector((SqlProvider)99).ShouldBeNull(); } // ---- connection-string reference resolution ---- @@ -189,8 +191,8 @@ public sealed class SqlDriverBrowserTests // ---- provider availability ---- /// - /// The falsifiability control for the widened browse gate: is still - /// reserved and must be reported as unavailable, naming itself. + /// The falsifiability control for the widened browse gate: an undeclared provider ordinal must be + /// reported as unavailable, naming itself. /// [Fact] public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild() @@ -199,9 +201,9 @@ public sealed class SqlDriverBrowserTests var ex = await Should.ThrowAsync(() => browser.OpenAsync( - ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None)); + ConfigJson("99", connectionStringRef: "MesStaging"), CancellationToken.None)); - ex.Message.ShouldContain("Odbc"); + ex.Message.ShouldContain("99"); ex.Message.ShouldContain("not available in this build"); } @@ -217,7 +219,7 @@ public sealed class SqlDriverBrowserTests var ex = await Should.ThrowAsync(() => browser.OpenAsync( - ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None)); + ConfigJson("99", connectionStringRef: "MesStaging"), CancellationToken.None)); ex.Message.ShouldContain("not available in this build"); reads.ShouldBeEmpty(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/odbc/run-odbc-gate.sh b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/odbc/run-odbc-gate.sh new file mode 100755 index 00000000..8e279839 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/odbc/run-odbc-gate.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# The Sql driver's ODBC live gate (OdbcReadTests, lmxopcua#753), fully throwaway. +# +# A macOS dev box has no ODBC vendor driver, so the gate runs the ALREADY-BUILT test assembly inside a Linux +# .NET SDK container that apt-installs unixODBC + psqlODBC, against a disposable PostgreSQL seeded with the same +# seed-pg.sql the Postgres suite uses. Everything it starts is removed on exit (trap), success or failure. +# +# dotnet build tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests -c Release +# tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/odbc/run-odbc-gate.sh +# +# Needs network egress from the SDK container for apt. SDK_IMAGE can be overridden. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PROJECT="$(cd "$HERE/../.." && pwd)" +BIN="$PROJECT/bin/Release/net10.0" +SDK_IMAGE="${SDK_IMAGE:-mcr.microsoft.com/dotnet/sdk:10.0.103-noble}" +NET=otopcua-sql-odbc-net +PG=otopcua-sql-odbc-pg +PW="Sqlodbc_dev_pw1" + +[ -f "$BIN/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.dll" ] || { echo "build the suite in Release first: $BIN" >&2; exit 2; } + +cleanup() { docker rm -f "$PG" >/dev/null 2>&1 || true; docker network rm "$NET" >/dev/null 2>&1 || true; } +trap cleanup EXIT +cleanup + +docker network create "$NET" >/dev/null +docker run -d --name "$PG" --network "$NET" --label project=lmxopcua \ + -e POSTGRES_PASSWORD="$PW" -e POSTGRES_DB=sqlpollfixture postgres:17-alpine >/dev/null + +for _ in $(seq 1 60); do docker exec "$PG" pg_isready -U postgres -d sqlpollfixture >/dev/null 2>&1 && break; sleep 1; done +docker exec -i "$PG" psql -q -v ON_ERROR_STOP=1 -v app_password="$PW" -U postgres -d sqlpollfixture \ + < "$PROJECT/Docker/postgres/seed-pg.sql" + +docker run --rm --network "$NET" -v "$BIN":/suite -w /suite \ + -e SQL_ODBC_CONNECTION_STRING="Driver={PostgreSQL Unicode};Server=$PG;Port=5432;Database=sqlpollfixture;Uid=otopcua;Pwd=$PW" \ + "$SDK_IMAGE" bash -c ' + set -e + apt-get update -qq >/dev/null && apt-get install -y -qq unixodbc odbc-postgresql >/dev/null + odbcinst -q -d + dotnet ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.dll -class ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.OdbcReadTests + ' diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/OdbcReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/OdbcReadTests.cs new file mode 100644 index 00000000..aea984ce --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/OdbcReadTests.cs @@ -0,0 +1,161 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; + +/// +/// The Sql driver over ODBC (lmxopcua#753) — OdbcDialect's ANSI profile through +/// System.Data.Odbc, the platform driver manager and a vendor ODBC driver, against the PostgreSQL +/// fixture's seed (Docker/postgres). What only a live ODBC stack can prove: positional ? +/// binding in planner/catalog order, FETCH FIRST paging, the INFORMATION_SCHEMA catalog through +/// a driver, and the named-query transaction over ODBC's manual-commit mode. +/// Env-gated on — a full ODBC connection string, e.g. +/// Driver={PostgreSQL Unicode};Server=…;Port=…;Database=sqlpollfixture;Uid=otopcua;Pwd=…. A macOS dev +/// box normally has no ODBC driver installed, so these skip there; they run inside a Linux container with +/// unixodbc + odbc-postgresql (see Docker/odbc/run-odbc-gate.sh). +/// +public sealed class OdbcReadTests +{ + /// The full ODBC connection string. Absent ⇒ skip. + public const string ConnectionStringEnvVar = "SQL_ODBC_CONNECTION_STRING"; + + private static readonly string? ConnectionString = + Environment.GetEnvironmentVariable(ConnectionStringEnvVar) is { Length: > 0 } cs ? cs : null; + + [Fact] + public async Task InitializeAndRead_overOdbc_keyValueWideRowAndTopByTimestamp() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(null, + Tag("Kv/Speed", new JsonObject + { + ["model"] = "KeyValue", ["table"] = "sqlpoll.tag_values", ["keyColumn"] = "tag_name", + ["keyValue"] = "Line1.Speed", ["valueColumn"] = "num_value", ["timestampColumn"] = "sample_ts", + }), + Tag("Kv/Pressure", new JsonObject + { + ["model"] = "KeyValue", ["table"] = "sqlpoll.tag_values", ["keyColumn"] = "tag_name", + ["keyValue"] = "Line1.Pressure", ["valueColumn"] = "num_value", + }), + Tag("Wide/Newest", new JsonObject + { + ["model"] = "WideRow", ["table"] = "sqlpoll.latest_status", ["columnName"] = "oven_temp", + ["rowSelector"] = new JsonObject { ["topByTimestamp"] = "sample_ts" }, + }), + Tag("Wide/Station7", new JsonObject + { + ["model"] = "WideRow", ["table"] = "sqlpoll.latest_status", ["columnName"] = "pressure", + ["rowSelector"] = new JsonObject { ["whereColumn"] = "station_id", ["whereValue"] = 7 }, + })); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + var snapshots = await driver.ReadAsync( + ["Kv/Speed", "Kv/Pressure", "Wide/Newest", "Wide/Station7"], TestContext.Current.CancellationToken); + + snapshots.Select(s => s.StatusCode).ShouldAllBe(code => code == SqlStatusCodes.Good); + // Two keys in one IN (?, ?) list: each value landing on its own tag is the positional-binding proof. + Number(snapshots[0]).ShouldBe(42.0); + Number(snapshots[1]).ShouldBe(3.5); + snapshots[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc)); + Number(snapshots[2]).ShouldBe(210.25); // ORDER BY … DESC FETCH FIRST 1 ROWS ONLY + Number(snapshots[3]).ShouldBe(1.2); + } + + [Fact] + public async Task NamedQuery_overOdbc_bindsPositionalParametersInAuthoredOrder() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + """{"band":{"sql":"SELECT station_id AS station, oven_temp AS temp FROM sqlpoll.latest_status WHERE pressure > ? AND pressure < ?","params":{"lo":1.3,"hi":1.5}}}""", + Tag("Q/Eight", new JsonObject + { + ["model"] = "Query", ["query"] = "band", ["column"] = "temp", + ["rowKey"] = new JsonObject { ["column"] = "station", ["value"] = 8 }, + }), + Tag("Q/Seven", new JsonObject + { + ["model"] = "Query", ["query"] = "band", ["column"] = "temp", + ["rowKey"] = new JsonObject { ["column"] = "station", ["value"] = 7 }, + })); + + var snapshots = await driver.ReadAsync(["Q/Eight", "Q/Seven"], TestContext.Current.CancellationToken); + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); // 1.4 is inside (1.3, 1.5) + Number(snapshots[0]).ShouldBe(210.25); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // 1.2 is outside — the bounds were bound in order + } + + [Fact] + public async Task Browse_overOdbc_walksSchemaToColumns() + { + SkipUnlessLive(); + + var config = new JsonObject + { + ["provider"] = nameof(SqlProvider.Odbc), + ["connectionString"] = ConnectionString, + }.ToJsonString(); + + await using var session = await new SqlDriverBrowser().OpenAsync(config, TestContext.Current.CancellationToken); + var schema = (await session.RootAsync(TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem("the fixture login can SELECT in exactly one schema"); + var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken); + var columns = await session.ExpandAsync( + tables.First(t => t.DisplayName == "tag_values").NodeId, TestContext.Current.CancellationToken); + columns.Select(c => c.DisplayName).ShouldBe(["tag_name", "num_value", "sample_ts"]); + } + + private static double Number(DataValueSnapshot snapshot) => Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture); + + private static void SkipUnlessLive() + { + if (ConnectionString is null) + Assert.Skip($"Set {ConnectionStringEnvVar} to a full ODBC connection string (and install the driver manager + a " + + "vendor ODBC driver) to run the Sql driver's ODBC gate. See Docker/odbc/run-odbc-gate.sh."); + } + + /// Builds the driver through the production factory with provider: Odbc. + private static async Task StartAsync(string? queriesJson, params RawTagEntry[] tags) + { + var reference = $"OdbcIt{Guid.NewGuid():N}"; + Environment.SetEnvironmentVariable(SqlConnectionStringResolver.EnvironmentVariableFor(reference), ConnectionString); + + var rawTags = new JsonArray(); + foreach (var tag in tags) + rawTags.Add(new JsonObject { ["rawPath"] = tag.RawPath, ["tagConfig"] = tag.TagConfig, ["writeIdempotent"] = false }); + + var config = new JsonObject + { + ["provider"] = nameof(SqlProvider.Odbc), + ["connectionStringRef"] = reference, + ["rawTags"] = rawTags, + }; + if (queriesJson is not null) config["queries"] = JsonNode.Parse(queriesJson); + var json = config.ToJsonString(); + + var driver = SqlDriverFactoryExtensions.CreateInstance("sql-odbc-integration", json); + try + { + await driver.InitializeAsync(json, TestContext.Current.CancellationToken); + } + catch + { + await driver.DisposeAsync(); + throw; + } + + return driver; + } + + private static RawTagEntry Tag(string rawPath, JsonObject config) + { + config["driver"] = "Sql"; + return new RawTagEntry(rawPath, config.ToJsonString(), WriteIdempotent: false); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OdbcDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OdbcDialectTests.cs new file mode 100644 index 00000000..c3b86742 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OdbcDialectTests.cs @@ -0,0 +1,130 @@ +using System.Data.Odbc; +using System.Text.Json; +using System.Text.Json.Nodes; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Golden tests for (lmxopcua#753) — the ANSI SQL:2008 profile over +/// System.Data.Odbc. These run on any host because none of them opens a connection (opening one needs +/// the platform's ODBC driver manager plus a vendor driver); the live leg is OdbcReadTests. +/// The sharp edge is positional binding. ODBC ignores parameter names and binds ? markers in +/// the order parameters are added, so every SQL text this dialect produces must place its markers in the order +/// its callers bind — pinned below for the planner's key list and the catalog's schema-then-table pair. +/// +public class OdbcDialectTests +{ + [Fact] + public void Dialect_identifiesItselfAsOdbc_withPositionalMarkers() + { + var d = new OdbcDialect(); + d.Provider.ShouldBe(SqlProvider.Odbc); + d.Factory.ShouldBeSameAs(OdbcFactory.Instance); + d.ParameterMarker("k0").ShouldBe("?"); + d.ParameterMarker("schema").ShouldBe("?"); + d.ParameterBindName("k0").ShouldBe("k0"); + d.LivenessSql.ShouldBe("SELECT 1"); + d.MaxBindParameters.ShouldBe(1000); + } + + [Fact] + public void QuoteIdentifier_isAnsiDoubleQuoting() + { + var d = new OdbcDialect(); + d.QuoteIdentifier("Speed").ShouldBe("\"Speed\""); + d.QuoteIdentifier("x\" ; DROP TABLE users --").ShouldBe("\"x\"\" ; DROP TABLE users --\""); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("a\0b")] + [InlineData("a‮b")] + public void QuoteIdentifier_rejectsWhatCannotBeACatalogName(string ident) + => Should.Throw(() => new OdbcDialect().QuoteIdentifier(ident)); + + [Fact] + public void QuoteIdentifier_capsAt128Characters() + { + new OdbcDialect().QuoteIdentifier(new string('a', 128)).Length.ShouldBe(130); + Should.Throw(() => new OdbcDialect().QuoteIdentifier(new string('a', 129))); + } + + [Fact] + public void RowLimits_areTheStandardFetchFirst_inTheSuffixPosition() + { + var d = new OdbcDialect(); + d.SingleRowLimitPrefix.ShouldBeEmpty(); + d.SingleRowLimitSuffix.ShouldBe(" FETCH FIRST 1 ROWS ONLY"); + d.RowLimitPrefix(3).ShouldBeEmpty(); + d.RowLimitSuffix(3).ShouldBe(" FETCH FIRST 3 ROWS ONLY"); + } + + [Fact] + public void CatalogSql_isInformationSchema_withSchemaBoundBeforeTable() + { + var d = new OdbcDialect(); + d.ListSchemasSql.ShouldContain("INFORMATION_SCHEMA.TABLES"); + d.ListSchemasSql.ShouldContain("AS \"TABLE_SCHEMA\""); + // The backend is unknown, so the common backends' system schemas are excluded by name (the live gate + // caught psqlODBC listing pg_catalog). + foreach (var system in new[] { "'INFORMATION_SCHEMA'", "'PG_CATALOG'", "'SYS'", "PG\\_TOAST%" }) + d.ListSchemasSql.ShouldContain(system); + d.ListTablesSql.Count(ch => ch == '?').ShouldBe(1); + // Two positional markers: the first must be the schema, because SqlCatalogLoader and SqlBrowseSession + // bind schema first. + d.ListColumnsSql.Count(ch => ch == '?').ShouldBe(2); + d.ListColumnsSql.IndexOf("TABLE_SCHEMA = ?", StringComparison.Ordinal) + .ShouldBeLessThan(d.ListColumnsSql.IndexOf("TABLE_NAME = ?", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("integer", DriverDataType.Int32)] + [InlineData("bigint", DriverDataType.Int64)] + [InlineData("smallint", DriverDataType.Int16)] + [InlineData("double precision", DriverDataType.Float64)] + [InlineData("numeric(10,2)", DriverDataType.Float64)] + [InlineData("real", DriverDataType.Float32)] + [InlineData("boolean", DriverDataType.Boolean)] + [InlineData("character varying", DriverDataType.String)] + [InlineData("timestamp without time zone", DriverDataType.DateTime)] + [InlineData("date", DriverDataType.DateTime)] + [InlineData("geometry", DriverDataType.String)] + [InlineData("", DriverDataType.String)] + public void MapColumnType_mapsTheStandardAndCommonSpellings(string sql, DriverDataType expected) + => new OdbcDialect().MapColumnType(sql).ShouldBe(expected); + + [Fact] + public void Planner_keyValuePlan_placesOneMarkerPerKey_inBindOrder() + { + var plan = SqlGroupPlanner.Plan( + [ + new SqlTagDefinition("A", SqlTagModel.KeyValue, "s.t", KeyColumn: "k", KeyValue: "a", ValueColumn: "v"), + new SqlTagDefinition("B", SqlTagModel.KeyValue, "s.t", KeyColumn: "k", KeyValue: "b", ValueColumn: "v"), + ], new OdbcDialect()).ShouldHaveSingleItem(); + + plan.SqlText.ShouldBe("SELECT \"k\", \"v\" FROM \"s\".\"t\" WHERE \"k\" IN (?, ?) FETCH FIRST 5 ROWS ONLY"); + plan.Parameters.ShouldBe(["a", "b"]); // positional: value i binds marker i + } + + [Fact] + public void Factory_aPositionalNamedQuery_mustCarryOneMarkerPerParameter() + { + SqlDriverFactoryExtensions.BuildOptions( + Dto("""{"q":{"sql":"SELECT a FROM t WHERE x = ? AND y = '?' AND z = ?","params":{"x":1,"z":2}}}"""), + "d1", new OdbcDialect()) + .Queries["q"].ParameterNames.ShouldBe(["x", "z"]); // the '?' inside a literal is not a marker + + Should.Throw(() => SqlDriverFactoryExtensions.BuildOptions( + Dto("""{"q":{"sql":"SELECT a FROM t WHERE x = ?","params":{"x":1,"z":2}}}"""), "d1", new OdbcDialect())) + .Message.ShouldContain("positional"); + } + + private static SqlDriverConfigDto Dto(string queriesJson) => JsonSerializer.Deserialize( + new JsonObject { ["connectionStringRef"] = "x", ["queries"] = JsonNode.Parse(queriesJson) }.ToJsonString(), + SqlDriverFactoryExtensions.JsonOptionsForTest)!; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs index c639ce1f..52cfdd36 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs @@ -145,8 +145,9 @@ public sealed class SqlDriverFactoryTests // ---- provider gate ---- /// - /// The falsifiability control for the widened gate: is still a - /// reserved name and must fail loudly at construction, naming itself. Without this leg, a gate that + /// The falsifiability control for the widened gate: every declared member now constructs, so an + /// out-of-range ordinal (a config authored against a future enum) stands in for "a provider this build + /// does not carry" — it must fail loudly at construction, naming itself. Without this leg, a gate that /// had quietly become "construct anything" would pass every test below. /// [Fact] @@ -157,9 +158,9 @@ public sealed class SqlDriverFactoryTests SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( - "d1", $$"""{"provider":"Odbc","connectionStringRef":"{{name}}"}""", null)); + "d1", $$"""{"provider":99,"connectionStringRef":"{{name}}"}""", null)); - ex.Message.ShouldContain("Odbc"); + ex.Message.ShouldContain("99"); ex.Message.ShouldContain("not available in this build"); } @@ -175,9 +176,10 @@ public sealed class SqlDriverFactoryTests SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( - "d1", $$"""{"provider":"Odbc","connectionStringRef":"{{name}}"}""", null)); + "d1", $$"""{"provider":99,"connectionStringRef":"{{name}}"}""", null)); ex.Message.ShouldContain(nameof(SqlProvider.SqlServer)); + ex.Message.ShouldContain(nameof(SqlProvider.Odbc)); ex.Message.ShouldContain(nameof(SqlProvider.Postgres)); ex.Message.ShouldContain(nameof(SqlProvider.Oracle)); ex.Message.ShouldContain(nameof(SqlProvider.MySql)); @@ -194,6 +196,7 @@ public sealed class SqlDriverFactoryTests [InlineData("Postgres", typeof(PostgresDialect))] [InlineData("Oracle", typeof(OracleDialect))] [InlineData("MySql", typeof(MySqlDialect))] + [InlineData("Odbc", typeof(OdbcDialect))] public void ParseBinding_aSupportedProvider_bindsThatProvidersDialect(string provider, Type expected) { var name = NewRef(); @@ -215,6 +218,7 @@ public sealed class SqlDriverFactoryTests [InlineData("Postgres")] [InlineData("Oracle")] [InlineData("MySql")] + [InlineData("Odbc")] public async Task CreateInstance_aNewlySupportedProvider_constructsPastTheProviderGate(string provider) { var name = NewRef(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs index 3a276062..f9693604 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs @@ -172,6 +172,7 @@ public sealed class SqlDriverProbeTests [InlineData(SqlProvider.Postgres)] [InlineData(SqlProvider.Oracle)] [InlineData(SqlProvider.MySql)] + [InlineData(SqlProvider.Odbc)] public void ProductionDialectGate_constructsEveryShippedProvider(SqlProvider provider) => SqlDriverProbe.DefaultDialectForTest(provider).Provider.ShouldBe(provider); @@ -180,7 +181,7 @@ public sealed class SqlDriverProbeTests /// rejected. Without this, widening the gate to "return a dialect for anything" would pass. /// [Theory] - [InlineData(SqlProvider.Odbc)] + [InlineData((SqlProvider)99)] // every declared member constructs; an undeclared ordinal must not public void ProductionDialectGate_stillRejectsReservedProviders(SqlProvider provider) => Should.Throw(() => SqlDriverProbe.DefaultDialectForTest(provider)) .Message.ShouldContain(provider.ToString()); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs index 697659bb..69770dfd 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlDriverFormContractTests.cs @@ -102,6 +102,7 @@ public sealed class SqlDriverFormContractTests [InlineData(SqlProvider.Postgres, "Postgres")] [InlineData(SqlProvider.Oracle, "Oracle")] [InlineData(SqlProvider.MySql, "MySql")] + [InlineData(SqlProvider.Odbc, "Odbc")] public void Every_dropdown_provider_round_trips_through_the_form_model(SqlProvider provider, string name) { var form = new SqlDriverForm.FormModel { Provider = provider, ConnectionStringRef = "MesStaging" }; @@ -117,18 +118,16 @@ public sealed class SqlDriverFormContractTests } /// - /// Odbc stays reserved-and-rejected in this build, so the dropdown deliberately does not offer it. Nothing in the form model blocks the value — this records the intent so a later "the enum has - /// five members, the dropdown should too" edit has to argue with a test. + /// The dropdown offers every provider the build constructs (lmxopcua#753 made all five enum members real), + /// so the form-model theory above must cover exactly — a provider added + /// to the map without a dropdown option (or the reverse) has to argue with this test. /// - [Theory] - [InlineData(SqlProvider.Odbc)] - public void Reserved_providers_are_not_offered_but_still_serialize_by_name(SqlProvider reserved) - { - var json = JsonSerializer.Serialize( - new SqlDriverForm.FormModel { Provider = reserved, ConnectionStringRef = "R" }.ToDto(), FormOptions); + [Fact] + public void Every_constructed_provider_is_covered_by_the_dropdown_theory() + => SqlDialects.Supported.ShouldBe( + [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.MySql, SqlProvider.Odbc, SqlProvider.Oracle], + ignoreOrder: true); - json.ShouldContain($"\"provider\":\"{reserved}\""); - } // ---- named queries (lmxopcua#753): the form refuses exactly what the factory refuses ---- ///