Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs
T

387 lines
15 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" });
}
// ---- 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()));
}
}