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 (rounded) window start in 100 ns ticks since the Unix epoch /// (, Bigint); /// $2 — the bucket width in ticks (, Bigint); /// $3 — the tag (Varchar); /// $4/$5 — the forward window [start, end) (TimestampTz via /// ). /// /// 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; both it and $1 are µs-aligned (R6), so the integer division is /// start-inclusive / end-exclusive to the tick, exactly like SQL Server's. Every row is at or after the start, /// 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 / Total / Count / Range / PercentGood / PercentBad. /// 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. public static string Simple(string sample) => "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) + ") GROUP BY b ORDER BY b"; /// /// Start / End / Delta: the first and last usable sample of each bucket by ts. /// Family columns: 6..10 first sample (kind, num_value, int_value, str_value, ts), 11..15 last /// 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. /// /// Prefixed sample table name. /// The SQL text. public static string StartEnd(string sample) => "WITH s AS (" + Classified(sample) + "), " + "st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " + "f AS (SELECT b, kind, num_value, int_value, str_value, ts, " + "row_number() OVER (PARTITION BY b ORDER BY ts ASC) AS rf, " + "row_number() OVER (PARTITION BY b ORDER BY ts DESC) AS rl FROM s WHERE u = 1) " + "SELECT st.*, " + "x.kind, x.num_value, x.int_value, x.str_value, x.ts, " + "y.kind, y.num_value, y.int_value, y.str_value, y.ts " + "FROM st LEFT JOIN (SELECT * FROM f WHERE rf = 1) x ON x.b = st.b " + "LEFT JOIN (SELECT * FROM f WHERE rl = 1) y ON y.b = st.b ORDER BY st.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). /// /// Prefixed sample table name. /// True for MaximumActualTime. /// The SQL text. public static string ActualTime(string sample, bool maximum) => "WITH s AS (" + Classified(sample) + "), " + "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 classified subquery s: the window's live samples with bucket index and the usable/good flags. private static string Classified(string sample) => "SELECT " + BucketExpression + " AS b, ts, kind, num_value, int_value, str_value, " + $"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 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"; }