Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs
T
Joseph Doherty 1da770253e 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.
2026-08-05 06:25:53 -04:00

465 lines
19 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Pins the query-mapping core: which tags share a query (the GroupKey), the exact SQL emitted for each
/// group, and — the thing this type exists to guarantee — that <b>every authored value is a bound
/// parameter and never text</b>.
/// <para>GroupKey correctness governs read correctness: too coarse and tags silently read each other's
/// values; too fine and the batching collapses to one query per tag. Both failure modes are asserted
/// here.</para>
/// </summary>
public class SqlGroupPlannerTests
{
private static SqlTagDefinition KvTag(
string name, string table, string keyColumn, string keyValue, string valueColumn, string? timestampColumn)
=> new(name, SqlTagModel.KeyValue, table,
KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
TimestampColumn: timestampColumn);
private static SqlTagDefinition WideTag(
string name, string table, string columnName,
string? selectorColumn = null, string? selectorValue = null,
string? topByTimestamp = null, string? timestampColumn = null)
=> new(name, SqlTagModel.WideRow, table,
TimestampColumn: timestampColumn, ColumnName: columnName,
RowSelectorColumn: selectorColumn, RowSelectorValue: selectorValue,
RowSelectorTopByTimestamp: topByTimestamp);
// ---- the plan's golden case ----
[Fact]
public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
{
var dialect = new SqlServerDialect();
var tags = new[]
{
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", "sample_ts"),
};
var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
plans[0].SqlText.ShouldContain("[tag_name]");
plans[0].Members.Count.ShouldBe(2);
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text
}
[Fact]
public void KeyValuePlan_emitsTheWholeStatement_keyValueTimestampFromTheQuotedTable()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts")],
new SqlServerDialect());
plans[0].SqlText.ShouldBe(
"SELECT [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
plans[0].ParameterNames.ShouldBe(new[] { "@k0" });
plans[0].SelectedColumns.ShouldBe(new[] { "tag_name", "num_value", "sample_ts" });
plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
plans[0].KeyColumn.ShouldBe("tag_name");
plans[0].ValueColumn.ShouldBe("num_value");
plans[0].TimestampColumn.ShouldBe("sample_ts");
}
[Fact]
public void KeyValuePlan_withoutATimestampColumn_omitsItFromTheSelectList()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)],
new SqlServerDialect());
plans[0].SqlText.ShouldBe(
"SELECT [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
plans[0].TimestampColumn.ShouldBeNull();
}
// ---- GroupKey: what must NOT fold together ----
[Fact]
public void KeyValueTags_onDifferentTables_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
KvTag("B", "dbo.OtherValues", "tag_name", "Line1.Temp", "num_value", "sample_ts"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
plans[0].GroupKey.ShouldNotBe(plans[1].GroupKey);
plans[0].SqlText.ShouldContain("[dbo].[TagValues]");
plans[1].SqlText.ShouldContain("[dbo].[OtherValues]");
}
[Fact]
public void KeyValueTags_onTheSameTableButDifferentValueColumn_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "str_value", "sample_ts"),
], new SqlServerDialect()).ToList();
// Folding these would make B read A's column — the silent cross-read this GroupKey prevents.
plans.Count.ShouldBe(2);
plans[0].SqlText.ShouldContain("[num_value]");
plans[1].SqlText.ShouldContain("[str_value]");
}
[Fact]
public void KeyValueTags_onTheSameTableButDifferentTimestampColumn_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", "sample_ts"),
KvTag("B", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", "recorded_at"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
}
[Fact]
public void KeyValueTags_onTheSameTableButDifferentKeyColumn_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("B", "dbo.TagValues", "alt_name", "Line1.Temp", "num_value", null),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
}
[Fact]
public void TagsOfDifferentModels_neverShareAPlan_evenOnTheSameTable()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.Latest", "tag_name", "Line1.Speed", "num_value", null),
WideTag("B", "dbo.Latest", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
plans[1].Model.ShouldBe(SqlTagModel.WideRow);
}
// ---- distinct parameter binding ----
[Fact]
public void KeyValueTags_sharingAKeyValue_bindTheKeyOnce_butKeepBothMembers()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("B", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("C", "dbo.TagValues", "tag_name", "Line1.Temp", "num_value", null),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(1);
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" });
plans[0].ParameterNames.ShouldBe(new[] { "@k0", "@k1" });
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
// Both tags stay members: two OPC UA nodes are fed from the one row.
plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "B", "C" });
}
[Fact]
public void ParameterNamesAndParameters_arePositionallyAligned_andMatchTheMarkersInTheSql()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.TagValues", "tag_name", "k0", "num_value", null),
KvTag("B", "dbo.TagValues", "tag_name", "k1", "num_value", null),
KvTag("C", "dbo.TagValues", "tag_name", "k2", "num_value", null),
], new SqlServerDialect()).ToList();
var plan = plans[0];
plan.ParameterNames.Count.ShouldBe(plan.Parameters.Count);
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")");
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]
public void WideRowTags_onTheSameRowSelector_foldIntoOnePlan_listingEachColumnOnce()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
WideTag("B", "dbo.LatestStatus", "pressure", selectorColumn: "station_id", selectorValue: "7"),
WideTag("C", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldBe(
"SELECT [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
plans[0].ParameterNames.ShouldBe(new[] { "@w" });
plans[0].Parameters.ShouldBe(new object[] { "7" }); // the whereValue is bound, never inlined
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure" });
plans[0].Members.Count.ShouldBe(3);
}
[Fact]
public void WideRowTags_onADifferentRowSelectorValue_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
WideTag("B", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "8"),
], new SqlServerDialect()).ToList();
// Folding these would publish station 7's temperature on station 8's node.
plans.Count.ShouldBe(2);
plans[0].Parameters.ShouldBe(new object[] { "7" });
plans[1].Parameters.ShouldBe(new object[] { "8" });
}
[Fact]
public void WideRowPlan_appendsEachDistinctMemberTimestampColumnAfterTheValueColumns()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.LatestStatus", "oven_temp",
selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"),
WideTag("B", "dbo.LatestStatus", "pressure",
selectorColumn: "station_id", selectorValue: "7", timestampColumn: "sample_ts"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(1);
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure", "sample_ts" });
plans[0].SqlText.ShouldBe(
"SELECT [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
}
[Fact]
public void WideRowTags_topByTimestamp_emitTheTop1OrderByDescForm_withNoParameters()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
WideTag("B", "dbo.Readings", "pressure", topByTimestamp: "sample_ts"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldBe(
"SELECT TOP 1 [oven_temp], [pressure] FROM [dbo].[Readings] ORDER BY [sample_ts] DESC");
plans[0].Parameters.ShouldBeEmpty();
plans[0].ParameterNames.ShouldBeEmpty();
}
[Fact]
public void WideRowTags_topByTimestamp_andAWherePair_onTheSameTable_produceTwoPlans()
{
var plans = SqlGroupPlanner.Plan(
[
WideTag("A", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
WideTag("B", "dbo.Readings", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
}
// ---- identifier quoting ----
[Fact]
public void DottedTableName_isQuotedPerPart_notAsOneIdentifier()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", "k", "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].SqlText.ShouldContain("[dbo].[TagValues]");
plans[0].SqlText.ShouldNotContain("[dbo.TagValues]"); // would name a nonexistent object
}
[Fact]
public void UndottedTableName_isQuotedAsASinglePart()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "TagValues", "tag_name", "k", "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].SqlText.ShouldContain("FROM [TagValues] ");
}
[Fact]
public void ThreePartTableName_isQuotedPerPart()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "Plant.dbo.TagValues", "tag_name", "k", "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].SqlText.ShouldContain("[Plant].[dbo].[TagValues]");
}
[Fact]
public void AnEmptyTableNamePart_isRejected_ratherThanEmitted()
=> Should.Throw<ArgumentException>(() => SqlGroupPlanner.Plan(
[KvTag("A", "dbo..TagValues", "tag_name", "k", "num_value", null)],
new SqlServerDialect()));
// ---- the injection boundary ----
[Fact]
public void AHostileKeyValue_isBoundAsAParameter_andNeverReachesTheSqlText()
{
const string payload = "'; DROP TABLE x --";
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", payload, "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].Parameters.ShouldBe(new object[] { payload });
plans[0].SqlText.ShouldNotContain("DROP");
plans[0].SqlText.ShouldNotContain("--");
plans[0].SqlText.ShouldNotContain("'");
}
[Fact]
public void AHostileWideRowSelectorValue_isBoundAsAParameter_andNeverReachesTheSqlText()
{
const string payload = "7'); DROP TABLE x --";
var plans = SqlGroupPlanner.Plan(
[WideTag("A", "dbo.LatestStatus", "oven_temp",
selectorColumn: "station_id", selectorValue: payload)],
new SqlServerDialect()).ToList();
plans[0].Parameters.ShouldBe(new object[] { payload });
plans[0].SqlText.ShouldNotContain("DROP");
}
[Fact]
public void AHostileColumnName_goesThroughQuoteIdentifier_ratherThanBeingConcatenatedRaw()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.T", "tag_name", "k", "v] ; DROP TABLE Users --", null)],
new SqlServerDialect()).ToList();
// A column name is an identifier — it cannot be parameterized, so the guarantee is that it is
// bracket-quoted with ] doubled, leaving one (nonexistent) identifier rather than executable SQL.
plans[0].SqlText.ShouldBe(
"SELECT [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
}
// ---- contract edges ----
[Fact]
public void NoTags_yieldNoPlans()
=> SqlGroupPlanner.Plan([], new SqlServerDialect()).ShouldBeEmpty();
[Fact]
public void PlanOrder_followsTheInputOrderOfTheFirstMemberOfEachGroup()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "dbo.Third", "k", "1", "v", null),
KvTag("B", "dbo.First", "k", "2", "v", null),
KvTag("C", "dbo.Third", "k", "3", "v", null),
], new SqlServerDialect()).ToList();
plans.Count.ShouldBe(2);
plans[0].Members.Select(m => m.Name).ShouldBe(new[] { "A", "C" });
plans[1].Members.Select(m => m.Name).ShouldBe(new[] { "B" });
}
[Fact]
public void TheDeferredQueryModel_throws_ratherThanBeingSilentlyDropped()
{
var tag = new SqlTagDefinition("A", SqlTagModel.Query, "dbo.T");
Should.Throw<NotSupportedException>(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
}
[Fact]
public void AWideRowTagWithNoRowSelectorAtAll_throws()
{
var tag = WideTag("A", "dbo.T", "oven_temp");
Should.Throw<ArgumentException>(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
}
}