feat(sql): SqlGroupPlanner folds tags into one parameterized query per group
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Folds a set of <see cref="SqlTagDefinition"/>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.
|
||||
/// <para><b>The GroupKey is the correctness contract.</b> Two tags share a query <em>only</em> 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
|
||||
/// <c>SqlGroupPlannerTests</c>.</para>
|
||||
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
|
||||
/// <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
|
||||
/// interpolation of tag content into SQL anywhere in this type.</para>
|
||||
/// </summary>
|
||||
public static class SqlGroupPlanner
|
||||
{
|
||||
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
|
||||
private const string KeyMarkerPrefix = "@k";
|
||||
|
||||
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
|
||||
private const string RowSelectorMarker = "@w";
|
||||
|
||||
/// <summary>
|
||||
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
/// <param name="tags">The tags to plan. An empty sequence yields no plans.</param>
|
||||
/// <param name="dialect">Supplies identifier quoting and the provider's row-limit syntax.</param>
|
||||
/// <returns>One plan per distinct group key.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tags"/> or <paramref name="dialect"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 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
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/>). <see cref="SqlEquipmentTagParser"/> rejects all of these
|
||||
/// up front, so reaching one here means a caller built a definition by hand with a broken invariant.
|
||||
/// </exception>
|
||||
/// <exception cref="NotSupportedException">
|
||||
/// A tag carries <see cref="SqlTagModel.Query"/> (design §5.4, deferred to P3), or a
|
||||
/// <c>topByTimestamp</c> selector is planned for a provider whose row-limit syntax is not yet modelled.
|
||||
/// <b>Deliberately loud rather than skipped:</b> 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.
|
||||
/// </exception>
|
||||
public static IReadOnlyList<SqlQueryPlan> Plan(IEnumerable<SqlTagDefinition> tags, ISqlDialect dialect)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tags);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
|
||||
// Ordered grouping: the dictionary decides membership, the list decides emission order.
|
||||
var order = new List<object>();
|
||||
var groups = new Dictionary<object, List<SqlTagDefinition>>();
|
||||
|
||||
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<SqlQueryPlan>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <see cref="SqlTagModel.KeyValue"/> → <c>(model, table, keyColumn, valueColumn, timestampColumn)</c>.
|
||||
/// Every one of those shapes the emitted SELECT: sharing a plan across two <c>valueColumn</c>s
|
||||
/// would make one tag publish the other's column.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <see cref="SqlTagModel.WideRow"/> → <c>(model, table, whereColumn, whereValue, topByTimestamp)</c>
|
||||
/// — i.e. the table plus the whole row selector, exactly design §5.3's
|
||||
/// <c>(table, rowSelector)</c>. The members' own <c>columnName</c>/<c>timestampColumn</c> are
|
||||
/// deliberately <b>not</b> in the key: differing there is the point of the model (many columns,
|
||||
/// one row), and they are added to the SELECT list instead.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>String comparison is <b>ordinal</b>. SQL Server object names are usually case-insensitive, so
|
||||
/// two tags authored <c>dbo.T</c> and <c>DBO.T</c> 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.</para>
|
||||
/// </summary>
|
||||
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),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Emits <c>SELECT <key>, <value>[, <ts>] FROM <table> WHERE <key> IN (@k0..@kN)</c>.
|
||||
/// The key column is selected because the reader indexes rows by it to slice values back per member.
|
||||
/// <para>Keys are de-duplicated (ordinal) before binding: two tags authored against the same
|
||||
/// <c>keyValue</c> bind one parameter but stay two members, so both nodes are fed from the one row.</para>
|
||||
/// </summary>
|
||||
private static SqlQueryPlan BuildKeyValuePlan(
|
||||
object groupKey, List<SqlTagDefinition> 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<string>(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<string>(members.Count);
|
||||
var values = new List<object?>(members.Count);
|
||||
var seen = new HashSet<string>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits <c>SELECT <cols> FROM <table> WHERE <whereColumn> = @w</c>, or — for a
|
||||
/// <c>topByTimestamp</c> selector — <c>SELECT TOP 1 <cols> FROM <table> ORDER BY <ts> DESC</c>.
|
||||
/// <para>The SELECT list is each member's <c>columnName</c> (distinct, in first-appearance order),
|
||||
/// followed by each distinct member <c>timestampColumn</c> — so members of one row may carry different
|
||||
/// timestamp columns without splitting the group. The where-column itself is <b>not</b> selected: every
|
||||
/// row in the result already matches the bound value, so reading it back adds nothing.</para>
|
||||
/// </summary>
|
||||
private static SqlQueryPlan BuildWideRowPlan(
|
||||
object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
|
||||
{
|
||||
var first = members[0];
|
||||
|
||||
var selected = new List<string>(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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quotes a possibly multi-part object name by splitting on <c>.</c> and quoting each part —
|
||||
/// <c>dbo.TagValues</c> becomes <c>[dbo].[TagValues]</c>.
|
||||
/// <para><see cref="ISqlDialect.QuoteIdentifier"/> quotes exactly <b>one</b> part; handing it the dotted
|
||||
/// form would yield <c>[dbo.TagValues]</c>, which is safe but names an object that does not exist. An
|
||||
/// empty part (<c>dbo..T</c>) is rejected by the dialect rather than emitted. As a consequence an object
|
||||
/// whose real name contains a literal <c>.</c> cannot be authored — an accepted v1 limitation.</para>
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Quotes each already-de-duplicated column name and joins them into a SELECT list.</summary>
|
||||
private static string QuoteList(IReadOnlyList<string> columns, ISqlDialect dialect)
|
||||
=> string.Join(", ", columns.Select(dialect.QuoteIdentifier));
|
||||
|
||||
/// <summary>Appends <paramref name="value"/> unless an ordinal-equal entry is already present.</summary>
|
||||
private static void AddDistinct(List<string> target, string value)
|
||||
{
|
||||
if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value);
|
||||
}
|
||||
|
||||
/// <summary>True when the string is null, empty, or all whitespace — i.e. cannot be an identifier.</summary>
|
||||
private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);
|
||||
|
||||
/// <summary>Returns a required identifier field, or throws naming both the tag and the field.</summary>
|
||||
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).");
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Data.Common;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// One compiled poll query and the tags it feeds (design §3.6, "group execution shape"). Every tag that
|
||||
/// shares a <see cref="GroupKey"/> reads from the <b>same</b> result set, so a poll pass executes one plan
|
||||
/// per group rather than one query per tag.
|
||||
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
|
||||
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
|
||||
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
|
||||
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
|
||||
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
|
||||
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
|
||||
/// </summary>
|
||||
/// <param name="Model">
|
||||
/// The tag→value mapping model every member shares. Determines how the reader slices the result set:
|
||||
/// <see cref="SqlTagModel.KeyValue"/> indexes rows by <see cref="KeyColumn"/> and matches each member's
|
||||
/// <see cref="SqlTagDefinition.KeyValue"/>; <see cref="SqlTagModel.WideRow"/> takes the single row and
|
||||
/// reads each member's <see cref="SqlTagDefinition.ColumnName"/>.
|
||||
/// </param>
|
||||
/// <param name="GroupKey">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <param name="SqlText">The single parameterized statement to execute for this group.</param>
|
||||
/// <param name="ParameterNames">
|
||||
/// The parameter markers appearing in <see cref="SqlText"/>, in the order they appear, aligned index-for-index
|
||||
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
|
||||
/// convention.
|
||||
/// </param>
|
||||
/// <param name="Parameters">
|
||||
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
|
||||
/// — a hostile value is inert here because it is data, not text.
|
||||
/// </param>
|
||||
/// <param name="Members">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <param name="SelectedColumns">
|
||||
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
|
||||
/// reader's map from result-set ordinal to source column.
|
||||
/// </param>
|
||||
public sealed record SqlQueryPlan(
|
||||
SqlTagModel Model,
|
||||
object GroupKey,
|
||||
string SqlText,
|
||||
IReadOnlyList<string> ParameterNames,
|
||||
IReadOnlyList<object?> Parameters,
|
||||
IReadOnlyList<SqlTagDefinition> Members,
|
||||
IReadOnlyList<string> SelectedColumns)
|
||||
{
|
||||
/// <summary>
|
||||
/// The key column all members are matched on — <see langword="null"/> for any model but
|
||||
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant,
|
||||
/// so <c>Members[0]</c> is authoritative.
|
||||
/// </summary>
|
||||
public string? KeyColumn => Model == SqlTagModel.KeyValue ? Members[0].KeyColumn : null;
|
||||
|
||||
/// <summary>
|
||||
/// The column every member's value is read from — <see langword="null"/> for any model but
|
||||
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant.
|
||||
/// </summary>
|
||||
public string? ValueColumn => Model == SqlTagModel.KeyValue ? Members[0].ValueColumn : null;
|
||||
|
||||
/// <summary>
|
||||
/// The shared source-timestamp column, or <see langword="null"/> when the group has none (the reader
|
||||
/// then stamps the poll time). <see cref="SqlTagModel.KeyValue"/> only — it is part of that model's
|
||||
/// group key, so it is uniform. A <see cref="SqlTagModel.WideRow"/> plan deliberately allows members to
|
||||
/// carry <em>different</em> timestamp columns (all of them are selected), so the reader must read
|
||||
/// <see cref="SqlTagDefinition.TimestampColumn"/> per member there.
|
||||
/// </summary>
|
||||
public string? TimestampColumn => Model == SqlTagModel.KeyValue ? Members[0].TimestampColumn : null;
|
||||
}
|
||||
Reference in New Issue
Block a user