diff --git a/docs/drivers/Sql.md b/docs/drivers/Sql.md
index c41a2aaca..33e1acffc 100644
--- a/docs/drivers/Sql.md
+++ b/docs/drivers/Sql.md
@@ -37,7 +37,7 @@ who believes writes are enabled can. → #753
## Tag models
-A tag says how to find its value. Two models ship; a third is deferred.
+A tag says how to find its value. Three models ship: `KeyValue`, `WideRow` and the named-query `Query`.
### `KeyValue` — the EAV shape
@@ -109,11 +109,53 @@ canonical text form. SQL Server behaves the same way.
authored `keyValue` refused the tag while the identically-bound `whereValue` was served — and the
deploy gate reported the key as *missing* when it was right there in the blob.)
-### `Query` — deferred to P3 (→ #753)
+### `Query` — a named read-only query (#753)
-Named arbitrary-`SELECT`. **The v1 parser rejects it**, and that refusal is enforced end-to-end —
-parser, validator, planner and the AdminUI editor all decline it rather than accepting the value and
-ignoring it later.
+For a join, a view you cannot create, or a computed projection. The **driver config** declares the
+statement under `queries`; the **tag** names the query, the result column it reads, and optionally the
+row:
+
+```json
+{ "queries": {
+ "activeBatch": {
+ "sql": "SELECT line, speed, temp, ts FROM dbo.LineStatus WHERE active = @active",
+ "params": { "active": true },
+ "maxRows": 100
+ } } }
+```
+
+```json
+{ "model": "Query", "query": "activeBatch", "column": "speed",
+ "rowKey": { "column": "line", "value": "Line1" }, "timestampColumn": "ts", "type": "Float64" }
+```
+
+- **One execution per query per poll**, however many tags read it.
+- **`rowKey` picks the row** (matched as text against that result column, like `keyValue`). Without a
+ `rowKey` the query must return exactly one row. No matching row ⇒ `BadNoData`; two matching rows (or
+ two rows with no `rowKey`) ⇒ `BadConfigurationError` — never "last row wins".
+- **Result column names** (`column`, `rowKey.column`, `timestampColumn`) match exactly first, then by a
+ unique case-insensitive match, so Postgres' lower-cased and Oracle's upper-cased aliases still bind. A
+ column the query does not return ⇒ `BadConfigurationError` plus a rate-limited warning listing what it
+ did return.
+- **`maxRows`** (default 1000, at most 100 000) is enforced client-side: the reader stops one row past
+ it and the whole query publishes `BadConfigurationError`. A statement cannot be wrapped in a provider
+ row limit portably, so narrow the `WHERE` rather than relying on the cap.
+- **Parameters are always bound.** Keys are bare names (`active`; a leading `@`/`:` is tolerated);
+ values are JSON scalars or `null`. Write the provider's own marker in the SQL: `@active` on SqlServer,
+ Postgres and MySql, `:p_active` on Oracle, a positional `?` on Odbc (bound in authored order). The
+ factory refuses a declared parameter whose marker the statement never uses.
+- **Read-only, twice over.** `SqlReadOnlyStatement` refuses at construction (and in the AdminUI form)
+ anything but a single `SELECT`/`WITH` statement, and any of `INSERT UPDATE DELETE MERGE UPSERT DROP
+ ALTER CREATE TRUNCATE EXEC EXECUTE CALL GRANT REVOKE DENY INTO COPY LOCK RENAME` outside a literal or
+ comment — so `SELECT … INTO`, `FOR UPDATE` and a data-modifying CTE are refused too. Behind that guard
+ the statement runs **inside a transaction the driver always rolls back**, so a side effect the guard
+ cannot see (a function that writes) is never committed. A non-transactional side effect (an e-mail
+ procedure, a sequence) is outside both, so give the connecting principal `SELECT` only.
+- A tag naming a query the driver config does not declare is **skipped** at Initialize (it reads
+ `BadNodeIdUnknown`, and `/hosts` names the missing query). One unusable `queries` entry **fails the
+ driver** instead, because every tag that names it would otherwise go dark unexplained.
+- Query tags read no table, so they need **no catalog gate** — a driver serving only named queries skips
+ the catalog load entirely. Nothing from a Query tag is ever quoted into SQL.
## Optional per-tag fields
@@ -151,6 +193,7 @@ existed in the parser — nothing reads it.)
| `maxConcurrentGroups` | `4` | Connection-pool guard: cap on group queries in flight. |
| `nullIsBad` | `false` | `false` ⇒ a NULL cell publishes **Uncertain**; `true` ⇒ **Bad**. |
| `allowWrites` | — | **Inert.** v1 has no write path; authoring `true` logs a warning. |
+| `queries` | — | Named read-only queries for `Query` tags — see [`Query`](#query--a-named-read-only-query-753). Edited as JSON in the driver form, which applies the factory's own rules. |
### Credentials never enter the deployment artifact
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
index 3c57d516d..4f3f4b009 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs
@@ -51,6 +51,13 @@ public sealed class SqlDriverConfigDto
///
public bool? AllowWrites { get; init; }
+ ///
+ /// Named read-only queries (design §5.1 / §5.4) a tag reads by name.
+ /// Keys are matched ordinally. Each entry is validated by at
+ /// construction; one bad entry fails the driver rather than silently serving the rest.
+ ///
+ public Dictionary? Queries { get; init; }
+
///
/// The authored raw tags the deploy artifact delivers for this driver instance — RawPath identity plus
/// the driver TagConfig blob, the same shape every other driver receives. The factory copies
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
index 9d7df3c70..1754c9afd 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs
@@ -4,7 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
///
-/// Pure mapper from an authored raw tag's TagConfig JSON (design §5.2 / §5.3) to a
+/// Pure mapper from an authored raw tag's TagConfig JSON (design §5.2 / §5.3 / §5.4) to a
/// , mirroring ModbusTagDefinitionFactory.FromTagConfig. The blob is
/// recognised by a leading { plus a "driver":"Sql" marker or a "model" discriminator;
/// the model and type enums are read with
@@ -21,8 +21,9 @@ public static class SqlEquipmentTagParser
/// Maps an authored TagConfig blob to a typed definition keyed by .
/// Returns — never throws — for anything the driver must not serve: a
/// non-object / unparseable blob, a blob belonging to another driver, a typo'd model or
- /// type enum, a missing model-required field, or the P3-deferred
- /// model.
+ /// type enum, or a missing model-required field. A tag needs
+ /// query + column (and, when present, a complete rowKey); whether the named query
+ /// exists is a driver-config fact the driver checks at Initialize.
///
/// The authored equipment-tag TagConfig JSON.
/// The tag's RawPath — becomes the definition's identity (Name).
@@ -53,9 +54,35 @@ public static class SqlEquipmentTagParser
? type
: null;
+ var timestampColumn = ReadString(root, "timestampColumn");
+
+ if (model == SqlTagModel.Query)
+ {
+ // A named query reads no table of its own: the tag names the driver-config query, the result
+ // column it reads and — optionally — the row. A half-authored rowKey is refused rather than
+ // read as "no rowKey", which would silently demand a one-row result instead.
+ var queryName = ReadString(root, "query");
+ var column = ReadString(root, "column");
+ if (string.IsNullOrWhiteSpace(queryName) || string.IsNullOrWhiteSpace(column)) return false;
+ string? rowKeyColumn = null, rowKeyValue = null;
+ if (root.TryGetProperty("rowKey", out var rowKey))
+ {
+ if (rowKey.ValueKind != JsonValueKind.Object) return false;
+ rowKeyColumn = ReadString(rowKey, "column");
+ rowKeyValue = ReadScalar(rowKey, "value");
+ if (string.IsNullOrWhiteSpace(rowKeyColumn) || rowKeyValue is null) return false;
+ }
+
+ def = new SqlTagDefinition(
+ Name: rawPath, Model: model, Table: string.Empty,
+ TimestampColumn: timestampColumn, ColumnName: column,
+ DeclaredType: declaredType, QueryName: queryName,
+ RowKeyColumn: rowKeyColumn, RowKeyValue: rowKeyValue);
+ return true;
+ }
+
var table = ReadString(root, "table");
if (string.IsNullOrWhiteSpace(table)) return false;
- var timestampColumn = ReadString(root, "timestampColumn");
switch (model)
{
@@ -100,8 +127,6 @@ public static class SqlEquipmentTagParser
}
default:
- // SqlTagModel.Query is design §5.4 / P3 — deferred. Reject rather than serve a model
- // the runtime cannot read.
return false;
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlNamedQuery.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlNamedQuery.cs
new file mode 100644
index 000000000..faabfb131
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlNamedQuery.cs
@@ -0,0 +1,298 @@
+using System.Text;
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
+
+///
+/// One entry of a Sql driver config's queries map (design §5.1 / §5.4) — a named, read-only
+/// statement a tag reads by name.
+/// The SQL is configuration, not tag input. It is authored on the driver by a
+/// ConfigEditor-privileged operator and travels in the deployment artifact. Runtime values still never
+/// reach the text: every entry is bound as a DbParameter.
+///
+public sealed class SqlNamedQueryDto
+{
+ ///
+ /// The statement — exactly one SELECT or WITH … SELECT. Parameter markers are written in
+ /// the provider's own spelling for each key (@name on SqlServer, Postgres
+ /// and MySql; :p_name on Oracle; positional ? on Odbc, in authored order). Checked by
+ /// .
+ ///
+ public string? Sql { get; init; }
+
+ ///
+ /// Bound parameter values, keyed by the bare parameter name (a leading @ or : is
+ /// tolerated and stripped). Values must be JSON scalars or null.
+ ///
+ public Dictionary? Params { get; init; }
+
+ ///
+ /// The most rows one poll reads before the whole query is refused as ambiguous
+ /// (BadConfigurationError). Absent ⇒ .
+ ///
+ public int? MaxRows { get; init; }
+}
+
+/// Limits and name rules shared by the factory's named-query parse and the AdminUI form.
+public static class SqlNamedQueryRules
+{
+ /// The default per-poll row ceiling of a named query.
+ public const int DefaultMaxRows = 1000;
+
+ /// The highest maxRows that may be authored.
+ public const int MaxMaxRows = 100_000;
+
+ ///
+ /// Normalises an authored parameter key to its bare base name — strips one leading @ or
+ /// : — and returns when the rest is not a plain identifier
+ /// ([A-Za-z_][A-Za-z0-9_]*, at most 30 characters, which is Oracle's bind-name ceiling). The
+ /// base name feeds the dialect's own marker and bind-name spelling, so it must not carry punctuation.
+ ///
+ /// The authored key.
+ /// The bare base name, or null when unusable.
+ public static string? NormaliseParameterName(string? key)
+ {
+ if (string.IsNullOrEmpty(key)) return null;
+ var name = key[0] is '@' or ':' ? key[1..] : key;
+ if (name.Length is 0 or > 30) return null;
+ if (!(char.IsAsciiLetter(name[0]) || name[0] == '_')) return null;
+ foreach (var ch in name)
+ if (!(char.IsAsciiLetterOrDigit(ch) || ch == '_')) return null;
+ return name;
+ }
+
+ ///
+ /// Validates one named query's provider-independent shape — name, statement, parameter names and
+ /// values, row ceiling — and returns the first problem, or when it is usable.
+ /// The runtime factory and the AdminUI driver form both call this, so what the form accepts is what
+ /// the driver constructs. (The factory additionally checks each parameter's marker appears in the
+ /// statement, which needs the provider's dialect.)
+ ///
+ /// The queries key.
+ /// The entry.
+ /// An operator-facing error, or null.
+ public static string? Describe(string name, SqlNamedQueryDto? query)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return "a named query has a blank name";
+ if (query is null) return $"named query '{name}' is null";
+
+ var statement = SqlReadOnlyStatement.Check(query.Sql, out _);
+ if (statement is not null) return $"named query '{name}': {statement}";
+
+ if (query.MaxRows is { } maxRows && (maxRows < 1 || maxRows > MaxMaxRows))
+ return $"named query '{name}' has maxRows {maxRows}; it must be between 1 and {MaxMaxRows}";
+
+ if (query.Params is { } parameters)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var (key, value) in parameters)
+ {
+ var bare = NormaliseParameterName(key);
+ if (bare is null)
+ return $"named query '{name}' has parameter '{key}', which is not a plain identifier " +
+ "(letters, digits and '_', starting with a letter or '_', at most 30 characters)";
+ if (!seen.Add(bare))
+ return $"named query '{name}' declares parameter '{bare}' more than once";
+ if (value.ValueKind is not (JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True
+ or JsonValueKind.False or JsonValueKind.Null))
+ return $"named query '{name}' parameter '{bare}' must be a string, number, boolean or null";
+ }
+ }
+
+ return null;
+ }
+}
+
+///
+/// The authoring-time read-only guard for a named query's SQL (design §5.4). It is a guard, not the
+/// boundary: the driver also runs every named query inside a transaction it always rolls back, and the
+/// connecting principal should hold SELECT only. The guard exists so an operator learns at
+/// construction — not from a database audit — that a statement is not a plain read.
+/// Rules, applied to the text outside string literals, quoted identifiers and comments:
+/// the statement begins with SELECT or WITH; it is a single statement (one trailing
+/// ; is tolerated and removed); and none of the data- or schema-changing keywords appears anywhere
+/// — which also refuses SELECT … INTO, FOR UPDATE and a data-modifying CTE.
+///
+public static class SqlReadOnlyStatement
+{
+ ///
+ /// Keywords that may not appear outside literals/comments in a named query. Statement-only keywords
+ /// (COMMIT, BEGIN, …) are not listed: the single-statement + SELECT-first rules
+ /// already exclude them, and listing them would refuse a column that merely shares the name. A column
+ /// named like a listed keyword can still be read by quoting it.
+ ///
+ private static readonly HashSet Forbidden = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "DROP", "ALTER", "CREATE", "TRUNCATE",
+ "EXEC", "EXECUTE", "CALL", "GRANT", "REVOKE", "DENY", "INTO", "COPY", "LOCK", "RENAME",
+ };
+
+ ///
+ /// Checks and returns the problem, or when it is a single
+ /// read-only statement. On success is the statement with surrounding
+ /// whitespace and one trailing ; removed (ODP.NET refuses a trailing terminator).
+ ///
+ /// The authored statement.
+ /// The statement to execute, when valid.
+ /// An operator-facing error, or null.
+ public static string? Check(string? sql, out string normalised)
+ {
+ normalised = string.Empty;
+ if (string.IsNullOrWhiteSpace(sql)) return "the statement is empty";
+
+ var words = new List();
+ var terminatorAt = -1;
+ var i = 0;
+ while (i < sql.Length)
+ {
+ var ch = sql[i];
+ var next = i + 1 < sql.Length ? sql[i + 1] : '\0';
+
+ if (ch == '-' && next == '-')
+ {
+ while (i < sql.Length && sql[i] != '\n') i++;
+ continue;
+ }
+
+ if (ch == '/' && next == '*')
+ {
+ var end = sql.IndexOf("*/", i + 2, StringComparison.Ordinal);
+ if (end < 0) return "the statement has an unterminated /* comment";
+ i = end + 2;
+ continue;
+ }
+
+ if (ch is '\'' or '"' or '`' or '[')
+ {
+ var close = ch == '[' ? ']' : ch;
+ var j = i + 1;
+ while (true)
+ {
+ if (j >= sql.Length) return "the statement has an unterminated quoted literal or identifier";
+ if (sql[j] == close)
+ {
+ // A doubled closing character is an escaped one inside the literal.
+ if (j + 1 < sql.Length && sql[j + 1] == close && close != ']') { j += 2; continue; }
+ break;
+ }
+
+ j++;
+ }
+
+ if (terminatorAt >= 0) return "the statement carries more than one statement (text after ';')";
+ i = j + 1;
+ continue;
+ }
+
+ if (ch == ';')
+ {
+ if (terminatorAt >= 0) return "the statement carries more than one statement (';' repeated)";
+ terminatorAt = i;
+ i++;
+ continue;
+ }
+
+ if (char.IsLetter(ch) || ch == '_')
+ {
+ var start = i;
+ while (i < sql.Length && (char.IsLetterOrDigit(sql[i]) || sql[i] is '_' or '$' or '#')) i++;
+ if (terminatorAt >= 0) return "the statement carries more than one statement (text after ';')";
+ words.Add(sql[start..i]);
+ continue;
+ }
+
+ if (!char.IsWhiteSpace(ch) && terminatorAt >= 0)
+ return "the statement carries more than one statement (text after ';')";
+ i++;
+ }
+
+ if (words.Count == 0) return "the statement is empty";
+ if (!words[0].Equals("SELECT", StringComparison.OrdinalIgnoreCase)
+ && !words[0].Equals("WITH", StringComparison.OrdinalIgnoreCase))
+ return "a named query must be a single SELECT (or WITH … SELECT) statement";
+
+ foreach (var word in words)
+ {
+ if (Forbidden.Contains(word))
+ return $"a named query must be read-only, and '{word.ToUpperInvariant()}' is not allowed outside a " +
+ "string literal or comment";
+ }
+
+ var body = terminatorAt >= 0 ? sql[..terminatorAt] : sql;
+ normalised = body.Trim();
+ return null;
+ }
+
+ ///
+ /// True when appears in as a parameter token — outside
+ /// literals and comments, and not as the prefix of a longer name (@line inside @lineId).
+ ///
+ /// The statement.
+ /// The provider marker, e.g. @line or :p_line.
+ /// True when the marker is used.
+ public static bool UsesMarker(string sql, string marker)
+ {
+ ArgumentNullException.ThrowIfNull(sql);
+ ArgumentException.ThrowIfNullOrEmpty(marker);
+
+ var code = StripLiteralsAndComments(sql);
+ var from = 0;
+ while (true)
+ {
+ var at = code.IndexOf(marker, from, StringComparison.OrdinalIgnoreCase);
+ if (at < 0) return false;
+ var after = at + marker.Length;
+ if (after >= code.Length || !(char.IsLetterOrDigit(code[after]) || code[after] == '_')) return true;
+ from = after;
+ }
+ }
+
+ /// Replaces literal, quoted-identifier and comment content with spaces, keeping offsets.
+ private static string StripLiteralsAndComments(string sql)
+ {
+ var builder = new StringBuilder(sql);
+ var i = 0;
+ while (i < sql.Length)
+ {
+ var ch = sql[i];
+ var next = i + 1 < sql.Length ? sql[i + 1] : '\0';
+ int end;
+ if (ch == '-' && next == '-')
+ {
+ end = sql.IndexOf('\n', i);
+ end = end < 0 ? sql.Length : end;
+ }
+ else if (ch == '/' && next == '*')
+ {
+ end = sql.IndexOf("*/", i + 2, StringComparison.Ordinal);
+ end = end < 0 ? sql.Length : end + 2;
+ }
+ else if (ch is '\'' or '"' or '`' or '[')
+ {
+ var close = ch == '[' ? ']' : ch;
+ end = i + 1;
+ while (end < sql.Length)
+ {
+ if (sql[end] == close)
+ {
+ if (end + 1 < sql.Length && sql[end + 1] == close && close != ']') { end += 2; continue; }
+ end++;
+ break;
+ }
+
+ end++;
+ }
+ }
+ else
+ {
+ i++;
+ continue;
+ }
+
+ for (var k = i; k < end; k++) builder[k] = ' ';
+ i = end;
+ }
+
+ return builder.ToString();
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs
index 0dd44c550..c61db291f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs
@@ -23,7 +23,7 @@ public enum SqlProvider
Oracle,
}
-/// Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).
+/// Tag→value mapping model: KeyValue, WideRow, and the named-query model Query (design §5.4).
public enum SqlTagModel
{
///
@@ -38,6 +38,11 @@ public enum SqlTagModel
///
WideRow,
- /// Named arbitrary-SELECT query (design §5.4). Deferred to P3 — not accepted by v1's parser.
+ ///
+ /// Named read-only query (design §5.4): the driver config declares queries, each a single
+ /// SELECT/WITH statement with bound parameters; the tag names a query, the result
+ /// column it reads, and optionally a rowKey picking its row. The SQL is configuration
+ /// authored by a ConfigEditor, never tag input, and runs inside a rolled-back transaction.
+ ///
Query,
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
index 755e2b9dc..8bf64cc94 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// into SQL. Value-bearing fields (, ) are bound
/// as DbParameters by the reader; identifier-bearing fields (,
/// , , ,
-/// , ,
+/// (for : the result-set column), ,
/// ) must be validated against INFORMATION_SCHEMA and
/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt
/// legitimate values, so the parser deliberately does not.
@@ -18,8 +18,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// The tag's identity — its RawPath, exactly as handed to the parser. The driver's forward
/// router keys published values on this, so it must never be derived from the blob's contents.
///
-/// Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only.
-/// The table or view to read (e.g. dbo.TagValues).
+/// Which tag→value mapping this tag uses: KeyValue, WideRow or Query.
+/// The table or view to read (e.g. dbo.TagValues). Empty for , which reads a named query instead.
/// : the column matched against .
/// : this tag's key — bound, never interpolated.
/// : the column the value is read from.
@@ -28,6 +28,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// : the whereColumn that picks the row.
/// : the whereValue — bound, never interpolated.
/// : newest-row selector — the timestamp column to order by, when no where-pair is authored.
+/// : the driver-config queries entry this tag reads.
+///
+/// : the result-set column that picks this tag's row. Null ⇒ the query must
+/// return exactly one row. Matched against the result set, never quoted into SQL.
+///
+/// : the value (matched as text).
///
/// Optional explicit type override from the blob's type field. ⇒ the
/// driver infers the type from the result set's column metadata.
@@ -44,4 +50,7 @@ public sealed record SqlTagDefinition(
string? RowSelectorColumn = null,
string? RowSelectorValue = null,
string? RowSelectorTopByTimestamp = null,
- DriverDataType? DeclaredType = null);
+ DriverDataType? DeclaredType = null,
+ string? QueryName = null,
+ string? RowKeyColumn = null,
+ string? RowKeyValue = null);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs
index bad2b98de..6750f1a15 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinitionFactory.cs
@@ -37,8 +37,8 @@ public static class SqlTagDefinitionFactory
/// Deploy-time inspection of an equipment-tag TagConfig blob. Returns human-readable warnings
/// for a blob would reject — an unparseable or
/// non-object document, a blob the driver does not recognise as a Sql one, a present-but-invalid
- /// model / type enum, the P3-deferred Query model, a missing table, or a
- /// missing model-required field. Empty when the parser accepts the blob, when the blob is blank, or
+ /// model / type enum, a missing table (KeyValue/WideRow), or a missing
+ /// model-required field (a Query tag's query/column/rowKey). Empty when the parser accepts the blob, when the blob is blank, or
/// when it is not a JSON object at all (the sibling factories' "not an equipment-tag TagConfig"
/// convention). Never throws.
///
@@ -113,9 +113,20 @@ public static class SqlTagDefinitionFactory
var model = TagConfigJson.ReadEnumOrDefault(root, "model", SqlTagModel.KeyValue);
if (model == SqlTagModel.Query)
{
- warnings.Add(
- "Sql tag model 'Query' is deferred (design §5.4 / P3) and is not served — the driver will "
- + "skip this tag.");
+ if (string.IsNullOrWhiteSpace(ReadString(root, "query")))
+ warnings.Add("Sql Query tag has no 'query' — name the driver-config named query it reads.");
+ if (string.IsNullOrWhiteSpace(ReadString(root, "column")))
+ warnings.Add("Sql Query tag has no 'column' — name the result-set column it reads.");
+ if (root.TryGetProperty("rowKey", out var rowKey)
+ && (rowKey.ValueKind != JsonValueKind.Object
+ || string.IsNullOrWhiteSpace(ReadString(rowKey, "column"))
+ || !HasScalar(rowKey, "value")))
+ {
+ warnings.Add(
+ "Sql Query tag has an incomplete 'rowKey' — it needs both a 'column' and a scalar 'value' "
+ + "(omit 'rowKey' entirely when the query returns exactly one row).");
+ }
+
return;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs
index 16b8def1e..f69efa4d6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalog.cs
@@ -40,6 +40,15 @@ public sealed class SqlCatalog
_columnsByTable = columnsByTable;
}
+ ///
+ /// A catalog that resolves nothing — used when no authored tag needs one (a driver serving only named
+ /// queries), so Initialize does not issue catalog queries whose answer nothing reads.
+ ///
+ internal static SqlCatalog Empty { get; } = new(
+ string.Empty, [],
+ new Dictionary>(StringComparer.Ordinal),
+ new Dictionary>(StringComparer.Ordinal));
+
/// The schema an unqualified authored object name is resolved in.
public string DefaultSchema { get; }
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs
index 3485da742..ec8f34aa8 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogGate.cs
@@ -77,6 +77,16 @@ public static class SqlCatalogGate
foreach (var definition in definitions)
{
ArgumentNullException.ThrowIfNull(definition);
+
+ // A named-query tag quotes no identifier into SQL: its statement is driver configuration (checked
+ // read-only at construction) and its column/rowKey/timestamp names are matched against the result
+ // set by the reader. There is nothing here to allow-list, so it passes through unchanged.
+ if (definition.Model == SqlTagModel.Query)
+ {
+ accepted.Add(definition);
+ continue;
+ }
+
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
accepted.Add(canonical!);
else
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
index ac10102ca..f1f2e3b1f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs
@@ -290,7 +290,8 @@ public sealed class SqlDriver
maxConcurrentGroups: options.MaxConcurrentGroups,
nullIsBad: options.NullIsBad,
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
- logger: _logger);
+ logger: _logger,
+ queries: options.Queries);
previous?.Dispose();
}
@@ -966,7 +967,25 @@ public sealed class SqlDriver
try
{
if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition))
+ {
+ if (definition.Model == SqlTagModel.Query
+ && !_options.Queries.ContainsKey(definition.QueryName!))
+ {
+ // The blob is well-formed but names a query this driver's config does not declare — a
+ // fact only the driver can see (the deploy gate inspects one tag blob at a time). Skip
+ // it like any unmappable tag, so it reads BadNodeIdUnknown and /hosts says why.
+ var missing =
+ $"reads named query '{definition.QueryName}', which this driver's config does not " +
+ "declare under 'queries'";
+ blobSkips.Add(new SkippedTag(entry.RawPath, missing));
+ _logger.LogWarning(
+ "Sql tag skipped. Driver={DriverInstanceId} RawPath={RawPath} Reason={Reason}",
+ _driverInstanceId, entry.RawPath, missing);
+ continue;
+ }
+
table[entry.RawPath] = definition;
+ }
else
{
// The reason is the DEPLOY GATE's own explanation (R06:S-4) — SqlTagDefinitionFactory.Inspect
@@ -1038,17 +1057,22 @@ public sealed class SqlDriver
var authored = Tags;
if (authored.Count == 0) return;
+ // Named-query tags read no table of their own and quote no identifier, so they need no catalog —
+ // SqlCatalogGate passes them through. A driver serving ONLY named queries therefore skips the load.
var tables = authored.Values
+ .Where(definition => definition.Model != SqlTagModel.Query)
.Select(definition => definition.Table)
.Where(table => !string.IsNullOrWhiteSpace(table))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
- var catalog = await RunBoundedAsync(
- token => LoadCatalogAsync(tables, token),
- _options.OperationTimeout,
- "the catalog queries did not return",
- cancellationToken).ConfigureAwait(false);
+ var catalog = tables.Length == 0
+ ? SqlCatalog.Empty
+ : await RunBoundedAsync(
+ token => LoadCatalogAsync(tables, token),
+ _options.OperationTimeout,
+ "the catalog queries did not return",
+ cancellationToken).ConfigureAwait(false);
var result = SqlCatalogGate.Apply(authored.Values, catalog, _dialect);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
index d4f46fa81..e92cc22de 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs
@@ -115,7 +115,7 @@ public static class SqlDriverFactoryExtensions
// Resolved BEFORE the knob validation in CreateInstance, so that every subsequent throw is a path
// that HAS the secret in hand — which is exactly where a careless interpolation would leak it.
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
- return new SqlDriverBinding(BuildOptions(dto, driverInstanceId), dialect, connectionString, dto);
+ return new SqlDriverBinding(BuildOptions(dto, driverInstanceId, dialect), dialect, connectionString, dto);
}
///
@@ -237,9 +237,16 @@ public static class SqlDriverFactoryExtensions
///
/// The deserialised config blob.
/// Named in every rejection message, so a fleet-wide log identifies the row.
+ ///
+ /// The provider seam, for the named-query marker check; null skips that one check (unit tests of the
+ /// other knobs).
+ ///
/// The typed options the driver is constructed with.
- /// A knob is out of range, or the two deadlines are inverted.
- internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId)
+ ///
+ /// A knob is out of range, the two deadlines are inverted, or a named query is unusable.
+ ///
+ internal static SqlDriverOptions BuildOptions(
+ SqlDriverConfigDto dto, string driverInstanceId, ISqlDialect? dialect = null)
{
ArgumentNullException.ThrowIfNull(dto);
@@ -280,9 +287,71 @@ public static class SqlDriverFactoryExtensions
CommandTimeout = commandTimeout,
MaxConcurrentGroups = maxConcurrentGroups,
NullIsBad = dto.NullIsBad ?? false,
+ Queries = BuildQueries(dto, driverInstanceId, dialect),
};
}
+ ///
+ /// Validates and types the config's named queries (design §5.4). One unusable entry fails the
+ /// driver: unlike a tag, a query is shared by every tag that names it, and a driver that silently
+ /// dropped it would turn all of them into an unexplained BadNodeIdUnknown.
+ /// The shape rules are — the same call the AdminUI driver
+ /// form makes — so the form refuses what this refuses. The one rule added here needs the dialect: every
+ /// declared parameter's marker () must appear in the statement.
+ /// A provider that binds positionally (?) is exempt, since there is no name to look for.
+ ///
+ private static IReadOnlyDictionary BuildQueries(
+ SqlDriverConfigDto dto, string driverInstanceId, ISqlDialect? dialect)
+ {
+ var queries = new Dictionary(StringComparer.Ordinal);
+ if (dto.Queries is not { Count: > 0 } authored) return queries;
+
+ foreach (var (name, query) in authored)
+ {
+ if (SqlNamedQueryRules.Describe(name, query) is { } problem)
+ throw new InvalidOperationException($"Sql driver config for '{driverInstanceId}': {problem}.");
+
+ SqlReadOnlyStatement.Check(query.Sql, out var sql);
+ var names = new List();
+ var values = new List