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