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 valueColumns + /// 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())); + } +}