Skip to content

Commit d277b20

Browse files
committed
ALTER TABLE … SET (SYSTEM_VERSIONING = OFF) for temporal-table teardown: new TryParseAlterTable parser (only this shape ships; other ALTER TABLE forms raise NotSupportedException) clears the parent's HeapTable.SystemVersioning link and the sibling's IsHistoryTable flag, releasing both tables to regular status so subsequent DROP TABLE succeeds. Per probe (2026-05-13 with elevated permissions): both tables flip to sys.tables.temporal_type = 0, parent's period columns retain AS_ROW_START / AS_ROW_END + is_hidden, and crucially INSERT continues to auto-populate the period columns after SET OFF (the engine keys the auto-populate on the per-column GENERATED ALWAYS marker, not the table-level versioning link). The simulator's Simulation.Insert.cs gate was tightened from "system-versioned" to "column has GeneratedAs marker" to match, with regression coverage; UPDATE/DELETE history-write paths correctly remain gated on the table-level link (probe-confirmed they no-op after SET OFF). New error factories: Msg 13591 (not-versioned target — fires for plain regular tables and the history sibling itself) and Msg 4902 (alter-table-specific table-not-found, distinct from Msg 208's generic wording). 8 new TemporalTableTests cover round-trip teardown, hidden-column persistence, post-SET-OFF INSERT auto-populate, post-SET-OFF Msg 13536, both 13591 paths, 4902, and the NotSupportedException shape-rejection. docs/claude/temporal-tables.md "Not modeled" trimmed to the SET-versioning-ON direction; CLAUDE.md trigger phrase extended and a new "Not modeled" bullet declares the broader ALTER TABLE grammar as unsupported.
1 parent 90e271e commit d277b20

6 files changed

Lines changed: 224 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
149149
- **Touching `CREATE TYPE … AS TABLE`, `DECLARE @t MyType`, TVP procedure parameters / `READONLY`, or the ADO.NET `DbParameter.TypeName` extension (DataTable / IDataReader as a TVP value source)**[`docs/claude/table-valued-parameters.md`](docs/claude/table-valued-parameters.md).
150150
- **Touching `CREATE SEQUENCE` / `ALTER SEQUENCE` / `DROP SEQUENCE`, `NEXT VALUE FOR`, the per-row dedup mechanism (`BatchContext.CurrentRowStamp` / `SequenceRowCache`), or `sys.sequences`**[`docs/claude/sequences.md`](docs/claude/sequences.md).
151151
- **Touching `CREATE TRIGGER` / `ALTER TRIGGER` / `DROP TRIGGER` / `DISABLE`/`ENABLE TRIGGER`, the `INSERTED` / `DELETED` pseudo-table materialization, the `TriggerFrame` / `FiringTriggerIds` recursion guard, `TRIGGER_NESTLEVEL()`, or `sys.triggers`**[`docs/claude/triggers.md`](docs/claude/triggers.md).
152-
- **Touching `PERIOD FOR SYSTEM_TIME` / `GENERATED ALWAYS AS ROW START / END [HIDDEN]` / `WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = …))` DDL, the auto-created history sibling, `FOR SYSTEM_TIME ALL / AS OF` query syntax, or `HeapTable.PeriodColumns` / `HeapTable.SystemVersioning` / `HeapColumn.GeneratedAs` / `HeapColumn.IsHidden`**[`docs/claude/temporal-tables.md`](docs/claude/temporal-tables.md).
152+
- **Touching `PERIOD FOR SYSTEM_TIME` / `GENERATED ALWAYS AS ROW START / END [HIDDEN]` / `WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = …))` DDL, `ALTER TABLE … SET (SYSTEM_VERSIONING = OFF)`, the auto-created history sibling, `FOR SYSTEM_TIME ALL / AS OF` query syntax, or `HeapTable.PeriodColumns` / `HeapTable.SystemVersioning` / `HeapColumn.GeneratedAs` / `HeapColumn.IsHidden`**[`docs/claude/temporal-tables.md`](docs/claude/temporal-tables.md).
153153
- **Adding a new top-level statement parser or changing the dispatch loop's statement-separator rules**[`docs/claude/grammar.md`](docs/claude/grammar.md) + [`docs/claude/control-flow.md`](docs/claude/control-flow.md).
154154

155155
## Not modeled
@@ -174,6 +174,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
174174
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare + UDF-body and stored-procedure-body value form) ship; unconditional jumps don't.
175175
- Multi-statement table-valued functions (`RETURNS @t TABLE (...) AS BEGIN ... END`), CLR functions, INSTEAD OF UPDATE / DELETE on *non-updatable* views (INSTEAD OF INSERT on any view ships; INSTEAD OF UPDATE / DELETE on updatable single-base views ships). DDL / logon triggers also unmodeled. Scalar UDFs, inline TVFs, views, DML-through-views (single-source updatable shape), stored procedures (including CREATE / ALTER / DROP, EXEC with input/output/default params, `@rc = EXEC` return-code capture, `CommandType.StoredProcedure`, `EXEC (@sql)` and `sp_executesql` dynamic SQL), user-defined table types + table-valued parameters (CREATE TYPE … AS TABLE / DROP TYPE / DECLARE @t MyType / READONLY proc params / EXEC with TVP arg / ADO.NET Structured parameter via the `DbParameter.TypeName` C# 14 extension property + DataTable/IDataReader sources), sequence objects (CREATE / ALTER / DROP SEQUENCE / NEXT VALUE FOR / sys.sequences — supports EF Core HiLo identity strategy end-to-end), and DML triggers (CREATE / ALTER / CREATE OR ALTER / DROP TRIGGER, FOR-as-AFTER synonym, AFTER on heap tables + INSTEAD OF on heap tables / views, multi-action INSERT,UPDATE,DELETE, INSERTED/DELETED pseudo-tables, multiple AFTER triggers per parent / one INSTEAD OF per action per target (Msg 2111), DISABLE/ENABLE TRIGGER, TRIGGER_NESTLEVEL(), direct-recursion suppression matching RECURSIVE_TRIGGERS OFF, sys.triggers + sys.objects integration, trigger-error rolls back firing DML, MERGE per-action routing through INSTEAD OF, DROP TABLE / DROP VIEW cascade-drops attached triggers) ship (see `docs/claude/programmable.md`, `docs/claude/table-valued-parameters.md`, `docs/claude/sequences.md`, and `docs/claude/triggers.md`). JOIN-view single-base-table UPDATE/DELETE, OUTPUT through views, and multi-source alias-form UPDATE/DELETE through views are deferred (Msg 4405 or `NotSupportedException` at the DML site). `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch. Value-form `RETURN N` is legal inside a scalar-UDF body and a stored-procedure body; bare batch / dynamic-SQL scope raises Msg 178. TRY/CATCH + THROW + RAISERROR (printf-style `%s %d/%i %u %o %x %X %% %ld %I64d` with width / precision / left-align / zero-pad; severity routing 0-10 informational vs 11-18 catchable; `WITH SETERROR`/`NOWAIT` ship, `WITH LOG` uniformly raises Msg 2778; numeric `msg_id` raises Msg 2732 for 50000 / `< 13000` and Msg 18054 otherwise — `sys.messages` registry / `sp_addmessage` not modeled) + live `@@ERROR` + `ERROR_*()` functions ship (see `docs/claude/control-flow.md`).
176176
- **`PRINT` message capture** — the statement parses + evaluates the operand (so operand-side errors like Msg 245 surface), but the message is discarded. `DbConnection` has no `InfoMessage` event (that's a `SqlConnection` extension), so adding a public observability surface would mean a new event on `SimulatedDbConnection`. Defer until an application needs it.
177+
- **`ALTER TABLE` grammar beyond `SET (SYSTEM_VERSIONING = OFF)`** — only the SET-versioning-off shape ships (see [`docs/claude/temporal-tables.md`](docs/claude/temporal-tables.md)). ADD / DROP COLUMN, ADD / DROP CONSTRAINT, DROP PERIOD FOR SYSTEM_TIME, REBUILD, SWITCH PARTITION, the SET-versioning-on direction, and every other shape raise `NotSupportedException` at the post-name dispatch point.
177178
- `hierarchyid`, `geography`, `geometry`.
178179

179180
## Quirks (modeled, not byte-identical to SQL Server)

SqlServerSimulator.Tests/TemporalTableTests.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,103 @@ public void Ddl_GeneratedColumnNotDatetime2_RaisesMsg13501()
179179
"create table t (id int, Vf datetime generated always as row start hidden not null, Vt datetime generated always as row end hidden not null, period for system_time (Vf, Vt))",
180180
13501,
181181
"Temporal generated always column 'Vf' has invalid data type.");
182+
183+
[TestMethod]
184+
public void AlterSystemVersioningOff_PermitsDropOnParentAndHistory()
185+
{
186+
var simulation = new Simulation();
187+
simulation.ExecuteBatches(
188+
CreateTemporalCustomers,
189+
"insert Customers (Id, Name) values (1, 'a')",
190+
"update Customers set Name = 'A' where Id = 1",
191+
"alter table Customers set (system_versioning = off)",
192+
"drop table Customers",
193+
"drop table CustomersHistory");
194+
// Both tables gone from sys.tables after the flip-then-drop.
195+
AreEqual(0, simulation.ExecuteScalar("select count(*) from sys.tables where name in ('Customers', 'CustomersHistory')"));
196+
}
197+
198+
[TestMethod]
199+
public void Insert_AfterAlterOff_StillAutoPopulatesPeriodColumns()
200+
{
201+
// Probed 2026-05-13: real SQL Server keeps auto-populating the period
202+
// columns on INSERT after SET OFF — the GENERATED ALWAYS markers on
203+
// the parent's columns persist independently of the versioning link,
204+
// and the engine respects them. Without this regression the simulator
205+
// would diverge: a post-SET-OFF INSERT would leave Vf/Vt as raw
206+
// NULLs (failing NOT NULL) or unset zeros (depending on path).
207+
var simulation = new Simulation();
208+
simulation.ExecuteBatches(
209+
CreateTemporalCustomers,
210+
"alter table Customers set (system_versioning = off)",
211+
"insert Customers (Id, Name) values (1, 'alice')");
212+
AreEqual(DateTime.MaxValue.Date, ((DateTime)simulation.ExecuteScalar("select Vt from Customers where Id = 1")!).Date);
213+
}
214+
215+
[TestMethod]
216+
public void Insert_AfterAlterOff_RejectsExplicitGeneratedColumn()
217+
{
218+
// Probed: Msg 13536 still fires post-SET-OFF (GENERATED ALWAYS marker
219+
// persists on the column, independently of the versioning link).
220+
// Pre-existing simulator path keys off column-level GeneratedAs and
221+
// not on table-level SystemVersioning, so this should pass without
222+
// changes — pin it down so future refactors can't regress.
223+
var simulation = new Simulation();
224+
simulation.ExecuteBatches(
225+
CreateTemporalCustomers,
226+
"alter table Customers set (system_versioning = off)");
227+
simulation.AssertSqlError(
228+
"insert Customers (Id, Name, Vf) values (1, 'a', sysutcdatetime())",
229+
13536,
230+
"Cannot insert an explicit value into a GENERATED ALWAYS column in table 'simulated.dbo.Customers'. Use INSERT with a column list to exclude the GENERATED ALWAYS column, or insert a DEFAULT into GENERATED ALWAYS column.");
231+
}
232+
233+
[TestMethod]
234+
public void AlterSystemVersioningOff_PreservesHiddenColumns()
235+
{
236+
var simulation = new Simulation();
237+
simulation.ExecuteBatches(
238+
CreateTemporalCustomers,
239+
"alter table Customers set (system_versioning = off)");
240+
// Probed: GENERATED ALWAYS + HIDDEN column metadata persists after SET
241+
// OFF on the parent. SELECT * still excludes the hidden period
242+
// columns and they're still reachable by explicit name.
243+
using var con = simulation.CreateOpenConnection();
244+
using var cmd = con.CreateCommand();
245+
cmd.CommandText = "select * from Customers";
246+
using var r = cmd.ExecuteReader();
247+
AreEqual(2, r.FieldCount);
248+
AreEqual("Id", r.GetName(0));
249+
AreEqual("Name", r.GetName(1));
250+
}
251+
252+
[TestMethod]
253+
public void AlterSystemVersioningOff_OnRegularTable_RaisesMsg13591()
254+
=> new Simulation().AssertSqlError(
255+
"create table t (id int); alter table t set (system_versioning = off)",
256+
13591,
257+
"SYSTEM_VERSIONING is not turned ON for table 'simulated.dbo.t'.");
258+
259+
[TestMethod]
260+
public void AlterSystemVersioningOff_OnHistorySibling_RaisesMsg13591()
261+
=> new Simulation().AssertSqlError(
262+
$"{CreateTemporalCustomers}; alter table CustomersHistory set (system_versioning = off)",
263+
13591,
264+
"SYSTEM_VERSIONING is not turned ON for table 'simulated.dbo.CustomersHistory'.");
265+
266+
[TestMethod]
267+
public void AlterTable_NonexistentTarget_RaisesMsg4902()
268+
=> new Simulation().AssertSqlError(
269+
"alter table dbo.tNoSuch set (system_versioning = off)",
270+
4902,
271+
"Cannot find the object \"dbo.tNoSuch\" because it does not exist or you do not have permissions.");
272+
273+
[TestMethod]
274+
public void AlterTable_UnsupportedShape_RaisesNotSupported()
275+
{
276+
var simulation = new Simulation();
277+
simulation.ExecuteBatches(CreateTemporalCustomers);
278+
_ = ThrowsExactly<NotSupportedException>(
279+
() => simulation.ExecuteNonQuery("alter table Customers add NewCol int"));
280+
}
182281
}

SqlServerSimulator/Errors/SimulatedSqlException.SchemaErrors.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,4 +601,27 @@ internal static SimulatedSqlException CannotInsertIntoTemporalHistoryTable(strin
601601
/// </summary>
602602
internal static SimulatedSqlException CannotDropTemporalTable(string qualifiedTableName) =>
603603
new($"Drop table operation failed on table '{qualifiedTableName}' because it is not a supported operation on system-versioned temporal tables.", 13552, 16, 1);
604+
605+
/// <summary>
606+
/// Mimics SQL Server error 13591: <c>ALTER TABLE … SET (SYSTEM_VERSIONING
607+
/// = OFF)</c> targeted a table that isn't system-versioned. Fires for
608+
/// plain regular tables and for the history sibling itself (the history
609+
/// sibling carries the <c>HISTORY_TABLE</c> role but doesn't "have"
610+
/// versioning — only the parent does). Probe-confirmed wording against
611+
/// SQL Server 2025.
612+
/// </summary>
613+
internal static SimulatedSqlException SystemVersioningNotOn(string qualifiedTableName) =>
614+
new($"SYSTEM_VERSIONING is not turned ON for table '{qualifiedTableName}'.", 13591, 16, 1);
615+
616+
/// <summary>
617+
/// Mimics SQL Server error 4902: <c>ALTER TABLE</c> named a target that
618+
/// doesn't resolve to an existing table. The qualified name is reported
619+
/// verbatim in double-quotes (distinct from the single-quoted form Msg
620+
/// 208 uses for un-qualified DML name resolution). Probe-confirmed
621+
/// wording against SQL Server 2025: <c>ALTER TABLE dbo.tNoSuch SET
622+
/// (SYSTEM_VERSIONING = OFF)</c> on a missing target raises this rather
623+
/// than the generic Msg 208.
624+
/// </summary>
625+
internal static SimulatedSqlException CannotFindObjectForAlterTable(string nameAsWritten) =>
626+
new($"Cannot find the object \"{nameAsWritten}\" because it does not exist or you do not have permissions.", 4902, 16, 1);
604627
}

SqlServerSimulator/Simulation/Simulation.Alter.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using SqlServerSimulator.Parser;
22
using SqlServerSimulator.Parser.Tokens;
3+
using SqlServerSimulator.Storage;
34

45
namespace SqlServerSimulator;
56

@@ -32,6 +33,8 @@ private static bool TryParseAlter(ParserContext context)
3233
return TryParseAlterSequence(context);
3334
case ReservedKeyword { Keyword: Keyword.Schema }:
3435
return TryParseAlterSchemaTransfer(context);
36+
case ReservedKeyword { Keyword: Keyword.Table }:
37+
return TryParseAlterTable(context);
3538
case ReservedKeyword { Keyword: Keyword.Database }:
3639
break;
3740
default:
@@ -395,6 +398,85 @@ private static void TransferObject(Schema sourceSchema, Schema destSchema, strin
395398
throw SimulatedSqlException.CannotFindObject(leafName);
396399
}
397400

401+
/// <summary>
402+
/// Parses <c>ALTER TABLE [schema.]name SET (SYSTEM_VERSIONING = OFF)</c>.
403+
/// The only <c>ALTER TABLE</c> shape modeled — every other shape (ADD /
404+
/// DROP COLUMN, ADD / DROP CONSTRAINT, SET other options, ENABLE /
405+
/// DISABLE, REBUILD, etc.) raises <see cref="NotSupportedException"/> at
406+
/// the post-name dispatch point. Entered with <see cref="ParserContext.Token"/>
407+
/// on the <c>TABLE</c> keyword.
408+
/// </summary>
409+
/// <remarks>
410+
/// <para>
411+
/// Probe-confirmed error paths (SQL Server 2025, 2026-05-13):
412+
/// </para>
413+
/// <list type="bullet">
414+
/// <item>Target name doesn't resolve → <strong>Msg 4902</strong>
415+
/// (alter-table-specific table-not-found variant; distinct from Msg 208's
416+
/// generic name-resolution wording).</item>
417+
/// <item>Target exists but isn't system-versioned (plain regular table
418+
/// or the history sibling itself) → <strong>Msg 13591</strong>.</item>
419+
/// <item>Grammar variants other than <c>SET (SYSTEM_VERSIONING = OFF)</c>
420+
/// → <see cref="NotSupportedException"/>. Real SQL Server reaches a
421+
/// range of error codes (Msg 102 syntax for typos, Msg 13510 for the
422+
/// <c>= ON</c> path without a period definition); the simulator collapses
423+
/// these to one not-supported signal since the broader ALTER TABLE
424+
/// grammar isn't modeled.</item>
425+
/// </list>
426+
/// <para>
427+
/// Successful flip clears <see cref="HeapTable.SystemVersioning"/> on the
428+
/// parent (the parent's link to its history sibling) and
429+
/// <see cref="HeapTable.IsHistoryTable"/> on the sibling — both tables
430+
/// revert to plain regular status. Period and GENERATED ALWAYS column
431+
/// metadata stays intact (probe-confirmed: <c>sys.columns.generated_always_type_desc</c>
432+
/// and <c>is_hidden</c> remain after SET OFF). DROP TABLE on either now
433+
/// succeeds.
434+
/// </para>
435+
/// </remarks>
436+
private static bool TryParseAlterTable(ParserContext context)
437+
{
438+
context.MoveNextRequired();
439+
if (context.Token is not Name)
440+
return false;
441+
var tableName = BatchContext.ParseObjectName(context);
442+
443+
// Cursor is on the last name segment; advance to the post-name token.
444+
if (context.GetNextRequired() is not ReservedKeyword { Keyword: Keyword.Set })
445+
throw new NotSupportedException("Only ALTER TABLE … SET (SYSTEM_VERSIONING = OFF) is supported.");
446+
447+
if (context.GetNextRequired() is not Operator { Character: '(' })
448+
throw SimulatedSqlException.SyntaxErrorNear(context);
449+
450+
if (context.GetNextRequired() is not UnquotedString { ContextualKeyword: ContextualKeyword.System_Versioning })
451+
throw new NotSupportedException("Only ALTER TABLE … SET (SYSTEM_VERSIONING = OFF) is supported.");
452+
453+
if (context.GetNextRequired() is not Operator { Character: '=' })
454+
throw SimulatedSqlException.SyntaxErrorNear(context);
455+
456+
if (context.GetNextRequired() is not ReservedKeyword { Keyword: Keyword.Off })
457+
throw new NotSupportedException("Only ALTER TABLE … SET (SYSTEM_VERSIONING = OFF) is supported (the = ON form requires the parent column-list grammar which isn't modeled).");
458+
459+
if (context.GetNextRequired() is not Operator { Character: ')' })
460+
throw SimulatedSqlException.SyntaxErrorNear(context);
461+
462+
if (context.Batch.IsSkipping)
463+
return true;
464+
465+
if (!context.Batch.TryResolveTable(tableName, out var table))
466+
throw SimulatedSqlException.CannotFindObjectForAlterTable(tableName.ToString());
467+
468+
if (table.SystemVersioning is null)
469+
throw SimulatedSqlException.SystemVersioningNotOn(QualifyTableName(table, context.CurrentDatabase));
470+
471+
// Flip both tables to regular status. Period and GENERATED ALWAYS
472+
// column metadata stays — only the parent → history link and the
473+
// sibling's history-role flag clear.
474+
var historyTable = table.SystemVersioning;
475+
table.SystemVersioning = null;
476+
historyTable.IsHistoryTable = false;
477+
return true;
478+
}
479+
398480
/// <summary>
399481
/// Moves every trigger whose <see cref="Trigger.Parent"/> matches
400482
/// <paramref name="movedParent"/> from <paramref name="sourceSchema"/>'s

SqlServerSimulator/Simulation/Simulation.Insert.cs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,21 @@ private static SimulatedStatementOutcome ProcessHeapInsert(HeapTable destination
358358
rowValues[i] = SqlValue.FromRowVersion(context.CurrentDatabase.AllocateRowVersion());
359359
}
360360

361-
// Auto-populate period columns on system-versioned temporal tables:
362-
// ROW START = the statement's frozen UtcNow, ROW END = max
363-
// datetime2 ('9999-12-31 23:59:59.9999999' — DateTime.MaxValue at
364-
// datetime2(7) precision).
365-
if (destinationTable.PeriodColumns is { } pc && destinationTable.SystemVersioning is not null)
361+
// Auto-populate period columns whose ordinals carry the GENERATED
362+
// ALWAYS markers: ROW START = the statement's frozen UtcNow, ROW
363+
// END = max datetime2 ('9999-12-31 23:59:59.9999999' —
364+
// DateTime.MaxValue at datetime2(7) precision). Gating on the
365+
// per-column GeneratedAs (not on the table-level SystemVersioning
366+
// link) matches real SQL Server's behavior probed 2026-05-13:
367+
// after ALTER TABLE … SET (SYSTEM_VERSIONING = OFF), the parent's
368+
// GENERATED ALWAYS column markers persist and INSERT continues to
369+
// auto-populate. The (former) history sibling never reaches here:
370+
// BuildHistoryTable strips the GENERATED markers, so its
371+
// PeriodColumns ordinals carry GeneratedAs.None and the gate
372+
// skips. (While versioning is still ON, INSERT into the history
373+
// sibling is rejected upstream by Msg 13559.)
374+
if (destinationTable.PeriodColumns is { } pc
375+
&& destinationTable.Columns[pc.StartOrdinal].GeneratedAs != GeneratedAlwaysAsRow.None)
366376
{
367377
rowValues[pc.StartOrdinal] = SqlValue.FromDateTime2(destinationTable.Columns[pc.StartOrdinal].Type, context.Batch.CurrentStatement.UtcNow);
368378
rowValues[pc.EndOrdinal] = SqlValue.FromDateTime2(destinationTable.Columns[pc.EndOrdinal].Type, DateTime.MaxValue);

0 commit comments

Comments
 (0)