Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OdbcDialect.cs
T
Joseph Doherty f5c30ed0ef
v2-ci / unit-tests (push) Has been cancelled
feat(sql): authored write block — Update/Insert/Upsert/Procedure + timestamp stamp; ODBC scope + login docs (lmxopcua#781)
Item 2: a tag's optional `write` block picks the statement the driver builds from its own
catalog-validated identifiers (no authored SQL; values always bound):
- Update (default, unchanged back-compat UPDATE ... WHERE key; 0 rows = BadNoData)
- Insert (key+value[+stamp]; append for a WideRow topByTimestamp tag)
- Upsert: SQL Server MERGE WITH (HOLDLOCK), PostgreSQL INSERT ... ON CONFLICT, MySQL
  ON DUPLICATE KEY UPDATE (1-or-2 rows), Oracle MERGE from DUAL
- Procedure: named stored procedure resolved against the routine catalog by the catalog gate
  (INFORMATION_SCHEMA.ROUTINES / ALL_PROCEDURES), value/key/timestamp params by name (MySQL positional)
- stampTimestamp sets the tag's timestamp column to the write time (UTC)
Odbc serves Update/Insert only (shared SqlWriteSupport table). Constraint failures map to
BadEntryExists (unique) / BadOutOfRange (other integrity, data exception); everything else,
permission denied included, stays BadCommunicationError. WriteIdempotent stays false.
The parser refuses malformed blocks (strict keys/enums/param charset); DraftValidator emits
blocking SqlWriteBlockInvalid / SqlWriteModeUnsupported from the driver's own parse; the AdminUI
editor surfaces the same message.

Items 1 + 4 (docs/drivers/Sql.md): ODBC stays the ANSI SQL:2008 profile (native providers for
SQL Server/MySQL, DB2 unsupported) with an ops note on driver manager + vendor driver on every
Odbc and AdminUI host; read-only drivers require a SELECT-only login, writable drivers a login
limited to their write targets.

Tests: SqlWriteModeTests (parse, every dialect's SQL, gate, status map, SQLite end-to-end),
DraftValidatorSqlWriteBlockTests, AdminUI round-trip; live SqlWriteModesLiveTests on SQL Server,
PostgreSQL, MySQL, Oracle (seeds gain write_targets + set_target) and an Odbc write leg.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:59:09 -05:00

185 lines
9.2 KiB
C#

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,
};
}
// ---- writes (lmxopcua#781 item 2) ----
// Update and Insert only (SqlWriteSupport): no Upsert (positional markers cannot repeat, and MERGE is missing or
// partial on several ODBC backends) and no Procedure (no portable routine catalog or calling convention).
/// <inheritdoc />
// The driver manager reports the backend's SQLSTATE on each OdbcError.
public SqlWriteFailure ClassifyWriteFailure(Exception exception)
=> exception is OdbcException odbc && odbc.Errors.Count > 0
? SqlWriteFailures.FromSqlState(odbc.Errors[0].SQLState)
: SqlWriteFailure.Other;
}