a68593e714
SqlTagModel.Query (design §5.4) is now served end to end instead of refused. The driver config declares `queries` (name -> sql, params, maxRows); a tag names the query, the result `column` it reads and optionally a `rowKey` picking its row. - Parser/deploy gate: query + column required, rowKey all-or-nothing; Inspect explains each refusal. Table is not read for this model. - SqlReadOnlyStatement: single SELECT/WITH only, no data/schema-changing keyword outside literals/comments (refuses SELECT INTO, FOR UPDATE, DML CTEs). Shared by the factory and the AdminUI driver form (SqlNamedQueryRules.Describe), so the form refuses what the driver refuses. One bad entry fails the driver. - Parameters are always bound; the factory refuses a declared parameter whose dialect marker the statement never uses. - Reader: one execution per query per poll, inside a transaction that is always rolled back; result columns matched exact-then-unique-CI; no row -> BadNoData, ambiguous row / missing column / over maxRows -> BadConfigurationError. - Catalog gate passes Query tags through (nothing is quoted into SQL); a Query-only driver skips the catalog load. A tag naming an undeclared query is skipped with the reason on /hosts. - AdminUI: typed editor offers Query (query/column/rowKey); driver form edits `queries` as JSON and keeps the last valid set on a typo. (lmxopcua#753) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
498 lines
20 KiB
C#
498 lines
20 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);
|
|
|
|
// ---- R06:S-1: the row cap ----
|
|
|
|
/// <summary>
|
|
/// A key-value plan reads up to twice its distinct keys (so stray duplicates are attributed per key) and
|
|
/// asks for one row more; a where-pair wide-row plan asks for two. Enough to SEE an ambiguous selector,
|
|
/// never enough to materialise a history table every poll.
|
|
/// The topByTimestamp form keeps its single-row limit and needs no cap.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Plans_carryARowCap_andAskForOneRowMore()
|
|
{
|
|
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", "k1", "num_value", null), // same key: one row serves both
|
|
WideTag("W", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
|
|
WideTag("T", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
|
|
], new SqlServerDialect()).ToList();
|
|
|
|
var kv = plans.Single(p => p.Model == SqlTagModel.KeyValue);
|
|
kv.RowCap.ShouldBe(4); // 2 distinct keys, with 2x headroom for per-key attribution
|
|
kv.SqlText.ShouldStartWith("SELECT TOP (5) ");
|
|
|
|
var wherePair = plans.Single(p => p.Parameters.Count == 1 && p.Model == SqlTagModel.WideRow);
|
|
wherePair.RowCap.ShouldBe(1);
|
|
wherePair.SqlText.ShouldStartWith("SELECT TOP (2) ");
|
|
|
|
var top = plans.Single(p => p.Parameters.Count == 0);
|
|
top.RowCap.ShouldBeNull();
|
|
top.SqlText.ShouldStartWith("SELECT TOP 1 ");
|
|
}
|
|
|
|
// ---- 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 TOP (3) [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 TOP (3) [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) + ")"); // T-SQL caps at the front
|
|
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) LIMIT 5");
|
|
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 LIMIT 2");
|
|
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 TOP (2) [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 TOP (2) [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 TOP (3) [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 AQueryTagWithNoNamedQueries_throws_ratherThanBeingSilentlyDropped()
|
|
{
|
|
var tag = new SqlTagDefinition("A", SqlTagModel.Query, string.Empty, ColumnName: "c", QueryName: "q");
|
|
Should.Throw<ArgumentException>(() => 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()));
|
|
}
|
|
}
|