Skip to content

Commit 287d1e8

Browse files
committed
Add leading-key-column range seeks (>/>=/</<=/BETWEEN, either operand order, two-sided and correlated bounds) over a lazily-built, incrementally-maintained ordered view of the per-Heap seek cache, so range predicates narrow instead of full-scanning while inheriting the journal's no-warm-up maintenance.
1 parent 96fe5f2 commit 287d1e8

7 files changed

Lines changed: 653 additions & 41 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 equality-seek acceleration via an **incrementally-maintained** per-`Heap` hash index: the first seek builds it from a scan and activates the heap's bounded mutation journal, and later writes append a delta the next seek replays instead of forcing a rebuild (no per-mutation warm-up; rollback / TRUNCATE invalidate it as the safety valve; the write path's locking stays untouched). See indexes.md for leading-prefix matching, MVCC/lock-plan decline rules, the journal mechanics, and the residual-WHERE correctness invariant.
110+
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree. Reads get equality- and range-seek acceleration via an **incrementally-maintained** per-`Heap` index: the first seek builds it from a scan and activates the heap's bounded mutation journal, and later writes append a delta the next seek replays instead of forcing a rebuild (no per-mutation warm-up; rollback / TRUNCATE invalidate it as the safety valve; the write path's locking stays untouched). Equality / IN seek the longest leading prefix; a range bound (`>`/`<`/`BETWEEN`) on a leading key column seeks an ordered view (`SortedSet`) of that column's keys. See indexes.md for leading-prefix matching, range-seek scope, MVCC/lock-plan decline rules, the journal mechanics, 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: 207 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -359,15 +359,6 @@ public void NullProbe_Declines()
359359
IsEmpty(rows);
360360
}
361361

362-
[TestMethod]
363-
public void RangePredicate_Declines()
364-
{
365-
var (trace, rows) = Run(TableT, "select id from t where id > 1");
366-
Contains("Scan(t)", trace);
367-
DoesNotContain("Seek(t)", trace);
368-
HasCount(2, rows);
369-
}
370-
371362
[TestMethod]
372363
public void NonIndexedColumn_Declines()
373364
{
@@ -844,4 +835,211 @@ public void InterleavedInsertSeekLoop_NeverStale()
844835
AreEqual(i * 100, ReadVal(c, $"select val from t where id = {i}"));
845836
}
846837
}
838+
839+
// ---- range seeks on a leading key column (>, >=, <, <=, BETWEEN). The bound
840+
// conjunct stays residual, so the seek only narrows; results match a scan. ----
841+
842+
[TestMethod]
843+
public void GreaterThan_OnPrimaryKey_RangeSeeks()
844+
{
845+
var (trace, rows) = Run(TableT, "select id from t where id > 1");
846+
Contains("RangeSeek(t)", trace);
847+
DoesNotContain("Scan(t)", trace);
848+
HasCount(2, rows);
849+
Contains(2, rows);
850+
Contains(3, rows);
851+
}
852+
853+
[TestMethod]
854+
public void GreaterOrEqual_OnPrimaryKey_RangeSeeks()
855+
{
856+
var (trace, rows) = Run(TableT, "select id from t where id >= 2");
857+
Contains("RangeSeek(t)", trace);
858+
HasCount(2, rows);
859+
}
860+
861+
[TestMethod]
862+
public void LessThan_OnPrimaryKey_RangeSeeks()
863+
{
864+
var (trace, rows) = Run(TableT, "select id from t where id < 3");
865+
Contains("RangeSeek(t)", trace);
866+
HasCount(2, rows);
867+
Contains(1, rows);
868+
Contains(2, rows);
869+
}
870+
871+
[TestMethod]
872+
public void LessOrEqual_OnPrimaryKey_RangeSeeks()
873+
{
874+
var (trace, rows) = Run(TableT, "select id from t where id <= 2");
875+
Contains("RangeSeek(t)", trace);
876+
HasCount(2, rows);
877+
}
878+
879+
[TestMethod]
880+
public void Between_OnPrimaryKey_RangeSeeksInclusiveBothEnds()
881+
{
882+
var (trace, rows) = Run(TableT, "select id from t where id between 2 and 3");
883+
Contains("RangeSeek(t)", trace);
884+
HasCount(2, rows);
885+
Contains(2, rows);
886+
Contains(3, rows);
887+
}
888+
889+
[TestMethod]
890+
public void TwoSidedRange_OnPrimaryKey_RangeSeeks()
891+
{
892+
var (trace, rows) = Run(TableT, "select id from t where id > 1 and id < 3");
893+
Contains("RangeSeek(t)", trace);
894+
HasCount(1, rows);
895+
AreEqual(2, rows[0]);
896+
}
897+
898+
[TestMethod]
899+
public void ReversedOperandOrder_RangeSeeks()
900+
{
901+
// `2 < id` is `id > 2` — the planner flips the operator when the column
902+
// is on the right.
903+
var (trace, rows) = Run(TableT, "select id from t where 2 < id");
904+
Contains("RangeSeek(t)", trace);
905+
HasCount(1, rows);
906+
AreEqual(3, rows[0]);
907+
}
908+
909+
[TestMethod]
910+
public void RangeOnSecondaryIndexLeadingColumn_RangeSeeks()
911+
{
912+
var (trace, rows) = Run("""
913+
create table c (id int not null, pid int not null);
914+
create index ix_c_pid on c (pid);
915+
insert c values (1, 10), (2, 20), (3, 30), (4, 5)
916+
""", "select id from c where pid >= 20");
917+
Contains("RangeSeek(c)", trace);
918+
HasCount(2, rows);
919+
}
920+
921+
[TestMethod]
922+
public void RangeOnNonIndexedColumn_Declines()
923+
{
924+
var (trace, rows) = Run(TableT, "select id from t where val > 100");
925+
Contains("Scan(t)", trace);
926+
DoesNotContain("RangeSeek(t)", trace);
927+
HasCount(1, rows);
928+
AreEqual(3, rows[0]);
929+
}
930+
931+
[TestMethod]
932+
public void EqualityPrefixThenRange_TakesEqualitySeek_NotRange()
933+
{
934+
// index (a, b): a = 1 AND b > 10 — the equality path seeks on the leading
935+
// column a (width 1) and the range on b stays residual. The single
936+
// leading-column range path doesn't fire here.
937+
var (trace, rows) = Run("""
938+
create table t (a int not null, b int not null, v int not null, primary key (a, b));
939+
insert t values (1, 10, 100), (1, 20, 200), (1, 30, 300), (2, 10, 400)
940+
""", "select v from t where a = 1 and b > 10");
941+
Contains("SeekWidth(t,1)", trace);
942+
DoesNotContain("RangeSeek(t)", trace);
943+
HasCount(2, rows);
944+
}
945+
946+
[TestMethod]
947+
public void NullBound_RangeSeeksToEmpty()
948+
{
949+
// `id > @null` is UNKNOWN for every row, so the range matches nothing — a
950+
// valid (empty) seek rather than a scan.
951+
var (trace, rows) = Run(TableT, "declare @v int = null; select id from t where id > @v");
952+
Contains("RangeSeek(t)", trace);
953+
IsEmpty(rows);
954+
}
955+
956+
[TestMethod]
957+
public void OutOfDomainUpperBound_PromotesUp_ReturnsAll()
958+
{
959+
// bigint bound wider than the int column: promotion is upward, so the
960+
// comparison is exact and every row qualifies.
961+
var (trace, rows) = Run(TableT, "select id from t where id < 9999999999");
962+
Contains("RangeSeek(t)", trace);
963+
HasCount(3, rows);
964+
}
965+
966+
[TestMethod]
967+
public void EmptyRange_LowerAboveUpper_RangeSeeksToEmpty()
968+
{
969+
var (trace, rows) = Run(TableT, "select id from t where id > 5 and id < 2");
970+
Contains("RangeSeek(t)", trace);
971+
IsEmpty(rows);
972+
}
973+
974+
[TestMethod]
975+
public void StringRange_CaseInsensitiveCollation_RangeSeeks()
976+
{
977+
var (trace, rows) = Run("""
978+
create table t (code varchar(10) not null primary key);
979+
insert t values ('apple'), ('mango'), ('pear')
980+
""", "select code from t where code >= 'm'");
981+
Contains("RangeSeek(t)", trace);
982+
HasCount(2, rows);
983+
}
984+
985+
[TestMethod]
986+
public void DateRange_HalfOpenInterval_RangeSeeks()
987+
{
988+
// The canonical clustered-key range: created >= @from AND created < @to.
989+
var (trace, rows) = Run("""
990+
create table e (created date not null primary key, label varchar(10) not null);
991+
insert e values ('2026-01-01', 'a'), ('2026-02-01', 'b'), ('2026-03-01', 'c')
992+
""", "select label from e where created >= '2026-01-15' and created < '2026-03-01'");
993+
Contains("RangeSeek(e)", trace);
994+
HasCount(1, rows);
995+
AreEqual("b", rows[0]);
996+
}
997+
998+
[TestMethod]
999+
public void RangeAfterWarmup_ReplaysDelta_SortedViewStaysCurrent()
1000+
{
1001+
// The lazily-built sorted view must be maintained incrementally: a row
1002+
// inserted after the range warm-up must appear in a later range scan via
1003+
// the journal replay, not a rebuild.
1004+
var (trace, rows) = WarmMutateProbe(
1005+
TableT, "select id from t where id > 0", "insert t values (4, 40)", "select id from t where id >= 3");
1006+
Contains("CacheReplay", trace);
1007+
DoesNotContain("CacheBuild", trace);
1008+
Contains("RangeSeek(t)", trace);
1009+
HasCount(2, rows);
1010+
Contains(3, rows);
1011+
Contains(4, rows);
1012+
}
1013+
1014+
[TestMethod]
1015+
public void RangeCorrectUnderManyMutations_MatchesIndependentBaseline()
1016+
{
1017+
// Stress the incrementally-maintained sorted view: interleave inserts,
1018+
// key-changing updates, and deletes, then confirm a range seek matches a
1019+
// brute-force filter over a no-WHERE full read (which doesn't seek).
1020+
var c = new Simulation().CreateDbConnection();
1021+
c.Open();
1022+
Exec(c, "create table t (id int not null primary key, val int not null)");
1023+
Exec(c, "insert t (id, val) select value, value from generate_series(1, 40)");
1024+
_ = ReadVal(c, "select id from t where id > 0");
1025+
Exec(c, "delete from t where id between 10 and 20");
1026+
Exec(c, "update t set id = id + 100 where id between 30 and 35");
1027+
Exec(c, "insert t values (5000, 0)");
1028+
1029+
var expected = new List<int>();
1030+
foreach (var o in ReadRows(c, "select id from t"))
1031+
{
1032+
var x = Convert.ToInt32(o);
1033+
if (x is > 25 and < 5001)
1034+
expected.Add(x);
1035+
}
1036+
1037+
var actual = new List<int>();
1038+
foreach (var o in ReadRows(c, "select id from t where id > 25 and id < 5001"))
1039+
actual.Add(Convert.ToInt32(o));
1040+
1041+
expected.Sort();
1042+
actual.Sort();
1043+
AreEqual(string.Join(",", expected), string.Join(",", actual));
1044+
}
8471045
}

SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@
55

66
namespace SqlServerSimulator.Parser;
77

8+
/// <summary>
9+
/// The four ordering comparison operators, exposed by
10+
/// <see cref="BooleanExpression.TryGetRangeOperands"/> so the index-seek planner
11+
/// can recognize a range predicate (<c>col &gt; v</c>, <c>col BETWEEN lo AND hi</c>)
12+
/// on a leading key column without reaching into the private comparison hierarchy.
13+
/// </summary>
14+
internal enum RangeComparison
15+
{
16+
Greater,
17+
GreaterOrEqual,
18+
Less,
19+
LessOrEqual,
20+
}
21+
822
/// <summary>
923
/// A specific type of expression used in WHERE clauses and similar branching scenarios.
1024
/// </summary>
@@ -515,6 +529,36 @@ internal virtual bool TryGetEqualityFamily([NotNullWhen(true)] out List<(Express
515529
return false;
516530
}
517531

532+
/// <summary>
533+
/// Exposes the operands and operator when this predicate is an ordering
534+
/// comparison (<c>&gt;</c> / <c>&gt;=</c> / <c>&lt;</c> / <c>&lt;=</c>);
535+
/// returns false otherwise. Lets the index-seek planner recognize a range
536+
/// bound (<c>col &gt; v</c> or <c>v &lt; col</c>) on a key column. The caller
537+
/// normalizes which side is the column and flips the operator accordingly.
538+
/// </summary>
539+
internal virtual bool TryGetRangeOperands([NotNullWhen(true)] out Expression? left, out RangeComparison op, [NotNullWhen(true)] out Expression? right)
540+
{
541+
left = null;
542+
right = null;
543+
op = RangeComparison.Greater;
544+
return false;
545+
}
546+
547+
/// <summary>
548+
/// Exposes the value and the inclusive lower / upper bounds when this
549+
/// predicate is a non-negated <c>value BETWEEN lower AND upper</c>; returns
550+
/// false otherwise (<c>NOT BETWEEN</c> is the non-contiguous complement, so
551+
/// it declines). Lets the index-seek planner treat BETWEEN as a two-sided
552+
/// inclusive range on a key column.
553+
/// </summary>
554+
internal virtual bool TryGetBetweenOperands([NotNullWhen(true)] out Expression? value, [NotNullWhen(true)] out Expression? lower, [NotNullWhen(true)] out Expression? upper)
555+
{
556+
value = null;
557+
lower = null;
558+
upper = null;
559+
return false;
560+
}
561+
518562
/// <summary>
519563
/// Three-valued <c>AND</c>: <c>false AND x = false</c> regardless of
520564
/// <c>x</c>; <c>true AND x = x</c>; <c>NULL AND NULL = NULL</c>. Short-
@@ -782,6 +826,14 @@ internal override void VisitOperandExpressions(Action<Expression> visitor)
782826
visitor(lower);
783827
visitor(upper);
784828
}
829+
830+
internal override bool TryGetBetweenOperands([NotNullWhen(true)] out Expression? v, [NotNullWhen(true)] out Expression? lo, [NotNullWhen(true)] out Expression? hi)
831+
{
832+
// NOT BETWEEN is the non-contiguous complement (two open ranges), not
833+
// a single seekable range, so only the positive form exposes bounds.
834+
(v, lo, hi) = (value, lower, upper);
835+
return !negated;
836+
}
785837
}
786838

787839
/// <summary>
@@ -1033,6 +1085,12 @@ private sealed class GreaterThanExpression(Expression left, Expression right) :
10331085
ComparePromoted(left, right, runtime, "greater than", static (l, r) => l.CompareTo(r) > 0);
10341086

10351087
internal override string DebugDisplay() => $"{left.DebugDisplay()} > {right.DebugDisplay()}";
1088+
1089+
internal override bool TryGetRangeOperands([NotNullWhen(true)] out Expression? l, out RangeComparison op, [NotNullWhen(true)] out Expression? r)
1090+
{
1091+
(l, op, r) = (left, RangeComparison.Greater, right);
1092+
return true;
1093+
}
10361094
}
10371095

10381096
private sealed class GreaterThanOrEqualExpression(Expression left, Expression right) : CompareExpression(left, right)
@@ -1041,6 +1099,12 @@ private sealed class GreaterThanOrEqualExpression(Expression left, Expression ri
10411099
ComparePromoted(left, right, runtime, "greater than or equal to", static (l, r) => l.CompareTo(r) >= 0);
10421100

10431101
internal override string DebugDisplay() => $"{left.DebugDisplay()} >= {right.DebugDisplay()}";
1102+
1103+
internal override bool TryGetRangeOperands([NotNullWhen(true)] out Expression? l, out RangeComparison op, [NotNullWhen(true)] out Expression? r)
1104+
{
1105+
(l, op, r) = (left, RangeComparison.GreaterOrEqual, right);
1106+
return true;
1107+
}
10441108
}
10451109

10461110
private sealed class LessThanExpression(Expression left, Expression right) : CompareExpression(left, right)
@@ -1049,6 +1113,12 @@ private sealed class LessThanExpression(Expression left, Expression right) : Com
10491113
ComparePromoted(left, right, runtime, "less than", static (l, r) => l.CompareTo(r) < 0);
10501114

10511115
internal override string DebugDisplay() => $"{left.DebugDisplay()} < {right.DebugDisplay()}";
1116+
1117+
internal override bool TryGetRangeOperands([NotNullWhen(true)] out Expression? l, out RangeComparison op, [NotNullWhen(true)] out Expression? r)
1118+
{
1119+
(l, op, r) = (left, RangeComparison.Less, right);
1120+
return true;
1121+
}
10521122
}
10531123

10541124
private sealed class LessThanOrEqualExpression(Expression left, Expression right) : CompareExpression(left, right)
@@ -1057,6 +1127,12 @@ private sealed class LessThanOrEqualExpression(Expression left, Expression right
10571127
ComparePromoted(left, right, runtime, "less than or equal to", static (l, r) => l.CompareTo(r) <= 0);
10581128

10591129
internal override string DebugDisplay() => $"{left.DebugDisplay()} <= {right.DebugDisplay()}";
1130+
1131+
internal override bool TryGetRangeOperands([NotNullWhen(true)] out Expression? l, out RangeComparison op, [NotNullWhen(true)] out Expression? r)
1132+
{
1133+
(l, op, r) = (left, RangeComparison.LessOrEqual, right);
1134+
return true;
1135+
}
10601136
}
10611137

10621138
/// <summary>

SqlServerSimulator/Parser/Selection.Execution.Aggregate.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,12 @@ internal readonly struct SqlValueKey(SqlValue[] values) : IEquatable<SqlValueKey
326326

327327
private readonly SqlValue[] values = values;
328328

329+
/// <summary>The number of tuple components (key columns) in this key.</summary>
330+
internal int ComponentCount => this.values.Length;
331+
332+
/// <summary>The component at <paramref name="index"/> — used by the range-seek ordered comparer.</summary>
333+
internal SqlValue ComponentAt(int index) => this.values[index];
334+
329335
public bool Equals(SqlValueKey other)
330336
{
331337
if (this.values.Length != other.values.Length)

0 commit comments

Comments
 (0)