using System.Globalization; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb; /// /// Push-down QuestDB SQL for processed (aggregate) reads (spec §3 "Processed", T0 Q8-confirmed forms), one query /// per , mirroring the SQL Server backend's SqlAggregateQueries family split and /// column order. Every query takes five positional parameters: /// /// $1 — the bucket base in 100 ns ticks since the Unix epoch /// (, Bigint: the rounded window start forward; re-anchored at /// the later time for a reverse read, #677); /// $2 — the bucket width in ticks (, Bigint); /// $3 — the tag (Varchar); /// $4/$5 — the earlier / later window edges (TimestampTz via /// ): [$4, $5) forward, ($4, $5] reverse. /// /// Each returns one row per non-empty bucket; the caller fills the empty ones. /// /// /// /// Bucketing. b = (cast(ts AS LONG) * 10 − $1) / $2. cast(ts AS LONG) is µs since 1970, so /// × 10 is ticks since 1970, so the integer division is exact to the tick, exactly like SQL Server's /// ( is the C# statement of the math). Every in-window row is at or after the base /// (forward) or strictly after the window start with the base one tick past the grid origin (reverse), so the /// dividend is never negative (no truncation-toward-zero surprise), and nothing overflows before 9999. /// SAMPLE BY is deliberately not used: its alignment rules differ from ours. /// /// /// Sample classes (S6). The status is stored as the unsigned 32-bit code in a LONG, so /// "not Bad" is status < 0x80000000 (not T-SQL's sign test). usable = not Bad AND /// kind <> 0 (an Empty sample is an outage marker and counts as Bad whatever its code); /// good = usable AND status < 0x40000000; bad = not usable; non-numeric = usable /// String/DateTime. The literals are generated from and . /// /// Common result columns (ordinals 0–5): b, total_count, usable_count, good_count, bad_count, /// non_numeric_count. Family columns follow from . /// internal static class QuestDbAggregateQueries { /// Ordinal of the first family-specific column. public const int FirstFamilyColumn = 6; /// status < SeverityBadBit, as a decimal LONG literal. internal static readonly string NotBadLimit = StatusBits.SeverityBadBit.ToString(CultureInfo.InvariantCulture); /// status < SeverityUncertainBit, as a decimal LONG literal. internal static readonly string GoodLimit = StatusBits.SeverityUncertainBit.ToString(CultureInfo.InvariantCulture); /// The kind values of String and DateTime: usable but not numeric. internal static readonly string NonNumericKinds = ((byte)HistorianValueKind.String).ToString(CultureInfo.InvariantCulture) + ", " + ((byte)HistorianValueKind.DateTime).ToString(CultureInfo.InvariantCulture); private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture); /// /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here). /// Family columns: 6 avg_value, 7 min_value, 8 max_value, 9 sum_value (DOUBLE, NULL when no usable sample). /// /// Prefixed sample table name. /// The SQL text. /// True for a reverse read (window ($4, $5]). public static string Simple(string sample, bool reverse = false) => "SELECT b, " + CommonCounts() + ", " + "avg(CASE WHEN u = 1 THEN num_value END) AS avg_value, " + "min(CASE WHEN u = 1 THEN num_value END) AS min_value, " + "max(CASE WHEN u = 1 THEN num_value END) AS max_value, " + "sum(CASE WHEN u = 1 THEN num_value END) AS sum_value " + "FROM (" + Classified(sample, reverse) + ") GROUP BY b ORDER BY b"; /// Ordinal of the StartEnd family's first-sample-of-any-quality columns (). public const int FirstAnyColumn = FirstFamilyColumn + 10; /// /// Start / End / Delta: the first and last usable sample of each bucket by ts (Delta's value), and the first /// and last sample of any quality (Start / End, and Delta's status — #680). /// Family columns: 6..10 first usable sample (kind, num_value, int_value, str_value, ts), 11..15 /// last usable sample (same order) — a bucket without a usable sample has NULL ts but kind = 0 (BYTE /// has no NULL), so the reader keys absence on the NULL time; then 16..21 the first sample of any quality /// (ts, kind, num_value, int_value, str_value, status) and 22..27 the last (same order), present for /// every returned (non-empty) bucket. /// /// /// /// One scan, one GROUP BY (live-gate 8 F1). The counts and all four picked samples come from a single read /// of the sample table. The first/last sample of any quality are QuestDB's first()/last() (the /// first/last row of the group in designated-timestamp order — null values included, so every column comes from /// the same row); the first/last usable sample are arg_min/arg_max keyed on uts, the /// row's time when usable and NULL otherwise (a NULL key is skipped, a NULL value is returned as is, and /// (ts, tag) is unique — DEDUP — so there are no ties). arg_min has no VARCHAR form, so the usable /// picks carry no str_value: only Delta reads them, and only when the bucket has no usable non-numeric /// sample. /// /// /// The previous form referenced its CTE four times (counts + three self-joins); QuestDB opens a separate table /// reader per reference, so a row committed between two of them produced a bucket whose counts included a sample /// its first/last join could not find (a NULL first-of-any-quality time that threw) — ~1 in 90k reads of a tag /// being written. One reference means one reader at one table version. A windowed (row_number()) /// single-scan form was also correct but ~2× slower; this one is not slower than the old query. /// /// /// ⚠️ first()/last() following timestamp order is QuestDB 10.0.1 IMPLEMENTATION behaviour (rows reach /// the GROUP BY in row-id order, and the parallel merge keeps row id), not a documented guarantee. It was verified /// on 20M rows with O3 inserts, single-threaded and parallel. Adding a JOIN, ORDER BY or UNION inside /// Classified, or upgrading QuestDB, can silently break Start/End — re-verify first(ts) = min(ts). /// /// /// Prefixed sample table name. /// The SQL text. /// True for a reverse read (window ($4, $5]). public static string StartEnd(string sample, bool reverse = false) { // The usable picks: kind, num_value, int_value, str_value (not available — see remarks), ts. static string Usable(string f, string g) => $"cast({f}(kind, uts) AS SHORT), {f}(num_value, uts), {f}(int_value, uts), cast(NULL AS VARCHAR), {g}(uts)"; // The any-quality picks: ts, kind, num_value, int_value, str_value, status — all from the group's first / last row. static string Any(string f) => $"{f}(ts), cast({f}(kind) AS SHORT), {f}(num_value), {f}(int_value), {f}(str_value), {f}(status)"; return "SELECT b, " + CommonCounts() + ", " + Usable("arg_min", "min") + ", " + Usable("arg_max", "max") + ", " + Any("first") + ", " + Any("last") + " " + "FROM (SELECT b, ts, kind, num_value, int_value, str_value, status, u, g, CASE WHEN u = 1 THEN ts END AS uts " + "FROM (" + Classified(sample, reverse) + ")) GROUP BY b ORDER BY b"; } /// /// MinimumActualTime / MaximumActualTime: the usable sample with the smallest (largest) num_value, ties /// broken by the earliest ts. Family columns: 6 num_value, 7 ts (NULL when the bucket has no usable /// numeric sample). /// /// /// Deliberately still two references to the classified rows (counts + the ranked extreme), unlike /// : the single-scan row_number() form measured ~2× slower, and QuestDB's /// arg_max/arg_min cannot express the earliest-time tie-break. Live-gate 8 F1's inconsistency (a row /// committed between the two readers — counted but not ranked) is absorbed by the reader and evaluator instead: a /// NULL extreme in a bucket with usable samples evaluates to BadNoData, never a value-less Good. /// /// Prefixed sample table name. /// True for MaximumActualTime. /// The SQL text. /// True for a reverse read (window ($4, $5]). public static string ActualTime(string sample, bool maximum, bool reverse = false) => "WITH s AS (" + Classified(sample, reverse) + "), " + "st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " + "r AS (SELECT b, num_value, ts, " + "row_number() OVER (PARTITION BY b ORDER BY num_value " + (maximum ? "DESC" : "ASC") + ", ts ASC) AS rn " + "FROM s WHERE u = 1 AND num_value IS NOT NULL) " + "SELECT st.*, r.num_value, r.ts " + "FROM st LEFT JOIN (SELECT * FROM r WHERE rn = 1) r ON r.b = st.b ORDER BY st.b"; /// /// Time-weighted family (#671), bounds statement — not pushed down (see ). /// Four LIMIT 1 seeks UNION ALL-ed, each tagged with k: 0 = latest usable sample before the /// start, 1 = latest sample of any quality before the start, 2 = first sample of any quality at or after the end, /// 3 = first usable one at or after the end. Parameters: $1 tag, $2/$3 the forward window /// start / end. Columns: k, then ts, kind, num_value, int_value, str_value, status, server_ts. /// /// Prefixed sample table name. /// The SQL text. public static string TimeWeightedBounds(string sample) { var usable = $" AND status < {NotBadLimit} AND kind <> {EmptyKind}"; string Seek(int k, string cmp, string filter, string order) => $"SELECT * FROM (SELECT {k.ToString(CultureInfo.InvariantCulture)} AS k, {SampleColumns} FROM {sample} " + $"WHERE tag = $1 AND ts {cmp}{filter} AND NOT deleted ORDER BY ts {order} LIMIT 1)"; return Seek(0, "< $2", usable, "DESC") + " UNION ALL " + Seek(1, "< $2", "", "DESC") + " UNION ALL " + Seek(2, ">= $3", "", "ASC") + " UNION ALL " + Seek(3, ">= $3", usable, "ASC"); } /// /// Time-weighted family (#671), window statement: the live samples in [$2, $3) for tag $1, ascending, /// at most $4 (the raw-row cap + 1). /// Columns: ts, kind, num_value, int_value, str_value, status, server_ts. The caller feeds the before-bounds, /// these rows and the after-bounds, in that order, into — the same evaluator /// the SQL Server backend uses, so the two agree exactly. Trade-off: the window's rows travel to the server rather /// than one row per bucket; Part 13's segment interpolation and bucket-edge splitting are then written once /// instead of twice in two SQL dialects that must stay bit-identical. /// /// Prefixed sample table name. /// The SQL text. public static string TimeWeightedWindow(string sample) => $"SELECT {SampleColumns} FROM {sample} WHERE tag = $1 AND ts >= $2 AND ts < $3 AND NOT deleted ORDER BY ts ASC LIMIT $4"; /// The row columns the time-weighted statements return (after k for the bounds). private const string SampleColumns = "ts, kind, num_value, int_value, str_value, status, server_ts"; /// The bucket-index expression (exposed for tests). internal const string BucketExpression = "(cast(ts AS LONG) * 10 - $1) / $2"; /// /// The tag's earliest live sample (any quality) at or before the window's later edge — half of the tag's data range /// (the start-of-data Partial rule, #677, and Count's in-range empty interval, #680; see /// ). Parameters $1 tag, $2 the later edge. Column: /// ts; no row when the tag has none — the data range then does not reach the window. /// /// Prefixed sample table name. /// The SQL text. public static string EarliestSample(string sample) => $"SELECT ts FROM {sample} WHERE tag = $1 AND ts <= $2 AND NOT deleted ORDER BY ts ASC LIMIT 1"; /// /// The tag's latest live sample (any quality) at or after the window's earlier edge — the other half of the data /// range (the end-of-data Partial rule and Count's in-range empty interval, #680). Parameters $1 tag, /// $2 the earlier edge. Column: ts; no row when the tag has none. /// /// Prefixed sample table name. /// The SQL text. public static string LatestSample(string sample) => $"SELECT ts FROM {sample} WHERE tag = $1 AND ts >= $2 AND NOT deleted ORDER BY ts DESC LIMIT 1"; /// The classified subquery s: the window's live samples with bucket index and the usable/good flags. private static string Classified(string sample, bool reverse) => "SELECT " + BucketExpression + " AS b, ts, kind, num_value, int_value, str_value, status, " + $"CASE WHEN status < {NotBadLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS u, " + $"CASE WHEN status < {GoodLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS g " + $"FROM {sample} WHERE tag = $3 AND " + (reverse ? "ts > $4 AND ts <= $5" : "ts >= $4 AND ts < $5") + " AND NOT deleted"; /// The five common count columns (after b). private static string CommonCounts() => "count() AS total_count, sum(u) AS usable_count, sum(g) AS good_count, count() - sum(u) AS bad_count, " + $"sum(CASE WHEN u = 1 AND kind IN ({NonNumericKinds}) THEN 1 ELSE 0 END) AS non_numeric_count"; }