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:
Joseph Doherty
2026-08-05 06:25:53 -04:00
parent 08c1728a98
commit 1da770253e
16 changed files with 527 additions and 36 deletions
@@ -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);
}
} }
@@ -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";