Skip to content

Commit 74d58db

Browse files
committed
SELECT INTO target: schema-inferring CREATE+INSERT path. Captures INTO clause inside ParseInner (Selection.IntoTarget + DestColumnSchema HeapColumn[] computed at parse time). Schema inference applies probe-confirmed rules: direct refs preserve source nullability + identity; identity propagates only from a single non-joined heap source; ISNULL non-null when either arg non-null; CASE non-null iff every branch non-null (no-ELSE = implicit nullable); arithmetic / CAST / COALESCE / aggregates always nullable; literals non-null. Target routes by #-prefix (temp vs regular table). Errors: Msg 1038 unnamed projection, Msg 2705 duplicate column name, Msg 2714 target exists. Two known quirks: string + reads as nullable (static dispatch can't distinguish concat from arithmetic); CTE drops identity + nullability.
1 parent 6b227bb commit 74d58db

16 files changed

Lines changed: 771 additions & 24 deletions

CLAUDE.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,18 @@ Not emitted by EF / not modeled: `JSON_QUERY`, `ISJSON`, `FOR JSON PATH`/`AUTO`.
325325
- `MERGE INTO target USING (VALUES ...) AS alias (cols) ON predicate WHEN NOT MATCHED THEN INSERT ... [OUTPUT ...]` (multi-row batch).
326326
- `WHEN MATCHED` parses but throws `NotSupportedException` if its predicate ever evaluates true.
327327

328+
### `SELECT … INTO target`
329+
Creates a destination table from the projection's inferred schema, then copies rows in. Target routes by `#`-prefix: `#foo` lands in the per-connection `TempTables` dict (same as `CREATE TABLE #foo`); regular names land in the current database's `HeapTables`. Probe-confirmed schema-inference rules (2026-05-11):
330+
331+
- **Nullability**: direct column refs preserve source nullability. Integer arithmetic, `CAST`, `COALESCE`, aggregates (incl. `COUNT`), and bare `NULL` literal all project as **nullable**. `ISNULL(x, y)` is **non-null when either arg is non-null** (asymmetric with COALESCE). `CASE` is non-null when every `THEN` branch is non-null AND the `ELSE` branch is non-null (no-`ELSE` = implicit `ELSE NULL` = nullable). Non-NULL literals are non-null. String `+` should also project non-null when both operands non-null, but the simulator's runtime-dispatch design (Add can be arithmetic or concat depending on operand types) makes static analysis impractical — projects as nullable (minor fidelity gap; staging tables rarely depend on this).
332+
- **Identity propagation**: only when the projection is a *direct column ref* (a `Reference`, possibly wrapped in `NamedExpression` for `AS alias`) AND the FROM clause is exactly one source with a `BackingTable` (a real heap, not a derived table / CTE / OPENJSON) AND no joins. WHERE/TOP/ORDER BY preserve. Any join, set-op, expression wrapping, or CTE drops it. Destination's `IdentityState` starts fresh with the source's seed+increment and tracks the copied values via `ObserveExplicit`.
333+
- **Implementation**: `Selection.IntoTarget` + `Selection.DestColumnSchema` (a `HeapColumn[]`) are captured at parse time inside `ParseInner` and propagated through `CombineSetOps` / `ApplyTopLevelOrderBy`. `Simulation.SelectInto.cs:ExecuteSelectInto` creates the heap table, runs the Selection, encodes each row through `RowEncoder.EncodeRow`, appends to the dest's heap, and tracks the active transaction's undo log so a `ROLLBACK` unwinds both the table creation (for temp tables) and the row writes.
334+
- **Schema rules + validation** live in `Selection.SelectInto.cs:ComputeIntoDestSchema`. Nullability uses `Expression.ResultIsNullable` (a new virtual override on `Value` / `Reference` / `NamedExpression` / `IsNullExpression` / `CaseExpression`; default `true` for everything else). Identity uses `UnwrapDirectRef` to drill through `NamedExpression` layers.
335+
- **Errors**: unnamed projection → **Msg 1038 Cl 15 St 5** (`SelectIntoMissingColumnName`); duplicate column name in projection → **Msg 2705 Cl 16 St 3** (`DuplicateColumnInSelectInto`, names the target table); target already exists → **Msg 2714** (reused factory); `##` global target → `NotSupportedException`.
336+
- **INTO + UNION**: real SQL Server allows `SELECT … INTO #t FROM a UNION ALL SELECT … FROM b` (INTO on first branch). The simulator parses this, propagates `IntoTarget` from the left branch through `CombineSetOps`, and strips identity on the combined dest schema. A right branch carrying its own INTO → Msg 156 (`Incorrect syntax near the keyword 'into'.`).
337+
- **INTO without FROM** works (`SELECT 1 AS x INTO #t`) — synthesized-row path threads `IntoTarget` through.
338+
- **Quirk**: CTE-wrapped single-heap source drops identity and nullability — the simulator's CTE bindings synthesize `HeapColumn` entries with `nullable: true` and no identity, so the analyzer can't peer through. Real SQL Server propagates both. Fix would require propagating column metadata through CTE bindings; future bundle.
339+
328340
### Local temp tables (`#foo`)
329341
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.
330342

@@ -366,7 +378,6 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
366378
- 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.
367379
- Table variables (`DECLARE @t TABLE (...)`) — separate feature with its own storage / scope / lifecycle.
368380
- **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.
369-
- **`SELECT ... INTO #foo`** (schema-inferring create) — separate feature; users must `CREATE TABLE #foo (...)` then `INSERT INTO #foo SELECT ...`. Schema-inference rules (nullability of derived columns, identity propagation) are the deferred design work.
370381
- **`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.
371382
- **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).
372383
- T-SQL control flow (`IF` / `WHILE` / `BEGIN ... END` / `BREAK` / `CONTINUE`) — Bundle 2 of scripting.
@@ -384,4 +395,6 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
384395
- **DELETE / UPDATE leak LOB chains**: orphaned LOB chains stay in `Heap.LobPages`. Other rows reference LOB pages by stable index, so list compaction would corrupt them.
385396
- **Mass-shift UPDATE on a unique key**: `UPDATE t SET k = k + 1` where k is PK/UNIQUE may spuriously raise Msg 2627 — the two-phase validator compares each affected row's new key against other affected rows' new keys, so post-shift values overlapping pre-shift values trigger a false positive.
386397
- **`GetBytes`/`GetChars` materialize, don't stream**: each call decodes the full column value via `RowDecoder` and slices into the caller's buffer. Behavior matches per-call observation; the streaming-memory guarantee doesn't.
398+
- **`SELECT INTO` string `+` reads as nullable**: real SQL Server projects `cs + 'x'` (both NOT NULL) as NOT NULL; the simulator can't statically distinguish string-concat from integer-add at projection-schema time (the dispatch happens runtime on operand types), so all `Add` results read as nullable. Conservative; no test reliance on string-`+`-non-null.
399+
- **`SELECT INTO` from a CTE drops identity + nullability**: CTE bindings synthesize their wrapper `HeapColumn` entries with `nullable: true` and no identity, so the analyzer treats CTE sources as derived plans. Real SQL Server preserves both through simple single-source CTEs. Fix requires propagating column metadata through CTE bindings.
387400
- **Temp-table DDL is transactional, regular-table DDL isn't**: `CREATE TABLE #foo` / `DROP TABLE #foo` inside `BEGIN TRAN` participate in the undo log (matching real SQL Server); the same statements on a regular table commit immediately regardless of an active transaction. Asymmetric, but no real workload depends on transactional regular-DDL (EF doesn't do schema changes through SaveChanges, and migrations run outside transactions on real SQL Server too).

0 commit comments

Comments
 (0)