f5c30ed0ef
v2-ci / unit-tests (push) Has been cancelled
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
185 lines
9.2 KiB
C#
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;
|
|
}
|