feat(sql): OracleDialect (ODP.NET managed, BindByName)
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Oracle Database dialect over the <b>fully managed</b> ODP.NET provider
|
||||
/// (<c>Oracle.ManagedDataAccess.Core</c>) — no Instant Client, no native library, no bitness constraint.
|
||||
/// Double-quote identifier quoting, <c>:</c> parameter markers with <b>bare</b> bind names, the
|
||||
/// <c>ALL_*</c> catalog views in place of the <c>INFORMATION_SCHEMA</c> Oracle does not have, and
|
||||
/// <c>FETCH FIRST</c> row limiting.
|
||||
/// <para><b>Supported floor: Oracle 12.2.</b> Two things pin it — <c>FETCH FIRST n ROWS ONLY</c> 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
|
||||
/// <see cref="QuoteIdentifier"/>, which is a clear error at the right layer, not a silent wrong answer.</para>
|
||||
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
||||
/// the AdminUI browse session.</para>
|
||||
/// </summary>
|
||||
public sealed class OracleDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal const int MaxIdentifierLength = 128;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => SqlProvider.Oracle;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => OracleClientFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Oracle markers are <c>:</c>-prefixed — <c>ParameterMarker("k0")</c> is <c>":k0"</c>.
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>:</c>-prefixed marker.</returns>
|
||||
public string ParameterMarker(string baseName) => ":" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// The <b>bare</b> name, with no <c>:</c> — ODP.NET's documented convention for
|
||||
/// <see cref="DbParameter.ParameterName"/>, and deliberately <em>not</em>
|
||||
/// <see cref="ParameterMarker"/>. This is the divergence that makes the two members exist at all
|
||||
/// (see <see cref="ISqlDialect.ParameterBindName"/>).
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The prefix-less bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => baseName;
|
||||
|
||||
/// <summary>
|
||||
/// Sets <c>OracleCommand.BindByName = true</c>. <b>This is the sharpest correctness edge in the
|
||||
/// driver's provider support.</b>
|
||||
/// <para>ODP.NET binds <em>by position</em> by default: parameter 0 fills the first marker in the
|
||||
/// statement, parameter 1 the second, and so on, <em>regardless of the names</em>. Every statement this
|
||||
/// driver builds with more than one marker would then be a coin flip — the planner's
|
||||
/// <c>IN (:k0, :k1, …)</c> key list, and <see cref="ListColumnsSql"/>'s <c>schema</c> + <c>table</c>
|
||||
/// pair — and the failure mode is not an exception but a <em>successful query over the wrong rows</em>,
|
||||
/// publishing one tag's value onto another tag's node. Worse still,
|
||||
/// <see cref="ListTablesSql"/> names <c>:schema</c> <b>twice</b> (once per branch of its
|
||||
/// <c>UNION ALL</c>) and is bound once: under positional binding that statement cannot even execute,
|
||||
/// because the second occurrence has no parameter to take.</para>
|
||||
/// <para>Must be called while the parameter collection is still empty — which is exactly what every
|
||||
/// call site does (immediately after <c>CreateCommand()</c>, before the first bind).</para>
|
||||
/// <para><b>A foreign command passes through untouched rather than throwing.</b> The dialect can be
|
||||
/// handed a <see cref="DbCommand"/> from another provider — an instrumented or substituted factory in
|
||||
/// a shared test harness is the realistic case — and an <c>InvalidCastException</c> 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.</para>
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure; only an <see cref="OracleCommand"/> is touched.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
if (command is OracleCommand oracle) oracle.BindByName = true;
|
||||
}
|
||||
|
||||
/// <summary>Oracle has no bare <c>SELECT</c>; every projection needs a table, and <c>DUAL</c> is the one-row one.</summary>
|
||||
public string LivenessSql => "SELECT 1 FROM DUAL";
|
||||
|
||||
/// <summary>
|
||||
/// Empty — Oracle 12c+ spells the row limit at the <em>end</em> of the statement (see
|
||||
/// <see cref="SingleRowLimitSuffix"/>). Anything here would land between <c>SELECT</c> and the column
|
||||
/// list, where Oracle has no limit syntax at all.
|
||||
/// </summary>
|
||||
public string SingleRowLimitPrefix => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The ANSI row-limiting clause Oracle 12.1 added, leading space included so it appends directly to a
|
||||
/// finished statement.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately <b>not</b> the older <c>WHERE ROWNUM = 1</c>: <c>ROWNUM</c> is assigned <em>before</em>
|
||||
/// <c>ORDER BY</c> runs, so it would take an arbitrary row and then sort it — silently returning the
|
||||
/// wrong row for the <c>topByTimestamp</c> 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.
|
||||
/// </remarks>
|
||||
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
|
||||
|
||||
/// <summary>
|
||||
/// Schemas visible to the connecting principal, from <c>ALL_USERS</c> (Oracle equates a schema with a
|
||||
/// user), aliased to the <c>TABLE_SCHEMA</c> column name the catalog readers project by.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Broader than the SQL Server dialect's <c>DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES</c>,
|
||||
/// which lists only schemas that own an object: <c>ALL_USERS</c> 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.
|
||||
/// </remarks>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT username AS TABLE_SCHEMA FROM ALL_USERS ORDER BY username";
|
||||
|
||||
/// <summary>
|
||||
/// <c>sys_context('userenv','current_schema')</c> is the schema an unqualified name resolves in for
|
||||
/// this session — which is what the catalog gate asks, and it correctly follows an
|
||||
/// <c>ALTER SESSION SET CURRENT_SCHEMA</c> rather than reporting the login name.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => "SELECT sys_context('userenv','current_schema') FROM dual";
|
||||
|
||||
/// <summary>
|
||||
/// Tables and views in one schema, unioned because Oracle splits them across two catalog views where
|
||||
/// <c>INFORMATION_SCHEMA.TABLES</c> carries both. The synthesised <c>TABLE_TYPE</c> literals are the
|
||||
/// <c>INFORMATION_SCHEMA</c> spellings the browser compares against to mark a view node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>UNION ALL</c> rather than <c>UNION</c>: 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.
|
||||
/// <para>Note this statement names <c>:schema</c> twice and is bound <b>once</b> — see
|
||||
/// <see cref="ConfigureCommand"/>.</para>
|
||||
/// </remarks>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Columns of one relation, from <c>ALL_TAB_COLUMNS</c> — which covers views as well as tables, so it
|
||||
/// serves both halves of <see cref="ListTablesSql"/>. <c>column_id</c> is Oracle's
|
||||
/// <c>ORDINAL_POSITION</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ALL_TAB_COLUMNS.NULLABLE</c> is <c>Y</c>/<c>N</c> where <c>INFORMATION_SCHEMA.IS_NULLABLE</c> is
|
||||
/// <c>YES</c>/<c>NO</c>, so it is translated rather than merely aliased: a column that carries a
|
||||
/// shared name across dialects has to carry shared <em>values</em> too, or the first consumer to read
|
||||
/// it gets a different answer per provider.
|
||||
/// </remarks>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Case matters once an identifier is quoted.</b> Oracle upper-cases unquoted identifiers but
|
||||
/// takes a quoted one literally, so <c>"speed"</c> and <c>"SPEED"</c> are different columns. This is
|
||||
/// safe here only because the catalog gate has already substituted the catalog's own spelling
|
||||
/// (<c>ALL_TAB_COLUMNS.column_name</c>, i.e. what the server actually stored) before anything reaches
|
||||
/// this method — an operator who authored <c>speed</c> against an Oracle <c>SPEED</c> column resolves
|
||||
/// to <c>SPEED</c> at Initialize, and it is <c>SPEED</c> that gets quoted.</para>
|
||||
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>): null / empty / all-whitespace; longer
|
||||
/// than <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL, and the
|
||||
/// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
|
||||
/// <see cref="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
|
||||
/// BOM. Those last cannot escape the quotes, but they spoof the <em>rendered</em> 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.</para>
|
||||
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
|
||||
/// input and this exception's text reaches the driver log.</para>
|
||||
/// <para><b>This is a backstop, not the only defence</b> — see
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> on the catalog gate. The rules below still run, and must
|
||||
/// hold for any future caller that has no catalog to check against.</para>
|
||||
/// </remarks>
|
||||
/// <param name="ident">The bare, unquoted identifier part.</param>
|
||||
/// <returns>The double-quoted identifier.</returns>
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid Oracle identifier.</exception>
|
||||
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), "\"");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds an <c>ALL_TAB_COLUMNS.DATA_TYPE</c> family name onto a <see cref="DriverDataType"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b><c>NUMBER</c> maps to <see cref="DriverDataType.Float64"/>, and that is a deliberate
|
||||
/// under-specification.</b> Oracle has one numeric type: an integer column, a currency column and a
|
||||
/// scientific measurement are all <c>NUMBER</c>, 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
|
||||
/// <c>NUMBER(38)</c> beyond 2^53 loses low-order digits in a <c>double</c>, and an integer-valued
|
||||
/// column surfaces as a floating-point OPC UA node. The documented escape hatch for both is authoring
|
||||
/// an explicit <c>"type"</c> on the tag (<c>Int64</c> for a true integer key, <c>String</c> to keep an
|
||||
/// exact high-precision decimal), which overrides this inference.</para>
|
||||
/// <para><b><c>TIMESTAMP</c> is matched by prefix</b>, unlike every other entry. Oracle reports the
|
||||
/// fractional-seconds precision and the time-zone qualifier <em>inside</em> the family name —
|
||||
/// <c>TIMESTAMP(6)</c>, <c>TIMESTAMP(6) WITH TIME ZONE</c>, <c>TIMESTAMP(6) WITH LOCAL TIME ZONE</c> —
|
||||
/// where <c>INFORMATION_SCHEMA</c> would report a bare <c>timestamp</c>. An exact-match table would
|
||||
/// send all of them to the String fallback.</para>
|
||||
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
|
||||
/// throws — a schema browse must render a table containing an exotic column (<c>RAW</c>,
|
||||
/// <c>LONG RAW</c>, <c>BLOB</c>, <c>BFILE</c>, <c>ROWID</c>, <c>XMLTYPE</c>, an object type) rather
|
||||
/// than crash on it. Null/blank input takes the same fallback. <c>RAW</c>/<c>BLOB</c> land here for
|
||||
/// the same reason SQL Server's <c>varbinary</c> does: there is no binary driver type.</para>
|
||||
/// <para><b>Deliberate non-mapping:</b> the <c>INTERVAL</c> types are durations, not points in time,
|
||||
/// and take the String fallback rather than being mistaken for a
|
||||
/// <see cref="DriverDataType.DateTime"/> — the same call the SQL Server dialect makes for
|
||||
/// <c>time</c>.</para>
|
||||
/// <para>Oracle's <c>DATE</c> carries a time-of-day component (it is not a date-only type), so
|
||||
/// <see cref="DriverDataType.DateTime"/> is the faithful mapping rather than a widening.</para>
|
||||
/// </remarks>
|
||||
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
|
||||
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
Reference in New Issue
Block a user