0a5aa9fc43
Key-value and where-pair plans had no row limit, so a selector pointed at a history or events table materialised every matching row on every poll. ISqlDialect gains RowLimitPrefix/RowLimitSuffix(n) (SQL Server TOP (n), Postgres LIMIT n, Oracle FETCH FIRST n ROWS ONLY); plans carry a RowCap (distinct keys, or 1) and ask for RowCap+1 rows; the reader also stops client-side at RowCap+1. Getting the extra row proves the selector ambiguous: the group publishes BadConfigurationError with a rate-limited warning. The reader reports those answered refusals so the driver counts them as answer evidence, and any refusal-only poll now restores Healthy after a comms fault. Refs #744 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
329 lines
20 KiB
C#
329 lines
20 KiB
C#
using System.Data.Common;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using Oracle.ManagedDataAccess.Client;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
|
|
|
/// <summary>
|
|
/// Oracle Database dialect over the <b>fully managed</b> ODP.NET provider
|
|
/// (<c>Oracle.ManagedDataAccess.Core</c>) — no Instant Client, no native library, no bitness constraint.
|
|
/// Double-quote identifier quoting, <c>:</c> parameter markers with <b>colon-less</b> bind names, the
|
|
/// <c>ALL_*</c> catalog views in place of the <c>INFORMATION_SCHEMA</c> Oracle does not have, and
|
|
/// <c>FETCH FIRST</c> row limiting.
|
|
/// <para><b>Supported floor: Oracle 12.2.</b> Two things pin it — <c>FETCH FIRST n ROWS ONLY</c> arrived
|
|
/// in 12.1, and the 128-character identifier limit in 12.2 (it was 30 before). Against an older server a
|
|
/// long-but-legal-here identifier would be rejected by the server rather than by
|
|
/// <see cref="QuoteIdentifier"/>, which is a clear error at the right layer, not a silent wrong answer.</para>
|
|
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
|
/// the AdminUI browse session.</para>
|
|
/// </summary>
|
|
public sealed class OracleDialect : ISqlDialect
|
|
{
|
|
/// <summary>
|
|
/// Oracle 12.2+'s identifier ceiling. Enforced because a longer string cannot name a real catalog
|
|
/// object — it is either a config mistake or padding, and rejecting it keeps the quoted output bounded.
|
|
/// <para><b>The unit is BYTES in the database character set, not characters</b> — see
|
|
/// <see cref="QuoteIdentifier"/>. Practically that means UTF-8 (<c>AL32UTF8</c>) on any modern estate,
|
|
/// so a 65-character Cyrillic name is already over the limit.</para>
|
|
/// </summary>
|
|
internal const int MaxIdentifierLength = 128;
|
|
|
|
/// <inheritdoc/>
|
|
public SqlProvider Provider => SqlProvider.Oracle;
|
|
|
|
/// <inheritdoc/>
|
|
public DbProviderFactory Factory => OracleClientFactory.Instance;
|
|
|
|
/// <summary>
|
|
/// Prepended to every Oracle bind name. <b>It is what keeps a bind name from colliding with an
|
|
/// Oracle reserved word</b>, which is a hard error rather than a style problem — see
|
|
/// <see cref="ParameterMarker"/>.
|
|
/// </summary>
|
|
private const string BindPrefix = "p_";
|
|
|
|
/// <inheritdoc />
|
|
// Oracle markers are :-prefixed and namespaced — ParameterMarker("k0") is
|
|
// ":p_k0".
|
|
//
|
|
// The p_ is load-bearing, and was found live. Oracle rejects a bind variable
|
|
// whose name is a reserved word with ORA-01745: invalid host/bind variable name — and the
|
|
// catalog readers' own base names include table (SqlCatalogLoader /
|
|
// SqlBrowseSession bind schema + table). So an un-prefixed
|
|
// ListColumnsSql could not execute at all: every Oracle driver instance failed its
|
|
// Initialize-time catalog load and rejected every authored tag, and every AdminUI browse of a table's
|
|
// columns threw. Caught by OracleReadTests against a real database; invisible to any offline
|
|
// test, because the SQL is only ever parsed by a server.
|
|
// Prefixing everything rather than maintaining a reserved-word list is deliberate:
|
|
// the list is long, version-dependent, and a future base name (date, user,
|
|
// comment, level…) would re-open the same hole silently. A prefixed name can never be
|
|
// a reserved word, and the marker and the bind name are derived from the same constant, so they
|
|
// cannot drift apart.
|
|
public string ParameterMarker(string baseName) => ":" + BindPrefix + baseName;
|
|
|
|
/// <inheritdoc />
|
|
// The same namespaced name without the : — ODP.NET's documented convention for
|
|
// DbParameter.ParameterName, and deliberately not
|
|
// ParameterMarker. This is the divergence that makes the two members exist at all
|
|
// (see ISqlDialect.ParameterBindName).
|
|
public string ParameterBindName(string baseName) => BindPrefix + baseName;
|
|
|
|
/// <inheritdoc />
|
|
// Sets OracleCommand.BindByName = true. This is the sharpest correctness edge in the
|
|
// driver's provider support.
|
|
// ODP.NET binds by position by default: parameter 0 fills the first marker in the
|
|
// statement, parameter 1 the second, and so on, regardless of the names. Every statement this
|
|
// driver builds with more than one marker would then be a coin flip — the planner's
|
|
// IN (:p_k0, :p_k1, …) key list, and ListColumnsSql's schema + table
|
|
// pair — and the failure mode is not an exception but a successful query over the wrong rows,
|
|
// publishing one tag's value onto another tag's node. Worse still,
|
|
// ListTablesSql names :p_schema twice (once per branch of its
|
|
// UNION ALL) and is bound once: under positional binding that statement cannot even execute,
|
|
// because the second occurrence has no parameter to take.
|
|
// Must be called while the parameter collection is still empty — which is exactly what every
|
|
// call site does (immediately after CreateCommand(), before the first bind).
|
|
// A foreign command passes through untouched rather than throwing. The dialect can be
|
|
// handed a DbCommand from another provider — an instrumented or substituted factory in
|
|
// a shared test harness is the realistic case — and an InvalidCastException there would fail a
|
|
// poll for a reason that has nothing to do with the database. There is nothing to configure on a
|
|
// non-ODP.NET command anyway: every other provider this repo speaks binds by name already.
|
|
public void ConfigureCommand(DbCommand command)
|
|
{
|
|
if (command is OracleCommand oracle) oracle.BindByName = true;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// 1000 — the tightest of the three ceilings, and the one that actually binds. Oracle allows
|
|
// 64k bind variables per statement, but an IN expression list is limited to 1000
|
|
// entries (ORA-01795), and the key-value model's IN (:p_k0, …) list is the only
|
|
// variable-length parameter list this driver emits. The seam therefore reports the limit the caller
|
|
// will hit rather than the larger one it never reaches — a dialect that reported 64000 here would be
|
|
// technically accurate and would still produce a statement the server refuses.
|
|
public int MaxBindParameters => 1000;
|
|
|
|
/// <inheritdoc />
|
|
// Oracle has no bare SELECT; every projection needs a table, and DUAL is the one-row one.
|
|
public string LivenessSql => "SELECT 1 FROM DUAL";
|
|
|
|
/// <inheritdoc />
|
|
// Empty — Oracle 12c+ spells the row limit at the end of the statement (see
|
|
// SingleRowLimitSuffix). Anything here would land between SELECT and the column
|
|
// list, where Oracle has no limit syntax at all.
|
|
public string SingleRowLimitPrefix => string.Empty;
|
|
|
|
/// <inheritdoc />
|
|
// The ANSI row-limiting clause Oracle 12.1 added, leading space included so it appends directly to a
|
|
// finished statement.
|
|
//
|
|
// Deliberately not the older WHERE ROWNUM = 1: ROWNUM is assigned before
|
|
// ORDER BY runs, so it would take an arbitrary row and then sort it — silently returning the
|
|
// wrong row for the topByTimestamp selector, whose entire purpose is "the newest one". Making
|
|
// that correct pre-12c requires wrapping the ordered query in a subselect, i.e. authorship of the
|
|
// statement shape, which this seam deliberately leaves with the planner.
|
|
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
|
|
|
|
/// <inheritdoc />
|
|
public string RowLimitPrefix(int rows)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// The same 12c+ row-limiting clause as SingleRowLimitSuffix; valid after a WHERE with no ORDER BY.
|
|
public string RowLimitSuffix(int rows)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
|
return " FETCH FIRST " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + " ROWS ONLY";
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// Schemas visible to the connecting principal, from ALL_USERS (Oracle equates a schema with a
|
|
// user), aliased to the TABLE_SCHEMA column name the catalog readers project by.
|
|
//
|
|
// Broader than the SQL Server dialect's DISTINCT TABLE_SCHEMA FROM
|
|
// INFORMATION_SCHEMA.TABLES, which lists only schemas that own an object: ALL_USERS
|
|
// includes empty ones. Harmless for the catalog gate — the list is used to resolve an authored
|
|
// schema name to the catalog's own spelling, and a schema holding no tables simply resolves and then
|
|
// matches no relation.
|
|
// ⚠️ It is NOT harmless for the browse tree, and that is a known, accepted trade rather than
|
|
// an oversight. This query has two consumers, and only the gate wants the
|
|
// broad list: SqlBrowseSession.RootAsync renders every row as an expandable schema folder, so
|
|
// on a real Oracle estate the operator is offered every database user — hundreds of them,
|
|
// nearly all empty, with nothing in the tree distinguishing the ones that hold a relation until each
|
|
// is expanded.
|
|
// The narrower query is
|
|
// SELECT DISTINCT owner FROM ALL_TABLES UNION SELECT DISTINCT owner FROM ALL_VIEWS, which
|
|
// would match what the other two dialects list and what the browse tree wants. It is deliberately not
|
|
// taken here because it also narrows the gate: a tag naming an empty schema today resolves and
|
|
// then fails at the table level, and afterwards would fail at the schema level with a different
|
|
// message — a behaviour change on the poll path that can only be validated against a real Oracle
|
|
// server (OracleReadTests), never offline, for the same reason the p_ bind-prefix defect
|
|
// was invisible until it ran. Make the swap when an Oracle estate is available to gate it on, and
|
|
// change both consumers' expectations in the same commit.
|
|
public string ListSchemasSql =>
|
|
"SELECT username AS TABLE_SCHEMA FROM ALL_USERS ORDER BY username";
|
|
|
|
/// <inheritdoc />
|
|
// sys_context('userenv','current_schema') is the schema an unqualified name resolves in for
|
|
// this session — which is what the catalog gate asks, and it correctly follows an
|
|
// ALTER SESSION SET CURRENT_SCHEMA rather than reporting the login name.
|
|
public string DefaultSchemaSql => "SELECT sys_context('userenv','current_schema') FROM dual";
|
|
|
|
/// <inheritdoc />
|
|
// Tables and views in one schema, unioned because Oracle splits them across two catalog views where
|
|
// INFORMATION_SCHEMA.TABLES carries both. The synthesised TABLE_TYPE literals are the
|
|
// INFORMATION_SCHEMA spellings the browser compares against to mark a view node.
|
|
//
|
|
// UNION ALL rather than UNION: the two sources are disjoint by construction (a name
|
|
// cannot be both a table and a view in one schema), so de-duplicating would only buy a sort.
|
|
// Note this statement names :p_schema twice and is bound once — see
|
|
// ConfigureCommand.
|
|
public string ListTablesSql =>
|
|
"SELECT table_name AS TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE owner = :p_schema " +
|
|
"UNION ALL " +
|
|
"SELECT view_name AS TABLE_NAME, 'VIEW' AS TABLE_TYPE FROM ALL_VIEWS WHERE owner = :p_schema " +
|
|
"ORDER BY TABLE_NAME";
|
|
|
|
/// <inheritdoc />
|
|
// Columns of one relation, from ALL_TAB_COLUMNS — which covers views as well as tables, so it
|
|
// serves both halves of ListTablesSql. column_id is Oracle's
|
|
// ORDINAL_POSITION.
|
|
//
|
|
// ALL_TAB_COLUMNS.NULLABLE is Y/N where INFORMATION_SCHEMA.IS_NULLABLE is
|
|
// YES/NO, so it is translated rather than merely aliased: a column that carries a
|
|
// shared name across dialects has to carry shared values too, or the first consumer to read
|
|
// it gets a different answer per provider.
|
|
public string ListColumnsSql =>
|
|
"SELECT column_name AS COLUMN_NAME, data_type AS DATA_TYPE, " +
|
|
"CASE nullable WHEN 'Y' THEN 'YES' ELSE 'NO' END AS IS_NULLABLE " +
|
|
"FROM ALL_TAB_COLUMNS WHERE owner = :p_schema AND table_name = :p_table ORDER BY column_id";
|
|
|
|
/// <inheritdoc />
|
|
// Double-quotes one identifier part, doubling any embedded " — the Oracle escape rule, and the
|
|
// only metacharacter that could terminate a quoted identifier early. Everything else (quotes of the
|
|
// other kind, semicolons, comment markers) is inert inside the quotes and passes through unchanged.
|
|
//
|
|
// Case matters once an identifier is quoted. Oracle upper-cases unquoted identifiers but
|
|
// takes a quoted one literally, so "speed" and "SPEED" are different columns. This is
|
|
// safe here only because the catalog gate has already substituted the catalog's own spelling
|
|
// (ALL_TAB_COLUMNS.column_name, i.e. what the server actually stored) before anything reaches
|
|
// this method — an operator who authored speed against an Oracle SPEED column resolves
|
|
// to SPEED at Initialize, and it is SPEED that gets quoted.
|
|
// The length ceiling is measured in UTF-8 bytes, not characters. Oracle's 128 bounds the
|
|
// name in the database character set (AL32UTF8 on any modern estate), so counting
|
|
// UTF-16 code units would accept a 65-character Cyrillic name — 130 bytes — and let it fail at the
|
|
// server with ORA-00972 rather than here, where the message can name the tag.
|
|
// SqlServerDialect deliberately does not do this: T-SQL's sysname is
|
|
// nvarchar(128), a genuine 128-character limit.
|
|
// Rejects (all as ArgumentException): null / empty / all-whitespace; longer
|
|
// than MaxIdentifierLength bytes; any control character (which covers embedded NUL, and the
|
|
// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
|
|
// UnicodeCategory.Format character — bidi overrides, zero-width spaces, soft hyphen,
|
|
// BOM. Those last cannot escape the quotes, but they spoof the rendered text of a log line or
|
|
// an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source class,
|
|
// CVE-2021-42574) — the same log/audit-integrity concern that motivates the control-character rule.
|
|
// The rejection messages deliberately do not echo the offending value — it is untrusted
|
|
// input and this exception's text reaches the driver log.
|
|
// This is a backstop, not the only defence — see
|
|
// ISqlDialect.QuoteIdentifier on the catalog gate. The rules below still run, and must
|
|
// hold for any future caller that has no catalog to check against.
|
|
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));
|
|
|
|
// Byte count, not ident.Length: Oracle's 128 is measured in the database character set, so a
|
|
// 65-character Cyrillic name is 130 UTF-8 bytes and the server answers ORA-00972. Counting UTF-16
|
|
// code units would send it anyway, failing at the server for a reason this dialect could have named.
|
|
var byteLength = Encoding.UTF8.GetByteCount(ident);
|
|
if (byteLength > MaxIdentifierLength)
|
|
throw new ArgumentException(
|
|
$"An Oracle identifier may not exceed {MaxIdentifierLength} bytes (got {byteLength}).",
|
|
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));
|
|
|
|
// Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
|
|
// zero-width character. They are inert inside the quotes but spoof rendered log/UI text.
|
|
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), "\"");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// Folds an ALL_TAB_COLUMNS.DATA_TYPE family name onto a DriverDataType.
|
|
//
|
|
// NUMBER maps to DriverDataType.Float64, and that is a deliberate
|
|
// under-specification. Oracle has one numeric type: an integer column, a currency column and a
|
|
// scientific measurement are all NUMBER, differing only in the precision/scale this method
|
|
// never sees — its whole input is the family name. Choosing an integral driver type would truncate
|
|
// every scaled value, so the widest numeric type wins. Two consequences worth knowing: a
|
|
// NUMBER(38) beyond 2^53 loses low-order digits in a double, and an integer-valued
|
|
// column surfaces as a floating-point OPC UA node. The documented escape hatch for both is authoring
|
|
// an explicit "type" on the tag (Int64 for a true integer key, String to keep an
|
|
// exact high-precision decimal), which overrides this inference.
|
|
// TIMESTAMP is matched by prefix, unlike every other entry. Oracle reports the
|
|
// fractional-seconds precision and the time-zone qualifier inside the family name —
|
|
// TIMESTAMP(6), TIMESTAMP(6) WITH TIME ZONE, TIMESTAMP(6) WITH LOCAL TIME ZONE —
|
|
// where INFORMATION_SCHEMA would report a bare timestamp. An exact-match table would
|
|
// send all of them to the String fallback.
|
|
// Fallback: an unrecognised family maps to DriverDataType.String and never
|
|
// throws — a schema browse must render a table containing an exotic column (RAW,
|
|
// LONG RAW, BLOB, BFILE, ROWID, XMLTYPE, an object type) rather
|
|
// than crash on it. Null/blank input takes the same fallback. RAW/BLOB land here for
|
|
// the same reason SQL Server's varbinary does: there is no binary driver type.
|
|
// Deliberate non-mapping: the INTERVAL types are durations, not points in time,
|
|
// and take the String fallback rather than being mistaken for a
|
|
// DriverDataType.DateTime — the same call the SQL Server dialect makes for
|
|
// time.
|
|
// Oracle's DATE carries a time-of-day component (it is not a date-only type), so
|
|
// DriverDataType.DateTime is the faithful mapping rather than a widening.
|
|
public DriverDataType MapColumnType(string sqlDataType)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
|
|
|
|
// ALL_TAB_COLUMNS reports these upper-cased; normalising up (rather than down, as the SQL Server
|
|
// dialect does) keeps the arms readable as the names an operator sees in the browse tree.
|
|
var family = sqlDataType.Trim().ToUpperInvariant();
|
|
|
|
// Prefix, not equality: the precision and time-zone qualifier live inside the family name.
|
|
if (family.StartsWith("TIMESTAMP", StringComparison.Ordinal)) return DriverDataType.DateTime;
|
|
|
|
return family switch
|
|
{
|
|
// One numeric family, plus the ANSI synonyms Oracle accepts at DDL time. Those synonyms are
|
|
// resolved to NUMBER/FLOAT in the catalog, so they should never actually be reported — they are
|
|
// here so a server or gateway that does surface one is not silently mapped to String.
|
|
"NUMBER" or "FLOAT" or "BINARY_DOUBLE" or "DOUBLE PRECISION"
|
|
or "INTEGER" or "INT" or "SMALLINT"
|
|
or "DECIMAL" or "DEC" or "NUMERIC" => DriverDataType.Float64,
|
|
|
|
// The only single-precision storage type Oracle has.
|
|
"BINARY_FLOAT" => DriverDataType.Float32,
|
|
|
|
"VARCHAR2" or "NVARCHAR2" or "VARCHAR" or "CHAR" or "NCHAR"
|
|
or "CLOB" or "NCLOB" => DriverDataType.String,
|
|
|
|
// Native BOOLEAN column type (Oracle 23ai and later). Older estates spell a boolean as
|
|
// NUMBER(1) or CHAR(1), which are invisible here and map by their own family.
|
|
"BOOLEAN" => DriverDataType.Boolean,
|
|
|
|
"DATE" => DriverDataType.DateTime,
|
|
|
|
_ => DriverDataType.String,
|
|
};
|
|
}
|
|
}
|