Skip to content

Commit 5ced3b5

Browse files
committed
Add keyset-pagination seeks to ORDER BY elimination (the a > @x OR (a = @x AND b > @y) staircase seeks past the cursor via a generalized composite-key bound). Fix AdjustForPrecedence to re-adjust recursively so 3+-term arithmetic chains led by a multiplication group correctly.
1 parent 690d4b7 commit 5ced3b5

8 files changed

Lines changed: 434 additions & 54 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ The `*.Tests` and `*.Tests.EFCore` suites are the authoritative behavior contrac
107107

108108
### Constraints
109109
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate. Inline column-level CHECK predicates may only reference their owning column — peer references raise **Msg 8141** at CREATE TABLE (probe-confirmed verbatim wording). The walker is structural via `Expression.VisitColumnReferences` + `BooleanExpression.VisitOperandExpressions`; coverage is currently limited to common container subclasses (`Reference`, `Parenthesized`, `TwoSidedExpression`, `Cast`, `Length`) — peer refs buried in less-common containers (`DATEPART`, `SUBSTRING`, nested `CASE`, etc.) silently escape the CREATE-TABLE check and surface at INSERT instead. Table-level CHECK has no peer restriction.
110-
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree. Reads get **incrementally-maintained** per-`Heap` seek acceleration (no per-mutation warm-up): equality / IN on the longest leading prefix, range (`>`/`<`/`BETWEEN`) on a leading key column, and ORDER BY elimination — single or multi-column leading-prefix sort (all NOT NULL, all one direction), including an equality-prefix continued by the order columns (`WHERE a = @x ORDER BY b`). See [`indexes.md`](docs/claude/indexes.md) for the journal mechanics, decline rules, and the residual-WHERE correctness invariant.
110+
- `PRIMARY KEY` / `UNIQUE` / secondary `CREATE INDEX`: linear scan (O(N) per insert); no B-tree. Reads get **incrementally-maintained** per-`Heap` seek acceleration (no per-mutation warm-up), identical across keys and non-clustered indexes (built from heap-row bytes keyed by the index's ordinals): equality / IN on the longest leading prefix, range (`>`/`<`/`BETWEEN`) on a leading key column, ORDER BY elimination — single or multi-column leading-prefix sort (all NOT NULL, all one direction), including an equality-prefix continued by the order columns (`WHERE a = @x ORDER BY b`) — and keyset (seek-method) pagination (`WHERE a > @x OR (a = @x AND b > @y) ORDER BY a, b` seeks past the cursor). See [`indexes.md`](docs/claude/indexes.md) for the journal mechanics, decline rules, and the residual-WHERE correctness invariant.
111111
- `FOREIGN KEY`: inline / table-level / named forms; all four referential actions on `ON DELETE` / `ON UPDATE`; enforced at INSERT / UPDATE / DELETE / MERGE; full `sys.foreign_keys` / `sys.foreign_key_columns`. Referential-action, cascade-cycle, PK/UNIQUE-target, and NULL-skip rules + Msg numbers in [`foreign-keys.md`](docs/claude/foreign-keys.md).
112112

113113
### Transactions

SqlServerSimulator.Tests.Internal/IndexSeekTests.cs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,124 @@ public void MultiColumnOrderByDifferential_MatchesIndependentSort()
12131213
AreEqual(string.Join(",", expected), string.Join(",", actual));
12141214
}
12151215

1216+
// ---- keyset pagination: WHERE a > @x OR (a = @x AND b > @y) ORDER BY a, b
1217+
// seeks past the cursor instead of scanning + sorting + skipping ----
1218+
1219+
[TestMethod]
1220+
public void KeysetForwardTwoColumn_SeeksPastCursor()
1221+
{
1222+
var (trace, rows) = Run("""
1223+
create table t (a int not null, b int not null, primary key (a, b));
1224+
insert t values (1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (3, 1)
1225+
""", "select 10 * a + b from t where a > 1 or (a = 1 and b > 2) order by a, b");
1226+
Contains("OrderedScan(t)", trace);
1227+
Contains("KeysetSeek(t)", trace);
1228+
AreEqual("13,21,22,31", Seq(rows));
1229+
}
1230+
1231+
[TestMethod]
1232+
public void KeysetDescendingTwoColumn_SeeksPastCursor()
1233+
{
1234+
// a DESC, b DESC keyset uses the < staircase; the cursor is the exclusive
1235+
// upper bound, and the ascending in-range list is reversed into the page.
1236+
var (trace, rows) = Run("""
1237+
create table t (a int not null, b int not null, primary key (a, b));
1238+
insert t values (1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (3, 1)
1239+
""", "select 10 * a + b from t where a < 3 or (a = 3 and b < 1) order by a desc, b desc");
1240+
Contains("OrderedScan(t)", trace);
1241+
Contains("KeysetSeek(t)", trace);
1242+
AreEqual("22,21,13,12,11", Seq(rows));
1243+
}
1244+
1245+
[TestMethod]
1246+
public void KeysetThreeColumn_SeeksPastCursor()
1247+
{
1248+
var (trace, rows) = Run("""
1249+
create table t (a int not null, b int not null, c int not null, primary key (a, b, c));
1250+
insert t values (1, 2, 1), (1, 2, 2), (1, 2, 3), (1, 3, 1), (2, 1, 1)
1251+
""", "select 100 * a + 10 * b + c from t where a > 1 or (a = 1 and b > 2) or (a = 1 and b = 2 and c > 2) order by a, b, c");
1252+
Contains("KeysetSeek(t)", trace);
1253+
// Rows (1,2,3),(1,3,1),(2,1,1) in key order → 100*a + 10*b + c.
1254+
AreEqual("123,131,211", Seq(rows));
1255+
}
1256+
1257+
[TestMethod]
1258+
public void KeysetOnNonClusteredCompositeIndex_SeeksPastCursor()
1259+
{
1260+
// The cursor seek rides a secondary CREATE INDEX, not just the PK —
1261+
// acceleration is index-source-agnostic.
1262+
var (trace, rows) = Run("""
1263+
create table t (id int not null primary key, a int not null, b int not null);
1264+
create index ix on t (a, b);
1265+
insert t values (10, 1, 1), (20, 1, 2), (30, 2, 1), (40, 2, 2), (50, 3, 9)
1266+
""", "select id from t where a > 2 or (a = 2 and b > 1) order by a, b");
1267+
Contains("OrderedScan(t)", trace);
1268+
Contains("KeysetSeek(t)", trace);
1269+
AreEqual("40,50", Seq(rows));
1270+
}
1271+
1272+
[TestMethod]
1273+
public void KeysetWithResidualFilter_SeeksAndFilters()
1274+
{
1275+
// The keyset OR is one conjunct; an unrelated AND filter stays residual.
1276+
var (trace, rows) = Run("""
1277+
create table t (a int not null, b int not null, active int not null, primary key (a, b));
1278+
insert t values (1, 1, 1), (1, 2, 0), (2, 1, 1), (2, 2, 1), (3, 1, 0)
1279+
""", "select 10 * a + b from t where (a > 1 or (a = 1 and b > 1)) and active = 1 order by a, b");
1280+
Contains("KeysetSeek(t)", trace);
1281+
AreEqual("21,22", Seq(rows));
1282+
}
1283+
1284+
[TestMethod]
1285+
public void KeysetParameterizedCursor_SeeksPastCursor()
1286+
{
1287+
// Variables are stable cursor values, the normal pagination shape. The
1288+
// DECLAREs share the query's batch (Run runs setup separately).
1289+
var (trace, rows) = Run("""
1290+
create table t (a int not null, b int not null, primary key (a, b));
1291+
insert t values (1, 1), (1, 2), (2, 1), (2, 2), (3, 1)
1292+
""", "declare @a int = 2; declare @b int = 1; select 10 * a + b from t where a > @a or (a = @a and b > @b) order by a, b");
1293+
Contains("KeysetSeek(t)", trace);
1294+
AreEqual("22,31", Seq(rows));
1295+
}
1296+
1297+
[TestMethod]
1298+
public void KeysetDifferential_MatchesIndependentFilterAndSort()
1299+
{
1300+
// Cross-check the keyset seek against a C# (a, b) > (cursor) filter + sort
1301+
// over a scrambled non-clustered composite index.
1302+
var c = new Simulation().CreateDbConnection();
1303+
c.Open();
1304+
Exec(c, "create table t (id int not null primary key, a int not null, b int not null)");
1305+
Exec(c, "create index ix on t (a, b)");
1306+
Exec(c, "insert t (id, a, b) select value, (value * 7919) % 23, (value * 104729) % 89 from generate_series(1, 400)");
1307+
1308+
var expected = new List<int>();
1309+
foreach (var (a, b) in ReadPairs(c, "select a, b from t"))
1310+
{
1311+
if ((a > 11) || ((a == 11) && (b > 40)))
1312+
expected.Add((1000 * a) + b);
1313+
}
1314+
expected.Sort();
1315+
1316+
var actual = new List<int>();
1317+
foreach (var o in ReadRows(c, "select 1000 * a + b from t where a > 11 or (a = 11 and b > 40) order by a, b"))
1318+
actual.Add(Convert.ToInt32(o));
1319+
1320+
AreEqual(string.Join(",", expected), string.Join(",", actual));
1321+
}
1322+
1323+
private static List<(int A, int B)> ReadPairs(SimulatedDbConnection c, string sql)
1324+
{
1325+
using var cmd = c.CreateCommand();
1326+
cmd.CommandText = sql;
1327+
using var r = cmd.ExecuteReader();
1328+
var pairs = new List<(int, int)>();
1329+
while (r.Read())
1330+
pairs.Add((Convert.ToInt32(r.GetValue(0)), Convert.ToInt32(r.GetValue(1))));
1331+
return pairs;
1332+
}
1333+
12161334
// ---- declines (keeps the buffered sort), still correct order ----
12171335

12181336
[TestMethod]
@@ -1285,6 +1403,21 @@ insert t values (1, 30), (1, null), (1, 10)
12851403
AreEqual(",10,30", Seq(rows));
12861404
}
12871405

1406+
[TestMethod]
1407+
public void KeysetInconsistentCursorValues_DeclinesKeyset_StillCorrect()
1408+
{
1409+
// a > 1 OR (a = 0 AND b > 5) isn't a clean (a, b) > cursor staircase (the
1410+
// a-value disagrees: 1 vs 0), so the keyset cursor declines — the ordered
1411+
// scan still runs and the residual OR filters it to the right rows.
1412+
var (trace, rows) = Run("""
1413+
create table t (a int not null, b int not null, primary key (a, b));
1414+
insert t values (0, 9), (1, 1), (1, 2), (2, 1)
1415+
""", "select 10 * a + b from t where a > 1 or (a = 0 and b > 5) order by a, b");
1416+
DoesNotContain("KeysetSeek(t)", trace);
1417+
Contains("OrderedScan(t)", trace);
1418+
AreEqual("9,21", Seq(rows));
1419+
}
1420+
12881421
[TestMethod]
12891422
public void OrderByColumnsNotMatchingKeyOrder_Declines()
12901423
{

SqlServerSimulator.Tests/OperatorPrecedenceTests.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ public sealed class OperatorPrecedenceTests
2424
[DataRow("10 + 8 / 4", 12)] // Divide on the right of Add (Divide is higher precedence; doesn't swap).
2525
[DataRow("1 + 5 % 3", 3)] // Modulus on the right of Add (same precedence): (1+5)%3 = 6%3 = 0... wait, swap means Modulus parent: 1+(5%3) = 1+2 = 3.
2626
[DataRow("2 * 3 + 4", 10)] // Add on the right of Multiply (Add lower precedence; swap).
27+
// Three-plus additive terms whose FIRST term is a multiplication: one rotation
28+
// lifts the top + but leaves the rotated-down Multiply mis-grouped, so these
29+
// need AdjustForPrecedence to re-adjust recursively. Before that fix
30+
// `100 * 2 + 10 * 3 + 4` parsed as `(100 * (2 + 10 * 3)) + 4`.
31+
[DataRow("100 * 2 + 10 * 3 + 4", 234)]
32+
[DataRow("100 * 2 + 3 + 4", 207)]
33+
[DataRow("2 * 1 + 3 * 2 + 4 * 1", 12)]
34+
[DataRow("1 + 2 * 3 + 4", 11)] // leading literal already worked; guards against regressing it.
35+
[DataRow("2 * 3 + 4 * 5 + 6", 32)]
36+
[DataRow("100 * 2 + 10 * 3 + 4 * 1 + 5", 239)] // four terms.
2737
public void OperatorChainProducesExpected(string expr, int expected) =>
2838
AreEqual(expected, ExecuteScalar($"select {expr}"));
2939
}

SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,15 @@ private static BetweenExpression ParseBetween(Expression left, ParserContext con
497497
/// </summary>
498498
internal virtual void CollectConjuncts(List<BooleanExpression> sink) => sink.Add(this);
499499

500+
/// <summary>
501+
/// Flattens a top-level <c>OR</c> chain into its individual disjunct terms,
502+
/// appending each to <paramref name="sink"/>. A non-<c>OR</c> predicate
503+
/// contributes itself. The mirror of <see cref="CollectConjuncts"/>, used to
504+
/// recognize the keyset-pagination staircase
505+
/// (<c>a &gt; @x OR (a = @x AND b &gt; @y)</c>).
506+
/// </summary>
507+
internal virtual void CollectDisjuncts(List<BooleanExpression> sink) => sink.Add(this);
508+
500509
/// <summary>
501510
/// Exposes the two operands when this predicate is an equality comparison
502511
/// (<c>=</c>); returns false for every other node. Lets the join planner
@@ -620,6 +629,12 @@ internal override void VisitOperandExpressions(Action<Expression> visitor)
620629
right.VisitOperandExpressions(visitor);
621630
}
622631

632+
internal override void CollectDisjuncts(List<BooleanExpression> sink)
633+
{
634+
left.CollectDisjuncts(sink);
635+
right.CollectDisjuncts(sink);
636+
}
637+
623638
// Flatten the OR-tree (any nesting depth) into leaf equality pairs.
624639
// Succeeds only when EVERY leaf is itself an equality family — a single
625640
// equality compare (one pair), a nested OR-tree (recursed via the

SqlServerSimulator/Parser/Expressions/TwoSidedExpression.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,14 @@ public TwoSidedExpression AdjustForPrecedence()
4343
if (this.right is not TwoSidedExpression rightTwo || rightTwo.Precedence < this.Precedence)
4444
return this;
4545

46+
// Left-rotate so this operator drops below rightTwo's equal-or-looser-
47+
// binding one. The rotated-down node (this, now holding the old
48+
// rightTwo.left as its right operand) can itself still be mis-grouped when
49+
// that operand is another operator chain, so re-adjust it: a single
50+
// rotation only fixes the top of a 3+-term right-leaning parse, leaving
51+
// e.g. `100*a + 10*b + c` as `(100 * (a + 10*b)) + c` without this step.
4652
(rightTwo.left, this.right) = (this, rightTwo.left);
53+
rightTwo.left = this.AdjustForPrecedence();
4754
return rightTwo;
4855
}
4956

SqlServerSimulator/Parser/IndexSeekDiagnostics.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ internal static class IndexSeekDiagnostics
1616
/// <summary>
1717
/// Per-thread decision log: <c>Seek(table)</c> when a scan narrows to an
1818
/// index seek, <c>Scan(table)</c> when an eligible single-base-table scan
19-
/// keeps its full scan, plus the per-Heap cache's own maintenance trace —
19+
/// keeps its full scan, <c>OrderedScan(table)</c> when an ORDER BY streams in
20+
/// key order instead of buffering + sorting (with <c>KeysetSeek(table)</c>
21+
/// when that ordered scan also positions past a keyset-pagination cursor),
22+
/// plus the per-Heap cache's own maintenance trace —
2023
/// <c>CacheBuild</c> when a seek (re)builds an entry from a full scan and
2124
/// <c>CacheReplay</c> when it instead applies the incremental journal delta
2225
/// (the "no warm-up" path). A test assigns a fresh list, drives a query to

0 commit comments

Comments
 (0)