diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
new file mode 100644
index 00000000..d9ac6b3f
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs
@@ -0,0 +1,278 @@
+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 bare 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;
+
+ ///
+ /// Oracle markers are :-prefixed — ParameterMarker("k0") is ":k0".
+ ///
+ /// The bare base name.
+ /// The :-prefixed marker.
+ public string ParameterMarker(string baseName) => ":" + baseName;
+
+ ///
+ /// The bare name, with no : — 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 prefix-less bind name.
+ public string ParameterBindName(string baseName) => 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 (:k0, :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 :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 :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 = :schema " +
+ "UNION ALL " +
+ "SELECT view_name AS TABLE_NAME, 'VIEW' AS TABLE_TYPE FROM ALL_VIEWS WHERE owner = :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 = :schema AND table_name = :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,
+ };
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs
new file mode 100644
index 00000000..fd3f3f30
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs
@@ -0,0 +1,268 @@
+using Microsoft.Data.Sqlite;
+using Oracle.ManagedDataAccess.Client;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
+
+///
+/// Golden tests for , mirroring : the
+/// quoting/injection boundary, the exact catalog SQL, the type-family map — plus the two tests that only
+/// Oracle needs, pinning OracleCommand.BindByName.
+///
+public class OracleDialectTests
+{
+ // ---- the load-bearing pair: ODP.NET binds by POSITION unless told otherwise ----
+
+ ///
+ /// The sharpest correctness edge on the whole provider seam. A real — no
+ /// connection needed — must report BindByName == false before
+ /// touches it and true afterwards.
+ /// The false-by-default half is the falsifiability control. Asserting only the post-state
+ /// would pass identically against an ODP.NET release that had flipped the default — i.e. it would prove
+ /// nothing about whether this dialect's call is load-bearing. Pinning both halves means the day the
+ /// provider default changes, this test says so instead of quietly becoming a tautology.
+ ///
+ [Fact]
+ public void ConfigureCommand_turnsOnBindByName_whichOdpNetLeavesOffByDefault()
+ {
+ using var command = new OracleCommand();
+
+ command.BindByName.ShouldBeFalse(
+ "ODP.NET binds by position by default — if this ever changes, the dialect's ConfigureCommand " +
+ "is no longer what makes named binding work and this test must be re-reasoned, not deleted.");
+
+ new OracleDialect().ConfigureCommand(command);
+
+ command.BindByName.ShouldBeTrue();
+ }
+
+ ///
+ /// A command from another provider passes through untouched and, crucially, does not throw:
+ /// the dialect can legitimately meet a foreign DbCommand (an instrumented or substituted
+ /// factory in a shared test harness), and an InvalidCastException there would fail a poll for a
+ /// reason that has nothing to do with the database.
+ ///
+ [Fact]
+ public void ConfigureCommand_leavesAForeignCommandUntouched()
+ {
+ using var command = new SqliteCommand("SELECT 1");
+
+ Should.NotThrow(() => new OracleDialect().ConfigureCommand(command));
+
+ command.CommandText.ShouldBe("SELECT 1");
+ command.Parameters.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void ParameterMarker_isColonPrefixed_andTheBindNameIsBare()
+ {
+ var d = new OracleDialect();
+
+ // The whole reason ISqlDialect models these as two members: on ODP.NET they are different strings.
+ d.ParameterMarker("k0").ShouldBe(":k0");
+ d.ParameterBindName("k0").ShouldBe("k0");
+ d.ParameterMarker("schema").ShouldBe(":schema");
+ d.ParameterBindName("schema").ShouldBe("schema");
+ }
+
+ // ---- identity ----
+
+ [Fact]
+ public void Dialect_identifiesItselfAsOracle_andExposesTheAbstractFactory()
+ {
+ var d = new OracleDialect();
+ d.Provider.ShouldBe(SqlProvider.Oracle);
+ d.Factory.ShouldNotBeNull();
+ d.Factory.ShouldBeOfType();
+ }
+
+ // ---- quoting / injection boundary ----
+
+ [Fact]
+ public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes()
+ {
+ var d = new OracleDialect();
+ d.QuoteIdentifier("SPEED").ShouldBe("\"SPEED\"");
+ d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\""); // " doubled, per Oracle's quoted-identifier rule
+ }
+
+ [Fact]
+ public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote()
+ => new OracleDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\"");
+
+ [Fact]
+ public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
+ {
+ // The only metacharacter that could terminate a quoted identifier early is ", and it is doubled.
+ // The result is a single (nonexistent) identifier, never executable SQL.
+ new OracleDialect().QuoteIdentifier("x\" ; DROP TABLE Users --")
+ .ShouldBe("\"x\"\" ; DROP TABLE Users --\"");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("\t")]
+ public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
+ => Should.Throw(() => new OracleDialect().QuoteIdentifier(ident!));
+
+ // NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category.
+ [Theory]
+ [InlineData("x\0y")]
+ [InlineData("x\u0001y")] // C0 SOH
+ [InlineData("x\u001By")] // C0 ESC
+ [InlineData("x\u007Fy")] // DEL
+ [InlineData("x\u0085y")] // C1 NEL
+ [InlineData("x\ty")]
+ [InlineData("x\ny")]
+ public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(string ident)
+ => Should.Throw(() => new OracleDialect().QuoteIdentifier(ident));
+
+ // Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of
+ // these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing
+ // byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
+ [Theory]
+ [InlineData("SP\u200BEED")] // zero-width space
+ [InlineData("\u202Ediop.selbat")] // right-to-left override
+ [InlineData("x\u2066y\u2069")] // bidi isolates
+ [InlineData("x\uFEFFy")] // BOM / zero-width no-break space
+ [InlineData("x\u00ADy")] // soft hyphen
+ public void QuoteIdentifier_rejectsUnicodeFormatCharacters(string ident)
+ => Should.Throw(() => new OracleDialect().QuoteIdentifier(ident));
+
+ [Fact]
+ public void QuoteIdentifier_acceptsExactly128Chars_andRejects129()
+ {
+ var d = new OracleDialect();
+ d.QuoteIdentifier(new string('a', 128)).ShouldBe("\"" + new string('a', 128) + "\"");
+ Should.Throw(() => d.QuoteIdentifier(new string('a', 129)));
+ }
+
+ // ---- liveness + row limit ----
+
+ [Fact]
+ public void LivenessSql_selectsFromDual()
+ => new OracleDialect().LivenessSql.ShouldBe("SELECT 1 FROM DUAL");
+
+ [Fact]
+ public void SingleRowLimit_isFetchFirst_inTheSuffixPosition_withItsOwnLeadingSpace()
+ {
+ var d = new OracleDialect();
+
+ // Oracle 12c+ spells the limit at the END of the statement, so the prefix must be empty — a
+ // non-empty one would land between SELECT and the column list and produce invalid SQL.
+ d.SingleRowLimitPrefix.ShouldBe("");
+ d.SingleRowLimitSuffix.ShouldBe(" FETCH FIRST 1 ROWS ONLY");
+ string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"A\" FROM \"T\"", d.SingleRowLimitSuffix)
+ .ShouldBe("SELECT \"A\" FROM \"T\" FETCH FIRST 1 ROWS ONLY");
+ }
+
+ // ---- catalog SQL ----
+
+ [Fact]
+ public void CatalogSql_readsTheAllViews_neverInformationSchema()
+ {
+ var d = new OracleDialect();
+
+ // Oracle has no INFORMATION_SCHEMA at all; the ALL_* views are the portable equivalent.
+ d.ListSchemasSql.ShouldContain("ALL_USERS");
+ d.ListTablesSql.ShouldContain("ALL_TABLES");
+ d.ListTablesSql.ShouldContain("ALL_VIEWS"); // INFORMATION_SCHEMA.TABLES includes views
+ d.ListColumnsSql.ShouldContain("ALL_TAB_COLUMNS");
+ d.DefaultSchemaSql.ShouldBe("SELECT sys_context('userenv','current_schema') FROM dual");
+
+ d.ListSchemasSql.ShouldNotContain("INFORMATION_SCHEMA");
+ d.ListTablesSql.ShouldNotContain("INFORMATION_SCHEMA");
+ d.ListColumnsSql.ShouldNotContain("INFORMATION_SCHEMA");
+ }
+
+ [Fact]
+ public void CatalogSql_isParameterizedWithColonMarkers_matchingParameterMarker()
+ {
+ var d = new OracleDialect();
+
+ d.ListTablesSql.ShouldContain(d.ParameterMarker("schema")); // never string-interpolated
+ d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
+ d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
+
+ // A leftover @ marker would bind on SqlClient and silently be a literal here.
+ d.ListTablesSql.ShouldNotContain("@");
+ d.ListColumnsSql.ShouldNotContain("@");
+ }
+
+ ///
+ /// SqlCatalogLoader/SqlBrowseSession project catalog columns by the
+ /// INFORMATION_SCHEMA names, so every dialect must alias its columns to them or
+ /// GetOrdinal throws at runtime — a failure the SQL text alone would not reveal.
+ ///
+ [Fact]
+ public void CatalogSql_aliasesItsColumnsToTheInformationSchemaNamesTheReadersProjectBy()
+ {
+ var d = new OracleDialect();
+
+ d.ListSchemasSql.ShouldContain("TABLE_SCHEMA");
+ d.ListTablesSql.ShouldContain("TABLE_NAME");
+ d.ListTablesSql.ShouldContain("TABLE_TYPE");
+ d.ListColumnsSql.ShouldContain("COLUMN_NAME");
+ d.ListColumnsSql.ShouldContain("DATA_TYPE");
+
+ // SqlBrowseSession compares TABLE_TYPE against the literal "VIEW" to mark a view node.
+ d.ListTablesSql.ShouldContain("'VIEW'");
+ d.ListTablesSql.ShouldContain("'BASE TABLE'");
+ }
+
+ // ---- type map ----
+
+ [Theory]
+ [InlineData("NUMBER", DriverDataType.Float64)]
+ [InlineData("FLOAT", DriverDataType.Float64)]
+ [InlineData("BINARY_DOUBLE", DriverDataType.Float64)]
+ [InlineData("BINARY_FLOAT", DriverDataType.Float32)]
+ [InlineData("INTEGER", DriverDataType.Float64)]
+ [InlineData("VARCHAR2", DriverDataType.String)]
+ [InlineData("NVARCHAR2", DriverDataType.String)]
+ [InlineData("CHAR", DriverDataType.String)]
+ [InlineData("NCHAR", DriverDataType.String)]
+ [InlineData("CLOB", DriverDataType.String)]
+ [InlineData("NCLOB", DriverDataType.String)]
+ [InlineData("DATE", DriverDataType.DateTime)]
+ public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
+ => new OracleDialect().MapColumnType(sql).ShouldBe(expected);
+
+ ///
+ /// ALL_TAB_COLUMNS.DATA_TYPE carries the fractional-seconds precision and the time-zone
+ /// qualifier inside the family name, unlike INFORMATION_SCHEMA. An exact-match table
+ /// would send every one of these to the String fallback.
+ ///
+ [Theory]
+ [InlineData("TIMESTAMP")]
+ [InlineData("TIMESTAMP(6)")]
+ [InlineData("TIMESTAMP(3)")]
+ [InlineData("TIMESTAMP(6) WITH TIME ZONE")]
+ [InlineData("TIMESTAMP(6) WITH LOCAL TIME ZONE")]
+ public void MapColumnType_matchesTheTimestampFamilyByPrefix(string sql)
+ => new OracleDialect().MapColumnType(sql).ShouldBe(DriverDataType.DateTime);
+
+ [Theory]
+ [InlineData("number", DriverDataType.Float64)]
+ [InlineData("VarChar2", DriverDataType.String)]
+ [InlineData("timestamp(6)", DriverDataType.DateTime)]
+ public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
+ => new OracleDialect().MapColumnType(sql).ShouldBe(expected);
+
+ [Theory]
+ [InlineData("RAW")] // binary, like SQL Server's varbinary
+ [InlineData("BLOB")]
+ [InlineData("LONG RAW")]
+ [InlineData("ROWID")]
+ [InlineData("XMLTYPE")]
+ [InlineData("INTERVAL DAY(3) TO SECOND(6)")] // a duration, deliberately NOT a DateTime
+ [InlineData("")]
+ [InlineData(null)]
+ public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
+ => new OracleDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
+}