Skip to content

Commit f576a59

Browse files
committed
Add TRUNCATE TABLE — clears heap rows + resets identity high-water mark; transactional via a single HeapTruncation undo entry that snapshots Pages/LobPages + each identity's high-water mark (rollback restores both — diverges from "identity bypasses log" because that rule is INSERT-only; TRUNCATE's explicit reset action does get undone, probe-confirmed). Reuses DROP TABLE's lenient multi-part name parser; Msg 4701 for missing target (distinct from DROP's 3701 and generic 208).
1 parent 1c3657a commit f576a59

8 files changed

Lines changed: 356 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,11 @@ Probe-confirmed semantics (2026-05-11) the simulator handles correctly because e
386386
### `WAITFOR DELAY`
387387
`WAITFOR DELAY '<time>'` and `WAITFOR DELAY @variable` block the calling thread via `Thread.Sleep(TimeSpan)`, matching real SQL Server's "blocks the connection" semantics. Operand grammar is strict (matches probe of SQL Server 2025, 2026-05-11): only a varchar/nvarchar string literal or an `@-variable` reference. `cast(...)`, integer literal, bare `NULL` literal all fail at parse (Msg 102/156); `time`-typed variable raises **Msg 9815** (`"Waitfor delay and waitfor time cannot be of type time."` — note SQL Server reserves the operand slot for *string-typed* values, not its own `time` type). Empty string and NULL-valued variable both silently succeed as zero delay. Bad-format string raises **Msg 148** with the offending value embedded. `@@ROWCOUNT` resets to 0. Skip-mode suppresses the sleep entirely (an `IF 1=0 WAITFOR DELAY '00:00:10'` returns instantly). **`WAITFOR TIME`** (absolute-time wait) raises `NotSupportedException` — scheduling-style primitive not yet needed. **Cancellation gap**: an `ExecuteReaderAsync` caller's `CancellationToken` isn't threaded into the sleep; the thread blocks until the duration elapses regardless.
388388

389+
### `TRUNCATE TABLE`
390+
`TRUNCATE TABLE <name>` empties the heap (clears `Pages` / `LobPages`) and resets every identity column's high-water mark to its declared seed — probe-confirmed against SQL Server 2025 (2026-05-11) that a subsequent INSERT receives the seed, not the next-after-prior-max. Routing reuses the same `#`-prefix dispatch as DROP TABLE (`#foo` → connection's `TempTables`; else current DB's `HeapTables`); the lenient multi-part name parser is shared via `ParseDropTargetName`. Missing target raises **Msg 4701** (`"Cannot find the object \"X\" because it does not exist or you do not have permissions."` — note this is distinct from DROP's Msg 3701 and from generic INSERT/UPDATE/DELETE's Msg 208; TRUNCATE has its own error path). `@@ROWCOUNT` resets to 0. Skip-mode suppresses the entire action including name resolution. `WHERE` clause fails at parse (Msg 102 — TRUNCATE doesn't accept predicates).
391+
392+
**Transactional rollback is supported** via a single `HeapTruncation` undo entry that snapshots the pre-truncate `Pages` / `LobPages` lists AND each identity column's pre-truncate high-water mark. `BEGIN TRAN; TRUNCATE; ROLLBACK` restores both — including the identity counter. **This diverges from the simulator's general "identity counters bypass the undo log" rule, which is INSERT-only**: INSERT-advanced counters stay advanced through rollback (probe-confirmed for real SQL Server too); TRUNCATE's explicit reset action gets undone on rollback. Outside an explicit transaction, TRUNCATE commits immediately (no log entry — same pattern as DROP TABLE on regular tables).
393+
389394
### Local temp tables (`#foo`)
390395
Per-connection `Dictionary<string, HeapTable> TempTables` on `SimulatedDbConnection`; routed by `BatchContext.TryResolveTable` (`#`-prefix → connection dict, else current DB + system tables). Auto-cleared on `Dispose`, matching real SQL Server's session-close drop. Lifecycle, cross-conn isolation, and Msg 208 from other sessions all probe-confirmed against SQL Server 2025.
391396

@@ -425,7 +430,7 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
425430
- Compound assignment (`SET @v += expr` / `-=` / `*=` etc.) — rewrite as `SET @v = @v + expr`. The arithmetic-operator runtime is locked behind `protected` instance methods on `TwoSidedExpression`; exposing them as static helpers is the prerequisite refactor.
426431
- Table variables (`DECLARE @t TABLE (...)`) — separate feature with its own storage / scope / lifecycle.
427432
- **Global temp tables (`##foo`)**`NotSupportedException` at parse. Local `#foo` works; the lifecycle for global temps (drops when creator session closes, visible across sessions) is the deferred scope.
428-
- **`ALTER TABLE #foo`**, **`TRUNCATE TABLE #foo`**, **`OBJECT_ID('tempdb..#foo')`** — none modeled (none of those exist for regular tables either yet). The common `IF OBJECT_ID(...) IS NOT NULL DROP TABLE` cleanup pattern works via `DROP TABLE IF EXISTS #foo` instead.
433+
- **`ALTER TABLE #foo`**, **`OBJECT_ID('tempdb..#foo')`** — none modeled (none of those exist for regular tables either yet). The common `IF OBJECT_ID(...) IS NOT NULL DROP TABLE` cleanup pattern works via `DROP TABLE IF EXISTS #foo` instead.
429434
- **Three-part name resolution outside DROP TABLE**: `tempdb..#foo` in FROM / INSERT / UPDATE / DELETE / MERGE / SET IDENTITY_INSERT raises `InvalidObjectName` (Msg 208) on the qualifier; use bare `#foo`. DROP TABLE alone tolerates the qualifier (probe pattern).
430435
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare) ship; unconditional jumps don't.
431436
- `TRY ... CATCH`, `THROW`, `RAISERROR`, `@@ERROR`, stored procs / UDFs. `BEGIN TRY` / `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch (peeked after `BEGIN`). Value-form `RETURN N` raises Msg 178 (reserved for the stored-proc / function scope, neither modeled yet).
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for <c>TRUNCATE TABLE</c>: row removal, identity-counter reset,
8+
/// transaction rollback semantics (rollback restores both rows AND the
9+
/// pre-truncate identity counter — distinct from the simulator's general
10+
/// "identity bypasses the log" rule that applies to INSERT). Errors and
11+
/// skip-mode interaction. Probed against SQL Server 2025 (2026-05-11).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class TruncateTableTests
15+
{
16+
[TestMethod]
17+
public void Truncate_RemovesAllRows()
18+
=> AreEqual(0, new Simulation().ExecuteScalar("""
19+
create table t (id int);
20+
insert t values (1), (2), (3);
21+
truncate table t;
22+
select count(*) from t
23+
"""));
24+
25+
[TestMethod]
26+
public void Truncate_ResetsIdentityToSeed()
27+
=> AreEqual(1, new Simulation().ExecuteScalar("""
28+
create table t (id int identity(1,1), v int);
29+
insert t (v) values (10), (20), (30);
30+
truncate table t;
31+
insert t (v) values (99);
32+
select id from t
33+
"""));
34+
35+
/// <summary>
36+
/// Custom seed (8, 3) — first insert after TRUNCATE gets the seed (8),
37+
/// not the next-after-prior-max. Probe-confirmed against SQL Server 2025.
38+
/// </summary>
39+
[TestMethod]
40+
public void Truncate_ResetsIdentityToCustomSeed()
41+
=> AreEqual(8, new Simulation().ExecuteScalar("""
42+
create table t (id int identity(8, 3), v int);
43+
insert t (v) values (10), (20);
44+
truncate table t;
45+
insert t (v) values (99);
46+
select id from t
47+
"""));
48+
49+
[TestMethod]
50+
public void Truncate_OnEmptyTable_Succeeds()
51+
=> AreEqual(0, new Simulation().ExecuteScalar("""
52+
create table t (id int);
53+
truncate table t;
54+
select count(*) from t
55+
"""));
56+
57+
[TestMethod]
58+
public void Truncate_OnTempTable_Works()
59+
=> AreEqual(0, new Simulation().ExecuteScalar("""
60+
create table #foo (id int);
61+
insert #foo values (1), (2);
62+
truncate table #foo;
63+
select count(*) from #foo
64+
"""));
65+
66+
[TestMethod]
67+
public void Truncate_ThreePartName_Resolves()
68+
=> AreEqual(0, new Simulation().ExecuteScalar("""
69+
create table t (id int);
70+
insert t values (1);
71+
truncate table simulated.dbo.t;
72+
select count(*) from t
73+
"""));
74+
75+
[TestMethod]
76+
public void Truncate_SetsRowCountToZero()
77+
{
78+
using var reader = new Simulation().ExecuteReader("""
79+
create table t (id int);
80+
insert t values (1), (2), (3);
81+
truncate table t;
82+
select @@rowcount as rc
83+
""");
84+
IsTrue(reader.Read());
85+
AreEqual(0, reader.GetInt32(0));
86+
}
87+
88+
[TestMethod]
89+
public void Truncate_NonExistentTable_Msg4701()
90+
=> new Simulation().AssertSqlError(
91+
"truncate table does_not_exist",
92+
4701,
93+
"Cannot find the object \"does_not_exist\" because it does not exist or you do not have permissions.");
94+
95+
[TestMethod]
96+
public void Truncate_NonExistentTempTable_Msg4701()
97+
=> new Simulation().AssertSqlError(
98+
"truncate table #does_not_exist",
99+
4701);
100+
101+
[TestMethod]
102+
public void Truncate_WithWhere_SyntaxError()
103+
{
104+
var ex = Throws<DbException>(() => new Simulation().ExecuteNonQuery("""
105+
create table t (id int);
106+
truncate table t where id = 1
107+
"""));
108+
AreEqual("102", ex.Data["HelpLink.EvtID"]);
109+
}
110+
111+
// ---- Transaction rollback ----
112+
// Probe-confirmed: ROLLBACK after TRUNCATE inside BEGIN TRAN restores
113+
// both the row data AND the identity counter.
114+
115+
[TestMethod]
116+
public void Truncate_InTransaction_RollbackRestoresRows()
117+
=> AreEqual(3, new Simulation().ExecuteScalar("""
118+
create table t (id int);
119+
insert t values (1), (2), (3);
120+
begin tran;
121+
truncate table t;
122+
rollback;
123+
select count(*) from t
124+
"""));
125+
126+
[TestMethod]
127+
public void Truncate_InTransaction_RollbackRestoresIdentity()
128+
{
129+
// After ROLLBACK, identity counter is back to where it was — the
130+
// next INSERT after re-inserting still continues from the prior
131+
// high-water mark.
132+
using var reader = new Simulation().ExecuteReader("""
133+
create table t (id int identity(1,1), v int);
134+
insert t (v) values (10), (20), (30);
135+
begin tran;
136+
truncate table t;
137+
rollback;
138+
insert t (v) values (99);
139+
select id from t order by id
140+
""");
141+
var ids = new List<int>();
142+
while (reader.Read()) ids.Add(reader.GetInt32(0));
143+
CollectionAssert.AreEqual(new[] { 1, 2, 3, 4 }, ids);
144+
}
145+
146+
[TestMethod]
147+
public void Truncate_InTransaction_Commit_TruncationPersists()
148+
=> AreEqual(0, new Simulation().ExecuteScalar("""
149+
create table t (id int);
150+
insert t values (1), (2), (3);
151+
begin tran;
152+
truncate table t;
153+
commit;
154+
select count(*) from t
155+
"""));
156+
157+
[TestMethod]
158+
public void Truncate_InTransaction_InsertThenRollback_FullRestore()
159+
{
160+
// ROLLBACK undoes both the post-TRUNCATE INSERT and the TRUNCATE
161+
// itself (LIFO: INSERT undo first, then TRUNCATE undo). Final state:
162+
// the original three rows.
163+
using var reader = new Simulation().ExecuteReader("""
164+
create table t (id int);
165+
insert t values (1), (2), (3);
166+
begin tran;
167+
truncate table t;
168+
insert t values (99);
169+
rollback;
170+
select id from t order by id
171+
""");
172+
var ids = new List<int>();
173+
while (reader.Read()) ids.Add(reader.GetInt32(0));
174+
CollectionAssert.AreEqual(new[] { 1, 2, 3 }, ids);
175+
}
176+
177+
// ---- Skip-mode ----
178+
179+
[TestMethod]
180+
public void Truncate_InUntakenIf_TableUntouched()
181+
=> AreEqual(3, new Simulation().ExecuteScalar("""
182+
create table t (id int);
183+
insert t values (1), (2), (3);
184+
if 1=0 truncate table t;
185+
select count(*) from t
186+
"""));
187+
188+
[TestMethod]
189+
public void Truncate_InUntakenIf_MissingTableNotChecked()
190+
{
191+
// Skip-mode gates the name resolution too — a TRUNCATE in an
192+
// un-taken branch against a non-existent table doesn't surface
193+
// Msg 4701.
194+
_ = new Simulation().ExecuteNonQuery("if 1=0 truncate table does_not_exist");
195+
}
196+
}

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,4 +362,16 @@ internal static SimulatedSqlException WaitForCannotBeTimeType() =>
362362
/// </summary>
363363
internal static SimulatedSqlException InlineCheckReferencesAnotherColumn(string owningColumn, string tableName) =>
364364
new($"Column CHECK constraint for column '{owningColumn}' references another column, table '{tableName}'.", 8141, 16, 0);
365+
366+
/// <summary>
367+
/// Mimics SQL Server's Msg 4701 — <c>TRUNCATE TABLE</c> against a name
368+
/// that doesn't resolve to a table. Distinct from <c>DROP TABLE</c>'s
369+
/// Msg 3701 and from generic INSERT/UPDATE/DELETE's Msg 208: TRUNCATE
370+
/// has its own error path. Probe-confirmed against SQL Server 2025
371+
/// (2026-05-11): Class 16, State 1, verbatim wording (the
372+
/// <c>"or you do not have permissions"</c> suffix is part of the
373+
/// real-server message and is preserved here).
374+
/// </summary>
375+
internal static SimulatedSqlException CannotTruncateObjectDoesNotExist(string name) =>
376+
new($"Cannot find the object \"{name}\" because it does not exist or you do not have permissions.", 4701, 16, 1);
365377
}

SqlServerSimulator/Parser/Selection.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
423423
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
424424
or Keyword.Set or Keyword.Declare or Keyword.If or Keyword.Else or Keyword.End
425425
or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return
426-
or Keyword.Print or Keyword.WaitFor
426+
or Keyword.Print or Keyword.WaitFor or Keyword.Truncate
427427
} when depth == 0:
428428
goto ExitWhileTokenLoop;
429429

@@ -567,7 +567,7 @@ or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
567567
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
568568
or Keyword.Set or Keyword.Declare or Keyword.If or Keyword.Else or Keyword.End
569569
or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return
570-
or Keyword.Print or Keyword.WaitFor
570+
or Keyword.Print or Keyword.WaitFor or Keyword.Truncate
571571
} when depth == 0:
572572
goto ExitWhileTokenLoop;
573573

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using SqlServerSimulator.Parser;
2+
using SqlServerSimulator.Parser.Tokens;
3+
using SqlServerSimulator.Storage;
4+
5+
namespace SqlServerSimulator;
6+
7+
partial class Simulation
8+
{
9+
/// <summary>
10+
/// Parses <c>TRUNCATE TABLE &lt;name&gt;</c>. Routes <c>#foo</c> names to
11+
/// the connection's <see cref="SimulatedDbConnection.TempTables"/> dict;
12+
/// everything else to <see cref="Database.HeapTables"/>. Missing target
13+
/// raises <c>Msg 4701</c> — distinct from <c>DROP TABLE</c>'s Msg 3701 and
14+
/// generic INSERT/UPDATE/DELETE's Msg 208.
15+
/// </summary>
16+
/// <remarks>
17+
/// <para>
18+
/// Truncation clears every row and resets each identity column's
19+
/// high-water mark to its declared seed — probe-confirmed against
20+
/// SQL Server 2025 (2026-05-11) that a subsequent INSERT receives the
21+
/// seed, not the next-after-the-prior-max. Distinct from the simulator's
22+
/// general "identity bypasses the undo log" rule (which is INSERT-only):
23+
/// TRUNCATE's reset DOES participate in rollback, so a
24+
/// <c>BEGIN TRAN; TRUNCATE; ROLLBACK</c> restores both the row data and
25+
/// the original identity counter — also probe-confirmed.
26+
/// </para>
27+
/// <para>
28+
/// Rollback support uses a single <see cref="UndoLog"/> entry that
29+
/// snapshots the heap's pre-truncate <see cref="Heap.Pages"/> /
30+
/// <see cref="Heap.LobPages"/> lists and each identity column's
31+
/// pre-truncate high-water mark. Outside an explicit transaction the
32+
/// truncation commits immediately (no log entry — same pattern as
33+
/// regular CREATE / DROP TABLE).
34+
/// </para>
35+
/// <para>
36+
/// <c>@@ROWCOUNT</c> resets to 0 (probe-confirmed); skip-mode (un-taken
37+
/// IF, after BREAK / CONTINUE / RETURN) suppresses the entire action.
38+
/// Multi-part names (<c>tempdb..#foo</c>, <c>claude.dbo.t</c>) are
39+
/// accepted via the same lenient parser <c>DROP TABLE</c> uses —
40+
/// qualifier segments are cosmetic; the leaf is the routing key.
41+
/// </para>
42+
/// </remarks>
43+
private static void ParseTruncateStatement(BatchContext batch)
44+
{
45+
var context = batch.Parser;
46+
context.MoveNextRequired(); // consume TRUNCATE
47+
if (context.Token is not ReservedKeyword { Keyword: Keyword.Table })
48+
throw SimulatedSqlException.SyntaxErrorNear(context);
49+
context.MoveNextRequired(); // consume TABLE
50+
51+
var name = ParseDropTargetName(context);
52+
53+
if (batch.IsSkipping)
54+
return;
55+
56+
var isTempTable = BatchContext.IsLocalTempName(name);
57+
var destination = isTempTable
58+
? context.Connection.TempTables
59+
: context.CurrentDatabase.HeapTables;
60+
61+
if (!destination.TryGetValue(name, out var table))
62+
throw SimulatedSqlException.CannotTruncateObjectDoesNotExist(name);
63+
64+
var oldPages = new List<HeapPage>(table.Heap.Pages);
65+
var oldLobPages = new List<HeapLobPage>(table.Heap.LobPages);
66+
67+
var identitySnapshots = new List<(IdentityState State, long? HighWaterMark)>();
68+
foreach (var column in table.Columns)
69+
{
70+
if (column.Identity is { } identity)
71+
identitySnapshots.Add((identity, identity.Snapshot()));
72+
}
73+
74+
table.Heap.Pages.Clear();
75+
table.Heap.LobPages.Clear();
76+
for (var i = 0; i < identitySnapshots.Count; i++)
77+
identitySnapshots[i].State.Restore(null);
78+
79+
if (context.Connection.CurrentTransaction is { } tx)
80+
tx.UndoLog.RecordTruncation(table.Heap, oldPages, oldLobPages, [.. identitySnapshots]);
81+
}
82+
}

SqlServerSimulator/Simulation/Simulation.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,12 @@ private IEnumerable<SimulatedStatementOutcome> DispatchOneStatement(BatchContext
334334
connection.LastStatementRowCount = 0;
335335
break;
336336

337+
case ReservedKeyword { Keyword: Keyword.Truncate }:
338+
ParseTruncateStatement(batch);
339+
if (!batch.IsSkipping)
340+
connection.LastStatementRowCount = 0;
341+
break;
342+
337343
case ReservedKeyword { Keyword: Keyword.Begin }:
338344
// Peek the token after BEGIN to disambiguate transaction-start
339345
// (BEGIN TRAN / BEGIN TRANSACTION / BEGIN DISTRIBUTED TRAN) from
@@ -425,7 +431,7 @@ or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
425431
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
426432
or Keyword.Set or Keyword.Declare or Keyword.With or Keyword.If or Keyword.Else
427433
or Keyword.End or Keyword.While or Keyword.Break or Keyword.Continue
428-
or Keyword.Return or Keyword.Print or Keyword.WaitFor
434+
or Keyword.Return or Keyword.Print or Keyword.WaitFor or Keyword.Truncate
429435
};
430436

431437
/// <summary>

0 commit comments

Comments
 (0)