Skip to content

Commit bd3db51

Browse files
committed
INSERT … SELECT source: Selection.Parse + Execute, full-buffer materialization via RowDecoder, parse-time projection-count check via selection.Schema.Length (Msg 120 / Msg 121). Source-kind dispatch (Values vs Select) lives at the post-OUTPUT-clause cursor; both branches funnel into one shared per-row encode loop that operates on SqlValue[] directly. Self-insert is safe — source materializes before any destination write. WHERE / JOIN / GROUP BY / aggregates / ORDER BY / TOP / OFFSET-FETCH / UNION all work on the source side. Statement-level atomicity carries over: a CHECK / NOT NULL / PK / UNIQUE violation mid-source rolls back every row from the SELECT.
1 parent 1ca25da commit bd3db51

4 files changed

Lines changed: 310 additions & 31 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,17 @@ Three entry points share one per-connection undo log: implicit (statement-level
339339
### `rowversion` (legacy synonym `timestamp`)
340340
8-byte big-endian database-scoped monotonic counter; `Simulation.AllocateRowVersion` advances on every INSERT into a rowversion-bearing table and every UPDATE that affects a row in one. Storage type name surfaces as `timestamp` in `information_schema` regardless of declaration keyword. Explicit insert → Msg 273; explicit update → Msg 272; second column on a table → Msg 2738. Outbound CAST: `varbinary(N)` / `binary(N)` copy the 8 bytes; `bigint` reads big-endian. `Promote(RowVersion, Varbinary)``Varbinary` so EF Core's `WHERE [rv] = @originalRv` optimistic-concurrency parameter works directly. `EF Core [Timestamp]` SaveChanges round-trips end-to-end via `UPDATE ... OUTPUT INSERTED.[RowVersion] WHERE [Id] = @p AND [RowVersion] = @originalRv`.
341341

342+
### INSERT … SELECT
343+
`INSERT [INTO] target [(cols)] SELECT …` accepts the same Selection grammar as a top-level SELECT — WHERE / JOIN / GROUP BY / aggregates / ORDER BY / TOP / OFFSET-FETCH / UNION / INTERSECT / EXCEPT all work on the source side. Probe-confirmed against SQL Server 2025 on 2026-05-10.
344+
345+
Source-kind dispatch happens after the OUTPUT-clause parse: a `Values`-keyword token routes to the existing tuple-parsing path; a `Select`-keyword token routes to `Selection.Parse(…).Execute()`. Both paths funnel into one shared per-row encode loop that handles defaults / identity / rowversion / computed / constraints / OUTPUT — VALUES eagerly evaluates each cell expression to a `SqlValue` upstream, SELECT-source rows arrive pre-decoded via `RowDecoder` from the executed `SimulatedSqlResultSet`'s row bytes.
346+
347+
Buffering is full: `ExecuteSelectSource` materializes the entire source result-set into `List<SqlValue[]>` before any destination write. This makes self-insert (`INSERT t SELECT … FROM t`) safe — without it, scanning the source's heap while inserting into it would yield undefined behavior.
348+
349+
Projection-count vs insert-list mismatch fires at parse time via `selection.Schema.Length`: too few SELECT columns → **Msg 120 St 1 Cls 15** (`"The select list for the INSERT statement contains fewer items than the insert list. The number of SELECT values must match the number of INSERT columns."`); too many → **Msg 121 St 1 Cls 15** with `"more items"` wording. Empty source → silent success with rows-affected 0. CHECK / NOT NULL / PK / UNIQUE violations mid-source still trigger statement-level rollback (every row from the SELECT is unwound, matching the VALUES path's atomicity).
350+
351+
EF Core 10 doesn't emit `INSERT … SELECT` from SaveChanges (which uses INSERT…OUTPUT VALUES for single-row and MERGE for batched-multi-row); this is reachable from raw SQL (`FromSqlInterpolated` / direct command text) and from application-side bulk-copy patterns. CTE-prefix INSERTs (`WITH … INSERT t SELECT …`) aren't modeled — orthogonal to this bundle since the simulator has no CTE support.
352+
342353
### MERGE / OUTPUT (EF Core SaveChanges shape only)
343354
- `INSERT ... OUTPUT INSERTED.<col>` (single-row).
344355
- `MERGE INTO target USING (VALUES ...) AS alias (cols) ON predicate WHEN NOT MATCHED THEN INSERT ... [OUTPUT ...]` (multi-row batch).

SqlServerSimulator.Tests/InsertTests.cs

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,237 @@ public void Insert_PersistedComputedNotNull_ResultNullRaisesMsg515()
472472
insert t (a) values (null)
473473
""", 515);
474474

475+
[TestMethod]
476+
public void InsertSelect_VanillaWithColumnList_CopiesAllRows()
477+
{
478+
var simulation = new Simulation();
479+
_ = simulation.ExecuteNonQuery("""
480+
create table src (id int, name varchar(50));
481+
create table dst (id int, name varchar(50));
482+
insert src (id, name) values (1, 'alpha'), (2, 'beta'), (3, 'gamma')
483+
""");
484+
AreEqual(3, simulation.ExecuteNonQuery("insert dst (id, name) select id, name from src"));
485+
AreEqual(3, simulation.ExecuteScalar("select count(*) from dst"));
486+
AreEqual("beta", simulation.ExecuteScalar("select name from dst where id = 2"));
487+
}
488+
489+
// No column list — INSERT skips IDENTITY and computed columns from the destination,
490+
// and the SELECT projection must match the remaining writable columns.
491+
[TestMethod]
492+
public void InsertSelect_NoColumnList_TargetsWritableColumns()
493+
{
494+
var simulation = new Simulation();
495+
_ = simulation.ExecuteNonQuery("""
496+
create table src (v varchar(20));
497+
create table dst (id int identity, v varchar(20));
498+
insert src (v) values ('alpha')
499+
""");
500+
AreEqual(1, simulation.ExecuteNonQuery("insert dst select v from src"));
501+
AreEqual("alpha", simulation.ExecuteScalar("select v from dst where id = 1"));
502+
}
503+
504+
[TestMethod]
505+
public void InsertSelect_TooManyColumns_RaisesMsg121()
506+
{
507+
var simulation = new Simulation();
508+
_ = simulation.ExecuteNonQuery("""
509+
create table src (a int, b int, c int);
510+
create table dst (a int, b int);
511+
insert src (a, b, c) values (1, 2, 3)
512+
""");
513+
simulation.AssertSqlError("insert dst (a, b) select a, b, c from src", 121,
514+
"The select list for the INSERT statement contains more items than the insert list. The number of SELECT values must match the number of INSERT columns.");
515+
}
516+
517+
[TestMethod]
518+
public void InsertSelect_TooFewColumns_RaisesMsg120()
519+
{
520+
var simulation = new Simulation();
521+
_ = simulation.ExecuteNonQuery("""
522+
create table src (a int);
523+
create table dst (a int, b int);
524+
insert src (a) values (1)
525+
""");
526+
simulation.AssertSqlError("insert dst (a, b) select a from src", 120,
527+
"The select list for the INSERT statement contains fewer items than the insert list. The number of SELECT values must match the number of INSERT columns.");
528+
}
529+
530+
[TestMethod]
531+
public void InsertSelect_EmptySource_SilentSuccess()
532+
{
533+
var simulation = new Simulation();
534+
_ = simulation.ExecuteNonQuery("""
535+
create table src (a int);
536+
create table dst (a int)
537+
""");
538+
AreEqual(0, simulation.ExecuteNonQuery("insert dst (a) select a from src where a > 0"));
539+
AreEqual(0, simulation.ExecuteScalar("select count(*) from dst"));
540+
}
541+
542+
[TestMethod]
543+
public void InsertSelect_WhereJoinAggregateOffset_AllSelectionFeaturesWorkInSource()
544+
{
545+
var simulation = new Simulation();
546+
_ = simulation.ExecuteNonQuery("""
547+
create table src (id int, score int);
548+
create table aux (id int, label varchar(20));
549+
create table dst (a int, b varchar(20));
550+
insert src (id, score) values (1, 10), (2, 20), (3, 30);
551+
insert aux (id, label) values (1, 'one'), (2, 'two'), (3, 'three')
552+
""");
553+
AreEqual(2, simulation.ExecuteNonQuery("""
554+
insert dst (a, b)
555+
select s.id, a.label
556+
from src s inner join aux a on a.id = s.id
557+
where s.score >= 20
558+
order by s.id
559+
offset 0 rows fetch next 5 rows only
560+
"""));
561+
AreEqual("two", simulation.ExecuteScalar("select b from dst where a = 2"));
562+
AreEqual("three", simulation.ExecuteScalar("select b from dst where a = 3"));
563+
}
564+
565+
[TestMethod]
566+
public void InsertSelect_UnionAllSource_BothBranchesInserted()
567+
{
568+
var simulation = new Simulation();
569+
_ = simulation.ExecuteNonQuery("""
570+
create table src (id int, score int);
571+
create table dst (v int);
572+
insert src (id, score) values (1, 10), (2, 20), (3, 30)
573+
""");
574+
AreEqual(6, simulation.ExecuteNonQuery("insert dst (v) select id from src union all select score from src"));
575+
AreEqual(6, simulation.ExecuteScalar("select count(*) from dst"));
576+
AreEqual(66, simulation.ExecuteScalar("select sum(v) from dst"));
577+
}
578+
579+
[TestMethod]
580+
public void InsertSelect_SelfInsertBuffersSourceBeforeWrite()
581+
{
582+
var simulation = new Simulation();
583+
_ = simulation.ExecuteNonQuery("""
584+
create table t (a int);
585+
insert t (a) values (1), (2)
586+
""");
587+
AreEqual(2, simulation.ExecuteNonQuery("insert t (a) select a + 100 from t"));
588+
AreEqual(4, simulation.ExecuteScalar("select count(*) from t"));
589+
AreEqual(206, simulation.ExecuteScalar("select sum(a) from t"));
590+
}
591+
592+
[TestMethod]
593+
public void InsertSelect_IdentityColumnSkippedFromAutoColumnList()
594+
{
595+
var simulation = new Simulation();
596+
_ = simulation.ExecuteNonQuery("""
597+
create table src (v varchar(20));
598+
create table dst (id int identity primary key, v varchar(20));
599+
insert src (v) values ('alpha'), ('beta'), ('gamma')
600+
""");
601+
AreEqual(3, simulation.ExecuteNonQuery("insert dst select v from src"));
602+
AreEqual(1, simulation.ExecuteScalar("select id from dst where v = 'alpha'"));
603+
AreEqual(3, simulation.ExecuteScalar("select id from dst where v = 'gamma'"));
604+
}
605+
606+
// Explicit IDENTITY column without SET IDENTITY_INSERT ON — same Msg 544 path
607+
// as the VALUES-side; SELECT-source uses the existing pre-source identity check.
608+
[TestMethod]
609+
public void InsertSelect_ExplicitIdentityWithoutInsertOn_RaisesMsg544()
610+
{
611+
var simulation = new Simulation();
612+
_ = simulation.ExecuteNonQuery("""
613+
create table src (id int, v varchar(20));
614+
create table dst (id int identity, v varchar(20));
615+
insert src (id, v) values (5, 'x')
616+
""");
617+
_ = simulation.AssertSqlError("insert dst (id, v) select id, v from src", 544);
618+
}
619+
620+
[TestMethod]
621+
public void InsertSelect_DefaultAppliesForOmittedColumn()
622+
{
623+
var simulation = new Simulation();
624+
_ = simulation.ExecuteNonQuery("""
625+
create table src (a int);
626+
create table dst (a int, b int default 99);
627+
insert src (a) values (1), (2)
628+
""");
629+
AreEqual(2, simulation.ExecuteNonQuery("insert dst (a) select a from src"));
630+
AreEqual(99, simulation.ExecuteScalar("select b from dst where a = 1"));
631+
AreEqual(99, simulation.ExecuteScalar("select b from dst where a = 2"));
632+
}
633+
634+
[TestMethod]
635+
public void InsertSelect_TypeCoercion_IntToBigInt()
636+
{
637+
var simulation = new Simulation();
638+
_ = simulation.ExecuteNonQuery("""
639+
create table src (a int);
640+
create table dst (a bigint);
641+
insert src (a) values (12345)
642+
""");
643+
AreEqual(1, simulation.ExecuteNonQuery("insert dst (a) select a from src"));
644+
AreEqual(12345L, simulation.ExecuteScalar("select a from dst"));
645+
}
646+
647+
// CHECK violation in mid-SELECT-source rolls back the whole statement
648+
// — same statement-level atomicity the VALUES path enjoys. The earlier
649+
// rows from the same INSERT must not survive.
650+
[TestMethod]
651+
public void InsertSelect_CheckViolation_RollsBackEntireStatement()
652+
{
653+
var simulation = new Simulation();
654+
_ = simulation.ExecuteNonQuery("""
655+
create table src (a int);
656+
create table dst (a int check (a > 0));
657+
insert src (a) values (10), (20), (-1)
658+
""");
659+
_ = simulation.AssertSqlError("insert dst (a) select a from src", 547);
660+
AreEqual(0, simulation.ExecuteScalar("select count(*) from dst"));
661+
}
662+
663+
[TestMethod]
664+
public void InsertSelect_OutputClause_ProjectsInsertedRows()
665+
{
666+
var simulation = new Simulation();
667+
_ = simulation.ExecuteNonQuery("""
668+
create table src (v varchar(20));
669+
create table dst (id int identity, v varchar(20));
670+
insert src (v) values ('alpha'), ('beta')
671+
""");
672+
673+
using var connection = simulation.CreateOpenConnection();
674+
using var reader = connection.CreateCommand("insert dst (v) output inserted.id, inserted.v select v from src order by v").ExecuteReader();
675+
var rows = new List<(int id, string v)>();
676+
while (reader.Read())
677+
rows.Add((reader.GetInt32(0), reader.GetString(1)));
678+
CollectionAssert.AreEqual(new[] { (1, "alpha"), (2, "beta") }, rows);
679+
}
680+
681+
[TestMethod]
682+
public void InsertSelect_BareWithoutInto_Works()
683+
{
684+
var simulation = new Simulation();
685+
_ = simulation.ExecuteNonQuery("""
686+
create table src (a int, b varchar(20));
687+
create table dst (a int, b varchar(20));
688+
insert src (a, b) values (1, 'x'), (2, 'y')
689+
""");
690+
AreEqual(2, simulation.ExecuteNonQuery("insert dst select a, b from src"));
691+
}
692+
693+
[TestMethod]
694+
public void InsertSelect_AggregateSource_SingleRow()
695+
{
696+
var simulation = new Simulation();
697+
_ = simulation.ExecuteNonQuery("""
698+
create table src (score int);
699+
create table dst (total int);
700+
insert src (score) values (10), (20), (30)
701+
""");
702+
AreEqual(1, simulation.ExecuteNonQuery("insert dst (total) select sum(score) from src"));
703+
AreEqual(60, simulation.ExecuteScalar("select total from dst"));
704+
}
705+
475706
private static void AddTypedParameter(DbCommand command, string name, DbType dbType, object value)
476707
{
477708
var parameter = command.CreateParameter();

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,22 @@ internal static SimulatedSqlException OrderByItemNotInSelectListWithDistinct() =
175175
internal static SimulatedSqlException LobTypesCannotBeComparedOrSorted() =>
176176
new("The text, ntext, and image data types cannot be compared or sorted, except when using IS NULL or LIKE operator.", 306, 16, 1);
177177

178+
/// <summary>
179+
/// Mimics SQL Server error 120: <c>INSERT … SELECT</c> whose source
180+
/// projects fewer columns than the destination's column list (explicit
181+
/// or implied). Distinct from Msg 121 (the "more items" variant).
182+
/// </summary>
183+
internal static SimulatedSqlException InsertSelectListFewerThanInsertList() =>
184+
new("The select list for the INSERT statement contains fewer items than the insert list. The number of SELECT values must match the number of INSERT columns.", 120, 15, 1);
185+
186+
/// <summary>
187+
/// Mimics SQL Server error 121: <c>INSERT … SELECT</c> whose source
188+
/// projects more columns than the destination's column list (explicit
189+
/// or implied). Distinct from Msg 120 (the "fewer items" variant).
190+
/// </summary>
191+
internal static SimulatedSqlException InsertSelectListMoreThanInsertList() =>
192+
new("The select list for the INSERT statement contains more items than the insert list. The number of SELECT values must match the number of INSERT columns.", 121, 15, 1);
193+
178194
/// <summary>
179195
/// Mimics SQL Server error 108: a positional <c>ORDER BY</c> ordinal
180196
/// (e.g. <c>order by 0</c>, <c>order by 5</c> with only 3 columns) is

SqlServerSimulator/Simulation/Simulation.Insert.cs

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -94,37 +94,12 @@ private static SimulatedStatementOutcome ProcessHeapInsert(HeapTable destination
9494

9595
var output = TryParseOutputClause(context, destinationTable, sourceColumnNames: null);
9696

97-
if (context.Token is not ReservedKeyword { Keyword: Keyword.Values })
98-
throw SimulatedSqlException.SyntaxErrorNear(context);
99-
100-
var sourceRows = new List<Expression[]>();
101-
102-
do
97+
var sourceRows = context.Token switch
10398
{
104-
if (context.GetNextRequired<Operator>() is not { Character: '(' })
105-
throw SimulatedSqlException.SyntaxErrorNear(context);
106-
107-
var sourceValues = new List<Expression>();
108-
while (true)
109-
{
110-
// Position context.Token at the start of the value expression
111-
// (just past the '(' or the previous ','), then let
112-
// Expression.Parse consume it. Parse leaves context.Token at
113-
// the first un-consumed token, which must be ',' or ')' here.
114-
context.MoveNextRequired();
115-
if (context.Token is Operator { Character: ',' or ')' })
116-
throw SimulatedSqlException.SyntaxErrorNear(context);
117-
sourceValues.Add(Expression.Parse(context));
118-
119-
if (context.Token is Operator { Character: ')' })
120-
break;
121-
if (context.Token is not Operator { Character: ',' })
122-
throw SimulatedSqlException.SyntaxErrorNear(context);
123-
}
124-
125-
sourceRows.Add([.. sourceValues]);
126-
127-
} while (context.GetNextOptional() is Operator { Character: ',' });
99+
ReservedKeyword { Keyword: Keyword.Values } => EvaluateValuesTuples(context),
100+
ReservedKeyword { Keyword: Keyword.Select } => ExecuteSelectSource(context, destinationColumns.Length),
101+
_ => throw SimulatedSqlException.SyntaxErrorNear(context),
102+
};
128103

129104
decimal? lastIdentityValue = null;
130105
var outputRows = output is null ? null : new List<byte[]>(sourceRows.Count);
@@ -170,7 +145,7 @@ private static SimulatedStatementOutcome ProcessHeapInsert(HeapTable destination
170145
}
171146
}
172147

173-
var source = sourceRow[i].Run(name => throw SimulatedSqlException.InvalidColumnName(name));
148+
var source = sourceRow[i];
174149
EnforceMaxLength(source, targetColumn, destinationTable.Name, context.Simulation);
175150
var coerced = CoerceForInsert(source, targetColumn.Type);
176151
rowValues[ordinal] = coerced;
@@ -232,4 +207,50 @@ private static SimulatedStatementOutcome ProcessHeapInsert(HeapTable destination
232207
? new SimulatedSqlResultSet(o2.Schema, o2.ColumnNames, outputRows!)
233208
: new SimulatedNonQuery(sourceRows.Count);
234209
}
210+
211+
/// <summary>
212+
/// Parses INSERT's <c>VALUES (…), (…)</c> source via the shared
213+
/// <c>ParseValuesTuples</c> helper, then eagerly evaluates each cell
214+
/// expression to a <see cref="SqlValue"/>. VALUES expressions can't
215+
/// reference columns; the column-resolver hook always raises
216+
/// <see cref="SimulatedSqlException.InvalidColumnName(string)"/>.
217+
/// </summary>
218+
private static List<SqlValue[]> EvaluateValuesTuples(ParserContext context)
219+
{
220+
var tuples = ParseValuesTuples(context);
221+
var rows = new List<SqlValue[]>(tuples.Count);
222+
foreach (var tuple in tuples)
223+
{
224+
var values = new SqlValue[tuple.Length];
225+
for (var i = 0; i < tuple.Length; i++)
226+
values[i] = tuple[i].Run(name => throw SimulatedSqlException.InvalidColumnName(name));
227+
rows.Add(values);
228+
}
229+
return rows;
230+
}
231+
232+
/// <summary>
233+
/// Parses and executes the <c>SELECT</c>-source side of <c>INSERT … SELECT</c>.
234+
/// Validates the projection-count vs insert-list count at parse time
235+
/// (Msg 120 / Msg 121, matching SQL Server's pre-execution diagnostic),
236+
/// then buffers the result into a list of rows so the existing per-row
237+
/// encode loop can run unchanged. Buffering also makes self-insert
238+
/// (<c>INSERT t SELECT … FROM t</c>) safe — the source materializes
239+
/// before any destination write.
240+
/// </summary>
241+
private static List<SqlValue[]> ExecuteSelectSource(ParserContext context, int expectedColumnCount)
242+
{
243+
var selection = Selection.Parse(context, depth: 0);
244+
245+
if (selection.Schema.Length < expectedColumnCount)
246+
throw SimulatedSqlException.InsertSelectListFewerThanInsertList();
247+
if (selection.Schema.Length > expectedColumnCount)
248+
throw SimulatedSqlException.InsertSelectListMoreThanInsertList();
249+
250+
var resultSet = selection.Execute();
251+
var rows = new List<SqlValue[]>();
252+
foreach (var rowBytes in resultSet.RowBytes)
253+
rows.Add(RowDecoder.DecodeRow(resultSet.Schema, rowBytes));
254+
return rows;
255+
}
235256
}

0 commit comments

Comments
 (0)