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:
@@ -58,10 +58,12 @@ public sealed class SqlDriverBrowserTests
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType<PostgresDialect>();
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType<OracleDialect>();
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.MySql).ShouldBeOfType<MySqlDialect>();
|
||||
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Odbc).ShouldBeOfType<OdbcDialect>();
|
||||
|
||||
SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle, SqlProvider.MySql];
|
||||
foreach (var provider in Enum.GetValues<SqlProvider>().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<SqlProvider>())
|
||||
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldNotBeNull();
|
||||
SqlDriverBrowser.DefaultDialectSelector((SqlProvider)99).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- connection-string reference resolution ----
|
||||
@@ -189,8 +191,8 @@ public sealed class SqlDriverBrowserTests
|
||||
// ---- provider availability ----
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control for the widened browse gate: <see cref="SqlProvider.Odbc"/> 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
|
||||
@@ -199,9 +201,9 @@ public sealed class SqlDriverBrowserTests
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
browser.OpenAsync(
|
||||
ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
ConfigJson("99", connectionStringRef: "MesStaging"), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("not available in this build");
|
||||
reads.ShouldBeEmpty();
|
||||
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Golden tests for <see cref="OdbcDialect"/> (lmxopcua#753) — the ANSI SQL:2008 profile over
|
||||
/// <c>System.Data.Odbc</c>. 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 <c>OdbcReadTests</c>.
|
||||
/// <para><b>The sharp edge is positional binding.</b> ODBC ignores parameter names and binds <c>?</c> 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.</para>
|
||||
/// </summary>
|
||||
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<ArgumentException>(() => new OdbcDialect().QuoteIdentifier(ident));
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_capsAt128Characters()
|
||||
{
|
||||
new OdbcDialect().QuoteIdentifier(new string('a', 128)).Length.ShouldBe(130);
|
||||
Should.Throw<ArgumentException>(() => 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<InvalidOperationException>(() => 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<SqlDriverConfigDto>(
|
||||
new JsonObject { ["connectionStringRef"] = "x", ["queries"] = JsonNode.Parse(queriesJson) }.ToJsonString(),
|
||||
SqlDriverFactoryExtensions.JsonOptionsForTest)!;
|
||||
}
|
||||
@@ -145,8 +145,9 @@ public sealed class SqlDriverFactoryTests
|
||||
// ---- provider gate ----
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability control for the widened gate: <see cref="SqlProvider.Odbc"/> 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -157,9 +158,9 @@ public sealed class SqlDriverFactoryTests
|
||||
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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();
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SqlProvider.Odbc)]
|
||||
[InlineData((SqlProvider)99)] // every declared member constructs; an undeclared ordinal must not
|
||||
public void ProductionDialectGate_stillRejectsReservedProviders(SqlProvider provider)
|
||||
=> Should.Throw<InvalidOperationException>(() => SqlDriverProbe.DefaultDialectForTest(provider))
|
||||
.Message.ShouldContain(provider.ToString());
|
||||
|
||||
Reference in New Issue
Block a user