886ce1b87c
ISqlDialect gains MaxBindParameters — the per-command ceiling the seam was missing while modelling everything else that differs per backend: 2098 usable of SQL Server's 2100 (sp_executesql spends two), 1000 for Oracle's IN-list limit (ORA-01795, tighter than its 64k bind count), 65535 for the Postgres Bind message. SqlGroupPlanner.AddKeyValuePlans now emits several plans for one group when its distinct keys exceed that ceiling, instead of one statement the provider refuses after the connection opened — which left every tag in an EAV group publishing BadCommunicationError on every poll, permanently, and reported the database endpoint as down. Chunks share the group key; members follow their own key so a shared keyValue never splits. SqlPollReader is unchanged: it runs one command per plan and slices each plan's own Members back, proven by a fixture read at a ceiling of 1.
108 lines
6.8 KiB
C#
108 lines
6.8 KiB
C#
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="ParameterBindNames"/>. There is
|
|
/// no path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
|
|
/// <para><b>Markers and bind names are carried separately</b> because they are not the same string on
|
|
/// every provider: ODP.NET's text says <c>:p_k0</c> while its <see cref="DbParameter.ParameterName"/> is the
|
|
/// bare <c>p_k0</c>. Both lists are positionally aligned with <see cref="Parameters"/>, so a binding site
|
|
/// copies the pairing the planner minted rather than re-deriving a provider convention it does not own.</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.
|
|
/// <para><b>Not unique across plans.</b> A key-value group holding more distinct keys than the dialect's
|
|
/// <see cref="ISqlDialect.MaxBindParameters"/> is emitted as several plans carrying <em>this same key</em>
|
|
/// — the chunking is an execution detail of one group, not a regrouping.</para>
|
|
/// </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="ParameterBindNames">
|
|
/// The <see cref="DbParameter.ParameterName"/> to bind each value under, from
|
|
/// <see cref="ISqlDialect.ParameterBindName"/>, aligned index-for-index with <see cref="Parameters"/> and
|
|
/// with <see cref="ParameterNames"/>. Equal to the marker on every provider but ODP.NET, which wants the
|
|
/// bare name — which is exactly why it is a carried list and not a transformation of the marker.
|
|
/// </param>
|
|
/// <param name="Parameters">
|
|
/// The values to bind, aligned with <see cref="ParameterNames"/> and <see cref="ParameterBindNames"/>.
|
|
/// 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<string> ParameterBindNames,
|
|
IReadOnlyList<object?> Parameters,
|
|
IReadOnlyList<SqlTagDefinition> Members,
|
|
IReadOnlyList<string> SelectedColumns)
|
|
{
|
|
// A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the
|
|
// planner's mutable working lists — an IReadOnlyList<T> holding a List<T> is downcastable, and a
|
|
// mutation would silently corrupt every later poll that reuses the plan. Snapshot at the boundary.
|
|
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
|
|
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
|
|
|
|
/// <summary>The parameter names to bind under, aligned with <see cref="Parameters"/>.</summary>
|
|
public IReadOnlyList<string> ParameterBindNames { get; } = [.. ParameterBindNames];
|
|
|
|
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
|
|
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
|
|
|
|
/// <summary>The tags this plan feeds, in input order; duplicates preserved.</summary>
|
|
public IReadOnlyList<SqlTagDefinition> Members { get; } = [.. Members];
|
|
|
|
/// <summary>The bare SELECT-list column names, in result-set ordinal order.</summary>
|
|
public IReadOnlyList<string> SelectedColumns { get; } = [.. 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;
|
|
}
|