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_andTheBindNameIsTheSameNameWithoutTheColon() { var d = new OracleDialect(); // The whole reason ISqlDialect models these as two members: on ODP.NET they are different strings. d.ParameterMarker("k0").ShouldBe(":p_k0"); d.ParameterBindName("k0").ShouldBe("p_k0"); d.ParameterMarker("schema").ShouldBe(":p_schema"); d.ParameterBindName("schema").ShouldBe("p_schema"); } /// /// No bind name may be spelled as its bare base name — the p_ namespace is what stops one /// colliding with an Oracle reserved word. /// /// /// This is a regression test for a live-found, provider-fatal defect. The catalog readers bind /// a parameter whose base name is table, and Oracle rejects a bind variable named after a /// reserved word outright: ORA-01745: invalid host/bind variable name. With the un-prefixed /// names this dialect first shipped with, ListColumnsSql could not execute at all — so 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. It was invisible to every offline test (only a /// server parses the SQL) and was caught by OracleReadTests against a real database. /// table is asserted explicitly because it is the one base name in use today that is /// actually reserved; the others guard the general rule. /// [Theory] [InlineData("table")] [InlineData("schema")] [InlineData("k0")] public void ParameterBindName_neverSurfacesTheBareBaseName_soItCannotBeAReservedWord(string baseName) { var d = new OracleDialect(); d.ParameterBindName(baseName).ShouldNotBe(baseName); d.ParameterMarker(baseName).ShouldNotBe(":" + baseName); // The two must stay derived from one another, or a statement's marker and its bind drift apart and // ODP.NET silently leaves the parameter unbound. d.ParameterMarker(baseName).ShouldBe(":" + d.ParameterBindName(baseName)); } // ---- 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_acceptsExactly128Bytes_andRejects129() { var d = new OracleDialect(); d.QuoteIdentifier(new string('a', 128)).ShouldBe("\"" + new string('a', 128) + "\""); Should.Throw(() => d.QuoteIdentifier(new string('a', 129))); } /// /// Oracle 12.2's 128 is a byte ceiling in the database character set (UTF-8 on any modern /// estate), so a 65-character Cyrillic name is 130 bytes and the server rejects it with /// ORA-00972. Measuring UTF-16 code units here would pass it through to fail at the server /// instead — for a reason this dialect could have named. /// [Fact] public void QuoteIdentifier_measuresTheCeilingInUtf8Bytes_notCharacters() { var d = new OracleDialect(); // 64 Cyrillic characters = 128 UTF-8 bytes — exactly at the limit. d.QuoteIdentifier(new string('б', 64)).ShouldBe("\"" + new string('б', 64) + "\""); // 65 characters = 130 bytes. A character-counting ceiling waves this through. Should.Throw(() => d.QuoteIdentifier(new string('б', 65))); } // ---- bind-parameter ceiling ---- /// /// 1000 is the IN-expression-list limit (ORA-01795), NOT Oracle's much larger 64k bind /// count — the seam has to report the limit the planner's IN (:p_k0, …) list actually hits, or /// it would chunk to a size the server still refuses. /// [Fact] public void MaxBindParameters_isTheInListLimit_notTheLargerBindVariableCount() { new OracleDialect().MaxBindParameters.ShouldBe(1000); new OracleDialect().MaxBindParameters.ShouldBeLessThan(new PostgresDialect().MaxBindParameters); } // ---- 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); }