diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
new file mode 100644
index 00000000..54f92d1d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
@@ -0,0 +1,253 @@
+using System.Globalization;
+using System.Text;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// Folds a set of s into the minimum number of parameterized queries
+/// (design §3.6) — the driver's batching core. Pure and side-effect free: no connection, no I/O, no clock.
+/// The GroupKey is the correctness contract. Two tags share a query only when every
+/// field that shapes the result set is identical. Too coarse and a tag silently reads a neighbour's cell;
+/// too fine and batching degenerates to one round-trip per tag. Both directions are pinned by
+/// SqlGroupPlannerTests .
+/// Injection boundary (design §8.1). Identifiers reach the command text through
+/// and nothing else; every authored value becomes a
+/// @k0 /@w marker with the value carried in . There is no
+/// interpolation of tag content into SQL anywhere in this type.
+///
+public static class SqlGroupPlanner
+{
+ /// The parameter-marker prefix for a key-value model's IN list.
+ private const string KeyMarkerPrefix = "@k";
+
+ /// The single parameter marker for a wide-row model's row selector.
+ private const string RowSelectorMarker = "@w";
+
+ ///
+ /// Groups by source and emits one per group.
+ /// Deterministic: groups appear in the input order of their first member, members keep their input
+ /// order within a group, and parameters keep the first-appearance order of their distinct values — so a
+ /// given tag list always yields byte-identical SQL.
+ ///
+ /// The tags to plan. An empty sequence yields no plans.
+ /// Supplies identifier quoting and the provider's row-limit syntax.
+ /// One plan per distinct group key.
+ /// or is null.
+ ///
+ /// A definition is missing a field its model requires (e.g. a wide-row tag with no row selector), or an
+ /// identifier cannot be quoted (an empty table-name part, a control character — see
+ /// ). rejects all of these
+ /// up front, so reaching one here means a caller built a definition by hand with a broken invariant.
+ ///
+ ///
+ /// A tag carries (design §5.4, deferred to P3), or a
+ /// topByTimestamp selector is planned for a provider whose row-limit syntax is not yet modelled.
+ /// Deliberately loud rather than skipped: silently dropping a tag would leave an authored node
+ /// permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice.
+ ///
+ public static IReadOnlyList Plan(IEnumerable tags, ISqlDialect dialect)
+ {
+ ArgumentNullException.ThrowIfNull(tags);
+ ArgumentNullException.ThrowIfNull(dialect);
+
+ // Ordered grouping: the dictionary decides membership, the list decides emission order.
+ var order = new List();
+ var groups = new Dictionary>();
+
+ foreach (var tag in tags)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+ var key = BuildGroupKey(tag);
+ if (!groups.TryGetValue(key, out var members))
+ {
+ members = [];
+ groups.Add(key, members);
+ order.Add(key);
+ }
+
+ members.Add(tag);
+ }
+
+ var plans = new List(order.Count);
+ foreach (var key in order)
+ {
+ var members = groups[key];
+ plans.Add(members[0].Model switch
+ {
+ SqlTagModel.KeyValue => BuildKeyValuePlan(key, members, dialect),
+ SqlTagModel.WideRow => BuildWideRowPlan(key, members, dialect),
+ _ => throw UnsupportedModel(members[0]),
+ });
+ }
+
+ return plans;
+ }
+
+ ///
+ /// Composes the equality key that decides which tags share a query. Both models produce a 5-tuple whose
+ /// first element is the model, so a key-value key can never compare equal to a wide-row key.
+ ///
+ /// -
+ ///
→ (model, table, keyColumn, valueColumn, timestampColumn) .
+ /// Every one of those shapes the emitted SELECT: sharing a plan across two valueColumn s
+ /// would make one tag publish the other's column.
+ ///
+ /// -
+ ///
→ (model, table, whereColumn, whereValue, topByTimestamp)
+ /// — i.e. the table plus the whole row selector, exactly design §5.3's
+ /// (table, rowSelector) . The members' own columnName /timestampColumn are
+ /// deliberately not in the key: differing there is the point of the model (many columns,
+ /// one row), and they are added to the SELECT list instead.
+ ///
+ ///
+ /// String comparison is ordinal . SQL Server object names are usually case-insensitive, so
+ /// two tags authored dbo.T and DBO.T plan as two groups. That costs one extra round-trip
+ /// and is never wrong; guessing the server's collation here could fold two genuinely different sources.
+ ///
+ private static object BuildGroupKey(SqlTagDefinition tag) => tag.Model switch
+ {
+ SqlTagModel.KeyValue => (tag.Model, tag.Table, tag.KeyColumn, tag.ValueColumn, tag.TimestampColumn),
+ SqlTagModel.WideRow => (tag.Model, tag.Table, tag.RowSelectorColumn, tag.RowSelectorValue,
+ tag.RowSelectorTopByTimestamp),
+ _ => throw UnsupportedModel(tag),
+ };
+
+ ///
+ /// Emits SELECT <key>, <value>[, <ts>] FROM <table> WHERE <key> IN (@k0..@kN) .
+ /// The key column is selected because the reader indexes rows by it to slice values back per member.
+ /// Keys are de-duplicated (ordinal) before binding: two tags authored against the same
+ /// keyValue bind one parameter but stay two members, so both nodes are fed from the one row.
+ ///
+ private static SqlQueryPlan BuildKeyValuePlan(
+ object groupKey, List members, ISqlDialect dialect)
+ {
+ var first = members[0];
+ var keyColumn = RequireIdentifier(first.KeyColumn, nameof(SqlTagDefinition.KeyColumn), first);
+ var valueColumn = RequireIdentifier(first.ValueColumn, nameof(SqlTagDefinition.ValueColumn), first);
+ var timestampColumn = Blank(first.TimestampColumn) ? null : first.TimestampColumn;
+
+ var selected = new List(3);
+ AddDistinct(selected, keyColumn);
+ AddDistinct(selected, valueColumn);
+ if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
+
+ // A keyValue may legitimately be the empty string, so only null is a broken invariant.
+ var names = new List(members.Count);
+ var values = new List(members.Count);
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var member in members)
+ {
+ var keyValue = member.KeyValue
+ ?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
+ if (!seen.Add(keyValue)) continue;
+ names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
+ values.Add(keyValue);
+ }
+
+ var sql = new StringBuilder("SELECT ")
+ .Append(QuoteList(selected, dialect))
+ .Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
+ .Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
+ .Append(" IN (").AppendJoin(", ", names).Append(')')
+ .ToString();
+
+ return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
+ }
+
+ ///
+ /// Emits SELECT <cols> FROM <table> WHERE <whereColumn> = @w , or — for a
+ /// topByTimestamp selector — SELECT TOP 1 <cols> FROM <table> ORDER BY <ts> DESC .
+ /// The SELECT list is each member's columnName (distinct, in first-appearance order),
+ /// followed by each distinct member timestampColumn — so members of one row may carry different
+ /// timestamp columns without splitting the group. The where-column itself is not selected: every
+ /// row in the result already matches the bound value, so reading it back adds nothing.
+ ///
+ private static SqlQueryPlan BuildWideRowPlan(
+ object groupKey, List members, ISqlDialect dialect)
+ {
+ var first = members[0];
+
+ var selected = new List(members.Count + 1);
+ foreach (var member in members)
+ AddDistinct(selected, RequireIdentifier(member.ColumnName, nameof(SqlTagDefinition.ColumnName), member));
+ foreach (var member in members)
+ if (!Blank(member.TimestampColumn))
+ AddDistinct(selected, member.TimestampColumn!);
+
+ var table = QuoteQualifiedName(first.Table, dialect);
+ var columns = QuoteList(selected, dialect);
+
+ // A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
+ if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
+ {
+ var sql = string.Concat(
+ "SELECT ", columns, " FROM ", table,
+ " WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
+ return new SqlQueryPlan(
+ SqlTagModel.WideRow, groupKey, sql,
+ [RowSelectorMarker], [first.RowSelectorValue], members, selected);
+ }
+
+ if (!Blank(first.RowSelectorTopByTimestamp))
+ {
+ // "TOP 1" is T-SQL. Other providers spell the row limit differently (LIMIT 1, FETCH FIRST,
+ // ROWNUM), and ISqlDialect does not model it yet because v1 constructs SqlServer only — so refuse
+ // loudly rather than emit a statement that would not parse. Promote a row-limit member onto
+ // ISqlDialect when P2 adds Postgres/ODBC.
+ if (dialect.Provider != SqlProvider.SqlServer)
+ throw new NotSupportedException(
+ $"The wide-row 'topByTimestamp' row selector needs a provider-specific row limit; " +
+ $"{dialect.Provider} is not modelled yet (v1 constructs SqlServer only).");
+
+ var sql = string.Concat(
+ "SELECT TOP 1 ", columns, " FROM ", table,
+ " ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC");
+ return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
+ }
+
+ throw new ArgumentException(
+ $"Wide-row tag '{first.Name}' has no row selector: author either a rowSelector.whereColumn/" +
+ "whereValue pair or a rowSelector.topByTimestamp column.", nameof(members));
+ }
+
+ ///
+ /// Quotes a possibly multi-part object name by splitting on . and quoting each part —
+ /// dbo.TagValues becomes [dbo].[TagValues] .
+ /// quotes exactly one part; handing it the dotted
+ /// form would yield [dbo.TagValues] , which is safe but names an object that does not exist. An
+ /// empty part (dbo..T ) is rejected by the dialect rather than emitted. As a consequence an object
+ /// whose real name contains a literal . cannot be authored — an accepted v1 limitation.
+ ///
+ private static string QuoteQualifiedName(string name, ISqlDialect dialect)
+ {
+ if (Blank(name))
+ throw new ArgumentException("A Sql tag's 'table' must be a non-empty object name.", nameof(name));
+
+ return string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier));
+ }
+
+ /// Quotes each already-de-duplicated column name and joins them into a SELECT list.
+ private static string QuoteList(IReadOnlyList columns, ISqlDialect dialect)
+ => string.Join(", ", columns.Select(dialect.QuoteIdentifier));
+
+ /// Appends unless an ordinal-equal entry is already present.
+ private static void AddDistinct(List target, string value)
+ {
+ if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value);
+ }
+
+ /// True when the string is null, empty, or all whitespace — i.e. cannot be an identifier.
+ private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);
+
+ /// Returns a required identifier field, or throws naming both the tag and the field.
+ private static string RequireIdentifier(string? value, string field, SqlTagDefinition tag)
+ => Blank(value) ? throw MissingField(field, tag) : value!;
+
+ private static ArgumentException MissingField(string field, SqlTagDefinition tag)
+ => new($"Sql tag '{tag.Name}' ({tag.Model}) is missing the required '{field}'.");
+
+ private static NotSupportedException UnsupportedModel(SqlTagDefinition tag)
+ => new($"Sql tag '{tag.Name}' uses the {tag.Model} model, which the poll planner does not support. " +
+ "v1 plans KeyValue and WideRow only; the named-query model is deferred (design §5.4).");
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs
new file mode 100644
index 00000000..0d933d07
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs
@@ -0,0 +1,75 @@
+using System.Data.Common;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
+
+///
+/// One compiled poll query and the tags it feeds (design §3.6, "group execution shape"). Every tag that
+/// shares a reads from the same result set, so a poll pass executes one plan
+/// per group rather than one query per tag.
+/// Injection boundary. is built from dialect-quoted identifiers only;
+/// every authored value lives in and is bound as a
+/// named by the positionally-aligned . There is no
+/// path by which a tag's keyValue or whereValue reaches the command text.
+/// Produced only by ; consumed by the poll reader, which executes
+/// once and slices the rows back to per-member snapshots.
+///
+///
+/// The tag→value mapping model every member shares. Determines how the reader slices the result set:
+/// indexes rows by and matches each member's
+/// ; takes the single row and
+/// reads each member's .
+///
+///
+/// The opaque equality key that decided this grouping — a value tuple, so two plans compare equal exactly
+/// when they would have folded together. Exposed for diagnostics and plan caching, never for parsing.
+///
+/// The single parameterized statement to execute for this group.
+///
+/// The parameter markers appearing in , in the order they appear, aligned index-for-index
+/// with . Carried explicitly so the reader never has to re-derive the naming
+/// convention.
+///
+///
+/// The values to bind, aligned with . Captured verbatim from the authored tags
+/// — a hostile value is inert here because it is data, not text.
+///
+///
+/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
+/// may legitimately read the same cell, and both must receive a value.
+///
+///
+/// The bare (unquoted) column names in 's SELECT list, in order and distinct — the
+/// reader's map from result-set ordinal to source column.
+///
+public sealed record SqlQueryPlan(
+ SqlTagModel Model,
+ object GroupKey,
+ string SqlText,
+ IReadOnlyList ParameterNames,
+ IReadOnlyList Parameters,
+ IReadOnlyList Members,
+ IReadOnlyList SelectedColumns)
+{
+ ///
+ /// The key column all members are matched on — for any model but
+ /// . Uniform across by the group-key invariant,
+ /// so Members[0] is authoritative.
+ ///
+ public string? KeyColumn => Model == SqlTagModel.KeyValue ? Members[0].KeyColumn : null;
+
+ ///
+ /// The column every member's value is read from — for any model but
+ /// . Uniform across by the group-key invariant.
+ ///
+ public string? ValueColumn => Model == SqlTagModel.KeyValue ? Members[0].ValueColumn : null;
+
+ ///
+ /// The shared source-timestamp column, or when the group has none (the reader
+ /// then stamps the poll time). only — it is part of that model's
+ /// group key, so it is uniform. A plan deliberately allows members to
+ /// carry different timestamp columns (all of them are selected), so the reader must read
+ /// per member there.
+ ///
+ public string? TimestampColumn => Model == SqlTagModel.KeyValue ? Members[0].TimestampColumn : null;
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs
new file mode 100644
index 00000000..85fe8f34
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs
@@ -0,0 +1,386 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
+
+///
+/// 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 every authored value is a bound
+/// parameter and never text .
+/// 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.
+///
+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" });
+ }
+
+ // ---- 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(() => 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(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
+ }
+
+ [Fact]
+ public void AWideRowTagWithNoRowSelectorAtAll_throws()
+ {
+ var tag = WideTag("A", "dbo.T", "oven_temp");
+ Should.Throw(() => SqlGroupPlanner.Plan([tag], new SqlServerDialect()));
+ }
+}