using System.Globalization; using System.Text; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// /// Folds a set of 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. /// "Minimum" is bounded by the provider, not only by the group key. A key-value group whose /// distinct keys exceed is emitted as several plans over the /// same group key rather than as one statement the backend refuses — see . /// The GroupKey is the correctness contract. Two tags share a query only 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 /// SqlGroupPlannerTests. /// Injection boundary (design §8.1). Identifiers reach the command text through /// and nothing else; every authored value becomes a /// k0/w parameter — spelled with the dialect's own /// (@k0 on SQL Server, :p_k0 on Oracle) — with the value carried in /// . There is no interpolation of tag content into SQL anywhere in /// this type. /// The base names are this planner's, the spelling is the dialect's. Each plan carries the /// markers it emitted and the matching s, so the /// reader binds what the planner minted instead of re-deriving a provider convention — the two differ on /// ODP.NET (:p_k0 in the text, bare p_k0 on the parameter). /// public static class SqlGroupPlanner { /// The base-name prefix for a key-value model's IN-list parameters (k0…kN). private const string KeyBaseNamePrefix = "k"; /// The single parameter base name for a wide-row model's row selector. private const string RowSelectorBaseName = "w"; /// /// Groups by source and emits one per group — or /// several, when a key-value group holds more distinct keys than the dialect's /// allows in one command (see /// ). Plans from one group share that group's key. /// Deterministic: groups appear in the input order of their first member, a split group's chunks /// stay adjacent and in key order, members keep their input order within a chunk, and parameters keep /// the first-appearance order of their distinct values — so a given tag list always yields /// byte-identical SQL. /// /// The tags to plan. An empty sequence yields no plans. /// /// Supplies identifier quoting, the provider's row-limit syntax, and its per-command bind-parameter /// ceiling. /// /// At least one plan per distinct group key; more when a key-value group had to be chunked. /// or is null. /// /// 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 /// ). rejects all of these /// up front, so reaching one here means a caller built a definition by hand with a broken invariant. /// /// /// A tag carries (design §5.4, deferred to P3). /// Deliberately loud rather than skipped: 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. /// public static IReadOnlyList Plan(IEnumerable tags, ISqlDialect dialect) { ArgumentNullException.ThrowIfNull(tags); ArgumentNullException.ThrowIfNull(dialect); // Ordered grouping: the dictionary decides membership, the list decides emission order. var order = new List(); var groups = new Dictionary>(); 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(order.Count); foreach (var key in order) { var members = groups[key]; switch (members[0].Model) { case SqlTagModel.KeyValue: // One group can yield SEVERAL plans: the IN list is chunked at the dialect's ceiling. AddKeyValuePlans(plans, key, members, dialect); break; case SqlTagModel.WideRow: plans.Add(BuildWideRowPlan(key, members, dialect)); break; default: throw UnsupportedModel(members[0]); } } return plans; } /// /// 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. /// /// /// → (model, table, keyColumn, valueColumn, timestampColumn). /// Every one of those shapes the emitted SELECT: sharing a plan across two valueColumns /// would make one tag publish the other's column. /// /// /// → (model, table, whereColumn, whereValue, topByTimestamp) /// — i.e. the table plus the whole row selector, exactly design §5.3's /// (table, rowSelector). The members' own columnName/timestampColumn are /// deliberately not in the key: differing there is the point of the model (many columns, /// one row), and they are added to the SELECT list instead. /// /// /// String comparison is ordinal. SQL Server object names are usually case-insensitive, so /// two tags authored dbo.T and DBO.T 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. /// 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), }; /// /// Emits one key-value plan per chunk of the group's distinct keys, sized by /// . A group at or under the ceiling — every ordinary group /// — still yields exactly one plan. /// Why chunk rather than let the provider refuse. Every backend caps parameters per /// command (2100 on SQL Server, 1000 IN-list entries on Oracle), and a key-value (EAV) staging /// table is precisely the shape that attracts thousands of tags on one table — which the /// group-key rule folds into one group. Over the cap the provider rejects the statement after /// the connection opened, so every tag in the group publishes BadCommunicationError on every /// poll, permanently, and the driver reports a communication fault against a database that is /// answering (Driver.Sql-002). /// Chunks share the group key, and no consumer needs to change. The reader executes one /// command per plan and slices each plan's own back, so several /// plans over one group key cost extra round-trips and nothing else. The alternative — a deploy-time /// tag-count gate — would refuse a configuration the driver can serve perfectly well. /// Members follow their key, never their position. Two tags authored on the same /// keyValue land in the same chunk (one bound parameter, both fed); a member placed in a chunk /// that does not bind its key would read a result set that cannot contain its row and publish /// BadNoData forever. /// private static void AddKeyValuePlans( List plans, object groupKey, List members, ISqlDialect dialect) { var ceiling = dialect.MaxBindParameters; if (ceiling < 1) { // Not a silent clamp: chunking to zero would loop forever, and a one-per-chunk fallback would // hide a broken dialect behind a poll that suddenly costs one round-trip per tag. throw new ArgumentException( $"The {dialect.Provider} dialect reports a per-command bind-parameter ceiling of {ceiling}; " + "it must be at least 1.", nameof(dialect)); } // Distinct keys in first-appearance order decide the chunk boundaries; every member then follows its // own key. Both halves keep the planner's determinism: a given tag list yields byte-identical SQL. var chunkOfKey = new Dictionary(StringComparer.Ordinal); foreach (var member in members) { var keyValue = member.KeyValue ?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member); if (!chunkOfKey.ContainsKey(keyValue)) chunkOfKey[keyValue] = chunkOfKey.Count / ceiling; } var chunkCount = ((chunkOfKey.Count - 1) / ceiling) + 1; if (chunkCount == 1) { plans.Add(BuildKeyValuePlan(groupKey, members, dialect)); return; } var chunks = new List[chunkCount]; for (var c = 0; c < chunkCount; c++) chunks[c] = []; foreach (var member in members) chunks[chunkOfKey[member.KeyValue!]].Add(member); foreach (var chunk in chunks) plans.Add(BuildKeyValuePlan(groupKey, chunk, dialect)); } /// /// Emits SELECT <key>, <value>[, <ts>] FROM <table> WHERE <key> IN (@k0..@kN) /// for one chunk of a group. The key column is selected because the reader indexes rows by it to slice /// values back per member. /// Keys are de-duplicated (ordinal) before binding: two tags authored against the same /// keyValue bind one parameter but stay two members, so both nodes are fed from the one row. /// Markers are numbered from k0 within the chunk — each chunk is its own command, so /// carrying a group-wide index forward would leave a three-parameter command spelling /// IN (@k3, @k4, @k5). /// private static SqlQueryPlan BuildKeyValuePlan( object groupKey, List 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(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 markers = new List(members.Count); var bindNames = new List(members.Count); var values = new List(members.Count); var seen = new HashSet(StringComparer.Ordinal); foreach (var member in members) { var keyValue = member.KeyValue ?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member); if (!seen.Add(keyValue)) continue; var baseName = KeyBaseNamePrefix + values.Count.ToString(CultureInfo.InvariantCulture); markers.Add(dialect.ParameterMarker(baseName)); bindNames.Add(dialect.ParameterBindName(baseName)); 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(", ", markers).Append(')') .ToString(); return new SqlQueryPlan( SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected); } /// /// Emits SELECT <cols> FROM <table> WHERE <whereColumn> = @w, or — for a /// topByTimestamp selector — the single-row form /// SELECT <limit> <cols> FROM <table> ORDER BY <ts> DESC <limit>, whose /// row limit comes from / /// (T-SQL fills the prefix: SELECT TOP 1 …). /// The SELECT list is each member's columnName (distinct, in first-appearance order), /// followed by each distinct member timestampColumn — so members of one row may carry different /// timestamp columns without splitting the group. The where-column itself is not selected: every /// row in the result already matches the bound value, so reading it back adds nothing. /// private static SqlQueryPlan BuildWideRowPlan( object groupKey, List members, ISqlDialect dialect) { var first = members[0]; var selected = new List(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 marker = dialect.ParameterMarker(RowSelectorBaseName); var sql = string.Concat( "SELECT ", columns, " FROM ", table, " WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker); return new SqlQueryPlan( SqlTagModel.WideRow, groupKey, sql, [marker], [dialect.ParameterBindName(RowSelectorBaseName)], [first.RowSelectorValue], members, selected); } if (!Blank(first.RowSelectorTopByTimestamp)) { // The row limit is dialect syntax and sits at opposite ends of the statement per provider // (T-SQL "SELECT TOP 1 …", SQLite/Postgres/MySQL "… LIMIT 1"), so both ends are emitted // unconditionally and the dialect decides which one it fills. See ISqlDialect.SingleRowLimitPrefix. var sql = string.Concat( "SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table, " ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC", dialect.SingleRowLimitSuffix); 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)); } /// /// Quotes a possibly multi-part object name by splitting on . and quoting each part — /// dbo.TagValues becomes [dbo].[TagValues]. /// quotes exactly one part; handing it the dotted /// form would yield [dbo.TagValues], which is safe but names an object that does not exist. An /// empty part (dbo..T) is rejected by the dialect rather than emitted. As a consequence an object /// whose real name contains a literal . cannot be authored — an accepted v1 limitation. /// 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)); } /// Quotes each already-de-duplicated column name and joins them into a SELECT list. private static string QuoteList(IReadOnlyList columns, ISqlDialect dialect) => string.Join(", ", columns.Select(dialect.QuoteIdentifier)); /// Appends unless an ordinal-equal entry is already present. private static void AddDistinct(List target, string value) { if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value); } /// True when the string is null, empty, or all whitespace — i.e. cannot be an identifier. private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value); /// Returns a required identifier field, or throws naming both the tag and the field. 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)."); }