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