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, }; } }