886ce1b87c
ISqlDialect gains MaxBindParameters — the per-command ceiling the seam was missing while modelling everything else that differs per backend: 2098 usable of SQL Server's 2100 (sp_executesql spends two), 1000 for Oracle's IN-list limit (ORA-01795, tighter than its 64k bind count), 65535 for the Postgres Bind message. SqlGroupPlanner.AddKeyValuePlans now emits several plans for one group when its distinct keys exceed that ceiling, instead of one statement the provider refuses after the connection opened — which left every tag in an EAV group publishing BadCommunicationError on every poll, permanently, and reported the database endpoint as down. Chunks share the group key; members follow their own key so a shared keyValue never splits. SqlPollReader is unchanged: it runs one command per plan and slices each plan's own Members back, proven by a fixture read at a ceiling of 1.
331 lines
15 KiB
C#
331 lines
15 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_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");
|
||
}
|
||
|
||
/// <summary>
|
||
/// No bind name may be spelled as its bare base name — the <c>p_</c> namespace is what stops one
|
||
/// colliding with an Oracle reserved word.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <b>This is a regression test for a live-found, provider-fatal defect.</b> The catalog readers bind
|
||
/// a parameter whose base name is <c>table</c>, and Oracle rejects a bind variable named after a
|
||
/// reserved word outright: <c>ORA-01745: invalid host/bind variable name</c>. With the un-prefixed
|
||
/// names this dialect first shipped with, <c>ListColumnsSql</c> 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 <c>OracleReadTests</c> against a real database.
|
||
/// <para><c>table</c> is asserted explicitly because it is the one base name in use today that is
|
||
/// actually reserved; the others guard the general rule.</para>
|
||
/// </remarks>
|
||
[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<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_acceptsExactly128Bytes_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)));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Oracle 12.2's 128 is a <b>byte</b> 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
|
||
/// <c>ORA-00972</c>. Measuring UTF-16 code units here would pass it through to fail at the server
|
||
/// instead — for a reason this dialect could have named.
|
||
/// </summary>
|
||
[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<ArgumentException>(() => d.QuoteIdentifier(new string('б', 65)));
|
||
}
|
||
|
||
// ---- bind-parameter ceiling ----
|
||
|
||
/// <summary>
|
||
/// 1000 is the <c>IN</c>-expression-list limit (<c>ORA-01795</c>), NOT Oracle's much larger 64k bind
|
||
/// count — the seam has to report the limit the planner's <c>IN (:p_k0, …)</c> list actually hits, or
|
||
/// it would chunk to a size the server still refuses.
|
||
/// </summary>
|
||
[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("@");
|
||
}
|
||
|
||
/// <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);
|
||
}
|