Skip to content

Commit 38df298

Browse files
committed
Optimized windowed partitioned aggregators.
1 parent 8469562 commit 38df298

9 files changed

Lines changed: 297 additions & 19 deletions

File tree

SqlServerSimulator.Tests/WindowFrameTests.cs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,4 +405,102 @@ insert d values (1), (3), (5)
405405
// Running averages: 1/1=1, 4/2=2, 9/3=3.
406406
CollectionAssert.AreEqual(new[] { (1, 1), (3, 2), (5, 3) }, values);
407407
}
408+
409+
// === Sliding frames exercise the incremental Remove path per aggregator ===
410+
411+
[TestMethod]
412+
public void Sum_RowsBetweenCurrentRowAndUnboundedFollowing_ReverseRunningTotal()
413+
{
414+
// Start advances (CURRENT ROW) with the end pinned at the partition's
415+
// last row — every step removes the leaving row from the slider.
416+
using var connection = SeededTies();
417+
using var reader = connection.CreateCommand(
418+
"select id, sum(v) over(partition by grp order by id rows between current row and unbounded following) from t order by grp, id").ExecuteReader();
419+
var byId = new Dictionary<int, int>();
420+
while (reader.Read())
421+
byId[reader.GetInt32(0)] = reader.GetInt32(1);
422+
// grp 1 v=10,20,20,30 → suffix sums 80,70,50,30.
423+
AreEqual(80, byId[1]);
424+
AreEqual(70, byId[2]);
425+
AreEqual(50, byId[3]);
426+
AreEqual(30, byId[4]);
427+
// grp 2 v=5,5,50 → 60,55,50.
428+
AreEqual(60, byId[5]);
429+
AreEqual(55, byId[6]);
430+
AreEqual(50, byId[7]);
431+
}
432+
433+
[TestMethod]
434+
public void Min_SlidingFrame_DropsLeavingExtreme()
435+
{
436+
// Ascending values with a trailing 2-PRECEDING frame: the current
437+
// minimum repeatedly leaves the window, so the removable multiset must
438+
// surface the next-smallest survivor rather than a stale extreme.
439+
using var connection = new Simulation().CreateOpenConnection();
440+
_ = connection.CreateCommand("""
441+
create table m (id int, v int);
442+
insert m values (1,10), (2,20), (3,30), (4,40), (5,50)
443+
""").ExecuteNonQuery();
444+
using var reader = connection.CreateCommand(
445+
"select id, min(v) over(order by id rows between 2 preceding and current row) from m order by id").ExecuteReader();
446+
var values = new List<int>();
447+
while (reader.Read())
448+
values.Add(reader.GetInt32(1));
449+
// Frames {10},{10,20},{10,20,30},{20,30,40},{30,40,50} → mins 10,10,10,20,30.
450+
CollectionAssert.AreEqual(new[] { 10, 10, 10, 20, 30 }, values);
451+
}
452+
453+
[TestMethod]
454+
public void Avg_SlidingFrame_RemovesLeavingRows()
455+
{
456+
using var connection = new Simulation().CreateOpenConnection();
457+
_ = connection.CreateCommand("""
458+
create table a (id int, v int);
459+
insert a values (1,10), (2,20), (3,30), (4,40)
460+
""").ExecuteNonQuery();
461+
using var reader = connection.CreateCommand(
462+
"select id, avg(v) over(order by id rows between 1 preceding and current row) from a order by id").ExecuteReader();
463+
var values = new List<int>();
464+
while (reader.Read())
465+
values.Add(reader.GetInt32(1));
466+
// Pairs {10}=10, {10,20}=15, {20,30}=25, {30,40}=35.
467+
CollectionAssert.AreEqual(new[] { 10, 15, 25, 35 }, values);
468+
}
469+
470+
[TestMethod]
471+
public void VarP_SlidingFrame_SubtractsMoments()
472+
{
473+
using var connection = new Simulation().CreateOpenConnection();
474+
_ = connection.CreateCommand("""
475+
create table s (id int, v int);
476+
insert s values (1,2), (2,4), (3,6), (4,8)
477+
""").ExecuteNonQuery();
478+
using var reader = connection.CreateCommand(
479+
"select id, varp(v) over(order by id rows between 1 preceding and current row) from s order by id").ExecuteReader();
480+
var values = new List<double>();
481+
while (reader.Read())
482+
values.Add(reader.GetDouble(1));
483+
// {2}→0, {2,4}→1, {4,6}→1, {6,8}→1 (sum / sum-of-squares moments subtract).
484+
CollectionAssert.AreEqual(new[] { 0.0, 1.0, 1.0, 1.0 }, values);
485+
}
486+
487+
[TestMethod]
488+
public void ChecksumAgg_SlidingFrame_MatchesDirectAggregate()
489+
{
490+
// XOR is its own inverse: removing the leaving row must leave exactly
491+
// the state of aggregating the survivors directly.
492+
using var connection = new Simulation().CreateOpenConnection();
493+
_ = connection.CreateCommand("""
494+
create table c (id int, v int);
495+
insert c values (1,11), (2,22), (3,33), (4,44)
496+
""").ExecuteNonQuery();
497+
using var reader = connection.CreateCommand(
498+
"select id, checksum_agg(v) over(order by id rows between 1 preceding and current row) from c order by id").ExecuteReader();
499+
var byId = new Dictionary<int, int>();
500+
while (reader.Read())
501+
byId[reader.GetInt32(0)] = reader.GetInt32(1);
502+
// Row 3's frame is {22, 33}; the slider reached it by removing 11.
503+
var expected = (int)connection.CreateCommand("select checksum_agg(v) from c where id in (2,3)").ExecuteScalar()!;
504+
AreEqual(expected, byId[3]);
505+
}
408506
}

SqlServerSimulator/Parser/Aggregator.cs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,52 @@ internal abstract class Aggregator
2323

2424
public abstract SqlValue Result();
2525

26+
/// <summary>
27+
/// Whether this instance supports <see cref="Remove"/> — i.e. can undo an
28+
/// earlier <see cref="Add"/> so a sliding window frame can be maintained
29+
/// incrementally. Defaults to <c>false</c>; the window executor only calls
30+
/// <see cref="Remove"/> after checking this, and a removable instance is
31+
/// requested via <see cref="Create"/>'s <c>removable</c> flag (some
32+
/// aggregators need a heavier removal-capable representation).
33+
/// </summary>
34+
public virtual bool CanRemove => false;
35+
36+
/// <summary>
37+
/// Reverses one prior <see cref="Add"/> of <paramref name="value"/>. Only
38+
/// valid when <see cref="CanRemove"/>; the value must have been added
39+
/// before. NULL inputs are no-ops on the matching <see cref="Add"/>, so
40+
/// removing a NULL is likewise a no-op.
41+
/// </summary>
42+
public virtual void Remove(SqlValue value) =>
43+
throw new NotSupportedException($"{this.GetType().Name} does not support incremental removal.");
44+
2645
/// <summary>
2746
/// Builds a fresh aggregator for the given expression. Caller supplies
2847
/// the operand's resolved type (some aggregators use it to choose an
2948
/// accumulator) and the aggregate's overall result type (used to size
3049
/// the returned <see cref="SqlValue"/>). The Selection executor calls
3150
/// this once per group; aggregators don't outlive a single group.
51+
/// <para>
52+
/// <paramref name="removable"/> asks for a representation that supports
53+
/// <see cref="Remove"/> (set by the window executor for sliding frames
54+
/// whose start advances). It only changes the shape of aggregators that
55+
/// would otherwise be cheaper without removal support — notably MIN / MAX,
56+
/// which keeps a single running extreme unless asked to track a removable
57+
/// multiset. Aggregators that are intrinsically removable (or never) ignore
58+
/// it.
59+
/// </para>
3260
/// </summary>
33-
public static Aggregator Create(AggregateExpression aggregate, SqlType operandType, SqlType resultType) => aggregate.Kind switch
61+
public static Aggregator Create(AggregateExpression aggregate, SqlType operandType, SqlType resultType, bool removable = false) => aggregate.Kind switch
3462
{
3563
AggregateKind.Count => new CountAggregator(isStar: aggregate.Operand is null, isBigCount: false, distinct: aggregate.Distinct),
3664
AggregateKind.CountBig => new CountAggregator(isStar: aggregate.Operand is null, isBigCount: true, distinct: aggregate.Distinct),
3765
AggregateKind.ApproxCountDistinct => new CountAggregator(isStar: false, isBigCount: true, distinct: true),
3866
AggregateKind.Max => operandType.IsLob || operandType is BitSqlType
3967
? throw SimulatedSqlException.OperandDataTypeInvalid(operandType, "max")
40-
: new MinMaxAggregator(resultType, isMax: true),
68+
: new MinMaxAggregator(resultType, isMax: true, removable),
4169
AggregateKind.Min => operandType.IsLob || operandType is BitSqlType
4270
? throw SimulatedSqlException.OperandDataTypeInvalid(operandType, "min")
43-
: new MinMaxAggregator(resultType, isMax: false),
71+
: new MinMaxAggregator(resultType, isMax: false, removable),
4472
AggregateKind.Sum => SumAggregator.Create(resultType, aggregate.Distinct),
4573
AggregateKind.Avg => AverageAggregator.Create(resultType, aggregate.Distinct),
4674
AggregateKind.Stdev or AggregateKind.StdevP or AggregateKind.Var or AggregateKind.VarP => new StatisticalAggregator(aggregate.Kind),

SqlServerSimulator/Parser/Aggregators/ChecksumAggAggregator.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,17 @@ public override void Add(SqlValue value)
2626
this.folded ^= value.GetHashCode();
2727
}
2828

29+
// XOR is its own inverse, so re-folding a value removes it — the window
30+
// frame slides incrementally. (Two equal values XOR-cancel each other, which
31+
// is exactly the multiset removal semantic.)
32+
public override bool CanRemove => true;
33+
34+
public override void Remove(SqlValue value)
35+
{
36+
if (value.IsNull)
37+
return;
38+
this.folded ^= value.GetHashCode();
39+
}
40+
2941
public override SqlValue Result() => SqlValue.FromInt32(this.folded);
3042
}

SqlServerSimulator/Parser/Aggregators/CountAggregator.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ public override void Add(SqlValue value)
3939
}
4040
}
4141

42+
// A plain row/value count decrements cleanly; the DISTINCT form can't,
43+
// since the dedup set doesn't carry per-value multiplicity. (COUNT(DISTINCT)
44+
// / APPROX_COUNT_DISTINCT are illegal with OVER, so the window path never
45+
// requests a removable distinct count.)
46+
public override bool CanRemove => this.seen is null;
47+
48+
public override void Remove(SqlValue value)
49+
{
50+
if (!isStar && value.IsNull)
51+
return;
52+
this.count--;
53+
}
54+
4255
public override SqlValue Result() => isBigCount
4356
? SqlValue.FromInt64(this.count)
4457
: this.count > int.MaxValue

SqlServerSimulator/Parser/Aggregators/MinMaxAggregator.cs

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,80 @@ namespace SqlServerSimulator.Parser.Aggregators;
88
/// returns NULL of the operand type. The DISTINCT keyword is honored at
99
/// parse time but doesn't affect the result (DISTINCT extremes equal plain
1010
/// extremes; SQL Server accepts the keyword as a no-op here).
11+
/// <para>
12+
/// A single running extreme can't be un-done (dropping the current extreme
13+
/// leaves the next one unknown), so the removable mode requested for sliding
14+
/// window frames keeps a directional multiset instead — ordered so the wanted
15+
/// extreme is always the first key, with per-value multiplicity for removal.
16+
/// GROUP BY and forward-cumulative windows never remove, so they keep the
17+
/// cheaper two-field running-extreme path.
18+
/// </para>
1119
/// </summary>
12-
internal sealed class MinMaxAggregator(SqlType resultType, bool isMax) : Aggregator
20+
internal sealed class MinMaxAggregator : Aggregator
1321
{
14-
private SqlValue current = SqlValue.Null(resultType);
22+
private readonly SqlType resultType;
23+
private readonly bool isMax;
24+
private readonly SortedDictionary<SqlValue, int>? multiset;
25+
26+
private SqlValue current;
1527
private bool sawAny;
1628

29+
public MinMaxAggregator(SqlType resultType, bool isMax, bool removable = false)
30+
{
31+
this.resultType = resultType;
32+
this.isMax = isMax;
33+
this.current = SqlValue.Null(resultType);
34+
if (removable)
35+
{
36+
this.multiset = new SortedDictionary<SqlValue, int>(
37+
Comparer<SqlValue>.Create(isMax ? static (a, b) => b.CompareTo(a) : static (a, b) => a.CompareTo(b)));
38+
}
39+
}
40+
1741
public override void Add(SqlValue value)
1842
{
1943
if (value.IsNull)
2044
return;
45+
if (this.multiset is { } bag)
46+
{
47+
_ = bag.TryGetValue(value, out var n);
48+
bag[value] = n + 1;
49+
return;
50+
}
2151
if (!this.sawAny)
2252
{
2353
this.current = value;
2454
this.sawAny = true;
2555
return;
2656
}
2757
var cmp = value.CompareTo(this.current);
28-
if ((isMax && cmp > 0) || (!isMax && cmp < 0))
58+
if ((this.isMax && cmp > 0) || (!this.isMax && cmp < 0))
2959
this.current = value;
3060
}
3161

32-
public override SqlValue Result() => this.sawAny ? this.current : SqlValue.Null(resultType);
62+
public override bool CanRemove => this.multiset is not null;
63+
64+
public override void Remove(SqlValue value)
65+
{
66+
if (value.IsNull)
67+
return;
68+
var bag = this.multiset!;
69+
var n = bag[value];
70+
if (n == 1)
71+
_ = bag.Remove(value);
72+
else
73+
bag[value] = n - 1;
74+
}
75+
76+
public override SqlValue Result()
77+
{
78+
if (this.multiset is { } bag)
79+
{
80+
foreach (var pair in bag)
81+
return pair.Key;
82+
return SqlValue.Null(this.resultType);
83+
}
84+
85+
return this.sawAny ? this.current : SqlValue.Null(this.resultType);
86+
}
3387
}

SqlServerSimulator/Parser/Aggregators/NumericAggregator.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ public sealed override void Add(SqlValue value)
5050
this.Count++;
5151
}
5252

53+
// A running sum subtracts cleanly, so SUM / AVG slide incrementally — but
54+
// only without DISTINCT, whose dedup set can't tell whether a removed value
55+
// still has surviving duplicates. (DISTINCT is illegal with OVER anyway, so
56+
// the window path never requests it.)
57+
public sealed override bool CanRemove => !this.distinct;
58+
59+
public sealed override void Remove(SqlValue value)
60+
{
61+
if (value.IsNull)
62+
return;
63+
this.Accumulator -= Extract(value.CoerceTo(this.ResultType));
64+
this.Count--;
65+
}
66+
5367
public sealed override SqlValue Result()
5468
{
5569
if (this.Count == 0)

SqlServerSimulator/Parser/Aggregators/StatisticalAggregator.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ public override void Add(SqlValue value)
2929
this.sumOfSquares += x * x;
3030
}
3131

32+
// The classical sum / sum-of-squares moments subtract directly, so the
33+
// statistical aggregates slide incrementally over a window frame.
34+
public override bool CanRemove => true;
35+
36+
public override void Remove(SqlValue value)
37+
{
38+
if (value.IsNull)
39+
return;
40+
var x = value.CoerceTo(SqlType.Float).AsDouble;
41+
this.count--;
42+
this.sum -= x;
43+
this.sumOfSquares -= x * x;
44+
}
45+
3246
public override SqlValue Result()
3347
{
3448
var isPopulation = kind is AggregateKind.StdevP or AggregateKind.VarP;

0 commit comments

Comments
 (0)