using System.Data.Common;
using System.Globalization;
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;
///
/// Oracle Database dialect over the fully managed ODP.NET provider
/// (Oracle.ManagedDataAccess.Core) — no Instant Client, no native library, no bitness constraint.
/// Double-quote identifier quoting, : parameter markers with colon-less bind names, the
/// ALL_* catalog views in place of the INFORMATION_SCHEMA Oracle does not have, and
/// FETCH FIRST row limiting.
/// Supported floor: Oracle 12.2. Two things pin it — FETCH FIRST n ROWS ONLY 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
/// , which is a clear error at the right layer, not a silent wrong answer.
/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.
///
public sealed class OracleDialect : ISqlDialect
{
///
/// 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.
///
internal const int MaxIdentifierLength = 128;
///
public SqlProvider Provider => SqlProvider.Oracle;
///
public DbProviderFactory Factory => OracleClientFactory.Instance;
///
/// Prepended to every Oracle bind name. It is what keeps a bind name from colliding with an
/// Oracle reserved word, which is a hard error rather than a style problem — see
/// .
///
private const string BindPrefix = "p_";
///
/// 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
/// 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.
///
/// The bare base name.
/// The :-prefixed, namespaced marker.
public string ParameterMarker(string baseName) => ":" + BindPrefix + baseName;
///
/// The same namespaced name without the : — ODP.NET's documented convention for
/// , and deliberately not
/// . This is the divergence that makes the two members exist at all
/// (see ).
///
/// The bare base name.
/// The colon-less bind name.
public string ParameterBindName(string baseName) => BindPrefix + baseName;
///
/// 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 '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,
/// 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 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.
///
/// The command to configure; only an is touched.
public void ConfigureCommand(DbCommand command)
{
if (command is OracleCommand oracle) oracle.BindByName = true;
}
/// Oracle has no bare SELECT; every projection needs a table, and DUAL is the one-row one.
public string LivenessSql => "SELECT 1 FROM DUAL";
///
/// Empty — Oracle 12c+ spells the row limit at the end of the statement (see
/// ). Anything here would land between SELECT and the column
/// list, where Oracle has no limit syntax at all.
///
public string SingleRowLimitPrefix => string.Empty;
///
/// 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";
///
/// 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 here —
/// 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.
///
public string ListSchemasSql =>
"SELECT username AS TABLE_SCHEMA FROM ALL_USERS ORDER BY username";
///
/// 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";
///
/// 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
/// .
///
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";
///
/// Columns of one relation, from ALL_TAB_COLUMNS — which covers views as well as tables, so it
/// serves both halves of . 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";
///
/// 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.
/// Rejects (all as ): null / empty / all-whitespace; longer
/// than ; any control character (which covers embedded NUL, and the
/// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
/// 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
/// on the catalog gate. The rules below still run, and must
/// hold for any future caller that has no catalog to check against.
///
/// The bare, unquoted identifier part.
/// The double-quoted identifier.
/// The value cannot be a valid Oracle identifier.
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 Oracle 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));
// 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), "\"");
}
///
/// Folds an ALL_TAB_COLUMNS.DATA_TYPE family name onto a .
///
///
/// NUMBER maps to , 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 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
/// — 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
/// is the faithful mapping rather than a widening.
///
/// The bare SQL type family name; matched case-insensitively.
/// The mapped driver data type, or when unrecognised.
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,
};
}
}