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";
}