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("ab")]
+ 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 ----
///