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
@@ -185,6 +185,84 @@ public class SqlGroupPlannerTests
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 ----
[Fact]