grammar: 3c — INSERT … SELECT row source (ADR-0033 §4)

Make the INSERT row source a Choice between the VALUES clause and
Subgrammar(&sql_select::SQL_SELECT_COMPOUND). SQL_SELECT_COMPOUND
is itself a Choice that admits a leading WITH, so a WITH-prefixed
SELECT row source (R4) parses through it for free; the two
branches start on disjoint keywords (values vs select/with) so the
Choice never ambiguously commits. No worker change — do_sql_insert
already executes the validated SQL and re-persists, and the engine
handles insert-from-query.

Tests: grammar accept (plain / column-list+projection / WITH-
prefixed / trailing-semi) and reject (__rdbms_* on the SELECT's
FROM slot, incomplete select); integration parse-path lowering +
worker round-trip (rows land, CSV re-persisted) incl. R4 WITH end-
to-end; walker cross-cut that the Phase-2 unknown_column diagnostic
fires on the INSERT…SELECT projection; DA-gate test that a self-
sourced INSERT…SELECT runs as a plain insert (no cascade summary —
that is DELETE-only). Still behind the dev `sqlinsert` entry word
(shared `insert` is 3j). 1493 tests green, clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-21 22:08:25 +00:00
parent 7f68a53f86
commit 6ff9144c7a
3 changed files with 230 additions and 2 deletions
+54 -2
View File
@@ -14,7 +14,7 @@
//! sub-phases.
use crate::dsl::grammar::sql_expr;
use crate::dsl::grammar::sql_select::reject_internal_table;
use crate::dsl::grammar::sql_select::{SQL_SELECT_COMPOUND, reject_internal_table};
use crate::dsl::grammar::{IdentSource, Node, Word};
static COMMA: Node = Node::Punct(',');
@@ -95,11 +95,21 @@ static VALUES_CLAUSE_NODES: &[Node] = &[
/// `VALUES tuple (',' tuple)*` — single- or multi-row.
const VALUES_CLAUSE: Node = Node::Seq(VALUES_CLAUSE_NODES);
/// The row source: either a `VALUES` clause or a `SELECT`
/// compound (ADR-0033 §4, sub-phase 3c). `SQL_SELECT_COMPOUND`
/// is itself a Choice that admits a leading `WITH` (ADR-0032
/// §10.3), so `INSERT INTO t WITH x AS (…) SELECT …` parses
/// through this slot for free (R4). The two branches start on
/// disjoint keywords (`values` vs `select`/`with`), so the
/// Choice never ambiguously commits.
static ROW_SOURCE_CHOICES: &[Node] = &[VALUES_CLAUSE, Node::Subgrammar(&SQL_SELECT_COMPOUND)];
const ROW_SOURCE: Node = Node::Choice(ROW_SOURCE_CHOICES);
static SQL_INSERT_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("into")),
TARGET_TABLE,
OPTIONAL_COLUMN_LIST,
VALUES_CLAUSE,
ROW_SOURCE,
Node::Optional(&Node::Punct(';')),
];
@@ -180,6 +190,48 @@ mod tests {
bad("into __rdbms_playground_relationships (a) values (1)");
}
#[test]
fn select_row_source() {
// 3c: the row source is a Choice between VALUES and a
// SELECT compound (which itself admits a leading WITH).
good("into archive select * from orders");
good("into archive select * from orders where created < '2025-01-01'");
good("into archive select * from orders;");
}
#[test]
fn select_row_source_with_column_list() {
good("into target (a, b) select x, y from source");
good("into target (id) select id from source");
}
#[test]
fn with_prefixed_select_row_source() {
// R4 invariant: a WITH-prefixed SELECT row source parses
// through SQL_SELECT_COMPOUND's WITH-prefixed branch.
good("into archive with t as (select * from orders) select * from t");
good(
"into summary (id, total) with t as (select * from orders) \
select id, total from t",
);
}
#[test]
fn select_row_source_rejects_internal_from_table() {
// DA gate: the SELECT's FROM slot must still reject
// `__rdbms_*` tables (Phase-2 gate, not silently dropped on
// the DML path).
bad("into archive select * from __rdbms_playground_columns");
}
#[test]
fn incomplete_select_row_source_rejected() {
// A bare `select` with no projection is not a complete row
// source.
bad("into archive select");
bad("into archive select * from");
}
#[test]
fn structurally_incomplete_or_wrong_rejected() {
// Missing VALUES.
+18
View File
@@ -3915,6 +3915,24 @@ mod tests {
);
}
#[test]
fn insert_select_unknown_projection_column_is_error() {
// ADR-0033 sub-phase 3c cross-cut: the Phase-2
// schema-existence pass fires on a SELECT row source
// embedded in an INSERT (no re-implementation needed).
// `nonexistent_col` is not a column of `a`.
let schema = two_table_schema();
let diags = diag_keys(
"sqlinsert into b select nonexistent_col from a",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("no such column")),
"expected unknown_column on the INSERT…SELECT projection; \
got {diags:?}",
);
}
#[test]
fn cte_name_is_valid_table_source() {
let schema = schema_with("base", &[("id", Type::Int)]);