269 lines
12 KiB
C#
269 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Golden tests for <see cref="OracleDialect"/>, mirroring <see cref="SqlServerDialectTests"/>: the
|
|
/// quoting/injection boundary, the exact catalog SQL, the type-family map — plus the two tests that only
|
|
/// Oracle needs, pinning <c>OracleCommand.BindByName</c>.
|
|
/// </summary>
|
|
public class OracleDialectTests
|
|
{
|
|
// ---- the load-bearing pair: ODP.NET binds by POSITION unless told otherwise ----
|
|
|
|
/// <summary>
|
|
/// The sharpest correctness edge on the whole provider seam. A real <see cref="OracleCommand"/> — no
|
|
/// connection needed — must report <c>BindByName == false</c> before
|
|
/// <see cref="OracleDialect.ConfigureCommand"/> touches it and <c>true</c> afterwards.
|
|
/// <para><b>The false-by-default half is the falsifiability control.</b> 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.</para>
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A command from another provider passes through untouched and, crucially, does <b>not</b> throw:
|
|
/// the dialect can legitimately meet a foreign <c>DbCommand</c> (an instrumented or substituted
|
|
/// factory in a shared test harness), and an <c>InvalidCastException</c> there would fail a poll for a
|
|
/// reason that has nothing to do with the database.
|
|
/// </summary>
|
|
[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<OracleClientFactory>();
|
|
}
|
|
|
|
// ---- 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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("@");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>SqlCatalogLoader</c>/<c>SqlBrowseSession</c> project catalog columns by the
|
|
/// <c>INFORMATION_SCHEMA</c> names, so every dialect must alias its columns to them or
|
|
/// <c>GetOrdinal</c> throws at runtime — a failure the SQL text alone would not reveal.
|
|
/// </summary>
|
|
[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);
|
|
|
|
/// <summary>
|
|
/// <c>ALL_TAB_COLUMNS.DATA_TYPE</c> carries the fractional-seconds precision and the time-zone
|
|
/// qualifier <em>inside</em> the family name, unlike <c>INFORMATION_SCHEMA</c>. An exact-match table
|
|
/// would send every one of these to the String fallback.
|
|
/// </summary>
|
|
[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);
|
|
}
|