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
@@ -413,6 +413,47 @@ public sealed class SqlPollReaderTests
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 ----
[Theory]