feat(sql): Odbc provider — ANSI SQL:2008 profile over System.Data.Odbc (lmxopcua#753)

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
This commit is contained in:
Joseph Doherty
2026-11-09 17:13:47 -05:00
parent 63372e757a
commit 3c9ab4b668
16 changed files with 606 additions and 39 deletions
@@ -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
'
@@ -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;
/// <summary>
/// The <c>Sql</c> driver over <b>ODBC</b> (lmxopcua#753) — <c>OdbcDialect</c>'s ANSI profile through
/// <c>System.Data.Odbc</c>, the platform driver manager and a vendor ODBC driver, against the PostgreSQL
/// fixture's seed (<c>Docker/postgres</c>). What only a live ODBC stack can prove: positional <c>?</c>
/// binding in planner/catalog order, <c>FETCH FIRST</c> paging, the <c>INFORMATION_SCHEMA</c> catalog through
/// a driver, and the named-query transaction over ODBC's manual-commit mode.
/// <para><b>Env-gated on <see cref="ConnectionStringEnvVar"/></b> — a full ODBC connection string, e.g.
/// <c>Driver={PostgreSQL Unicode};Server=…;Port=…;Database=sqlpollfixture;Uid=otopcua;Pwd=…</c>. A macOS dev
/// box normally has no ODBC driver installed, so these skip there; they run inside a Linux container with
/// <c>unixodbc</c> + <c>odbc-postgresql</c> (see <c>Docker/odbc/run-odbc-gate.sh</c>).</para>
/// </summary>
public sealed class OdbcReadTests
{
/// <summary>The full ODBC connection string. Absent ⇒ skip.</summary>
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.");
}
/// <summary>Builds the driver through the production factory with <c>provider: Odbc</c>.</summary>
private static async Task<SqlDriver> 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);
}
}