using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
///
/// Push-down T-SQL for processed (aggregate) reads (spec §4 "Processed"), one query per
/// ( picks it). Every query takes
/// @id (int TagId), @start/@end (datetime2(7), the earlier / later window edges —
/// /), @intervalTicks (bigint bucket width in
/// 100 ns ticks) and @bucketAdjustTicks (bigint, ), and returns
/// one row per non-empty bucket (ascending bucket index). The caller generates every interval and fills the
/// empty ones.
///
///
///
/// Bucketing. b = (elapsedTicks + @bucketAdjustTicks) / @intervalTicks (#677: the adjust is 0 for a
/// forward read and re-anchors a reverse read's grid at its later time — is the C#
/// statement of the same math). The window is [@start, @end) forward and (@start, @end] reverse (a
/// reverse read includes its later time), selected by the reverse argument. elapsedTicks is
/// elapsedTicks = DATEDIFF_BIG(microsecond, @start, t) * 10 + tick-of-microsecond(t) − tick-of-microsecond(@start)
/// and tick-of-microsecond(x) = (DATEPART(nanosecond, x) / 100) % 10. The spec sketched
/// millisecond, but DATEDIFF counts unit boundaries crossed, not elapsed time, so with a
/// non-millisecond-aligned @start a sample one tick before a bucket edge would land in the next
/// bucket. Counting microsecond boundaries and correcting by the two sub-microsecond tick digits gives
/// the exact elapsed time in 100 ns ticks (10·(⌊t/10⌋ − ⌊s/10⌋) + t mod 10 − s mod 10 = t − s), so the
/// integer division is start-inclusive / end-exclusive to the tick. Unlike a nanosecond DATEDIFF_BIG
/// (which overflows a bigint past ≈292 years, SQL error 535), the microsecond count over the whole
/// datetime2 range (0001–9999, ≈3.2e17 µs, ≈3.2e18 ticks) fits a bigint.
///
///
/// Sample classes (status stored bit-for-bit in an int, so the severity bits are the sign
/// and bit 30): usable = severity Good or Uncertain (StatusCode >= 0) AND a stored value
/// (ValueKind <> 0) — an Empty sample is an outage marker and counts as Bad whatever its
/// code; good = usable with severity Good (StatusCode < 0x40000000); bad = not
/// usable. Average/Minimum/Maximum/Range/*ActualTime value columns are computed over good samples only
/// (#684); Start/End/Delta picks over usable samples (or every sample).
///
/// Common result columns (ordinals 0–5): Bucket, TotalCount, UsableCount, GoodCount, BadCount,
/// NonNumericCount (usable String/DateTime samples). Family columns follow.
///
internal static class SqlAggregateQueries
{
/// Ordinal of the first family-specific column.
public const int FirstFamilyColumn = 6;
/// ValueKind of String (4) and DateTime (5): usable but not numeric.
private const string NonNumericKinds = "4, 5";
///
/// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here).
/// Family columns: 6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue over the Good numeric samples (float,
/// NULL when none — #684: Part 13 and the SDK compute these over Good values only), then 10 UncMin, 11 UncMax
/// over the Uncertain ones (the Part 13 Minimum/Maximum/Range status rule, ), and
/// 12 GoodNonNumericCount.
///
/// Schema-qualified Sample table name.
/// The SQL text.
/// True for a reverse read (window (@start, @end]).
public static string Simple(string sample, bool reverse = false) =>
Classified(sample, reverse) +
"SELECT b, " + CommonCounts() + ", " +
"AVG(CASE WHEN g = 1 THEN NumValue END) AS AvgValue, " +
"MIN(CASE WHEN g = 1 THEN NumValue END) AS MinValue, " +
"MAX(CASE WHEN g = 1 THEN NumValue END) AS MaxValue, " +
"SUM(CASE WHEN g = 1 THEN NumValue END) AS SumValue, " +
UncertainExtremes() + " " +
"FROM s 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 SourceTimeUtc (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 (ValueKind, NumValue, IntValue, StrValue, SourceTimeUtc),
/// 11..15 last usable sample (same order) — all NULL when the bucket has no usable sample; then
/// 16..21 the first sample of any quality (SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue,
/// StatusCode) and 22..27 the last (same order) — present for every returned (non-empty) bucket.
///
///
///
/// Aggregate, then join back on the key (#683). One GROUP BY over the window computes the counts and
/// each bucket's four pick TIMES — MIN/MAX(SourceTimeUtc) over every sample (any quality) and over
/// the usable ones — and each pick's columns are then fetched by a seek on the clustered key
/// (TagId, SourceTimeUtc) (unique, so one row per pick; a NULL usable time joins nothing ⇒ NULL columns,
/// as before). Output is identical to the previous windowed form (one ROW_NUMBER pass + conditional
/// MAX, live-gate 8 F1) — EXCEPT both ways is empty on 6M rows, forward and reverse — without that
/// form's four window sorts, which spilled: on 6M rows / 30 days, 30 d @ 1 h ~3.4 s vs 18–32 s, 7 d @ 1 d
/// ~0.8 s vs 5.2–6.1 s, 1 d @ 1 min ~0.16 s vs 0.31–0.40 s (docs/Historian.md).
///
///
/// ⚠️ The key seeks are separate reads of the table within one statement, so under READ COMMITTED a sample
/// deleted (HistoryUpdate DeleteRaw/DeleteAtTime) between the aggregate and its seek leaves that pick NULL while
/// the counts still include it. The reader maps a NULL pick to "absent" (ReadStoredOrNull, live-gate 8 F1)
/// and the evaluator answers that bucket's Start/End/Delta BadNoData — transient, until the next read. A
/// sample replaced in between is picked with its new value (same time); a sample inserted in between is in
/// neither half (the pick times come from the aggregate).
///
///
/// Schema-qualified Sample table name.
/// The SQL text.
/// True for a reverse read (window (@start, @end]).
public static string StartEnd(string sample, bool reverse = false)
{
// A pick's columns from its joined row, in the established order: usable picks (kind, num, int, str, time), any-
// quality picks (time, kind, num, int, str, status).
static string Usable(string a) => $"{a}.ValueKind, {a}.NumValue, {a}.IntValue, {a}.StrValue, {a}.SourceTimeUtc";
static string Any(string a) => $"{a}.SourceTimeUtc, {a}.ValueKind, {a}.NumValue, {a}.IntValue, {a}.StrValue, {a}.StatusCode";
string Join(string a, string time) => $"LEFT JOIN {sample} {a} ON {a}.TagId = @id AND {a}.SourceTimeUtc = st.{time} ";
return Classified(sample, reverse) +
", st AS (SELECT b, " + CommonCounts() + ", " +
"MIN(CASE WHEN u = 1 THEN SourceTimeUtc END) AS fu, MAX(CASE WHEN u = 1 THEN SourceTimeUtc END) AS lu, " +
"MIN(SourceTimeUtc) AS fa, MAX(SourceTimeUtc) AS la FROM s GROUP BY b) " +
"SELECT st.b, st.TotalCount, st.UsableCount, st.GoodCount, st.BadCount, st.NonNumericCount, " +
Usable("x") + ", " + Usable("y") + ", " + Any("p") + ", " + Any("q") + " FROM st " +
Join("x", "fu") + Join("y", "lu") + Join("p", "fa") + Join("q", "la") + "ORDER BY st.b;";
}
///
/// MinimumActualTime / MaximumActualTime: the Good sample (#684) with the smallest (largest) NumValue,
/// ties broken by the earliest SourceTimeUtc. Family columns: 6 NumValue, 7 SourceTimeUtc
/// (NULL when the bucket has no Good numeric sample), then 8 UncMin, 9 UncMax, 10 GoodNonNumericCount as in .
///
///
/// Deliberately still two references to s (counts + the ranked extreme), unlike :
/// every single-scan form measured (a ROW_NUMBER or windowed MIN/MAX over every row) was
/// 1.2–2× slower on day-plus windows than ranking only the filtered usable rows. Live-gate 8 F1's inconsistency —
/// a sample committed between the two scans, counted but not ranked — is instead absorbed by the reader: a
/// NULL extreme in a bucket with usable samples evaluates to BadNoData (never a value-less Good).
///
/// Schema-qualified Sample table name.
/// True for MaximumActualTime.
/// The SQL text.
/// True for a reverse read (window (@start, @end]).
public static string ActualTime(string sample, bool maximum, bool reverse = false) =>
Classified(sample, reverse) +
", st AS (SELECT b, " + CommonCounts() + ", " + UncertainExtremes() + " FROM s GROUP BY b)" +
", r AS (SELECT b, NumValue, SourceTimeUtc, " +
"ROW_NUMBER() OVER (PARTITION BY b ORDER BY NumValue " + (maximum ? "DESC" : "ASC") + ", SourceTimeUtc ASC) AS rn " +
"FROM s WHERE g = 1 AND NumValue IS NOT NULL) " +
"SELECT st.b, st.TotalCount, st.UsableCount, st.GoodCount, st.BadCount, st.NonNumericCount, " +
"r.NumValue, r.SourceTimeUtc, st.UncMin, st.UncMax, st.GoodNonNumericCount " +
"FROM st LEFT JOIN r ON r.b = st.b AND r.rn = 1 ORDER BY st.b;";
///
/// Time-weighted family (#671: TimeAverage / TimeAverage2 / Total / Total2 / Interpolative) — not pushed down.
/// One batch of five result sets, each SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode:
/// (1) the latest usable sample before @start, (2) the latest sample of any quality before @start,
/// (3) the samples in [@start, @end) ascending, at most @maxRows (the raw-row cap + 1), (4) the first sample of any quality at or after
/// @end, (5) the first usable one at or after @end. Each bound is a TOP 1 seek on the
/// (TagId, SourceTimeUtc) key. The caller streams them, in that order, into
/// (shared with QuestDB, so both backends agree exactly).
///
///
/// Trade-off: the window's rows travel to the server instead of one row per bucket. Part 13's per-segment
/// interpolation, bucket-edge splitting and the two bounding-value definitions would otherwise have to be written
/// twice (T-SQL and QuestDB SQL) and kept bit-identical; evaluating once in C# keeps the backends equal by
/// construction. Memory stays O(buckets): the rows are streamed, never materialised.
///
/// Schema-qualified Sample table name.
/// The SQL batch.
public static string TimeWeighted(string sample)
{
const string cols = "SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode";
const string usable = " AND StatusCode >= 0 AND ValueKind <> 0";
var key = $"FROM {sample} WHERE TagId = @id AND ";
return
$"SELECT TOP 1 {cols} {key}SourceTimeUtc < @start{usable} ORDER BY SourceTimeUtc DESC; " +
$"SELECT TOP 1 {cols} {key}SourceTimeUtc < @start ORDER BY SourceTimeUtc DESC; " +
$"SELECT TOP (@maxRows) {cols} {key}SourceTimeUtc >= @start AND SourceTimeUtc < @end ORDER BY SourceTimeUtc ASC; " +
$"SELECT TOP 1 {cols} {key}SourceTimeUtc >= @end ORDER BY SourceTimeUtc ASC; " +
$"SELECT TOP 1 {cols} {key}SourceTimeUtc >= @end{usable} ORDER BY SourceTimeUtc ASC;";
}
///
/// The tag's data range as one row of two columns (each NULL when none): 0 the earliest stored sample
/// of any quality at or before the window's later edge, 1 the latest at or after its earlier edge. Used by the
/// start-of-data (#677) and end-of-data (#680) Partial rules and by Count's in-range empty interval (#680) — only when
/// says the answer can change a result. Each is a MIN /
/// MAX over the (TagId, SourceTimeUtc) key — a single seek — bounded by the window (a sample past the
/// later edge cannot start the data inside it, nor one before the earlier edge end it there; a NULL then means "the
/// data range does not reach the window").
///
/// Schema-qualified Sample table name.
/// The SQL text.
public static string DataRange(string sample) =>
$"SELECT (SELECT MIN(SourceTimeUtc) FROM {sample} WHERE TagId = @id AND SourceTimeUtc <= @end), " +
$"(SELECT MAX(SourceTimeUtc) FROM {sample} WHERE TagId = @id AND SourceTimeUtc >= @start);";
/// The s CTE: the window's samples with bucket index and the usable/good flags.
private static string Classified(string sample, bool reverse) =>
"WITH s AS (SELECT (DATEDIFF_BIG(microsecond, @start, SourceTimeUtc) * 10 " +
"+ (DATEPART(nanosecond, SourceTimeUtc) / 100) % 10 - (DATEPART(nanosecond, @start) / 100) % 10 " +
"+ @bucketAdjustTicks) / @intervalTicks AS b, " +
"SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, " +
"CASE WHEN StatusCode >= 0 AND ValueKind <> 0 THEN 1 ELSE 0 END AS u, " +
"CASE WHEN StatusCode >= 0 AND StatusCode < 1073741824 AND ValueKind <> 0 THEN 1 ELSE 0 END AS g " +
$"FROM {sample} WHERE TagId = @id AND " +
(reverse ? "SourceTimeUtc > @start AND SourceTimeUtc <= @end) " : "SourceTimeUtc >= @start AND SourceTimeUtc < @end) ");
///
/// The minimum and maximum Uncertain (usable, not Good) numeric value of the bucket — UncMin, UncMax
/// (#684). Not result values: Part 13 makes Minimum/Maximum/Range Uncertain when one lies beyond the Good extreme.
/// Then GoodNonNumericCount, the Good String/DateTime samples (the Good-value aggregates' refusal check).
///
private static string UncertainExtremes() =>
"MIN(CASE WHEN u = 1 AND g = 0 THEN NumValue END) AS UncMin, MAX(CASE WHEN u = 1 AND g = 0 THEN NumValue END) AS UncMax, " +
$"SUM(CASE WHEN g = 1 AND ValueKind IN ({NonNumericKinds}) THEN 1 ELSE 0 END) AS GoodNonNumericCount";
/// The five common count columns (after b).
private static string CommonCounts() =>
"COUNT(*) AS TotalCount, SUM(u) AS UsableCount, SUM(g) AS GoodCount, COUNT(*) - SUM(u) AS BadCount, " +
$"SUM(CASE WHEN u = 1 AND ValueKind IN ({NonNumericKinds}) THEN 1 ELSE 0 END) AS NonNumericCount";
}