3c9ab4b668
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
131 lines
6.0 KiB
C#
131 lines
6.0 KiB
C#
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)!;
|
||
}
|