Skip to content

Commit d968deb

Browse files
committed
OUTPUT INSERTED.* / DELETED.* / <source>.* star expansion — parse-time expansion across all three OUTPUT parsers (TryParseOutputClauseForMutation / TryParseOutputClause / TryParseMergeOutputClause). New TryDetectStarReference helper in Simulation.Output.cs uses SaveCheckpoint/RestoreCheckpoint to do lookahead-3 on the <Name>.* shape, leaving cursor on the * token for the caller to advance past; AppendStarExpansion synthesizes one Reference(qualifier, col) per target column (or per source-alias projected column, in MERGE), using the underlying column's leaf name as the output name (probe-confirmed against SQL Server 2025 — not the qualified INSERTED.col form). UPDATE / DELETE expand to the target table's columns under the existing allowInserted / allowDeleted gates (DELETE's INSERTED.* lands on Msg 4104 via the same path that already rejected INSERTED.col); INSERT expands INSERTED.* only; MERGE supports the full INSERTED.* / DELETED.* / <source>.* trio (DELETED columns NULL-fill on WHEN NOT MATCHED INSERT rows; INSERTED columns NULL-fill on WHEN MATCHED DELETE rows — inherited from the existing per-row resolver, no new runtime path). MERGE's $action handling refactored to flow through the same loop iteration with its alias-suffix check inline (previous structure forked the loop body before the alias check; star + $action coverage exposed it). Expansion runs before Expression.Parse, so the INSERTED.* AS alias shape inherits real SQL Server's Msg 102 rejection naturally — the cursor lands on , or end-of-OUTPUT terminator, never a bare alias-eligible Name. Unbound qualifier on .* raises Msg 4104 with the qualified form (foo.*) in the message. 11 new OutputClauseTests cover INSERT INSERTED.*, mixed-with-explicit, UPDATE both-stars, DELETE star, DELETE INSERTED.* and INSERT DELETED.* rejections, INSERT INSERTED.* INTO target, MERGE $action + INSERTED.* + DELETED.* with NULL-fill assertion across UPDATE / INSERT rows, MERGE source-alias s.*, and unbound-qualifier .* rejection. 3354 / 264 tests pass; dotnet format clean. CLAUDE.md "Not modeled" entry removed; docs/claude/dml.md OUTPUT bullet expanded with the parse-time-expansion mechanics, MERGE NULL-fill semantic, and alias-rejection inheritance note.
1 parent 523b424 commit d968deb

5 files changed

Lines changed: 307 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,6 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
163163
- `LIKE` with `COLLATE` override (default collation only).
164164
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121`.
165165
- `LEN(ntext)` raising Msg 8116; legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
166-
- `OUTPUT DELETED.*` / `INSERTED.*` star expansion. (`OUTPUT INTO <target>` ships for both `@t` and regular tables — see Table variables below.)
167166
- MERGE source as a CTE-headed SELECT (`USING (WITH cte AS … SELECT …)`) — Selection.Parse's CTE entry doesn't reach the USING-clause grammar; wrap the CTE inside a regular SELECT instead. MERGE inside a CTE body, multi-statement MERGE WHEN-clause bodies, and `MERGE INTO <view>` (real SQL Server's updatable-view MERGE) are also deferred.
168167
- `PRIMARY KEY` / `UNIQUE` on a computed column (`NotSupportedException`).
169168
- Heap allocation tracking (flat page list, no IAM/PFS).

SqlServerSimulator.Tests/OutputClauseTests.cs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,179 @@ public void Merge_UpdatesScopeIdentity()
230230
when not matched then insert (v) values (i.v);
231231
select scope_identity()
232232
"""));
233+
234+
[TestMethod]
235+
public void InsertOutput_InsertedStar_ExpandsAllColumns()
236+
{
237+
using var reader = new Simulation().CreateCommand("""
238+
create table t (id int identity, v int, c char(2) default 'AB');
239+
insert t (v) output inserted.* values (10), (20)
240+
""").ExecuteReader();
241+
242+
Assert.AreEqual(3, reader.FieldCount);
243+
Assert.AreEqual("id", reader.GetName(0));
244+
Assert.AreEqual("v", reader.GetName(1));
245+
Assert.AreEqual("c", reader.GetName(2));
246+
Assert.IsTrue(reader.Read());
247+
Assert.AreEqual(1, reader.GetInt32(0));
248+
Assert.AreEqual(10, reader.GetInt32(1));
249+
Assert.AreEqual("AB", reader.GetString(2));
250+
Assert.IsTrue(reader.Read());
251+
Assert.AreEqual(2, reader.GetInt32(0));
252+
Assert.AreEqual(20, reader.GetInt32(1));
253+
}
254+
255+
[TestMethod]
256+
public void InsertOutput_StarMixedWithExtra_AppendsAfter()
257+
{
258+
using var reader = new Simulation().CreateCommand("""
259+
create table t (id int identity, v int);
260+
insert t (v) output inserted.*, 'tag' as note values (5)
261+
""").ExecuteReader();
262+
263+
Assert.AreEqual(3, reader.FieldCount);
264+
Assert.AreEqual("id", reader.GetName(0));
265+
Assert.AreEqual("v", reader.GetName(1));
266+
Assert.AreEqual("note", reader.GetName(2));
267+
Assert.IsTrue(reader.Read());
268+
Assert.AreEqual(1, reader.GetInt32(0));
269+
Assert.AreEqual(5, reader.GetInt32(1));
270+
Assert.AreEqual("tag", reader.GetString(2));
271+
}
272+
273+
[TestMethod]
274+
public void UpdateOutput_InsertedAndDeletedStar_BothExpand()
275+
{
276+
using var reader = new Simulation().CreateCommand("""
277+
create table t (id int primary key, v int);
278+
insert t values (1, 10), (2, 20);
279+
update t set v = v + 100
280+
output inserted.*, deleted.*
281+
where id = 1
282+
""").ExecuteReader();
283+
284+
Assert.AreEqual(4, reader.FieldCount);
285+
Assert.IsTrue(reader.Read());
286+
Assert.AreEqual(1, reader.GetInt32(0));
287+
Assert.AreEqual(110, reader.GetInt32(1));
288+
Assert.AreEqual(1, reader.GetInt32(2));
289+
Assert.AreEqual(10, reader.GetInt32(3));
290+
}
291+
292+
[TestMethod]
293+
public void DeleteOutput_DeletedStar_ExpandsAllColumns()
294+
{
295+
using var reader = new Simulation().CreateCommand("""
296+
create table t (id int primary key, v int, n nvarchar(10));
297+
insert t values (1, 10, 'a'), (2, 20, 'b');
298+
delete t output deleted.* where id = 1
299+
""").ExecuteReader();
300+
301+
Assert.AreEqual(3, reader.FieldCount);
302+
Assert.IsTrue(reader.Read());
303+
Assert.AreEqual(1, reader.GetInt32(0));
304+
Assert.AreEqual(10, reader.GetInt32(1));
305+
Assert.AreEqual("a", reader.GetString(2));
306+
}
307+
308+
[TestMethod]
309+
public void DeleteOutput_InsertedStar_RaisesMsg4104()
310+
=> _ = new Simulation().AssertSqlError("""
311+
create table t (id int primary key, v int);
312+
delete t output inserted.* where id = 1
313+
""", 4104);
314+
315+
[TestMethod]
316+
public void InsertOutput_DeletedStar_RaisesMsg4104()
317+
=> _ = new Simulation().AssertSqlError("""
318+
create table t (id int primary key, v int);
319+
insert t output deleted.* values (1, 10)
320+
""", 4104);
321+
322+
[TestMethod]
323+
public void InsertOutput_StarInto_MatchesTargetColumnCount()
324+
{
325+
using var conn = new Simulation().CreateDbConnection();
326+
conn.Open();
327+
using (var setup = conn.CreateCommand())
328+
{
329+
setup.CommandText = "create table t (id int identity, v int); create table audit (id int, v int)";
330+
_ = setup.ExecuteNonQuery();
331+
}
332+
using (var ins = conn.CreateCommand())
333+
{
334+
ins.CommandText = "insert t (v) output inserted.* into audit values (50)";
335+
_ = ins.ExecuteNonQuery();
336+
}
337+
using var verify = conn.CreateCommand();
338+
verify.CommandText = "select id, v from audit";
339+
using var reader = verify.ExecuteReader();
340+
Assert.IsTrue(reader.Read());
341+
Assert.AreEqual(1, reader.GetInt32(0));
342+
Assert.AreEqual(50, reader.GetInt32(1));
343+
}
344+
345+
[TestMethod]
346+
public void MergeOutput_InsertedAndDeletedStar_NullForUnmatchedSide()
347+
{
348+
using var reader = new Simulation().CreateCommand("""
349+
create table t (id int primary key, v int);
350+
insert t values (1, 10);
351+
merge t as tg
352+
using (values (1, 100), (2, 200)) as s (id, v) on tg.id = s.id
353+
when matched then update set v = s.v
354+
when not matched by target then insert (id, v) values (s.id, s.v)
355+
output $action, inserted.*, deleted.*;
356+
""").ExecuteReader();
357+
358+
Assert.AreEqual(5, reader.FieldCount);
359+
var rows = new List<(string action, int? insId, int? insV, int? delId, int? delV)>();
360+
while (reader.Read())
361+
{
362+
rows.Add((
363+
reader.GetString(0),
364+
reader.IsDBNull(1) ? null : reader.GetInt32(1),
365+
reader.IsDBNull(2) ? null : reader.GetInt32(2),
366+
reader.IsDBNull(3) ? null : reader.GetInt32(3),
367+
reader.IsDBNull(4) ? null : reader.GetInt32(4)));
368+
}
369+
rows.Sort((a, b) => (a.insId ?? 0).CompareTo(b.insId ?? 0));
370+
Assert.HasCount(2, rows);
371+
Assert.AreEqual("UPDATE", rows[0].action);
372+
Assert.AreEqual(1, rows[0].insId);
373+
Assert.AreEqual(100, rows[0].insV);
374+
Assert.AreEqual(1, rows[0].delId);
375+
Assert.AreEqual(10, rows[0].delV);
376+
Assert.AreEqual("INSERT", rows[1].action);
377+
Assert.AreEqual(2, rows[1].insId);
378+
Assert.AreEqual(200, rows[1].insV);
379+
Assert.IsNull(rows[1].delId);
380+
Assert.IsNull(rows[1].delV);
381+
}
382+
383+
[TestMethod]
384+
public void MergeOutput_SourceAliasStar_ExpandsSourceColumns()
385+
{
386+
using var reader = new Simulation().CreateCommand("""
387+
create table t (id int primary key, v int);
388+
merge t
389+
using (values (5, 50)) as s (id, v) on t.id = s.id
390+
when not matched by target then insert (id, v) values (s.id, s.v)
391+
output s.*;
392+
""").ExecuteReader();
393+
394+
Assert.AreEqual(2, reader.FieldCount);
395+
Assert.AreEqual("id", reader.GetName(0));
396+
Assert.AreEqual("v", reader.GetName(1));
397+
Assert.IsTrue(reader.Read());
398+
Assert.AreEqual(5, reader.GetInt32(0));
399+
Assert.AreEqual(50, reader.GetInt32(1));
400+
}
401+
402+
[TestMethod]
403+
public void Output_StarWithUnknownQualifier_Msg4104()
404+
=> _ = new Simulation().AssertSqlError("""
405+
create table t (id int primary key, v int);
406+
insert t output foo.* values (1, 10)
407+
""", 4104);
233408
}

SqlServerSimulator/Simulation/Simulation.Merge.cs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -610,19 +610,49 @@ SqlType ResolveOutputType(MultiPartName name)
610610
do
611611
{
612612
context.MoveNextRequired();
613-
Expression expr;
614613
// $action pseudo-column: detected by the tokenizer's $action
615614
// single-token emission. Synthesize a literal reference that
616615
// the runtime resolves to the action verb via the row context.
617616
if (context.Token is UnquotedString u && u.Value.Equals("$action", StringComparison.OrdinalIgnoreCase))
618617
{
619-
expr = new MergeActionReference();
618+
Expression actionExpr = new MergeActionReference();
620619
context.MoveNextOptional();
620+
switch (context.Token)
621+
{
622+
case ReservedKeyword { Keyword: Keyword.As }:
623+
actionExpr = Expression.AssignName(actionExpr, context.GetNextRequired<Name>());
624+
context.MoveNextOptional();
625+
break;
626+
case Name actionAlias:
627+
actionExpr = Expression.AssignName(actionExpr, actionAlias);
628+
context.MoveNextOptional();
629+
break;
630+
}
631+
expressions.Add(actionExpr);
632+
columnNames.Add(string.IsNullOrEmpty(actionExpr.Name) ? "$action" : actionExpr.Name);
633+
continue;
621634
}
622-
else
635+
if (TryDetectStarReference(context, out var starQualifier))
623636
{
624-
expr = Expression.Parse(context);
637+
string[]? cols = null;
638+
if (Collation.Default.Equals(starQualifier, "INSERTED")
639+
|| Collation.Default.Equals(starQualifier, "DELETED"))
640+
{
641+
cols = new string[destinationTable.Columns.Length];
642+
for (var i = 0; i < destinationTable.Columns.Length; i++)
643+
cols[i] = destinationTable.Columns[i].Name;
644+
}
645+
else if (Collation.Default.Equals(starQualifier, sourceAlias))
646+
{
647+
cols = sourceColumnNames;
648+
}
649+
if (cols is null)
650+
throw SimulatedSqlException.MultiPartIdentifierCouldNotBeBound($"{starQualifier}.*");
651+
AppendStarExpansion(starQualifier, cols, expressions, columnNames);
652+
context.MoveNextOptional();
653+
continue;
625654
}
655+
var expr = Expression.Parse(context);
626656
switch (context.Token)
627657
{
628658
case ReservedKeyword { Keyword: Keyword.As }:
@@ -635,7 +665,7 @@ SqlType ResolveOutputType(MultiPartName name)
635665
break;
636666
}
637667
expressions.Add(expr);
638-
columnNames.Add(string.IsNullOrEmpty(expr.Name) && IsMergeActionRef(expr) ? "$action" : expr.Name);
668+
columnNames.Add(expr.Name);
639669
}
640670
while (context.Token is Operator { Character: ',' });
641671

SqlServerSimulator/Simulation/Simulation.Output.cs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,78 @@
11
using SqlServerSimulator.Parser;
2+
using SqlServerSimulator.Parser.Expressions;
23
using SqlServerSimulator.Parser.Tokens;
34
using SqlServerSimulator.Storage;
45

56
namespace SqlServerSimulator;
67

78
partial class Simulation
89
{
10+
/// <summary>
11+
/// Detects an <c>&lt;qualifier&gt;.*</c> star reference at the current
12+
/// cursor position. When present, leaves the cursor on the <c>*</c>
13+
/// operator (caller appends the expansion then calls
14+
/// <see cref="ParserContext.MoveNextOptional"/>) and returns the
15+
/// qualifier name. When absent (the cursor sees a regular expression
16+
/// or a qualified non-star reference), restores to the original
17+
/// position and returns <see langword="false"/> so the caller can
18+
/// fall through to <see cref="Expression.Parse"/>.
19+
/// </summary>
20+
/// <remarks>
21+
/// Probe-confirmed against SQL Server 2025 (2026-05-13): <c>INSERTED.*</c>
22+
/// expands to every column of the destination table in storage order
23+
/// (including identity / computed / default-bearing columns); <c>DELETED.*</c>
24+
/// is the same shape; in MERGE OUTPUT the source-alias <c>s.*</c>
25+
/// expands to the source's projected columns. Each expanded column
26+
/// takes the underlying column's leaf name (not the qualified form).
27+
/// <c>foo.* AS alias</c> isn't a valid shape — real SQL Server raises
28+
/// Msg 102 ("Incorrect syntax near 'alias'"); the simulator inherits
29+
/// that since star expansion advances past <c>*</c> and the parser
30+
/// loop's alias-check sees the next token as either <c>,</c> or
31+
/// statement-boundary, never a bare Name.
32+
/// </remarks>
33+
private static bool TryDetectStarReference(ParserContext context, out string qualifier)
34+
{
35+
qualifier = string.Empty;
36+
if (context.Token is not Name namedToken)
37+
return false;
38+
var name = namedToken.Value;
39+
var checkpoint = context.SaveCheckpoint();
40+
if (!context.MoveNext() || context.Token is not Operator { Character: '.' })
41+
{
42+
context.RestoreCheckpoint(checkpoint);
43+
return false;
44+
}
45+
if (!context.MoveNext() || context.Token is not Operator { Character: '*' })
46+
{
47+
context.RestoreCheckpoint(checkpoint);
48+
return false;
49+
}
50+
qualifier = name;
51+
return true;
52+
}
53+
54+
/// <summary>
55+
/// Appends one synthesized <see cref="Reference"/> per column in
56+
/// <paramref name="columnNames"/> qualified by
57+
/// <paramref name="qualifier"/>. Used by all three OUTPUT parsers
58+
/// (mutation / insert / merge) for <c>INSERTED.* / DELETED.* /
59+
/// &lt;source&gt;.*</c> expansion. The qualified Reference resolves
60+
/// through the same per-row resolver that handles regular
61+
/// <c>INSERTED.col</c> refs — no separate runtime path.
62+
/// </summary>
63+
private static void AppendStarExpansion(
64+
string qualifier,
65+
string[] columnNames,
66+
List<Expression> expressions,
67+
List<string> names)
68+
{
69+
for (var i = 0; i < columnNames.Length; i++)
70+
{
71+
expressions.Add(new Reference(qualifier, columnNames[i]));
72+
names.Add(columnNames[i]);
73+
}
74+
}
75+
976
/// <summary>
1077
/// OUTPUT-clause parser for UPDATE / DELETE. Supports literal /
1178
/// parameter expressions and <c>INSERTED.&lt;col&gt;</c> /
@@ -44,6 +111,19 @@ partial class Simulation
44111
do
45112
{
46113
context.MoveNextRequired();
114+
if (TryDetectStarReference(context, out var starQualifier))
115+
{
116+
var insertedRef = Collation.Default.Equals(starQualifier, "INSERTED");
117+
var deletedRef = Collation.Default.Equals(starQualifier, "DELETED");
118+
if ((insertedRef && !allowInserted) || (deletedRef && !allowDeleted) || (!insertedRef && !deletedRef))
119+
throw SimulatedSqlException.MultiPartIdentifierCouldNotBeBound($"{starQualifier}.*");
120+
var columnNameList = new string[table.Columns.Length];
121+
for (var i = 0; i < table.Columns.Length; i++)
122+
columnNameList[i] = table.Columns[i].Name;
123+
AppendStarExpansion(starQualifier, columnNameList, expressions, names);
124+
context.MoveNextOptional();
125+
continue;
126+
}
47127
var expr = Expression.Parse(context);
48128
switch (context.Token)
49129
{
@@ -341,6 +421,22 @@ SqlType ResolveOutputType(MultiPartName name)
341421
do
342422
{
343423
context.MoveNextRequired();
424+
if (TryDetectStarReference(context, out var starQualifier))
425+
{
426+
if (Collation.Default.Equals(starQualifier, "INSERTED"))
427+
{
428+
var cols = new string[destinationTable.Columns.Length];
429+
for (var i = 0; i < destinationTable.Columns.Length; i++)
430+
cols[i] = destinationTable.Columns[i].Name;
431+
AppendStarExpansion(starQualifier, cols, expressions, columnNames);
432+
}
433+
else
434+
{
435+
throw SimulatedSqlException.MultiPartIdentifierCouldNotBeBound($"{starQualifier}.*");
436+
}
437+
context.MoveNextOptional();
438+
continue;
439+
}
344440
var expr = Expression.Parse(context);
345441
switch (context.Token)
346442
{

docs/claude/dml.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- **OUTPUT** supported only when the leading identifier resolves to a real table name; OUTPUT + alias-form multi-source → `NotSupportedException` (EF doesn't combine those).
88
- **Multi-column SET evaluates RHS against pre-update snapshot**`UPDATE t SET a = 100, b = a + 1` over `(a=10, b=20)``(a=100, b=11)`. Scalar subquery RHS sees pre-update state.
99
- Identity update → Msg 8102. Computed update → Msg 271. Rowversion update → Msg 272. Per-row constraint re-validation: NOT NULL → Msg 515 ("UPDATE fails."); CHECK → Msg 547 ("UPDATE statement"); PK/UNIQUE → Msg 2627 (verbatim "Cannot insert duplicate key" wording even on UPDATE — SQL Server quirk). PK/UNIQUE validation runs against the post-update virtual state, so mass-shift on a unique key can false-positive (see Quirks).
10-
- `OUTPUT INSERTED.<col>` (post-update) / `DELETED.<col>` (pre-update). UPDATE allows both qualifiers; DELETE rejects `INSERTED.<col>` at parse → Msg 4104. Star expansion (`INSERTED.*`/`DELETED.*`) not modeled. `OUTPUT … INTO @t [(cols)]` ships for INSERT/UPDATE/DELETE/MERGE — see [Table variables](table-variables.md).
10+
- `OUTPUT INSERTED.<col>` (post-update) / `DELETED.<col>` (pre-update). UPDATE allows both qualifiers; DELETE rejects `INSERTED.<col>` at parse → Msg 4104. **Star expansion** (`INSERTED.*` / `DELETED.*`, plus the MERGE source-alias `<src>.*`) ships via parse-time expansion in `Simulation.Output.cs`: `TryDetectStarReference` peeks for the `<qualifier>.*` token sequence and `AppendStarExpansion` synthesizes one `Reference("qualifier", col)` per column of the target (or per column of the MERGE source alias). Expanded names take the underlying column's leaf (probe-confirmed — not the qualified form). The MERGE form keeps the per-action NULL-fill semantic for `INSERTED.* / DELETED.*` (DELETED columns are NULL on a WHEN NOT MATCHED INSERT row; INSERTED columns are NULL on a WHEN MATCHED DELETE row). Unbound qualifier on `.*` raises Msg 4104, same as `<qualifier>.<col>`. The expansion runs before `Expression.Parse`, so the alias-suffix shape (`INSERTED.* AS x`) inherits real SQL Server's Msg 102 rejection naturally — the cursor advances past `*` to either `,` or the end-of-OUTPUT terminator. `OUTPUT … INTO @t [(cols)]` ships for INSERT/UPDATE/DELETE/MERGE — see [Table variables](table-variables.md).
1111

1212
## `rowversion` (legacy synonym `timestamp`)
1313
8-byte big-endian database-scoped monotonic counter; advances on every INSERT into a rowversion-bearing table and every UPDATE affecting one. Storage type name surfaces as `timestamp` in `information_schema` regardless of declaration. Explicit insert → Msg 273; explicit update → Msg 272; second column on a table → Msg 2738. Outbound CAST: `varbinary(N)`/`binary(N)` copy 8 bytes; `bigint` reads big-endian. `Promote(RowVersion, Varbinary) → Varbinary` so EF's `WHERE [rv] = @originalRv` parameter works directly. EF `[Timestamp]` SaveChanges round-trips end-to-end.

0 commit comments

Comments
 (0)