Skip to content

Commit 8469562

Browse files
committed
Indexes now have a simple implementation using a created-on-demand-and-invalidated-on-write dictionary aligned with the index keys.
1 parent d736d0b commit 8469562

15 files changed

Lines changed: 863 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ INNER / bare JOIN / LEFT [OUTER] / RIGHT [OUTER] / FULL [OUTER] / CROSS / CROSS
110110

111111
### Constraints
112112
- `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.
113-
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree.
113+
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree. Reads get equality-seek acceleration via a read-only lazy hash index (rebuilt on a mutation-generation bump, so writes and their locking are untouched); see indexes.md for the leading-prefix matching, MVCC/lock-plan decline rules, and the per-`Heap` cache.
114114
- `FOREIGN KEY`: inline `col REFERENCES other(col)` + table-level `FOREIGN KEY (cols) REFERENCES other(cols)` + named `CONSTRAINT name`; all four referential actions on both `ON DELETE` / `ON UPDATE` (`NO ACTION` default / `CASCADE` / `SET NULL` / `SET DEFAULT`); enforcement at INSERT / UPDATE / DELETE / MERGE; cascade-cycle rejection at CREATE (Msg 1785); referenced columns must form PK or UNIQUE (Msg 1776); NULL in FK column skips check (including partial NULL in composite); DROP TABLE on referenced parent → Msg 3726; full `sys.foreign_keys` (22 cols) + `sys.foreign_key_columns` (6 cols); `sys.objects.type = 'F '`. See `docs/claude/foreign-keys.md`.
115115

116116
### Transactions
Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
using SqlServerSimulator.Parser;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Guards the equality-seek narrowing (<c>Selection.Execution.IndexSeek.cs</c>):
8+
/// a single-base-table scan with <c>indexedColumn = &lt;stable value&gt;</c> WHERE
9+
/// conjuncts must take the index seek, keyed on the longest leading key-column
10+
/// prefix those conjuncts cover (the <c>SeekWidth(table,n)</c> trace records the
11+
/// prefix length); reads where the seek would be unsound (snapshot / RCSI,
12+
/// tx-scoped row locks, non-indexed or range predicates, NULL probe) must keep
13+
/// the full scan. The seek is
14+
/// result-transparent, so the correctness suite passes either way — these read
15+
/// the opt-in <see cref="IndexSeekDiagnostics"/> trace (recorded at the single
16+
/// decision point) to assert the path directly, and check the row results stay
17+
/// correct under it. Queries here are non-aggregate projections on purpose: the
18+
/// aggregate / window projectors don't flow through the seek-bearing path.
19+
/// </summary>
20+
[TestClass]
21+
public sealed class IndexSeekTests
22+
{
23+
// Runs setup then query on one connection, capturing the seek/scan trace and
24+
// the first column of every result row.
25+
private static (List<string> Trace, List<object?> Rows) Run(string setup, string query)
26+
{
27+
var connection = new Simulation().CreateDbConnection();
28+
connection.Open();
29+
using (var s = connection.CreateCommand())
30+
{
31+
s.CommandText = setup;
32+
_ = s.ExecuteNonQuery();
33+
}
34+
35+
IndexSeekDiagnostics.Sink = [];
36+
try
37+
{
38+
using var command = connection.CreateCommand();
39+
command.CommandText = query;
40+
using var reader = command.ExecuteReader();
41+
var rows = new List<object?>();
42+
while (reader.Read())
43+
rows.Add(reader.IsDBNull(0) ? null : reader.GetValue(0));
44+
return (IndexSeekDiagnostics.Sink, rows);
45+
}
46+
finally
47+
{
48+
IndexSeekDiagnostics.Sink = null;
49+
}
50+
}
51+
52+
private const string TableT = """
53+
create table t (id int not null primary key, val int not null);
54+
insert t values (1, 5), (2, 50), (3, 500)
55+
""";
56+
57+
// ---- the seek fires and stays correct ----
58+
59+
[TestMethod]
60+
public void PrimaryKeyPointLookup_Seeks()
61+
{
62+
var (trace, rows) = Run(TableT, "select val from t where id = 2");
63+
Contains("Seek(t)", trace);
64+
HasCount(1, rows);
65+
AreEqual(50, rows[0]);
66+
}
67+
68+
[TestMethod]
69+
public void PointLookup_NoMatch_SeeksToEmptyBucket()
70+
{
71+
var (trace, rows) = Run(TableT, "select val from t where id = 99");
72+
Contains("Seek(t)", trace);
73+
IsEmpty(rows);
74+
}
75+
76+
[TestMethod]
77+
public void NonUniqueIndexLeadingColumn_Seeks()
78+
{
79+
var (trace, rows) = Run("""
80+
create table c (id int not null, pid int not null);
81+
create index ix_c_pid on c (pid);
82+
insert c values (1, 10), (2, 10), (3, 10), (4, 20)
83+
""", "select id from c where pid = 10");
84+
Contains("Seek(c)", trace);
85+
HasCount(3, rows);
86+
}
87+
88+
[TestMethod]
89+
public void CorrelatedExists_InnerSeeks()
90+
{
91+
var (trace, rows) = Run("""
92+
create table p (id int not null primary key);
93+
create table c (id int not null, pid int not null);
94+
create index ix_c_pid on c (pid);
95+
insert p values (1), (2), (3);
96+
insert c values (10, 1), (11, 1), (12, 3)
97+
""", "select id from p where exists (select 1 from c where c.pid = p.id)");
98+
Contains("Seek(c)", trace);
99+
HasCount(2, rows);
100+
}
101+
102+
[TestMethod]
103+
public void CorrelatedScalarSubquery_InnerSeeks()
104+
{
105+
var (trace, rows) = Run("""
106+
create table p (id int not null primary key);
107+
create table c (pid int not null primary key, amount int not null);
108+
insert p values (1), (2);
109+
insert c values (1, 100), (2, 200)
110+
""", "select (select amount from c where c.pid = p.id) from p where id = 2");
111+
Contains("Seek(c)", trace);
112+
HasCount(1, rows);
113+
AreEqual(200, rows[0]);
114+
}
115+
116+
[TestMethod]
117+
public void StringKey_CaseInsensitiveCollation_Seeks()
118+
{
119+
var (trace, rows) = Run("""
120+
create table t (code varchar(10) not null primary key);
121+
insert t values ('Abc'), ('Def')
122+
""", "select code from t where code = 'ABC'");
123+
Contains("Seek(t)", trace);
124+
HasCount(1, rows);
125+
}
126+
127+
[TestMethod]
128+
public void CharKey_TrailingSpaceInsensitive_Seeks()
129+
{
130+
var (trace, rows) = Run("""
131+
create table t (c char(5) not null primary key);
132+
insert t values ('ab')
133+
""", "select c from t where c = 'ab'");
134+
Contains("Seek(t)", trace);
135+
HasCount(1, rows);
136+
}
137+
138+
[TestMethod]
139+
public void TypeMismatchProbe_PromotesAndSeeks()
140+
{
141+
var (trace, rows) = Run("""
142+
create table t (id int not null primary key);
143+
insert t values (5)
144+
""", "declare @v bigint = 5; select id from t where id = @v");
145+
Contains("Seek(t)", trace);
146+
HasCount(1, rows);
147+
}
148+
149+
[TestMethod]
150+
public void OutOfDomainProbe_SeeksToEmptyBucket()
151+
{
152+
var (trace, rows) = Run("""
153+
create table t (id int not null primary key);
154+
insert t values (1), (2)
155+
""", "declare @v bigint = 9999999999; select id from t where id = @v");
156+
Contains("Seek(t)", trace);
157+
IsEmpty(rows);
158+
}
159+
160+
[TestMethod]
161+
public void SeekWithResidualPredicate_AppliesBoth()
162+
{
163+
var (trace, rows) = Run(TableT, "select id from t where id = 2 and val > 10");
164+
Contains("Seek(t)", trace);
165+
HasCount(1, rows);
166+
}
167+
168+
// ---- pure conversions on the value side are peeled and still seek ----
169+
170+
[TestMethod]
171+
public void CastLiteralValueSide_Seeks()
172+
{
173+
var (trace, rows) = Run(TableT, "select val from t where id = cast(2 as bigint)");
174+
Contains("Seek(t)", trace);
175+
HasCount(1, rows);
176+
AreEqual(50, rows[0]);
177+
}
178+
179+
[TestMethod]
180+
public void ConvertVariableValueSide_Seeks()
181+
{
182+
var (trace, rows) = Run(TableT, "declare @v bigint = 2; select val from t where id = convert(int, @v)");
183+
Contains("Seek(t)", trace);
184+
HasCount(1, rows);
185+
AreEqual(50, rows[0]);
186+
}
187+
188+
[TestMethod]
189+
public void ParenthesizedValueSide_Seeks()
190+
{
191+
var (trace, rows) = Run(TableT, "select val from t where id = ((2))");
192+
Contains("Seek(t)", trace);
193+
HasCount(1, rows);
194+
AreEqual(50, rows[0]);
195+
}
196+
197+
[TestMethod]
198+
public void CorrelatedCastOfOuterRef_InnerSeeks()
199+
{
200+
var (trace, rows) = Run("""
201+
create table p (id int not null primary key);
202+
create table c (id int not null, pid int not null);
203+
create index ix_c_pid on c (pid);
204+
insert p values (1), (2), (3);
205+
insert c values (10, 1), (11, 3)
206+
""", "select id from p where exists (select 1 from c where c.pid = cast(p.id as bigint))");
207+
Contains("Seek(c)", trace);
208+
HasCount(2, rows);
209+
}
210+
211+
// Peeling a conversion that bottoms out in a column of THIS source must not
212+
// seek — the value isn't row-invariant.
213+
[TestMethod]
214+
public void CastOfSameTableColumn_Declines()
215+
{
216+
var (trace, rows) = Run(TableT, "select id from t where id = cast(val as bigint)");
217+
Contains("Scan(t)", trace);
218+
DoesNotContain("Seek(t)", trace);
219+
IsEmpty(rows);
220+
}
221+
222+
// ---- composite (multi-column prefix) seeks ----
223+
224+
private const string CompositeT = """
225+
create table t (a int not null, b int not null, v int not null, primary key (a, b));
226+
insert t values (1, 10, 100), (1, 20, 200), (1, 30, 300), (2, 10, 400)
227+
""";
228+
229+
[TestMethod]
230+
public void CompositeKey_FullEquality_SeeksOnWholePrefix()
231+
{
232+
var (trace, rows) = Run(CompositeT, "select v from t where a = 1 and b = 20");
233+
Contains("Seek(t)", trace);
234+
Contains("SeekWidth(t,2)", trace);
235+
HasCount(1, rows);
236+
AreEqual(200, rows[0]);
237+
}
238+
239+
// Conjunct order doesn't matter — columns map to the index prefix by ordinal.
240+
[TestMethod]
241+
public void CompositeKey_ReversedConjunctOrder_SeeksOnWholePrefix()
242+
{
243+
var (trace, rows) = Run(CompositeT, "select v from t where b = 20 and a = 1");
244+
Contains("SeekWidth(t,2)", trace);
245+
HasCount(1, rows);
246+
AreEqual(200, rows[0]);
247+
}
248+
249+
// Only the leading column is constrained: seek narrows on it alone, the
250+
// second key column stays unfiltered.
251+
[TestMethod]
252+
public void CompositeKey_LeadingColumnOnly_SeeksWidthOne()
253+
{
254+
var (trace, rows) = Run(CompositeT, "select v from t where a = 1");
255+
Contains("SeekWidth(t,1)", trace);
256+
HasCount(3, rows);
257+
}
258+
259+
// A non-selective leading column is exactly the case the prefix seek exists
260+
// for: the bit flag alone barely narrows, the full (flag, id) prefix is precise.
261+
[TestMethod]
262+
public void CompositeIndex_NonSelectiveLeadingFlag_SeeksOnWholePrefix()
263+
{
264+
var (trace, rows) = Run("""
265+
create table t (finalized bit not null, item int not null, v int not null);
266+
create index ix on t (finalized, item);
267+
insert t values (1, 5, 50), (1, 6, 60), (0, 5, 70), (1, 5, 80)
268+
""", "select v from t where finalized = 1 and item = 5");
269+
Contains("SeekWidth(t,2)", trace);
270+
HasCount(2, rows);
271+
}
272+
273+
// index (a, b, c) with the middle column unconstrained: the prefix stops at
274+
// the first gap, so only the leading column anchors the seek.
275+
[TestMethod]
276+
public void CompositeIndex_GapInPrefix_SeeksUpToGap()
277+
{
278+
var (trace, rows) = Run("""
279+
create table t (a int not null, b int not null, c int not null, v int not null);
280+
create index ix on t (a, b, c);
281+
insert t values (1, 10, 100, 1), (1, 20, 100, 2), (2, 10, 100, 3)
282+
""", "select v from t where a = 1 and c = 100");
283+
Contains("SeekWidth(t,1)", trace);
284+
HasCount(2, rows);
285+
}
286+
287+
[TestMethod]
288+
public void ThreeColumnKey_FullEquality_SeeksOnWholePrefix()
289+
{
290+
var (trace, rows) = Run("""
291+
create table t (a int not null, b int not null, c int not null, v int not null, primary key (a, b, c));
292+
insert t values (1, 10, 100, 1), (1, 10, 200, 2), (1, 20, 100, 3)
293+
""", "select v from t where a = 1 and b = 10 and c = 200");
294+
Contains("SeekWidth(t,3)", trace);
295+
HasCount(1, rows);
296+
AreEqual(2, rows[0]);
297+
}
298+
299+
// A NULL on a non-leading key component can't anchor a seek, so the prefix
300+
// stops before it; the residual `b = @n` then excludes every candidate.
301+
[TestMethod]
302+
public void CompositeKey_NullSecondComponent_SeeksLeadingThenExcludes()
303+
{
304+
var (trace, rows) = Run(CompositeT, "declare @n int = null; select v from t where a = 1 and b = @n");
305+
Contains("SeekWidth(t,1)", trace);
306+
IsEmpty(rows);
307+
}
308+
309+
// Correlated composite seek: the inner re-keys both prefix columns per outer
310+
// row off the shared per-heap cache.
311+
[TestMethod]
312+
public void CompositeKey_CorrelatedExists_InnerSeeksWholePrefix()
313+
{
314+
var (trace, rows) = Run("""
315+
create table p (a int not null, b int not null);
316+
create table c (a int not null, b int not null, primary key (a, b));
317+
insert p values (1, 10), (1, 99), (2, 10);
318+
insert c values (1, 10), (2, 10)
319+
""", "select b from p where exists (select 1 from c where c.a = p.a and c.b = p.b)");
320+
Contains("SeekWidth(c,2)", trace);
321+
HasCount(2, rows);
322+
}
323+
324+
// ---- the seek correctly declines (full scan) ----
325+
326+
[TestMethod]
327+
public void NullProbe_Declines()
328+
{
329+
var (trace, rows) = Run(TableT, "declare @v int = null; select id from t where id = @v");
330+
Contains("Scan(t)", trace);
331+
DoesNotContain("Seek(t)", trace);
332+
IsEmpty(rows);
333+
}
334+
335+
[TestMethod]
336+
public void RangePredicate_Declines()
337+
{
338+
var (trace, rows) = Run(TableT, "select id from t where id > 1");
339+
Contains("Scan(t)", trace);
340+
DoesNotContain("Seek(t)", trace);
341+
HasCount(2, rows);
342+
}
343+
344+
[TestMethod]
345+
public void NonIndexedColumn_Declines()
346+
{
347+
var (trace, rows) = Run(TableT, "select id from t where val = 50");
348+
Contains("Scan(t)", trace);
349+
DoesNotContain("Seek(t)", trace);
350+
HasCount(1, rows);
351+
}
352+
353+
[TestMethod]
354+
public void RcsiRead_Declines()
355+
{
356+
var (trace, rows) = Run($"alter database simulated set read_committed_snapshot on; {TableT}",
357+
"select val from t where id = 2");
358+
Contains("Scan(t)", trace);
359+
DoesNotContain("Seek(t)", trace);
360+
HasCount(1, rows);
361+
AreEqual(50, rows[0]);
362+
}
363+
364+
[TestMethod]
365+
public void RepeatableReadHint_Declines()
366+
{
367+
var (trace, rows) = Run(TableT, "select val from t with (repeatableread) where id = 2");
368+
Contains("Scan(t)", trace);
369+
DoesNotContain("Seek(t)", trace);
370+
HasCount(1, rows);
371+
}
372+
}

0 commit comments

Comments
 (0)