merge: Sql driver Postgres + Oracle providers (managed Npgsql/ODP.NET, dialect-owned binding, live-gated fixtures)
This commit is contained in:
@@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
/// <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
|
||||
/// <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
|
||||
/// knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
|
||||
/// read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; no name from a NodeId
|
||||
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
|
||||
/// read verbatim and its <c>schema</c> / <c>table</c> parameters are <b>bound as parameters</b> under
|
||||
/// <see cref="ISqlDialect.ParameterBindName"/> — so the bind names cannot drift from the markers the
|
||||
/// dialect's own SQL carries; no name from a NodeId is ever concatenated into a command text, so a
|
||||
/// hostile or merely awkward catalog name is inert here.
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
|
||||
/// never needs an identifier in text.</para>
|
||||
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
|
||||
@@ -47,6 +49,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
|
||||
private const string ViewTableType = "VIEW";
|
||||
|
||||
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
|
||||
private const string SchemaParameter = "schema";
|
||||
|
||||
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
|
||||
private const string TableParameter = "table";
|
||||
|
||||
/// <summary>
|
||||
/// The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
|
||||
/// (design §4.3), so there is nothing else a picked column could be.
|
||||
@@ -195,7 +203,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
{
|
||||
var rows = await QueryAsync(
|
||||
_dialect.ListTablesSql,
|
||||
command => Bind(command, "@schema", schema),
|
||||
command => Bind(command, _dialect.ParameterBindName(SchemaParameter), schema),
|
||||
static reader => (
|
||||
Name: ReadString(reader, TableNameColumn),
|
||||
Type: ReadOptionalString(reader, TableTypeColumn)),
|
||||
@@ -248,8 +256,8 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
_dialect.ListColumnsSql,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
Bind(command, _dialect.ParameterBindName(SchemaParameter), schema);
|
||||
Bind(command, _dialect.ParameterBindName(TableParameter), table);
|
||||
},
|
||||
static reader => (
|
||||
Name: ReadString(reader, ColumnNameColumn),
|
||||
@@ -275,6 +283,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
|
||||
var results = new List<T>();
|
||||
await using var command = _connection.CreateCommand();
|
||||
// Before the binds: on ODP.NET this is what switches bind-by-position off, and the two-parameter
|
||||
// column query is exactly where positional binding swaps the schema and the table silently.
|
||||
_dialect.ConfigureCommand(command);
|
||||
command.CommandText = sql;
|
||||
bind(command);
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
/// <param name="dialectSelector">
|
||||
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
|
||||
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
|
||||
/// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
|
||||
/// provider gets added without touching this class.
|
||||
/// (SqlServer, Postgres and Oracle). A constructor parameter rather than a test hatch: it is also how
|
||||
/// a further provider gets added without touching this class.
|
||||
/// </param>
|
||||
/// <param name="environmentReader">
|
||||
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
|
||||
@@ -117,14 +117,23 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
ConnectionStringEnvironmentPrefix + connectionStringRef;
|
||||
|
||||
/// <summary>
|
||||
/// The providers this build can browse. v1 constructs SQL Server only; every other
|
||||
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> and
|
||||
/// the caller reports it as unavailable rather than crashing on a null dialect.
|
||||
/// The providers this build can browse — SQL Server, PostgreSQL and Oracle. The remaining
|
||||
/// <see cref="SqlProvider"/> members (<see cref="SqlProvider.MySql"/>, <see cref="SqlProvider.Odbc"/>)
|
||||
/// are reserved names, so they resolve to <see langword="null"/> and the caller reports them as
|
||||
/// unavailable rather than crashing on a null dialect.
|
||||
/// <para>Kept in step with <c>SqlDriverFactoryExtensions.CreateDialect</c>: a provider the runtime
|
||||
/// driver constructs but the browser cannot select would leave the operator authoring tags by hand
|
||||
/// against a database the AdminUI refuses to show them.</para>
|
||||
/// </summary>
|
||||
/// <param name="provider">The authored provider.</param>
|
||||
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
|
||||
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
|
||||
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
|
||||
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="InvalidOperationException">
|
||||
@@ -143,12 +152,13 @@ public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
var literal = ReadPastedConnectionString(configJson);
|
||||
var config = Deserialize(configJson, literal);
|
||||
|
||||
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
|
||||
// Provider first: "this build cannot browse MySql" is true regardless of how the database is
|
||||
// named, and answering it before touching the environment keeps the two failures from masking.
|
||||
var dialect = _dialectSelector(config.Provider)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Sql provider '{config.Provider}' is not available in this build; v1 browses "
|
||||
+ $"'{SqlProvider.SqlServer}' only.");
|
||||
$"Sql provider '{config.Provider}' is not available in this build; browsing supports "
|
||||
+ $"'{SqlProvider.SqlServer}', '{SqlProvider.Postgres}' and '{SqlProvider.Oracle}' "
|
||||
+ $"('{SqlProvider.MySql}' and '{SqlProvider.Odbc}' are reserved names).");
|
||||
|
||||
var reference = config.ConnectionStringRef;
|
||||
var connectionString = ResolveConnectionString(literal, reference);
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
/// </summary>
|
||||
public sealed class SqlDriverConfigDto
|
||||
{
|
||||
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/> — the only provider v1 constructs.</summary>
|
||||
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/>; <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/> are also constructed.</summary>
|
||||
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
|
||||
/// <summary>
|
||||
/// Which ADO.NET provider/dialect backs a Sql driver instance. <b>Constructed:</b> SqlServer, Postgres,
|
||||
/// Oracle — each over a fully managed provider package, no native client install. <b>Reserved:</b> MySql
|
||||
/// and Odbc are declared names only; authoring one fails at driver construction and at browse.
|
||||
/// </summary>
|
||||
public enum SqlProvider
|
||||
{
|
||||
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
|
||||
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c>.</summary>
|
||||
SqlServer,
|
||||
|
||||
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
|
||||
/// <summary>PostgreSQL via the managed <c>Npgsql</c> provider.</summary>
|
||||
Postgres,
|
||||
|
||||
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
|
||||
/// <summary>MySQL / MariaDB — reserved; no dialect ships, so this is not constructed.</summary>
|
||||
MySql,
|
||||
|
||||
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
|
||||
/// <summary>Generic ODBC — reserved; no dialect ships, so this is not constructed.</summary>
|
||||
Odbc,
|
||||
|
||||
/// <summary>Oracle — reserved; not constructed in v1.</summary>
|
||||
/// <summary>Oracle Database via the fully managed <c>Oracle.ManagedDataAccess.Core</c> provider.</summary>
|
||||
Oracle,
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// </summary>
|
||||
public interface ISqlDialect
|
||||
{
|
||||
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
|
||||
/// <summary>
|
||||
/// Which backend this dialect speaks. One implementation ships per constructed
|
||||
/// <see cref="SqlProvider"/> member — <see cref="SqlProvider.SqlServer"/>,
|
||||
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>.
|
||||
/// </summary>
|
||||
SqlProvider Provider { get; }
|
||||
|
||||
/// <summary>
|
||||
@@ -50,6 +54,58 @@ public interface ISqlDialect
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
|
||||
string QuoteIdentifier(string ident);
|
||||
|
||||
/// <summary>
|
||||
/// The token that stands for a bound parameter <b>in the command text</b> — <c>"@k0"</c> for
|
||||
/// SqlClient/Npgsql, <c>":p_k0"</c> for ODP.NET — given the caller's bare base name (<c>"k0"</c>,
|
||||
/// <c>"w"</c>, <c>"schema"</c>).
|
||||
/// <para><b>Why the marker and the bind name are two members.</b> They are the same string on every
|
||||
/// provider but Oracle, where the SQL text carries <c>:p_schema</c> while
|
||||
/// <see cref="DbParameter.ParameterName"/> is the bare <c>schema</c>. Modelling one and deriving the
|
||||
/// other means a call site guessing at a convention it does not own — the exact defect this seam
|
||||
/// exists to prevent, and one that surfaces as a runtime bind failure (or, worse under
|
||||
/// bind-by-position, as a <em>silently mis-bound value</em>) rather than a compile error.</para>
|
||||
/// <para>The base name is caller-owned and never operator input: the poll planner mints
|
||||
/// <c>k0…kN</c>/<c>w</c>, the catalog path uses <c>schema</c>/<c>table</c>. Authored <em>values</em>
|
||||
/// ride the parameter; authored <em>identifiers</em> go through <see cref="QuoteIdentifier"/>. Nothing
|
||||
/// else reaches a command text.</para>
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name, without any provider prefix.</param>
|
||||
/// <returns>The marker to embed in the SQL text.</returns>
|
||||
string ParameterMarker(string baseName);
|
||||
|
||||
/// <summary>
|
||||
/// The value to assign to <see cref="DbParameter.ParameterName"/> for the same
|
||||
/// <paramref name="baseName"/> — <c>"@k0"</c> for SqlClient-style providers (which expect the marker
|
||||
/// verbatim), the bare <c>"k0"</c> for ODP.NET-style ones (whose documented convention is a
|
||||
/// prefix-less bind name).
|
||||
/// <para>Always paired with <see cref="ParameterMarker"/> over the <em>same</em> base name: a plan
|
||||
/// carries both lists and every binding site <b>copies</b> them index-for-index rather than
|
||||
/// re-deriving either from the other.</para>
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name, without any provider prefix.</param>
|
||||
/// <returns>The parameter name to bind under.</returns>
|
||||
string ParameterBindName(string baseName);
|
||||
|
||||
/// <summary>
|
||||
/// Applies provider-specific setup to a freshly created <see cref="DbCommand"/>, <b>before</b> its
|
||||
/// parameters are added or it is executed. A no-op for providers that need none.
|
||||
/// <para><b>Oracle uses this to set <c>OracleCommand.BindByName = true</c></b>, and that call is the
|
||||
/// sharpest correctness edge on this seam: ODP.NET binds <em>by position</em> by default, so a
|
||||
/// multi-parameter statement (the planner's <c>IN (:p_k0, :p_k1, …)</c> list, the catalog path's
|
||||
/// <c>schema</c> + <c>table</c> pair) would bind values to the wrong markers whenever the parameter
|
||||
/// order and the marker order differ — publishing one tag's value onto another tag's node with no
|
||||
/// error anywhere. Every command that carries parameters — the poll path, the catalog path, the
|
||||
/// browse path — therefore calls this immediately after <c>CreateCommand()</c> and <b>before</b> the
|
||||
/// first parameter is added. (Bind mode cannot affect a parameterless statement, so the liveness
|
||||
/// ones are indifferent: <c>SqlDriverProbe</c> calls it anyway for uniformity,
|
||||
/// <c>SqlDriver.PingAsync</c> does not.)</para>
|
||||
/// <para>Implementations must tolerate <em>any</em> <see cref="DbCommand"/> they are handed: the
|
||||
/// driver can be pointed at an instrumented or substituted factory, so a dialect that casts to its own
|
||||
/// provider type must do so defensively rather than throwing.</para>
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure.</param>
|
||||
void ConfigureCommand(DbCommand command);
|
||||
|
||||
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
|
||||
string LivenessSql { get; }
|
||||
|
||||
@@ -105,10 +161,19 @@ public interface ISqlDialect
|
||||
/// </remarks>
|
||||
string DefaultSchemaSql { get; }
|
||||
|
||||
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
|
||||
/// <summary>
|
||||
/// Catalog query listing tables + views in one schema — the browser's schema-expand level. Carries one
|
||||
/// parameter under the base name <c>schema</c>, spelled with this dialect's own
|
||||
/// <see cref="ParameterMarker"/> (<c>@schema</c> on SqlClient, <c>:p_schema</c> on ODP.NET); callers bind
|
||||
/// it under <see cref="ParameterBindName"/> of the same base name.
|
||||
/// </summary>
|
||||
string ListTablesSql { get; }
|
||||
|
||||
/// <summary>Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind <c>@schema</c> and <c>@table</c>.</summary>
|
||||
/// <summary>
|
||||
/// Catalog query listing columns of one table — the browser's table-expand / attributes level. Carries
|
||||
/// parameters under the base names <c>schema</c> and <c>table</c>, spelled and bound exactly as
|
||||
/// <see cref="ListTablesSql"/> describes.
|
||||
/// </summary>
|
||||
string ListColumnsSql { get; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
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>colon-less</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>
|
||||
/// Prepended to every Oracle bind name. <b>It is what keeps a bind name from colliding with an
|
||||
/// Oracle reserved word</b>, which is a hard error rather than a style problem — see
|
||||
/// <see cref="ParameterMarker"/>.
|
||||
/// </summary>
|
||||
private const string BindPrefix = "p_";
|
||||
|
||||
/// <summary>
|
||||
/// Oracle markers are <c>:</c>-prefixed <b>and namespaced</b> — <c>ParameterMarker("k0")</c> is
|
||||
/// <c>":p_k0"</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The <c>p_</c> is load-bearing, and was found live.</b> Oracle rejects a bind variable
|
||||
/// whose name is a reserved word with <c>ORA-01745: invalid host/bind variable name</c> — and the
|
||||
/// catalog readers' own base names include <c>table</c> (<c>SqlCatalogLoader</c> /
|
||||
/// <c>SqlBrowseSession</c> bind <c>schema</c> + <c>table</c>). So an un-prefixed
|
||||
/// <see cref="ListColumnsSql"/> could not execute at all: 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. Caught by <c>OracleReadTests</c> against a real database; invisible to any offline
|
||||
/// test, because the SQL is only ever parsed by a server.</para>
|
||||
/// <para>Prefixing <em>everything</em> rather than maintaining a reserved-word list is deliberate:
|
||||
/// the list is long, version-dependent, and a future base name (<c>date</c>, <c>user</c>,
|
||||
/// <c>comment</c>, <c>level</c>…) would re-open the same hole silently. A prefixed name can never be
|
||||
/// a reserved word, and the marker and the bind name are derived from the same constant, so they
|
||||
/// cannot drift apart.</para>
|
||||
/// </remarks>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>:</c>-prefixed, namespaced marker.</returns>
|
||||
public string ParameterMarker(string baseName) => ":" + BindPrefix + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// The same namespaced name <b>without the <c>:</c></b> — 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 colon-less bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => BindPrefix + 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 (:p_k0, :p_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>:p_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>:p_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 = :p_schema " +
|
||||
"UNION ALL " +
|
||||
"SELECT view_name AS TABLE_NAME, 'VIEW' AS TABLE_TYPE FROM ALL_VIEWS WHERE owner = :p_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 = :p_schema AND table_name = :p_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,251 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL dialect (design §2), over the fully managed <c>Npgsql</c> ADO.NET provider — no native
|
||||
/// client install anywhere. Double-quote identifier quoting, <c>information_schema</c> catalog SQL, an
|
||||
/// end-of-statement <c>LIMIT 1</c>, and a type map over the type names
|
||||
/// <c>information_schema.columns.data_type</c> actually reports.
|
||||
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
||||
/// the AdminUI browse session — the same contract <c>SqlServerDialect</c> holds to.</para>
|
||||
/// </summary>
|
||||
public sealed class PostgresDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// PostgreSQL's identifier ceiling — <c>NAMEDATALEN - 1</c>, 63 by default. Enforced because the
|
||||
/// server <b>silently truncates</b> anything longer, so a 70-character authored name would resolve to
|
||||
/// a different object than it spells; rejecting it surfaces the config mistake instead.
|
||||
/// </summary>
|
||||
internal const int MaxIdentifierLength = 63;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => SqlProvider.Postgres;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => NpgsqlFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Npgsql markers are <c>@</c>-prefixed, exactly as SQL Server's are — <c>ParameterMarker("k0")</c>
|
||||
/// is <c>"@k0"</c>. (Npgsql also accepts <c>:</c>, but this dialect commits to one spelling so the
|
||||
/// catalog SQL below and the planner's markers can never drift apart.)
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed marker.</returns>
|
||||
public string ParameterMarker(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// Identical to <see cref="ParameterMarker"/>: <c>NpgsqlParameter.ParameterName</c> is matched against
|
||||
/// the marker in the statement, and Npgsql strips the prefix itself either way. Spelling it out keeps
|
||||
/// the parameter collection readable in a log and matches what the SQL text says.
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// No-op. <c>NpgsqlCommand</c> binds by name out of the box, so there is nothing Postgres needs set on
|
||||
/// a fresh command — the member exists for Oracle's <c>BindByName</c> (see
|
||||
/// <see cref="ISqlDialect.ConfigureCommand"/>). It deliberately does not cast
|
||||
/// <paramref name="command"/>, so a substituted or instrumented factory's command passes through
|
||||
/// untouched rather than throwing.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure; untouched here.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: see the summary.
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
/// <summary>
|
||||
/// Empty — Postgres spells its row limit at the end of the statement, in
|
||||
/// <see cref="SingleRowLimitSuffix"/>. The mirror image of T-SQL's <c>TOP 1</c>, which is exactly why
|
||||
/// the seam carries both ends.
|
||||
/// </summary>
|
||||
public string SingleRowLimitPrefix => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
|
||||
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
|
||||
/// </summary>
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <summary>
|
||||
/// The schemas holding relations this connection can actually see, system schemas excluded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Sourced from <c>information_schema.tables</c>, not <c>information_schema.schemata</c></b>
|
||||
/// — and that is a deliberate correctness choice, not a copy of the SQL Server dialect's shape.
|
||||
/// Postgres' <c>schemata</c> view lists only schemas the current role <em>owns</em>, so a service
|
||||
/// account granted <c>USAGE</c> + <c>SELECT</c> on someone else's schema — the normal way a read-only
|
||||
/// poller is provisioned — would see an empty schema list and every tag would be rejected. Deriving
|
||||
/// the list from the relations the principal can see answers the question the catalog gate is really
|
||||
/// asking: "which schemas hold something I may read?"</para>
|
||||
/// <para><b>What counts as a system schema.</b> <c>pg_catalog</c> and <c>information_schema</c> are
|
||||
/// named explicitly (design §2). <c>pg_toast</c> and the per-backend <c>pg_temp_N</c> /
|
||||
/// <c>pg_toast_temp_N</c> namespaces are excluded by prefix as well: they hold storage internals and
|
||||
/// session-local temp objects, can never contain an authored poll target, and a <c>pg_temp_N</c> in
|
||||
/// particular names a different thing per session — offering it in the browse tree would present a
|
||||
/// schema that vanishes. The <c>ESCAPE</c> clause makes the <c>_</c> literal rather than the
|
||||
/// single-character wildcard <c>LIKE</c> would otherwise treat it as.</para>
|
||||
/// <para>Aliased <c>"TABLE_SCHEMA"</c> in double quotes — see <see cref="ListTablesSql"/>.</para>
|
||||
/// </remarks>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT table_schema AS \"TABLE_SCHEMA\" FROM information_schema.tables " +
|
||||
"WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " +
|
||||
"AND table_schema NOT LIKE 'pg\\_toast%' ESCAPE '\\' " +
|
||||
"AND table_schema NOT LIKE 'pg\\_temp%' ESCAPE '\\' " +
|
||||
"ORDER BY \"TABLE_SCHEMA\"";
|
||||
|
||||
/// <summary>
|
||||
/// <c>current_schema()</c> is the first schema on the connection's <c>search_path</c> — the one an
|
||||
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => "SELECT current_schema()";
|
||||
|
||||
/// <summary>
|
||||
/// Tables + views in one schema, matching <c>SqlServerDialect.ListTablesSql</c> column for column
|
||||
/// (<c>TABLE_NAME</c>, <c>TABLE_TYPE</c>, ordered by name) so both consumers read the same shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The aliases are quoted, and that is load-bearing.</b> <c>SqlCatalogLoader</c> and
|
||||
/// <c>SqlBrowseSession</c> both read every catalog column by name
|
||||
/// (<c>DbDataReader.GetOrdinal("TABLE_NAME")</c>), while Postgres folds an <em>unquoted</em> output
|
||||
/// column name to lower case. Double-quoting each alias pins the exact upper-case spelling those
|
||||
/// consumers ask for rather than relying on a provider's case-insensitive <c>GetOrdinal</c>
|
||||
/// fallback.</para>
|
||||
/// <para>The <c>@schema</c> marker is written literally here because
|
||||
/// <see cref="ParameterMarker"/> spells it identically; the two agree by construction on this dialect
|
||||
/// (they diverge only on ODP.NET), and a test pins that they do.</para>
|
||||
/// </remarks>
|
||||
public string ListTablesSql =>
|
||||
"SELECT table_name AS \"TABLE_NAME\", table_type AS \"TABLE_TYPE\" " +
|
||||
"FROM information_schema.tables " +
|
||||
"WHERE table_schema = @schema ORDER BY \"TABLE_NAME\"";
|
||||
|
||||
/// <summary>
|
||||
/// Columns of one table in ordinal order — the same three columns
|
||||
/// <c>SqlServerDialect.ListColumnsSql</c> projects (<c>COLUMN_NAME</c>, <c>DATA_TYPE</c>,
|
||||
/// <c>IS_NULLABLE</c>), quoted for the reason <see cref="ListTablesSql"/> gives.
|
||||
/// </summary>
|
||||
public string ListColumnsSql =>
|
||||
"SELECT column_name AS \"COLUMN_NAME\", data_type AS \"DATA_TYPE\", " +
|
||||
"is_nullable AS \"IS_NULLABLE\" FROM information_schema.columns " +
|
||||
"WHERE table_schema = @schema AND table_name = @table ORDER BY ordinal_position";
|
||||
|
||||
/// <summary>
|
||||
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — the SQL-standard escape rule
|
||||
/// Postgres follows, and the only metacharacter that could terminate a quoted identifier early.
|
||||
/// Backslashes, semicolons and comment markers are inert inside a quoted identifier and pass through
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>, mirroring
|
||||
/// <c>SqlServerDialect.QuoteIdentifier</c> so a test written against one dialect fails the same way
|
||||
/// against the other): 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).</para>
|
||||
/// <para>NUL deserves its own note here: Postgres rejects a NUL byte anywhere in a query string
|
||||
/// outright, so passing one through would fail the whole statement rather than just the one
|
||||
/// identifier.</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> Design §8.1's catalog gate
|
||||
/// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at Initialize
|
||||
/// and substitutes the catalog's own spelling — which on Postgres is the <em>lower-cased</em> one an
|
||||
/// unquoted <c>CREATE TABLE</c> produced — so on the poll path the value arriving here is a string
|
||||
/// read back out of the catalog.</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 PostgreSQL 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(
|
||||
$"A PostgreSQL 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>information_schema.columns.data_type</c> family name onto a
|
||||
/// <see cref="DriverDataType"/> per design §3.7.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>The input is the canonical, spelled-out name Postgres reports</b> —
|
||||
/// <c>double precision</c>, <c>character varying</c>, <c>timestamp without time zone</c> — never the
|
||||
/// <c>float8</c>/<c>varchar</c>/<c>timestamptz</c> aliases and never a length
|
||||
/// (<c>character varying(50)</c> is reported as <c>character varying</c>). The internal aliases are
|
||||
/// accepted anyway, so a caller reading types from <c>pg_catalog</c> instead maps identically.</para>
|
||||
/// <para><b>Precision caveat:</b> <c>numeric</c> / <c>decimal</c> / <c>money</c> collapse to
|
||||
/// <see cref="DriverDataType.Float64"/>, as they do on SQL Server. A Postgres <c>numeric</c> is
|
||||
/// arbitrary-precision, so a high-scale column loses low-order digits; authoring an explicit
|
||||
/// <c>"type": "String"</c> on the tag preserves the exact textual value when that matters.</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>json</c>,
|
||||
/// <c>jsonb</c>, <c>inet</c>, <c>tsvector</c>, an enum or composite reported as
|
||||
/// <c>USER-DEFINED</c>, an array reported as <c>ARRAY</c>) rather than crash on it. Null/blank input
|
||||
/// takes the same fallback, and so does <c>bytea</c> — matching how SQL Server's <c>varbinary</c>
|
||||
/// falls back rather than pretending binary is a scalar.</para>
|
||||
/// <para><b>Deliberate non-mappings.</b> Postgres <c>bit</c> / <c>bit varying</c> are <em>bit
|
||||
/// strings</em>, not booleans — mapping <c>bit</c> to <see cref="DriverDataType.Boolean"/> the way
|
||||
/// T-SQL's <c>bit</c> demands would silently coerce a multi-bit value, so they fall back.
|
||||
/// <c>time</c> / <c>time with time zone</c> are times of day rather than points in time and take the
|
||||
/// same fallback as T-SQL's <c>time</c>; <c>interval</c> is a duration, likewise.</para>
|
||||
/// <para><c>uuid</c> maps to <see cref="DriverDataType.String"/>, matching how SQL Server's
|
||||
/// <c>uniqueidentifier</c> is handled.</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;
|
||||
|
||||
return sqlDataType.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => DriverDataType.Boolean,
|
||||
"smallint" or "int2" => DriverDataType.Int16,
|
||||
"integer" or "int" or "int4" => DriverDataType.Int32,
|
||||
"bigint" or "int8" => DriverDataType.Int64,
|
||||
"real" or "float4" => DriverDataType.Float32,
|
||||
"double precision" or "float8"
|
||||
or "numeric" or "decimal" or "money" => DriverDataType.Float64,
|
||||
"text" or "character varying" or "varchar" or "character" or "char" or "bpchar"
|
||||
or "name" or "uuid" => DriverDataType.String,
|
||||
"date"
|
||||
or "timestamp without time zone" or "timestamp with time zone"
|
||||
or "timestamp" or "timestamptz" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting,
|
||||
/// Microsoft SQL Server dialect (design §2.2) — the default provider. Bracket quoting,
|
||||
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
|
||||
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
||||
/// the AdminUI browse session.</para>
|
||||
@@ -27,6 +27,34 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => SqlClientFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// T-SQL 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>
|
||||
/// Identical to <see cref="ParameterMarker"/>: <c>Microsoft.Data.SqlClient</c> expects
|
||||
/// <see cref="DbParameter.ParameterName"/> to carry the same <c>@</c> the statement does. (It also
|
||||
/// accepts a bare name and prepends the <c>@</c> itself, but spelling it out keeps the parameter
|
||||
/// collection readable in a profiler trace and matches what the SQL text says.)
|
||||
/// </summary>
|
||||
/// <param name="baseName">The bare base name.</param>
|
||||
/// <returns>The <c>@</c>-prefixed bind name.</returns>
|
||||
public string ParameterBindName(string baseName) => "@" + baseName;
|
||||
|
||||
/// <summary>
|
||||
/// No-op. <c>SqlCommand</c> binds by name out of the box, so there is nothing SQL Server needs set on a
|
||||
/// fresh command — the member exists for Oracle's <c>BindByName</c> (see
|
||||
/// <see cref="ISqlDialect.ConfigureCommand"/>).
|
||||
/// </summary>
|
||||
/// <param name="command">The command to configure; untouched here.</param>
|
||||
public void ConfigureCommand(DbCommand command)
|
||||
{
|
||||
// Intentionally empty: see the summary.
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
|
||||
+6
-1
@@ -21,8 +21,13 @@
|
||||
<!-- The reason this project exists rather than the dialect living in .Contracts: ISqlDialect
|
||||
exposes DbProviderFactory and SqlServerDialect returns SqlClientFactory.Instance, so the
|
||||
provider package is unavoidable here. .Contracts is deliberately provider-free. This is
|
||||
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type. -->
|
||||
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type.
|
||||
PostgresDialect and OracleDialect land here for the same reason, each returning its
|
||||
provider's DbProviderFactory (NpgsqlFactory / OracleClientFactory); both packages are fully
|
||||
managed ADO.NET drivers, so adding them costs no native client install anywhere. -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Oracle.ManagedDataAccess.Core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
|
||||
|
||||
@@ -14,8 +14,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
|
||||
/// warehouse.</para>
|
||||
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
|
||||
/// <c>@schema</c>/<c>@table</c> parameters — the same discipline the schema browser follows — so loading
|
||||
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.</para>
|
||||
/// <c>schema</c>/<c>table</c> parameters — the same discipline the schema browser follows — so loading
|
||||
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.
|
||||
/// The bind names come from <see cref="ISqlDialect.ParameterBindName"/> over those two base names, so
|
||||
/// they can never disagree with the markers in the dialect's own catalog SQL (<c>@schema</c> on SQL
|
||||
/// Server, <c>:p_schema</c> on Oracle).</para>
|
||||
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> An empty catalog would reject
|
||||
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
|
||||
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
|
||||
@@ -33,6 +36,12 @@ internal static class SqlCatalogLoader
|
||||
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
|
||||
private const string ColumnNameColumn = "COLUMN_NAME";
|
||||
|
||||
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
|
||||
private const string SchemaParameter = "schema";
|
||||
|
||||
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
|
||||
private const string TableParameter = "table";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
|
||||
/// </summary>
|
||||
@@ -53,7 +62,7 @@ internal static class SqlCatalogLoader
|
||||
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
|
||||
|
||||
var schemas = await QueryColumnAsync(
|
||||
connection, dialect.ListSchemasSql, SchemaColumn,
|
||||
connection, dialect, dialect.ListSchemasSql, SchemaColumn,
|
||||
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
|
||||
@@ -94,8 +103,8 @@ internal static class SqlCatalogLoader
|
||||
if (!tablesBySchema.TryGetValue(schema, out var tables))
|
||||
{
|
||||
tables = await QueryColumnAsync(
|
||||
connection, dialect.ListTablesSql, TableNameColumn,
|
||||
command => Bind(command, "@schema", schema),
|
||||
connection, dialect, dialect.ListTablesSql, TableNameColumn,
|
||||
command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
tablesBySchema[schema] = tables;
|
||||
}
|
||||
@@ -106,11 +115,11 @@ internal static class SqlCatalogLoader
|
||||
if (columnsByTable.ContainsKey(key)) continue;
|
||||
|
||||
columnsByTable[key] = await QueryColumnAsync(
|
||||
connection, dialect.ListColumnsSql, ColumnNameColumn,
|
||||
connection, dialect, dialect.ListColumnsSql, ColumnNameColumn,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
Bind(command, dialect.ParameterBindName(SchemaParameter), schema);
|
||||
Bind(command, dialect.ParameterBindName(TableParameter), table);
|
||||
},
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -133,6 +142,7 @@ internal static class SqlCatalogLoader
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = dialect.DefaultSchemaSql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -144,6 +154,7 @@ internal static class SqlCatalogLoader
|
||||
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
|
||||
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
|
||||
DbConnection connection,
|
||||
ISqlDialect dialect,
|
||||
string sql,
|
||||
string columnName,
|
||||
Action<DbCommand> bind,
|
||||
@@ -155,6 +166,8 @@ internal static class SqlCatalogLoader
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
// Before the binds: ODP.NET's BindByName has to be set while the parameter collection is empty.
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
bind(command);
|
||||
|
||||
@@ -201,17 +201,27 @@ public static class SqlDriverFactoryExtensions
|
||||
|
||||
/// <summary>
|
||||
/// Selects the provider seam.
|
||||
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> only. The other members exist on the shipped
|
||||
/// enum as reserved names; authoring one is a config mistake that must fail at construction with a
|
||||
/// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.</para>
|
||||
/// <para>This build constructs <see cref="SqlProvider.SqlServer"/>, <see cref="SqlProvider.Postgres"/>
|
||||
/// and <see cref="SqlProvider.Oracle"/>. <see cref="SqlProvider.MySql"/> and
|
||||
/// <see cref="SqlProvider.Odbc"/> remain reserved names on the shipped enum; authoring one is a config
|
||||
/// mistake that must fail at construction with a message that says so, rather than silently degrading
|
||||
/// to another backend's SQL dialect against a database that does not speak it.</para>
|
||||
/// <para><b>Every arm returns that provider's OWN dialect — there is no fallback arm.</b> A default
|
||||
/// that resolved to <see cref="SqlServerDialect"/> would construct happily and then emit T-SQL
|
||||
/// (<c>INFORMATION_SCHEMA</c>, <c>TOP 1</c>, <c>@</c> markers) at a Postgres or Oracle server, which
|
||||
/// fails at poll time as an unintelligible provider error rather than here as a config error.</para>
|
||||
/// </summary>
|
||||
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
|
||||
=> provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
SqlProvider.Postgres => new PostgresDialect(),
|
||||
SqlProvider.Oracle => new OracleDialect(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
|
||||
$"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
|
||||
$"available in this build. Supported: '{nameof(SqlProvider.SqlServer)}', " +
|
||||
$"'{nameof(SqlProvider.Postgres)}', '{nameof(SqlProvider.Oracle)}'. Reserved (declared on " +
|
||||
$"the enum but not implemented): '{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -134,6 +134,9 @@ public sealed class SqlDriverProbe : IDriverProbe
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
// Uniformly applied at every command site, even where this statement takes no
|
||||
// parameters — a provider hook that is only *sometimes* honoured is the harder bug.
|
||||
dialect.ConfigureCommand(command);
|
||||
command.CommandText = dialect.LivenessSql;
|
||||
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -13,16 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// <c>SqlGroupPlannerTests</c>.</para>
|
||||
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
|
||||
/// <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
|
||||
/// interpolation of tag content into SQL anywhere in this type.</para>
|
||||
/// <c>k0</c>/<c>w</c> parameter — spelled with the dialect's own <see cref="ISqlDialect.ParameterMarker"/>
|
||||
/// (<c>@k0</c> on SQL Server, <c>:p_k0</c> on Oracle) — with the value carried in
|
||||
/// <see cref="SqlQueryPlan.Parameters"/>. There is no interpolation of tag content into SQL anywhere in
|
||||
/// this type.</para>
|
||||
/// <para><b>The base names are this planner's, the spelling is the dialect's.</b> Each plan carries the
|
||||
/// markers it emitted <em>and</em> the matching <see cref="ISqlDialect.ParameterBindName"/>s, so the
|
||||
/// reader binds what the planner minted instead of re-deriving a provider convention — the two differ on
|
||||
/// ODP.NET (<c>:p_k0</c> in the text, bare <c>p_k0</c> on the parameter).</para>
|
||||
/// </summary>
|
||||
public static class SqlGroupPlanner
|
||||
{
|
||||
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
|
||||
private const string KeyMarkerPrefix = "@k";
|
||||
/// <summary>The base-name prefix for a key-value model's <c>IN</c>-list parameters (<c>k0…kN</c>).</summary>
|
||||
private const string KeyBaseNamePrefix = "k";
|
||||
|
||||
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
|
||||
private const string RowSelectorMarker = "@w";
|
||||
/// <summary>The single parameter base name for a wide-row model's row selector.</summary>
|
||||
private const string RowSelectorBaseName = "w";
|
||||
|
||||
/// <summary>
|
||||
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
|
||||
@@ -132,7 +138,8 @@ public static class SqlGroupPlanner
|
||||
if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
|
||||
|
||||
// A keyValue may legitimately be the empty string, so only null is a broken invariant.
|
||||
var names = new List<string>(members.Count);
|
||||
var markers = new List<string>(members.Count);
|
||||
var bindNames = new List<string>(members.Count);
|
||||
var values = new List<object?>(members.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var member in members)
|
||||
@@ -140,7 +147,9 @@ public static class SqlGroupPlanner
|
||||
var keyValue = member.KeyValue
|
||||
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
|
||||
if (!seen.Add(keyValue)) continue;
|
||||
names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
|
||||
var baseName = KeyBaseNamePrefix + values.Count.ToString(CultureInfo.InvariantCulture);
|
||||
markers.Add(dialect.ParameterMarker(baseName));
|
||||
bindNames.Add(dialect.ParameterBindName(baseName));
|
||||
values.Add(keyValue);
|
||||
}
|
||||
|
||||
@@ -148,10 +157,11 @@ public static class SqlGroupPlanner
|
||||
.Append(QuoteList(selected, dialect))
|
||||
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
|
||||
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
|
||||
.Append(" IN (").AppendJoin(", ", names).Append(')')
|
||||
.Append(" IN (").AppendJoin(", ", markers).Append(')')
|
||||
.ToString();
|
||||
|
||||
return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -183,12 +193,14 @@ public static class SqlGroupPlanner
|
||||
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
|
||||
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
|
||||
{
|
||||
var marker = dialect.ParameterMarker(RowSelectorBaseName);
|
||||
var sql = string.Concat(
|
||||
"SELECT ", columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.WideRow, groupKey, sql,
|
||||
[RowSelectorMarker], [first.RowSelectorValue], members, selected);
|
||||
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
|
||||
[first.RowSelectorValue], members, selected);
|
||||
}
|
||||
|
||||
if (!Blank(first.RowSelectorTopByTimestamp))
|
||||
@@ -200,7 +212,7 @@ public static class SqlGroupPlanner
|
||||
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
|
||||
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
|
||||
dialect.SingleRowLimitSuffix);
|
||||
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
|
||||
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], [], members, selected);
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
|
||||
@@ -393,13 +393,17 @@ public sealed class SqlPollReader
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
// Before any parameter is added: on ODP.NET this is what turns bind-by-position off, and
|
||||
// a positionally-bound IN list feeds one tag's value to another tag's node in silence.
|
||||
_dialect.ConfigureCommand(command);
|
||||
command.CommandText = plan.SqlText;
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
for (var i = 0; i < plan.ParameterNames.Count; i++)
|
||||
for (var i = 0; i < plan.ParameterBindNames.Count; i++)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
// Bound by the plan's own index pairing — never by re-deriving the marker convention.
|
||||
parameter.ParameterName = plan.ParameterNames[i];
|
||||
// Bound by the plan's own index pairing — never by re-deriving the marker convention,
|
||||
// and never from ParameterNames: the marker and the bind name differ on ODP.NET.
|
||||
parameter.ParameterName = plan.ParameterBindNames[i];
|
||||
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// per group rather than one query per tag.
|
||||
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
|
||||
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
|
||||
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
|
||||
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
|
||||
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterBindNames"/>. There is
|
||||
/// no path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
|
||||
/// <para><b>Markers and bind names are carried separately</b> because they are not the same string on
|
||||
/// every provider: ODP.NET's text says <c>:p_k0</c> while its <see cref="DbParameter.ParameterName"/> is the
|
||||
/// bare <c>p_k0</c>. Both lists are positionally aligned with <see cref="Parameters"/>, so a binding site
|
||||
/// copies the pairing the planner minted rather than re-deriving a provider convention it does not own.</para>
|
||||
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
|
||||
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
|
||||
/// </summary>
|
||||
@@ -30,9 +34,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
|
||||
/// convention.
|
||||
/// </param>
|
||||
/// <param name="ParameterBindNames">
|
||||
/// The <see cref="DbParameter.ParameterName"/> to bind each value under, from
|
||||
/// <see cref="ISqlDialect.ParameterBindName"/>, aligned index-for-index with <see cref="Parameters"/> and
|
||||
/// with <see cref="ParameterNames"/>. Equal to the marker on every provider but ODP.NET, which wants the
|
||||
/// bare name — which is exactly why it is a carried list and not a transformation of the marker.
|
||||
/// </param>
|
||||
/// <param name="Parameters">
|
||||
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
|
||||
/// — a hostile value is inert here because it is data, not text.
|
||||
/// The values to bind, aligned with <see cref="ParameterNames"/> and <see cref="ParameterBindNames"/>.
|
||||
/// Captured verbatim from the authored tags — a hostile value is inert here because it is data, not text.
|
||||
/// </param>
|
||||
/// <param name="Members">
|
||||
/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
|
||||
@@ -47,6 +57,7 @@ public sealed record SqlQueryPlan(
|
||||
object GroupKey,
|
||||
string SqlText,
|
||||
IReadOnlyList<string> ParameterNames,
|
||||
IReadOnlyList<string> ParameterBindNames,
|
||||
IReadOnlyList<object?> Parameters,
|
||||
IReadOnlyList<SqlTagDefinition> Members,
|
||||
IReadOnlyList<string> SelectedColumns)
|
||||
@@ -57,6 +68,9 @@ public sealed record SqlQueryPlan(
|
||||
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
|
||||
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
|
||||
|
||||
/// <summary>The parameter names to bind under, aligned with <see cref="Parameters"/>.</summary>
|
||||
public IReadOnlyList<string> ParameterBindNames { get; } = [.. ParameterBindNames];
|
||||
|
||||
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
|
||||
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user