From 9d4760fd2f8905854988b3e78e96ffee735f55d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 08:06:27 -0400 Subject: [PATCH] docs(driver.sql): topByTimestamp is only defined when the ordering column is unique (Driver.Sql-008) --- docs/drivers/Sql.md | 8 ++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/drivers/Sql.md b/docs/drivers/Sql.md index 38163f50..58fa4c29 100644 --- a/docs/drivers/Sql.md +++ b/docs/drivers/Sql.md @@ -80,6 +80,14 @@ the nested form. } ``` +⚠️ **`topByTimestamp` needs an ordering column that is unique per row.** It is executed as an +`ORDER BY DESC` with the dialect's single-row limit, so when the column ties the server +returns an arbitrary row among the tied ones — and the driver cannot warn about it, because the other +rows were discarded before the result set reached it (the `whereColumn`/`whereValue` form *does* +warn, on `Rows.Count > 1`). A `date`-typed column ties for every row written on the same day, and +browse-commit will nominate one. Prefer a `datetime2`/sequence column that is unique per row, or +point the tag at a latest-row view. + `whereValue` may be authored as a JSON string, number or boolean (it is bound as a parameter either way). `keyValue` on the `KeyValue` model must be a **string**. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs index a979f59a..8e17407b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs @@ -566,9 +566,19 @@ public sealed class SqlPollReader /// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous /// (Rows.Count > 1), converting a loud misconfiguration into a permanently silent one — /// precisely the failure mode this class's summary calls its top risk. The topByTimestamp - /// form keeps its row limit because there the ORDER BY makes "the first row" a defined - /// answer. The accepted cost is that an ambiguous selector materialises every matching row for one + /// form keeps its row limit because there the ORDER BY at least fixes which rows can + /// be first. The accepted cost is that an ambiguous selector materialises every matching row for one /// poll; the warning is what gets it fixed. + /// ⚠ The topByTimestamp answer is only defined when the ordering column is unique + /// (Driver.Sql-008). ORDER BY ts DESC plus a one-row limit over a column with ties returns an + /// arbitrary row among the tied ones, and — unlike the where-pair form — this method can never + /// detect it, because the server discarded the other rows before the result set was built: there is + /// no Rows.Count > 1 left to warn about. The tie shape is reachable rather than + /// theoretical, since SqlBrowseSession.PreferredTimestampColumn will nominate a + /// date-typed column, which ties for every row written on the same day. The model's premise is + /// unchanged — the selector must identify one row — but for this form the operator, not the driver, + /// is the only thing that can check it. Point the tag at a column that is unique per row (or at a + /// latest-row view). /// private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome) {