refactor(sql): dialect-owned parameter markers, bind names and command configuration
ISqlDialect gains ParameterMarker (the token in the SQL text), ParameterBindName
(the DbParameter.ParameterName) and ConfigureCommand (per-command provider setup,
where Oracle will set BindByName). SQL Server and the test SQLite dialect answer
"@"+name for both spellings and no-op the hook, so SQL Server behaviour is
byte-identical — every existing expected-SQL assertion passes untouched.
SqlGroupPlanner drops its hardcoded @k/@w consts and mints base names (k0..kN, w)
that the dialect spells; SqlQueryPlan carries the emitted markers AND the matching
bind names, so SqlPollReader keeps copying the plan's pairing rather than
re-deriving a provider convention (the two diverge on ODP.NET: ":k0" in the text,
bare "k0" on the parameter). SqlCatalogLoader and SqlBrowseSession bind
schema/table through the dialect, so their bind names cannot drift from the
markers in the dialect-owned catalog SQL. Every parameter-carrying command site
calls ConfigureCommand before its first parameter.
Controlled by a new test-only ColonMarkerDialect (":name" markers, bare bind
names) that really executes against the SQLite fixture. Each leg was falsified
against the pre-change behaviour: the planner emitted "@k0", the reader bound
":k0", and a hardcoded "@schema" in the loader failed catalog load outright.
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"/> /
|
/// <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
|
/// <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
|
/// 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
|
/// read verbatim and its <c>schema</c> / <c>table</c> parameters are <b>bound as parameters</b> under
|
||||||
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
|
/// <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
|
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
|
||||||
/// never needs an identifier in text.</para>
|
/// never needs an identifier in text.</para>
|
||||||
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
|
/// <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>
|
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
|
||||||
private const string ViewTableType = "VIEW";
|
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>
|
/// <summary>
|
||||||
/// The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
|
/// 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.
|
/// (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(
|
var rows = await QueryAsync(
|
||||||
_dialect.ListTablesSql,
|
_dialect.ListTablesSql,
|
||||||
command => Bind(command, "@schema", schema),
|
command => Bind(command, _dialect.ParameterBindName(SchemaParameter), schema),
|
||||||
static reader => (
|
static reader => (
|
||||||
Name: ReadString(reader, TableNameColumn),
|
Name: ReadString(reader, TableNameColumn),
|
||||||
Type: ReadOptionalString(reader, TableTypeColumn)),
|
Type: ReadOptionalString(reader, TableTypeColumn)),
|
||||||
@@ -248,8 +256,8 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
|||||||
_dialect.ListColumnsSql,
|
_dialect.ListColumnsSql,
|
||||||
command =>
|
command =>
|
||||||
{
|
{
|
||||||
Bind(command, "@schema", schema);
|
Bind(command, _dialect.ParameterBindName(SchemaParameter), schema);
|
||||||
Bind(command, "@table", table);
|
Bind(command, _dialect.ParameterBindName(TableParameter), table);
|
||||||
},
|
},
|
||||||
static reader => (
|
static reader => (
|
||||||
Name: ReadString(reader, ColumnNameColumn),
|
Name: ReadString(reader, ColumnNameColumn),
|
||||||
@@ -275,6 +283,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
|||||||
|
|
||||||
var results = new List<T>();
|
var results = new List<T>();
|
||||||
await using var command = _connection.CreateCommand();
|
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;
|
command.CommandText = sql;
|
||||||
bind(command);
|
bind(command);
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,58 @@ public interface ISqlDialect
|
|||||||
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
|
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
|
||||||
string QuoteIdentifier(string ident);
|
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>":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>: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 (:k0, :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>
|
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
|
||||||
string LivenessSql { get; }
|
string LivenessSql { get; }
|
||||||
|
|
||||||
@@ -105,10 +157,19 @@ public interface ISqlDialect
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
string DefaultSchemaSql { get; }
|
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>:schema</c> on ODP.NET); callers bind
|
||||||
|
/// it under <see cref="ParameterBindName"/> of the same base name.
|
||||||
|
/// </summary>
|
||||||
string ListTablesSql { get; }
|
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; }
|
string ListColumnsSql { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -27,6 +27,34 @@ public sealed class SqlServerDialect : ISqlDialect
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public DbProviderFactory Factory => SqlClientFactory.Instance;
|
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/>
|
/// <inheritdoc/>
|
||||||
public string LivenessSql => "SELECT 1";
|
public string LivenessSql => "SELECT 1";
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
|
||||||
/// warehouse.</para>
|
/// warehouse.</para>
|
||||||
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
|
/// <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
|
/// <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>
|
/// 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>:schema</c> on Oracle).</para>
|
||||||
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> An empty catalog would reject
|
/// <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
|
/// 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
|
/// 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>
|
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
|
||||||
private const string ColumnNameColumn = "COLUMN_NAME";
|
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>
|
/// <summary>
|
||||||
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
|
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -53,7 +62,7 @@ internal static class SqlCatalogLoader
|
|||||||
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
|
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
|
||||||
|
|
||||||
var schemas = await QueryColumnAsync(
|
var schemas = await QueryColumnAsync(
|
||||||
connection, dialect.ListSchemasSql, SchemaColumn,
|
connection, dialect, dialect.ListSchemasSql, SchemaColumn,
|
||||||
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
|
// 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))
|
if (!tablesBySchema.TryGetValue(schema, out var tables))
|
||||||
{
|
{
|
||||||
tables = await QueryColumnAsync(
|
tables = await QueryColumnAsync(
|
||||||
connection, dialect.ListTablesSql, TableNameColumn,
|
connection, dialect, dialect.ListTablesSql, TableNameColumn,
|
||||||
command => Bind(command, "@schema", schema),
|
command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
|
||||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||||
tablesBySchema[schema] = tables;
|
tablesBySchema[schema] = tables;
|
||||||
}
|
}
|
||||||
@@ -106,11 +115,11 @@ internal static class SqlCatalogLoader
|
|||||||
if (columnsByTable.ContainsKey(key)) continue;
|
if (columnsByTable.ContainsKey(key)) continue;
|
||||||
|
|
||||||
columnsByTable[key] = await QueryColumnAsync(
|
columnsByTable[key] = await QueryColumnAsync(
|
||||||
connection, dialect.ListColumnsSql, ColumnNameColumn,
|
connection, dialect, dialect.ListColumnsSql, ColumnNameColumn,
|
||||||
command =>
|
command =>
|
||||||
{
|
{
|
||||||
Bind(command, "@schema", schema);
|
Bind(command, dialect.ParameterBindName(SchemaParameter), schema);
|
||||||
Bind(command, "@table", table);
|
Bind(command, dialect.ParameterBindName(TableParameter), table);
|
||||||
},
|
},
|
||||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
@@ -133,6 +142,7 @@ internal static class SqlCatalogLoader
|
|||||||
var command = connection.CreateCommand();
|
var command = connection.CreateCommand();
|
||||||
await using (command.ConfigureAwait(false))
|
await using (command.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
dialect.ConfigureCommand(command);
|
||||||
command.CommandText = dialect.DefaultSchemaSql;
|
command.CommandText = dialect.DefaultSchemaSql;
|
||||||
command.CommandTimeout = timeoutSeconds;
|
command.CommandTimeout = timeoutSeconds;
|
||||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
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>
|
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
|
||||||
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
|
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
|
||||||
DbConnection connection,
|
DbConnection connection,
|
||||||
|
ISqlDialect dialect,
|
||||||
string sql,
|
string sql,
|
||||||
string columnName,
|
string columnName,
|
||||||
Action<DbCommand> bind,
|
Action<DbCommand> bind,
|
||||||
@@ -155,6 +166,8 @@ internal static class SqlCatalogLoader
|
|||||||
var command = connection.CreateCommand();
|
var command = connection.CreateCommand();
|
||||||
await using (command.ConfigureAwait(false))
|
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.CommandText = sql;
|
||||||
command.CommandTimeout = timeoutSeconds;
|
command.CommandTimeout = timeoutSeconds;
|
||||||
bind(command);
|
bind(command);
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ public sealed class SqlDriverProbe : IDriverProbe
|
|||||||
var command = connection.CreateCommand();
|
var command = connection.CreateCommand();
|
||||||
await using (command.ConfigureAwait(false))
|
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;
|
command.CommandText = dialect.LivenessSql;
|
||||||
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
|
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,16 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
|||||||
/// <c>SqlGroupPlannerTests</c>.</para>
|
/// <c>SqlGroupPlannerTests</c>.</para>
|
||||||
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
|
/// <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
|
/// <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
|
/// <c>k0</c>/<c>w</c> parameter — spelled with the dialect's own <see cref="ISqlDialect.ParameterMarker"/>
|
||||||
/// interpolation of tag content into SQL anywhere in this type.</para>
|
/// (<c>@k0</c> on SQL Server, <c>: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>:k0</c> in the text, bare <c>k0</c> on the parameter).</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SqlGroupPlanner
|
public static class SqlGroupPlanner
|
||||||
{
|
{
|
||||||
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
|
/// <summary>The base-name prefix for a key-value model's <c>IN</c>-list parameters (<c>k0…kN</c>).</summary>
|
||||||
private const string KeyMarkerPrefix = "@k";
|
private const string KeyBaseNamePrefix = "k";
|
||||||
|
|
||||||
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
|
/// <summary>The single parameter base name for a wide-row model's row selector.</summary>
|
||||||
private const string RowSelectorMarker = "@w";
|
private const string RowSelectorBaseName = "w";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
|
/// 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);
|
if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
|
||||||
|
|
||||||
// A keyValue may legitimately be the empty string, so only null is a broken invariant.
|
// 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 values = new List<object?>(members.Count);
|
||||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var member in members)
|
foreach (var member in members)
|
||||||
@@ -140,7 +147,9 @@ public static class SqlGroupPlanner
|
|||||||
var keyValue = member.KeyValue
|
var keyValue = member.KeyValue
|
||||||
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
|
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
|
||||||
if (!seen.Add(keyValue)) continue;
|
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);
|
values.Add(keyValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,10 +157,11 @@ public static class SqlGroupPlanner
|
|||||||
.Append(QuoteList(selected, dialect))
|
.Append(QuoteList(selected, dialect))
|
||||||
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
|
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
|
||||||
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
|
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
|
||||||
.Append(" IN (").AppendJoin(", ", names).Append(')')
|
.Append(" IN (").AppendJoin(", ", markers).Append(')')
|
||||||
.ToString();
|
.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>
|
/// <summary>
|
||||||
@@ -183,12 +193,14 @@ public static class SqlGroupPlanner
|
|||||||
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
|
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
|
||||||
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
|
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
|
||||||
{
|
{
|
||||||
|
var marker = dialect.ParameterMarker(RowSelectorBaseName);
|
||||||
var sql = string.Concat(
|
var sql = string.Concat(
|
||||||
"SELECT ", columns, " FROM ", table,
|
"SELECT ", columns, " FROM ", table,
|
||||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
|
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
|
||||||
return new SqlQueryPlan(
|
return new SqlQueryPlan(
|
||||||
SqlTagModel.WideRow, groupKey, sql,
|
SqlTagModel.WideRow, groupKey, sql,
|
||||||
[RowSelectorMarker], [first.RowSelectorValue], members, selected);
|
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
|
||||||
|
[first.RowSelectorValue], members, selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Blank(first.RowSelectorTopByTimestamp))
|
if (!Blank(first.RowSelectorTopByTimestamp))
|
||||||
@@ -200,7 +212,7 @@ public static class SqlGroupPlanner
|
|||||||
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
|
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
|
||||||
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
|
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
|
||||||
dialect.SingleRowLimitSuffix);
|
dialect.SingleRowLimitSuffix);
|
||||||
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
|
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], [], members, selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
|
|||||||
@@ -393,13 +393,17 @@ public sealed class SqlPollReader
|
|||||||
var command = connection.CreateCommand();
|
var command = connection.CreateCommand();
|
||||||
await using (command.ConfigureAwait(false))
|
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.CommandText = plan.SqlText;
|
||||||
command.CommandTimeout = _commandTimeoutSeconds;
|
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();
|
var parameter = command.CreateParameter();
|
||||||
// Bound by the plan's own index pairing — never by re-deriving the marker convention.
|
// Bound by the plan's own index pairing — never by re-deriving the marker convention,
|
||||||
parameter.ParameterName = plan.ParameterNames[i];
|
// 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;
|
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
|
||||||
command.Parameters.Add(parameter);
|
command.Parameters.Add(parameter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
|||||||
/// per group rather than one query per tag.
|
/// per group rather than one query per tag.
|
||||||
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
|
/// <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
|
/// 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
|
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterBindNames"/>. There is
|
||||||
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
|
/// 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>:k0</c> while its <see cref="DbParameter.ParameterName"/> is the
|
||||||
|
/// bare <c>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
|
/// <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>
|
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
|
||||||
/// </summary>
|
/// </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
|
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
|
||||||
/// convention.
|
/// convention.
|
||||||
/// </param>
|
/// </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">
|
/// <param name="Parameters">
|
||||||
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
|
/// The values to bind, aligned with <see cref="ParameterNames"/> and <see cref="ParameterBindNames"/>.
|
||||||
/// — a hostile value is inert here because it is data, not text.
|
/// Captured verbatim from the authored tags — a hostile value is inert here because it is data, not text.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Members">
|
/// <param name="Members">
|
||||||
/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
|
/// 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,
|
object GroupKey,
|
||||||
string SqlText,
|
string SqlText,
|
||||||
IReadOnlyList<string> ParameterNames,
|
IReadOnlyList<string> ParameterNames,
|
||||||
|
IReadOnlyList<string> ParameterBindNames,
|
||||||
IReadOnlyList<object?> Parameters,
|
IReadOnlyList<object?> Parameters,
|
||||||
IReadOnlyList<SqlTagDefinition> Members,
|
IReadOnlyList<SqlTagDefinition> Members,
|
||||||
IReadOnlyList<string> SelectedColumns)
|
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>
|
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
|
||||||
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
|
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>
|
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
|
||||||
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
|
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
|
||||||
|
|
||||||
|
|||||||
@@ -371,4 +371,31 @@ public sealed class SqlBrowseSessionTests
|
|||||||
await session.AttributesAsync(columnNodeId, CancellationToken.None);
|
await session.AttributesAsync(columnNodeId, CancellationToken.None);
|
||||||
session.LastUsedUtc.ShouldBeGreaterThan(afterExpand);
|
session.LastUsedUtc.ShouldBeGreaterThan(afterExpand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The browse path binds the <em>dialect's</em> parameter names, not a hardcoded <c>"@schema"</c>.
|
||||||
|
/// Driven through <see cref="ColonMarkerDialect"/> (markers <c>:schema</c>/<c>:table</c>, bare bind
|
||||||
|
/// names) — ODP.NET's shape, and the only place the two spellings differ.
|
||||||
|
/// <para>Discriminating in two independent ways: a session still binding <c>"@schema"</c> leaves the
|
||||||
|
/// colon marker unbound (the expand yields nothing, or the provider refuses the command), and the
|
||||||
|
/// recorded names would carry the prefix.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheCatalogParameters_areBoundUnderTheDialectsBindNames()
|
||||||
|
{
|
||||||
|
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||||
|
var dialect = new ColonMarkerDialect();
|
||||||
|
await using var session = new SqlBrowseSession(db.Connection, dialect);
|
||||||
|
|
||||||
|
var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None))
|
||||||
|
.First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);
|
||||||
|
var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None);
|
||||||
|
|
||||||
|
columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn);
|
||||||
|
dialect.BoundParameterNames.ShouldContain("schema");
|
||||||
|
dialect.BoundParameterNames.ShouldContain("table");
|
||||||
|
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
|
||||||
|
// Every catalog command went through the provider hook (Oracle's BindByName lives there).
|
||||||
|
dialect.ConfiguredCommands.ShouldBe(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -45,6 +45,13 @@
|
|||||||
— see its class docs.
|
— see its class docs.
|
||||||
-->
|
-->
|
||||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
|
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
|
||||||
|
<!--
|
||||||
|
Linked for the same reason and on the same terms: ColonMarkerDialect is the control for
|
||||||
|
dialect-owned parameter naming (":schema" in the text, bare "schema" on the parameter — ODP.NET's
|
||||||
|
shape), and the browse session binds those names. It decorates SqliteDialect, which is already
|
||||||
|
linked above, so the pair travels together.
|
||||||
|
-->
|
||||||
|
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\ColonMarkerDialect.cs" Link="ColonMarkerDialect.cs"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <b>test-only</b> dialect that spells parameters the ODP.NET way — <c>:name</c> in the SQL text and a
|
||||||
|
/// <b>bare</b> <c>name</c> as the <see cref="DbParameter.ParameterName"/> — over the SQLite fixture. It is
|
||||||
|
/// the falsifiability control for dialect-owned parameter naming: every marker in this repo was hardcoded
|
||||||
|
/// <c>"@"</c> before, and a planner or loader that still hardcodes it emits <c>@k0</c> against this
|
||||||
|
/// dialect and fails loudly.
|
||||||
|
/// <para><b>Why the two halves must differ.</b> A dialect whose marker and bind name are the same string
|
||||||
|
/// (SQL Server, Npgsql, SQLite) cannot tell a call site that <em>copies</em> the plan's bind name apart
|
||||||
|
/// from one that reuses the marker. Oracle is the provider where they genuinely diverge, so this fake
|
||||||
|
/// reproduces that divergence rather than merely changing the prefix.</para>
|
||||||
|
/// <para><b>It really executes.</b> Everything but the parameter naming (and the catalog SQL's markers)
|
||||||
|
/// delegates to <see cref="SqliteDialect"/>, and Microsoft.Data.Sqlite accepts both a <c>:</c>-prefixed
|
||||||
|
/// marker and a prefix-less parameter name — so a poll or a catalog load driven through this dialect runs
|
||||||
|
/// against the real fixture database rather than being asserted on as text.</para>
|
||||||
|
/// <para>A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and
|
||||||
|
/// <c>new</c>-hiding a member would leave interface dispatch calling the original — the substitution
|
||||||
|
/// would silently not happen and the control would pass for the wrong reason.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ColonMarkerDialect : ISqlDialect
|
||||||
|
{
|
||||||
|
private static readonly SqliteDialect Inner = new();
|
||||||
|
|
||||||
|
/// <summary>Every command handed to <see cref="ConfigureCommand"/>, kept so its binds can be read back.</summary>
|
||||||
|
private readonly List<DbCommand> _commands = [];
|
||||||
|
|
||||||
|
/// <summary>How many times <see cref="ConfigureCommand"/> has been called on this instance.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Oracle's <c>BindByName</c> is set here, and a command created without it mis-binds by position
|
||||||
|
/// rather than failing — so "was every command configured" has to be observable to be testable.
|
||||||
|
/// </remarks>
|
||||||
|
public int ConfiguredCommands
|
||||||
|
{
|
||||||
|
get { lock (_commands) return _commands.Count; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="DbParameter.ParameterName"/> of every parameter bound on every configured command,
|
||||||
|
/// read after the fact.
|
||||||
|
/// <para><b>Why this and not the query's own success.</b> Microsoft.Data.Sqlite resolves a parameter
|
||||||
|
/// whether it is named <c>:k0</c> or bare <c>k0</c>, so a poll that returns the right values proves
|
||||||
|
/// the SQL parsed — not that the caller bound the dialect's bind name. Only the name actually put on
|
||||||
|
/// the parameter distinguishes the two, and on ODP.NET the wrong one is a hard bind failure.</para>
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> BoundParameterNames
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_commands)
|
||||||
|
return _commands
|
||||||
|
.SelectMany(c => c.Parameters.Cast<DbParameter>())
|
||||||
|
.Select(p => p.ParameterName)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public SqlProvider Provider => Inner.Provider;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public DbProviderFactory Factory => Inner.Factory;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string LivenessSql => Inner.LivenessSql;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ListSchemasSql => Colonize(Inner.ListSchemasSql);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string DefaultSchemaSql => Colonize(Inner.DefaultSchemaSql);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ListTablesSql => Colonize(Inner.ListTablesSql);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ListColumnsSql => Colonize(Inner.ListColumnsSql);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ParameterMarker(string baseName) => ":" + baseName;
|
||||||
|
|
||||||
|
/// <summary>The bare name, with no prefix — ODP.NET's convention, and deliberately NOT the marker.</summary>
|
||||||
|
/// <param name="baseName">The planner's/loader's base name.</param>
|
||||||
|
/// <returns>The bind name.</returns>
|
||||||
|
public string ParameterBindName(string baseName) => baseName;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void ConfigureCommand(DbCommand command)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
lock (_commands) _commands.Add(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rewrites the SQLite dialect's <c>@</c> markers as <c>:</c> markers. The catalog SQL is dialect-owned
|
||||||
|
/// text, so a colon-marker dialect must carry colon markers in it — that is precisely the coupling the
|
||||||
|
/// catalog loader's bind names have to stay in step with.
|
||||||
|
/// </summary>
|
||||||
|
private static string Colonize(string sql) => sql.Replace('@', ':');
|
||||||
|
}
|
||||||
@@ -227,6 +227,50 @@ public sealed class SqlCatalogGateDriverTests
|
|||||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The catalog path against a dialect whose markers are <c>:schema</c>/<c>:table</c> and whose bind
|
||||||
|
/// names are the bare <c>schema</c>/<c>table</c> — ODP.NET's shape. The gate still resolves a real
|
||||||
|
/// table and still refuses an unknown column, so the loader bound names the dialect's SQL actually
|
||||||
|
/// carries.
|
||||||
|
/// <para>Behaviourally discriminating, not decorative: a loader still binding the literal
|
||||||
|
/// <c>"@schema"</c> leaves <c>:schema</c> unbound, the catalog query matches nothing, and
|
||||||
|
/// <em>every</em> tag — including the honest one — falls to <c>BadNodeIdUnknown</c>.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task The_catalog_load_binds_the_dialects_bind_names_rather_than_a_hardcoded_marker()
|
||||||
|
{
|
||||||
|
using var fixture = new SqlitePollFixture();
|
||||||
|
var dialect = new ColonMarkerDialect();
|
||||||
|
await using var driver = new SqlDriver(
|
||||||
|
new SqlDriverOptions
|
||||||
|
{
|
||||||
|
RawTags =
|
||||||
|
[
|
||||||
|
KvEntry("Speed", SqlitePollFixture.PresentKey),
|
||||||
|
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"),
|
||||||
|
],
|
||||||
|
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||||
|
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||||
|
},
|
||||||
|
DriverInstanceId,
|
||||||
|
dialect,
|
||||||
|
fixture.ConnectionString,
|
||||||
|
factory: fixture.Factory,
|
||||||
|
logger: NullLogger<SqlDriver>.Instance);
|
||||||
|
|
||||||
|
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||||
|
|
||||||
|
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
|
||||||
|
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
|
||||||
|
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||||
|
|
||||||
|
// The catalog queries bound the bare names, and every catalog command went through the hook.
|
||||||
|
dialect.BoundParameterNames.ShouldContain("schema");
|
||||||
|
dialect.BoundParameterNames.ShouldContain("table");
|
||||||
|
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
|
||||||
|
dialect.ConfiguredCommands.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -260,6 +304,12 @@ public sealed class SqlCatalogGateDriverTests
|
|||||||
|
|
||||||
public string ListColumnsSql => Inner.ListColumnsSql;
|
public string ListColumnsSql => Inner.ListColumnsSql;
|
||||||
|
|
||||||
|
public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName);
|
||||||
|
|
||||||
|
public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName);
|
||||||
|
|
||||||
|
public void ConfigureCommand(System.Data.Common.DbCommand command) => Inner.ConfigureCommand(command);
|
||||||
|
|
||||||
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
||||||
|
|
||||||
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
||||||
|
|||||||
@@ -185,6 +185,84 @@ public class SqlGroupPlannerTests
|
|||||||
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
|
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- dialect-owned parameter markers ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The falsifiability control for dialect-owned markers: against a <c>:</c>-marker dialect the emitted
|
||||||
|
/// statement must carry <c>:k0</c>, and no <c>@</c> at all. A planner holding the T-SQL prefix in a
|
||||||
|
/// const passes every SQL Server test in this class and fails only here.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void KeyValueMarkers_comeFromTheDialect_ratherThanAHardcodedAtSign()
|
||||||
|
{
|
||||||
|
var plans = SqlGroupPlanner.Plan(
|
||||||
|
[
|
||||||
|
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
|
||||||
|
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||||
|
], new ColonMarkerDialect()).ToList();
|
||||||
|
|
||||||
|
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1)");
|
||||||
|
plans[0].SqlText.ShouldNotContain("@");
|
||||||
|
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheWideRowSelectorMarker_comesFromTheDialect_ratherThanAHardcodedAtSign()
|
||||||
|
{
|
||||||
|
var plans = SqlGroupPlanner.Plan(
|
||||||
|
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
|
||||||
|
new ColonMarkerDialect()).ToList();
|
||||||
|
|
||||||
|
plans[0].SqlText.ShouldEndWith("= :w");
|
||||||
|
plans[0].SqlText.ShouldNotContain("@");
|
||||||
|
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bind names ride the plan <em>separately</em> from the markers, because on ODP.NET they are a
|
||||||
|
/// different string. A plan that carried only markers would leave the reader deriving the bind name —
|
||||||
|
/// the convention it is not entitled to know.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ParameterBindNames_areTheDialects_andNeedNotEqualTheMarkers()
|
||||||
|
{
|
||||||
|
var plans = SqlGroupPlanner.Plan(
|
||||||
|
[
|
||||||
|
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
|
||||||
|
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||||
|
], new ColonMarkerDialect()).ToList();
|
||||||
|
|
||||||
|
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" }); // in the SQL text
|
||||||
|
plans[0].ParameterBindNames.ShouldBe(new[] { "k0", "k1" }); // on the DbParameter
|
||||||
|
plans[0].ParameterBindNames.Count.ShouldBe(plans[0].Parameters.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheWideRowSelectorBindName_isTheDialects_andNeedNotEqualTheMarker()
|
||||||
|
{
|
||||||
|
var plans = SqlGroupPlanner.Plan(
|
||||||
|
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
|
||||||
|
new ColonMarkerDialect()).ToList();
|
||||||
|
|
||||||
|
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
|
||||||
|
plans[0].ParameterBindNames.ShouldBe(new[] { "w" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// On a SqlClient-style dialect the two lists coincide — which is exactly why the colon control above
|
||||||
|
/// exists, and why this one cannot stand in for it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void OnSqlServer_theBindNamesAreTheMarkers()
|
||||||
|
{
|
||||||
|
var plans = SqlGroupPlanner.Plan(
|
||||||
|
[KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)],
|
||||||
|
new SqlServerDialect()).ToList();
|
||||||
|
|
||||||
|
plans[0].ParameterBindNames.ShouldBe(plans[0].ParameterNames);
|
||||||
|
plans[0].ParameterBindNames.ShouldBe(new[] { "@k0" });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- wide row ----
|
// ---- wide row ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -413,6 +413,47 @@ public sealed class SqlPollReaderTests
|
|||||||
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
|
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- dialect-owned parameter naming ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a real poll through a dialect whose SQL markers (<c>:k0</c>) and bind names (bare <c>k0</c>)
|
||||||
|
/// <b>differ</b> — ODP.NET's shape — against the SQLite fixture, and asserts <em>both</em> that the
|
||||||
|
/// values arrive and that the names actually put on the parameters are the dialect's bind names.
|
||||||
|
/// <para><b>The name assertion is the load-bearing half.</b> Microsoft.Data.Sqlite happily resolves
|
||||||
|
/// <c>:k0</c> and bare <c>k0</c> alike, so a reader that bound its markers would still return the
|
||||||
|
/// right values here — and pass a values-only test — while failing outright on ODP.NET.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_bindsTheDialectsBindNames_whichNeedNotBeTheMarkersInTheSql()
|
||||||
|
{
|
||||||
|
using var fixture = new SqlitePollFixture();
|
||||||
|
var dialect = new ColonMarkerDialect();
|
||||||
|
var reader = new SqlPollReader(
|
||||||
|
fixture.Factory, fixture.ConnectionString, dialect,
|
||||||
|
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
||||||
|
maxConcurrentGroups: 4, nullIsBad: false,
|
||||||
|
resolve: Resolver(
|
||||||
|
KvTag("Speed", SqlitePollFixture.PresentKey),
|
||||||
|
KvTag("Pressure", SqlitePollFixture.SecondPresentKey),
|
||||||
|
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)));
|
||||||
|
|
||||||
|
var snapshots = await reader.ReadAsync(
|
||||||
|
["Speed", "Pressure", "Oven"], CancellationToken.None);
|
||||||
|
|
||||||
|
// The IN list bound both of its distinct values, and the wide-row selector bound its one.
|
||||||
|
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||||
|
snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||||
|
snapshots[2].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
|
||||||
|
snapshots.All(s => SqlStatusCodes.IsGood(s.StatusCode)).ShouldBeTrue();
|
||||||
|
|
||||||
|
// The bind names are the dialect's bare ones, NOT the ":"-prefixed markers in the SQL text.
|
||||||
|
dialect.BoundParameterNames.ShouldBe(new[] { "k0", "k1", "w" }, ignoreOrder: true);
|
||||||
|
|
||||||
|
// Two groups, two commands — and the provider hook ran on each. On Oracle this is BindByName, whose
|
||||||
|
// absence mis-binds an IN list by position instead of failing.
|
||||||
|
dialect.ConfiguredCommands.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- connection lifecycle ----
|
// ---- connection lifecycle ----
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class SqlQueryPlanImmutabilityTests
|
|||||||
public void Plan_doesNotAliasTheCallersLists()
|
public void Plan_doesNotAliasTheCallersLists()
|
||||||
{
|
{
|
||||||
var names = new List<string> { "@k0" };
|
var names = new List<string> { "@k0" };
|
||||||
|
var bindNames = new List<string> { "@k0" };
|
||||||
var values = new List<object?> { "v" };
|
var values = new List<object?> { "v" };
|
||||||
var members = new List<SqlTagDefinition>
|
var members = new List<SqlTagDefinition>
|
||||||
{
|
{
|
||||||
@@ -19,14 +20,17 @@ public class SqlQueryPlanImmutabilityTests
|
|||||||
};
|
};
|
||||||
var selected = new List<string> { "k", "c" };
|
var selected = new List<string> { "k", "c" };
|
||||||
|
|
||||||
var plan = new SqlQueryPlan(SqlTagModel.KeyValue, "gk", "SELECT 1", names, values, members, selected);
|
var plan = new SqlQueryPlan(
|
||||||
|
SqlTagModel.KeyValue, "gk", "SELECT 1", names, bindNames, values, members, selected);
|
||||||
|
|
||||||
names.Add("@k1");
|
names.Add("@k1");
|
||||||
|
bindNames.Add("@k1");
|
||||||
values.Add("other");
|
values.Add("other");
|
||||||
members.Add(members[0]);
|
members.Add(members[0]);
|
||||||
selected.Add("extra");
|
selected.Add("extra");
|
||||||
|
|
||||||
plan.ParameterNames.Count.ShouldBe(1);
|
plan.ParameterNames.Count.ShouldBe(1);
|
||||||
|
plan.ParameterBindNames.Count.ShouldBe(1);
|
||||||
plan.Parameters.Count.ShouldBe(1);
|
plan.Parameters.Count.ShouldBe(1);
|
||||||
plan.Members.Count.ShouldBe(1);
|
plan.Members.Count.ShouldBe(1);
|
||||||
plan.SelectedColumns.Count.ShouldBe(2);
|
plan.SelectedColumns.Count.ShouldBe(2);
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ public sealed class SqliteDialect : ISqlDialect
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public DbProviderFactory Factory => SqliteFactory.Instance;
|
public DbProviderFactory Factory => SqliteFactory.Instance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>@</c>-prefixed, matching SQL Server — SQLite accepts <c>@</c>, <c>:</c> and <c>$</c> equally, so
|
||||||
|
/// this dialect deliberately keeps the <em>same</em> spelling as the production one. Divergent
|
||||||
|
/// parameter naming is the job of <c>ColonMarkerDialect</c>, which is the control for it; if this
|
||||||
|
/// dialect diverged too, a SQL-Server-shaped assumption would have nothing left holding it in place.
|
||||||
|
/// </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 the marker — <c>Microsoft.Data.Sqlite</c> matches on the prefixed name.</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>SqliteCommand</c> binds by name and needs no per-command setup.</summary>
|
||||||
|
/// <param name="command">The command to configure; untouched here.</param>
|
||||||
|
public void ConfigureCommand(DbCommand command)
|
||||||
|
{
|
||||||
|
// Intentionally empty: see the summary.
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public string LivenessSql => "SELECT 1";
|
public string LivenessSql => "SELECT 1";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user