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:
@@ -223,6 +223,18 @@ public static class SqlReadOnlyStatement
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The marker a positional provider (ODBC) spells every parameter with.</summary>
|
||||
public const string PositionalMarker = "?";
|
||||
|
||||
/// <summary>Counts the positional <c>?</c> markers outside literals and comments.</summary>
|
||||
/// <param name="sql">The statement.</param>
|
||||
/// <returns>The marker count.</returns>
|
||||
public static int CountPositionalMarkers(string sql)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sql);
|
||||
return StripLiteralsAndComments(sql).Count(ch => ch == '?');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="marker"/> appears in <paramref name="sql"/> as a parameter token — outside
|
||||
/// literals and comments, and not as the prefix of a longer name (<c>@line</c> inside <c>@lineId</c>).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Which ADO.NET provider/dialect backs a Sql driver instance. <b>Constructed:</b> SqlServer, Postgres,
|
||||
/// Oracle — each over a fully managed provider package, no native client install. <b>Reserved:</b> MySql
|
||||
/// and Odbc are declared names only; authoring one fails at driver construction and at browse.
|
||||
/// Which ADO.NET provider/dialect backs a Sql driver instance. Every member is constructed: SqlServer,
|
||||
/// Postgres, Oracle and MySql over fully managed provider packages, and Odbc over <c>System.Data.Odbc</c>
|
||||
/// (which also needs the host's ODBC driver manager and the vendor driver).
|
||||
/// </summary>
|
||||
public enum SqlProvider
|
||||
{
|
||||
@@ -13,10 +13,13 @@ public enum SqlProvider
|
||||
/// <summary>PostgreSQL via the managed <c>Npgsql</c> provider.</summary>
|
||||
Postgres,
|
||||
|
||||
/// <summary>MySQL / MariaDB — reserved; no dialect ships, so this is not constructed.</summary>
|
||||
/// <summary>MySQL / MariaDB via the fully managed <c>MySqlConnector</c> provider.</summary>
|
||||
MySql,
|
||||
|
||||
/// <summary>Generic ODBC — reserved; no dialect ships, so this is not constructed.</summary>
|
||||
/// <summary>
|
||||
/// Generic ODBC via <c>System.Data.Odbc</c>, speaking the ANSI SQL:2008 profile (<c>INFORMATION_SCHEMA</c>,
|
||||
/// <c>FETCH FIRST</c>, positional <c>?</c>) — see <c>OdbcDialect</c> for which backends that fits.
|
||||
/// </summary>
|
||||
Odbc,
|
||||
|
||||
/// <summary>Oracle Database via the fully managed <c>Oracle.ManagedDataAccess.Core</c> provider.</summary>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Data.Common;
|
||||
using System.Data.Odbc;
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Generic ODBC dialect (design §2, P3; lmxopcua#753) over <c>System.Data.Odbc</c> — the <b>ANSI SQL:2008
|
||||
/// profile</b>: double-quote identifier quoting, positional <c>?</c> parameter markers, the standard
|
||||
/// <c>INFORMATION_SCHEMA</c> catalog views, and the standard <c>FETCH FIRST n ROWS ONLY</c> row limit.
|
||||
/// <para><b>What that means for a backend.</b> ODBC abstracts the wire, not the SQL: this dialect works
|
||||
/// against a data source whose SQL accepts all four of those — PostgreSQL (psqlODBC), Snowflake, Oracle 12c+
|
||||
/// with an <c>INFORMATION_SCHEMA</c> compatibility view set, and similar. A backend that lacks one (SQL Server
|
||||
/// needs <c>OFFSET … FETCH</c> after an <c>ORDER BY</c>; MySQL has no <c>FETCH FIRST</c>; DB2 has no
|
||||
/// <c>INFORMATION_SCHEMA</c>) should use its native provider. Unlike every other dialect, ODBC also needs a
|
||||
/// native driver manager (unixODBC / iODBC / the Windows one) and the vendor's ODBC driver installed on the
|
||||
/// host.</para>
|
||||
/// <para><b>Parameters are positional.</b> ODBC ignores <see cref="DbParameter.ParameterName"/> and binds
|
||||
/// <c>?</c> markers in the order parameters are added, so every caller must add them in the order their
|
||||
/// markers appear: the planner's key list is already in marker order, and the catalog SQL below binds
|
||||
/// <c>schema</c> before <c>table</c>, the order <c>SqlCatalogLoader</c> and the browse session add them.</para>
|
||||
/// <para>Stateless and immutable, so one instance is safely shared.</para>
|
||||
/// </summary>
|
||||
public sealed class OdbcDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// The identifier ceiling — SQL:2008's 128 characters, which is also SQL Server's and Oracle's. A
|
||||
/// backend with a lower limit rejects an over-long name itself; the catalog gate only ever quotes
|
||||
/// names the catalog returned.
|
||||
/// </summary>
|
||||
internal const int MaxIdentifierLength = 128;
|
||||
|
||||
/// <summary>The positional marker every ODBC parameter is spelled with.</summary>
|
||||
internal const string PositionalMarker = "?";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => SqlProvider.Odbc;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => OdbcFactory.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ParameterMarker(string baseName) => PositionalMarker;
|
||||
|
||||
// The name is ignored by the ODBC driver manager (binding is positional) but kept as the bare base name so
|
||||
// the parameter collection stays readable in a log.
|
||||
/// <inheritdoc />
|
||||
public string ParameterBindName(string baseName) => baseName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: OdbcCommand needs no per-command setup.
|
||||
}
|
||||
|
||||
// 1000 — conservative, because the backend behind the DSN is unknown: it is Oracle's IN-list ceiling (the
|
||||
// strictest common one) and well inside SQL Server's 2100. A larger key-value group is chunked into several
|
||||
// statements, which costs round-trips and nothing else.
|
||||
/// <inheritdoc />
|
||||
public int MaxBindParameters => 1000;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitPrefix => string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " FETCH FIRST " + rows.ToString(CultureInfo.InvariantCulture) + " ROWS ONLY";
|
||||
}
|
||||
|
||||
// The schemas holding relations this connection can see — derived from TABLES (not SCHEMATA, which some
|
||||
// backends restrict to owned schemas; the PostgresDialect note). The backend is unknown, so the system
|
||||
// schemas of the common INFORMATION_SCHEMA backends are excluded by name, case-insensitively (UPPER is
|
||||
// standard): the standard's own, PostgreSQL's pg_catalog / pg_toast* / pg_temp* (psqlODBC lists pg_catalog,
|
||||
// proven by the live gate), and SQL Server's / MySQL's sys, mysql, performance_schema. Aliases are
|
||||
// double-quoted so a backend that folds unquoted output names still returns the spelling the consumers read
|
||||
// by name.
|
||||
/// <inheritdoc />
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT TABLE_SCHEMA AS \"TABLE_SCHEMA\" FROM INFORMATION_SCHEMA.TABLES " +
|
||||
"WHERE UPPER(TABLE_SCHEMA) NOT IN ('INFORMATION_SCHEMA', 'PG_CATALOG', 'SYS', 'MYSQL', 'PERFORMANCE_SCHEMA') " +
|
||||
"AND UPPER(TABLE_SCHEMA) NOT LIKE 'PG\\_TOAST%' ESCAPE '\\' " +
|
||||
"AND UPPER(TABLE_SCHEMA) NOT LIKE 'PG\\_TEMP%' ESCAPE '\\' " +
|
||||
"ORDER BY 1";
|
||||
|
||||
// SQL:2008's CURRENT_SCHEMA is the portable spelling where it exists; a backend without it fails the query
|
||||
// and the loader falls back to the sole visible schema (or authoring must qualify every table).
|
||||
/// <inheritdoc />
|
||||
public string DefaultSchemaSql => "SELECT CURRENT_SCHEMA";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ListTablesSql =>
|
||||
"SELECT TABLE_NAME AS \"TABLE_NAME\", TABLE_TYPE AS \"TABLE_TYPE\" FROM INFORMATION_SCHEMA.TABLES " +
|
||||
"WHERE TABLE_SCHEMA = ? ORDER BY 1";
|
||||
|
||||
// schema BEFORE table — positional binding makes this order part of the contract.
|
||||
/// <inheritdoc />
|
||||
public string ListColumnsSql =>
|
||||
"SELECT COLUMN_NAME AS \"COLUMN_NAME\", DATA_TYPE AS \"DATA_TYPE\", IS_NULLABLE AS \"IS_NULLABLE\" " +
|
||||
"FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION";
|
||||
|
||||
// ANSI double-quote quoting, doubling an embedded quote. Same rejection discipline as the sibling dialects
|
||||
// (blank, over-long, control characters incl. NUL, Unicode format characters), never echoing the value.
|
||||
/// <inheritdoc />
|
||||
public string QuoteIdentifier(string ident)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ident))
|
||||
throw new ArgumentException("A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
|
||||
|
||||
if (ident.Length > MaxIdentifierLength)
|
||||
throw new ArgumentException(
|
||||
$"An ODBC identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
|
||||
nameof(ident));
|
||||
|
||||
foreach (var ch in ident)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
|
||||
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain Unicode format characters "
|
||||
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
|
||||
}
|
||||
|
||||
return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
|
||||
}
|
||||
|
||||
// The union of the standard type names and the common backends' spellings (INFORMATION_SCHEMA.DATA_TYPE and
|
||||
// the ODBC driver's GetDataTypeName both reach here). Never throws; unknown ⇒ String.
|
||||
/// <inheritdoc />
|
||||
public DriverDataType MapColumnType(string sqlDataType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
|
||||
|
||||
var name = sqlDataType.Trim().ToLowerInvariant();
|
||||
var paren = name.IndexOf('(', StringComparison.Ordinal);
|
||||
if (paren >= 0) name = name[..paren].Trim();
|
||||
|
||||
return name switch
|
||||
{
|
||||
"boolean" or "bool" => DriverDataType.Boolean,
|
||||
"smallint" or "int2" or "tinyint" => DriverDataType.Int16,
|
||||
"integer" or "int" or "int4" => DriverDataType.Int32,
|
||||
"bigint" or "int8" => DriverDataType.Int64,
|
||||
"real" or "float4" => DriverDataType.Float32,
|
||||
"double precision" or "double" or "float" or "float8" or "decimal" or "numeric" or "number"
|
||||
=> DriverDataType.Float64,
|
||||
"character varying" or "varchar" or "character" or "char" or "nvarchar" or "nchar" or "text"
|
||||
or "varchar2" or "nvarchar2" or "clob" or "uuid" => DriverDataType.String,
|
||||
"date" or "timestamp" or "timestamp without time zone" or "timestamp with time zone"
|
||||
or "timestamptz" or "datetime" or "datetime2" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public static class SqlDialects
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
SqlProvider.MySql => new MySqlDialect(),
|
||||
SqlProvider.Odbc => new OdbcDialect(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
+3
@@ -33,6 +33,9 @@
|
||||
<PackageReference Include="Oracle.ManagedDataAccess.Core" />
|
||||
<!-- MySqlDialect (lmxopcua#753): fully managed, so it keeps the "no native client" rule. -->
|
||||
<PackageReference Include="MySqlConnector" />
|
||||
<!-- OdbcDialect (lmxopcua#753). Managed wrapper only; the native driver manager is a host prerequisite
|
||||
that is touched only when an Odbc instance opens a connection. -->
|
||||
<PackageReference Include="System.Data.Odbc" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
|
||||
|
||||
@@ -284,7 +284,8 @@ public static class SqlDriverFactoryExtensions
|
||||
/// <para>The shape rules are <see cref="SqlNamedQueryRules.Describe"/> — 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 (<see cref="ISqlDialect.ParameterMarker"/>) must appear in the statement.
|
||||
/// A provider that binds positionally (<c>?</c>) is exempt, since there is no name to look for.</para>
|
||||
/// A provider that binds positionally (<c>?</c>) is checked by count instead, since there is no name to
|
||||
/// look for.</para>
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<string, SqlNamedQuery> 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);
|
||||
}
|
||||
|
||||
+6
-3
@@ -18,14 +18,17 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Provider</label>
|
||||
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@* Every provider SqlDialects constructs; Odbc stays a reserved SqlProvider member and is
|
||||
deliberately not offered (authoring one fails the driver). *@
|
||||
@* Every provider SqlDialects constructs. *@
|
||||
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
|
||||
<option value="@SqlProvider.Postgres">@SqlProvider.Postgres</option>
|
||||
<option value="@SqlProvider.Oracle">@SqlProvider.Oracle</option>
|
||||
<option value="@SqlProvider.MySql">@SqlProvider.MySql</option>
|
||||
<option value="@SqlProvider.Odbc">@SqlProvider.Odbc</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">SQL Server, PostgreSQL, Oracle and MySQL/MariaDB supported; Odbc reserved.</div>
|
||||
<div class="form-text">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
|
||||
|
||||
Reference in New Issue
Block a user