From 74435801bb4a21989d920cb1c89b0a1b5a370351 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 5 Aug 2026 06:40:41 -0400 Subject: [PATCH] feat(sql): OracleDialect (ODP.NET managed, BindByName) --- .../OracleDialect.cs | 278 ++++++++++++++++++ .../OracleDialectTests.cs | 268 +++++++++++++++++ 2 files changed, 546 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs 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); +}