grammar+walker: 3j — shared insert/update/delete entry words (ADR-0033 §2 / Amendments 1 & 3)
Wire `insert`/`update`/`delete` as shared DSL/SQL entry words through the
category-grouped dispatcher (ADR-0033 Amendment 1): the Advanced SQL nodes
move off the dev words (`sqlinsert`/`sql_update`/`sql_delete`) to the real
keywords, registered alongside the Simple DSL nodes. Remove the dev-word
scaffold; collapse build_sql_{insert,update,delete} to source.trim();
de-duplicate the two REGISTRY entry-word listing sites.
Dispatch model (ADR-0033 Amendment 3, written this round):
- A command is the mode-rooted grammar-path outcome; identity is intrinsic.
Advanced mode tries SQL first, falling back to the Simple DSL command when
no SQL branch matches a token (`delete … --all-rows` falls back;
`update … --all-rows` does not — the SET expression absorbs it, harmless
since the engine treats `--all-rows` as a comment).
- Simple mode commits the DSL candidate for a shared word, surfacing the real
DSL error; bare "this is SQL" is reserved for SQL-only entry words
(`select`/`with`). A content rejection on the SQL candidate (internal
table) is committed, never masked by the DSL fallback.
Combined DSL-error + advanced-SQL pointer (ADR-0033 Amendment 3): a Simple-mode
definite DSL error that would run as SQL in advanced mode gains the
`advanced_mode.also_valid_sql` suffix — in the live hint (ambient_hint_in_mode)
and on submit (dispatch_dsl), via the shared advanced_alternative_note — so the
actionable DSL fix and the mode pointer coexist (submit covers constructs that
surface only on submit, e.g. `delete … returning`).
Internal-table rejection symmetrised (/runda finding B, ADR-0030 §6): the DSL
data-command target slots (insert/update/delete/show data/show table) gained
reject_internal_table, so `__rdbms_*` tables are refused in Simple mode too —
previously only the advanced SQL grammar rejected them.
Mode-awareness: classify_input_with_schema_in_mode and
invalid_ident_at_cursor_in_mode stop leaking the advanced SQL view into
simple-mode hints for shared words.
Tests: dev-word inputs migrated to the real words (advanced); DSL grammar /
completion / phase-D / db tests parse in Simple mode (the DSL surface); replay
keeps its advanced-mode model (one stale assertion fixed); dispatcher routing,
combined-pointer, and internal-table tests added. Suite 1626 pass / 0 fail /
1 ignored; clippy --all-targets -D warnings clean.
Defer M4 (execution-time mode side-channel; tracked in requirements.md) to its
own ADR.
This commit is contained in:
+146
-106
@@ -293,12 +293,18 @@ pub fn completion_probe_in_mode(
|
||||
use crate::dsl::grammar::{REGISTRY, is_advanced_only};
|
||||
|
||||
let mode_filtered_entries = || -> Vec<outcome::Expectation> {
|
||||
// De-duplicate shared entry words (sub-phase 3j): `insert` /
|
||||
// `update` / `delete` each appear twice in `REGISTRY` (a
|
||||
// `Simple` DSL node and an `Advanced` SQL node), but the
|
||||
// entry word should be offered to the user only once.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
REGISTRY
|
||||
.iter()
|
||||
.filter(|(c, _)| {
|
||||
mode == crate::mode::Mode::Advanced
|
||||
|| !is_advanced_only(c.entry.primary)
|
||||
})
|
||||
.filter(|(c, _)| seen.insert(c.entry.primary))
|
||||
.map(|(c, _)| outcome::Expectation::Word(c.entry.primary))
|
||||
.collect()
|
||||
};
|
||||
@@ -2096,12 +2102,17 @@ pub fn expected_at_input_in_mode(
|
||||
use crate::dsl::grammar::{REGISTRY, is_advanced_only};
|
||||
|
||||
let mode_filtered = || -> Vec<outcome::Expectation> {
|
||||
// De-duplicate shared entry words (sub-phase 3j): `insert` /
|
||||
// `update` / `delete` each appear twice in `REGISTRY` (Simple
|
||||
// DSL + Advanced SQL nodes); offer the word once.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
REGISTRY
|
||||
.iter()
|
||||
.filter(|(c, _)| {
|
||||
mode == crate::mode::Mode::Advanced
|
||||
|| !is_advanced_only(c.entry.primary)
|
||||
})
|
||||
.filter(|(c, _)| seen.insert(c.entry.primary))
|
||||
.map(|(c, _)| outcome::Expectation::Word(c.entry.primary))
|
||||
.collect()
|
||||
};
|
||||
@@ -2353,27 +2364,25 @@ fn decide(
|
||||
|
||||
match mode {
|
||||
crate::mode::Mode::Simple => {
|
||||
let Some(&(sidx, snode)) = simple.first() else {
|
||||
// No DSL candidate — the entry word is SQL-only.
|
||||
let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
|
||||
return Decision::ThisIsSql { primary };
|
||||
};
|
||||
if advanced.is_empty() {
|
||||
return Decision::Commit { idx: sidx, node: snode };
|
||||
// Simple mode is the DSL surface (ADR-0003). A shared
|
||||
// entry word (`insert`/`update`/`delete`) commits its DSL
|
||||
// candidate so the user sees DSL completion and the *real*
|
||||
// DSL error — with its position — rather than a bare
|
||||
// "this is SQL" that discards the actionable detail
|
||||
// (ADR-0033 Amendment 3). "This is SQL" is reserved for
|
||||
// entry words with no DSL form (`select` / `with`): there
|
||||
// the DSL surface has nothing to offer. For a DSL line
|
||||
// that fails here but *would* run in advanced mode, a
|
||||
// "(valid as SQL in advanced mode)" pointer is appended at
|
||||
// the rendering layer (see `advanced_alternative_note`),
|
||||
// combining the DSL fix with the mode hint.
|
||||
match simple.first() {
|
||||
Some(&(sidx, snode)) => Decision::Commit { idx: sidx, node: snode },
|
||||
None => {
|
||||
let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
|
||||
Decision::ThisIsSql { primary }
|
||||
}
|
||||
}
|
||||
// Shared entry word: prefer the DSL node; only point
|
||||
// at advanced mode when the DSL shape does not match
|
||||
// but the SQL shape does.
|
||||
if scratch_full_match(effective_source, kw_start, kw_end, snode, mode, schema) {
|
||||
return Decision::Commit { idx: sidx, node: snode };
|
||||
}
|
||||
let (_, anode) = advanced[0];
|
||||
if scratch_full_match(effective_source, kw_start, kw_end, anode, mode, schema) {
|
||||
return Decision::ThisIsSql {
|
||||
primary: anode.entry.primary,
|
||||
};
|
||||
}
|
||||
Decision::Commit { idx: sidx, node: snode }
|
||||
}
|
||||
crate::mode::Mode::Advanced => {
|
||||
// Advanced candidates first, DSL as the fallback.
|
||||
@@ -2385,13 +2394,28 @@ fn decide(
|
||||
let (idx, node) = ordered[0];
|
||||
return Decision::Commit { idx, node };
|
||||
}
|
||||
// Commit the first candidate that fully matches OR is
|
||||
// rejected by a content validator. A `ValidationFailed`
|
||||
// means the shape *fits* but the content is invalid (e.g.
|
||||
// an internal `__rdbms_*` target rejected by
|
||||
// `reject_internal_table`); that error must surface rather
|
||||
// than being masked by falling back to a candidate that
|
||||
// lacks the validator (sub-phase 3j — the DSL `insert`
|
||||
// node has no internal-table rail). A structural
|
||||
// `Mismatch` (e.g. the DSL-only `--all-rows` the SQL shape
|
||||
// can't consume) is *not* committed here, so the fallback
|
||||
// to the DSL node still works.
|
||||
for &(idx, node) in &ordered {
|
||||
if scratch_full_match(effective_source, kw_start, kw_end, node, mode, schema) {
|
||||
if matches!(
|
||||
scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema),
|
||||
WalkOutcome::Match { .. } | WalkOutcome::ValidationFailed { .. }
|
||||
) {
|
||||
return Decision::Commit { idx, node };
|
||||
}
|
||||
}
|
||||
// None fully matched — commit the furthest-progress
|
||||
// candidate, keeping the first (advanced) on ties.
|
||||
// None matched or content-rejected — commit the
|
||||
// furthest-progress candidate, keeping the first
|
||||
// (advanced) on ties.
|
||||
let mut best = ordered[0];
|
||||
let mut best_progress =
|
||||
scratch_progress(effective_source, kw_start, kw_end, best.1, mode, schema);
|
||||
@@ -2466,22 +2490,6 @@ fn scratch_outcome(
|
||||
result.outcome
|
||||
}
|
||||
|
||||
/// Whether a candidate fully matches the input (a clean
|
||||
/// `WalkOutcome::Match`), tested on a scratch context.
|
||||
fn scratch_full_match(
|
||||
effective_source: &str,
|
||||
kw_start: usize,
|
||||
kw_end: usize,
|
||||
node: &'static crate::dsl::grammar::CommandNode,
|
||||
mode: crate::mode::Mode,
|
||||
schema: Option<&crate::completion::SchemaCache>,
|
||||
) -> bool {
|
||||
matches!(
|
||||
scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema),
|
||||
WalkOutcome::Match { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// How far (byte position) a candidate's walk progressed. A full
|
||||
/// match scores the whole input; a failure scores its failure
|
||||
/// position. Used only to tie-break when no candidate fully
|
||||
@@ -2709,10 +2717,20 @@ mod tests {
|
||||
//! the walker's contract for the migrated commands so
|
||||
//! Phase B-F migrations can refactor without regression.
|
||||
use crate::dsl::command::{AppCommand, Command, MessagesValue, ModeValue};
|
||||
use crate::dsl::parser::parse_command;
|
||||
use crate::dsl::parser::parse_command_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
/// Parse in **Simple mode** — the DSL surface (ADR-0003). The
|
||||
/// tests in this module exercise the DSL grammar (app commands,
|
||||
/// DDL, and the `insert`/`update`/`delete` DSL forms), all
|
||||
/// canonical in Simple mode. Sub-phase 3j made
|
||||
/// `insert`/`update`/`delete` shared entry words (ADR-0033 §2,
|
||||
/// Amendment 3), so parsing them in Advanced mode would route the
|
||||
/// overlap to the SQL command variants; the SQL surface is
|
||||
/// validated through the advanced-mode diagnostic helpers
|
||||
/// (`diag_keys` / `diagnostics_advanced`) and `tests/sql_*.rs`.
|
||||
fn parse(input: &str) -> Result<Command, crate::dsl::ParseError> {
|
||||
parse_command(input)
|
||||
parse_command_in_mode(input, Mode::Simple)
|
||||
}
|
||||
|
||||
// ---- Bare no-arg commands ---------------------------------
|
||||
@@ -3637,11 +3655,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn not_like_on_a_numeric_column_is_also_a_warning() {
|
||||
// The `LIKE`-on-numeric predicate warning is a DSL diagnostic
|
||||
// (its sibling `like_on_a_numeric_column_is_a_warning` uses
|
||||
// the Simple-mode `diagnostics` helper). Since sub-phase 3j
|
||||
// made `delete` a shared entry word (ADR-0033 Amendment 3),
|
||||
// this verdict is taken in Simple mode so the input routes to
|
||||
// the DSL `delete` and its predicate diagnostics.
|
||||
let schema = schema_with("Orders", &[("Total", Type::Decimal)]);
|
||||
assert_eq!(
|
||||
super::input_verdict(
|
||||
super::input_verdict_in_mode(
|
||||
"delete from Orders where Total not like '9%'",
|
||||
Some(&schema),
|
||||
crate::mode::Mode::Simple,
|
||||
),
|
||||
Some(super::Severity::Warning),
|
||||
);
|
||||
@@ -3939,7 +3964,19 @@ mod tests {
|
||||
// =========================================================
|
||||
|
||||
use crate::completion::{SchemaCache, TableColumn};
|
||||
use crate::dsl::parser::parse_command_with_schema;
|
||||
use crate::dsl::parser::parse_command_with_schema_in_mode;
|
||||
|
||||
/// Phase-D typed value slots are the **DSL** surface (Simple
|
||||
/// mode). Sub-phase 3j made `insert`/`update`/`delete` shared
|
||||
/// entry words (ADR-0033 Amendment 3), so these typed-slot tests
|
||||
/// parse in Simple mode to reach the DSL grammar rather than the
|
||||
/// SQL one. Thin wrapper keeps the existing call sites unchanged.
|
||||
fn parse_command_with_schema(
|
||||
input: &str,
|
||||
schema: &SchemaCache,
|
||||
) -> Result<crate::dsl::command::Command, crate::dsl::ParseError> {
|
||||
parse_command_with_schema_in_mode(input, schema, crate::mode::Mode::Simple)
|
||||
}
|
||||
|
||||
fn schema_with(table: &str, columns: &[(&str, Type)]) -> SchemaCache {
|
||||
let cols: Vec<TableColumn> = columns
|
||||
@@ -4522,7 +4559,7 @@ mod tests {
|
||||
// list (the target isn't a binding, so a targeted pass covers
|
||||
// it).
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
|
||||
let diags = diag_keys("sqlinsert into t (nonexistent) values (1)", &schema);
|
||||
let diags = diag_keys("insert into t (nonexistent) values (1)", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such column")),
|
||||
"unknown INSERT column should be flagged; got {diags:?}",
|
||||
@@ -4537,7 +4574,7 @@ mod tests {
|
||||
// SELECT's source tables — otherwise the flat-scope
|
||||
// bare-column branch falsely flags it against `b`.
|
||||
let schema = two_table_schema(); // a(id,name), b(id,total)
|
||||
let diags = diag_keys("sqlinsert into a (name) select total from b", &schema);
|
||||
let diags = diag_keys("insert into a (name) select total from b", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("no such column")),
|
||||
"target column `name` must not be flagged against the SELECT source; got {diags:?}",
|
||||
@@ -4552,7 +4589,7 @@ mod tests {
|
||||
// against `b`.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into a (id) select id from b on conflict (name) do nothing",
|
||||
"insert into a (id) select id from b on conflict (name) do nothing",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -4568,7 +4605,7 @@ mod tests {
|
||||
// resolves to the target `a`, not the SELECT source `b`.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into a (id) select id from b on conflict (id) do update set name = name",
|
||||
"insert into a (id) select id from b on conflict (id) do update set name = name",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -4584,7 +4621,7 @@ mod tests {
|
||||
// against the target). Covers the SET RHS and the WHERE.
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
|
||||
let set_rhs = diag_keys(
|
||||
"sqlinsert into t (a, b) values (1, 'x') on conflict (a) do update set b = nope",
|
||||
"insert into t (a, b) values (1, 'x') on conflict (a) do update set b = nope",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -4592,7 +4629,7 @@ mod tests {
|
||||
"unknown DO UPDATE SET RHS ref should be flagged; got {set_rhs:?}",
|
||||
);
|
||||
let where_ref = diag_keys(
|
||||
"sqlinsert into t (a, b) values (1, 'x') on conflict (a) do update set b = 'y' where nope = 1",
|
||||
"insert into t (a, b) values (1, 'x') on conflict (a) do update set b = 'y' where nope = 1",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -4608,7 +4645,7 @@ mod tests {
|
||||
// target-only column must resolve to the target `a`, not be
|
||||
// flagged against the SELECT source `b`.
|
||||
let schema = two_table_schema(); // a(id,name), b(id,total)
|
||||
let diags = diag_keys("sqlinsert into a (id) select id from b returning name", &schema);
|
||||
let diags = diag_keys("insert into a (id) select id from b returning name", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("no such column")),
|
||||
"RETURNING `name` (a's column) must not flag against `b`; got {diags:?}",
|
||||
@@ -4621,7 +4658,7 @@ mod tests {
|
||||
// qualified star) in an INSERT … SELECT resolves to the
|
||||
// target `a`, not flagged against the SELECT source `b`.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys("sqlinsert into a (id) select id from b returning a.*", &schema);
|
||||
let diags = diag_keys("insert into a (id) select id from b returning a.*", &schema);
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"target-qualified `a.*` in RETURNING must resolve cleanly; got {diags:?}",
|
||||
@@ -4631,7 +4668,7 @@ mod tests {
|
||||
#[test]
|
||||
fn unrelated_qualified_star_in_returning_still_flagged() {
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys("sqlinsert into a (id) select id from b returning zzz.*", &schema);
|
||||
let diags = diag_keys("insert into a (id) select id from b returning zzz.*", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such table or alias")),
|
||||
"unrelated `zzz.*` qualifier should still flag; got {diags:?}",
|
||||
@@ -4645,7 +4682,7 @@ mod tests {
|
||||
// target `a`, not flagged as an unknown qualifier (a is the
|
||||
// target, b is the SELECT source).
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys("sqlinsert into a (id) select id from b returning a.name", &schema);
|
||||
let diags = diag_keys("insert into a (id) select id from b returning a.name", &schema);
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"target-qualified RETURNING ref must resolve cleanly; got {diags:?}",
|
||||
@@ -4656,7 +4693,7 @@ mod tests {
|
||||
fn target_qualified_ref_unknown_column_still_flagged() {
|
||||
// `a.nope` — a is the target but nope isn't its column.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys("sqlinsert into a (id) select id from b returning a.nope", &schema);
|
||||
let diags = diag_keys("insert into a (id) select id from b returning a.nope", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such column")),
|
||||
"unknown column under the target qualifier should flag; got {diags:?}",
|
||||
@@ -4668,7 +4705,7 @@ mod tests {
|
||||
// A qualifier that's neither `excluded` nor the target is
|
||||
// still an unknown qualifier (the leak guard holds).
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys("sqlinsert into a (id) select id from b returning zzz.name", &schema);
|
||||
let diags = diag_keys("insert into a (id) select id from b returning zzz.name", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such table or alias")),
|
||||
"unrelated qualifier should still flag; got {diags:?}",
|
||||
@@ -4680,7 +4717,7 @@ mod tests {
|
||||
// Flip side: a RETURNING ref to a column on neither table is
|
||||
// flagged (against the INSERT target).
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys("sqlinsert into a (id) select id from b returning nope", &schema);
|
||||
let diags = diag_keys("insert into a (id) select id from b returning nope", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such column")),
|
||||
"unknown RETURNING column should be flagged; got {diags:?}",
|
||||
@@ -4692,9 +4729,9 @@ mod tests {
|
||||
// The VALUES INSERT … RETURNING case (no bindings at all):
|
||||
// a valid target column is silent, an unknown one flags.
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
|
||||
let ok = diag_keys("sqlinsert into t (a) values (1) returning b", &schema);
|
||||
let ok = diag_keys("insert into t (a) values (1) returning b", &schema);
|
||||
assert!(!ok.iter().any(|d| d.contains("no such column")), "got {ok:?}");
|
||||
let bad = diag_keys("sqlinsert into t (a) values (1) returning nope", &schema);
|
||||
let bad = diag_keys("insert into t (a) values (1) returning nope", &schema);
|
||||
assert!(bad.iter().any(|d| d.contains("no such column")), "got {bad:?}");
|
||||
}
|
||||
|
||||
@@ -4703,7 +4740,7 @@ mod tests {
|
||||
// And a valid bare ref to a target column is silent.
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into t (a, b) values (1, 'x') on conflict (a) do update set b = 'y' where a > 0",
|
||||
"insert into t (a, b) values (1, 'x') on conflict (a) do update set b = 'y' where a > 0",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -4718,7 +4755,7 @@ mod tests {
|
||||
// neither table is flagged (against the INSERT target).
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into a (id) values (1) on conflict (nope) do nothing",
|
||||
"insert into a (id) values (1) on conflict (nope) do nothing",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -4732,7 +4769,7 @@ mod tests {
|
||||
// The flip side: a column in neither the target nor the source
|
||||
// is still flagged (against the target, by the dedicated pass).
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys("sqlinsert into a (nope) select total from b", &schema);
|
||||
let diags = diag_keys("insert into a (nope) select total from b", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such column")),
|
||||
"a genuinely unknown INSERT column should still be flagged; got {diags:?}",
|
||||
@@ -4742,7 +4779,7 @@ mod tests {
|
||||
#[test]
|
||||
fn insert_column_list_known_columns_silent() {
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
|
||||
let diags = diag_keys("sqlinsert into t (a, b) values (1, 'x')", &schema);
|
||||
let diags = diag_keys("insert into t (a, b) values (1, 'x')", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("no such column")),
|
||||
"known columns must not flag; got {diags:?}",
|
||||
@@ -4755,7 +4792,7 @@ mod tests {
|
||||
// SET column — an unknown DO UPDATE SET column is flagged.
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into t (a, b) values (1, 'x') on conflict (a) do update set nosuch = 1",
|
||||
"insert into t (a, b) values (1, 'x') on conflict (a) do update set nosuch = 1",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -4768,7 +4805,7 @@ mod tests {
|
||||
fn upsert_do_update_known_set_column_silent() {
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into t (a, b) values (1, 'x') on conflict (a) do update set b = excluded.b",
|
||||
"insert into t (a, b) values (1, 'x') on conflict (a) do update set b = excluded.b",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -4782,7 +4819,7 @@ mod tests {
|
||||
// 3i cross-cut: the Phase-2 predicate-warning pass fires on a
|
||||
// DML SET/WHERE sql_expr slot (here `= NULL` in an UPDATE).
|
||||
let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Int)]);
|
||||
let diags = diag_keys("sql_update t set v = 1 where v = NULL", &schema);
|
||||
let diags = diag_keys("update t set v = 1 where v = NULL", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("IS NULL")),
|
||||
"eq_null should fire on the UPDATE WHERE; got {diags:?}",
|
||||
@@ -4796,7 +4833,7 @@ mod tests {
|
||||
"t",
|
||||
&[("a", Type::Int, false, false), ("b", Type::Text, true, false)],
|
||||
);
|
||||
let diags = diag_keys("sqlinsert into t (a) values (1)", &schema);
|
||||
let diags = diag_keys("insert into t (a) values (1)", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("is required")),
|
||||
"omitting NOT NULL `b` should warn; got {diags:?}",
|
||||
@@ -4809,7 +4846,7 @@ mod tests {
|
||||
"t",
|
||||
&[("a", Type::Int, false, false), ("b", Type::Text, true, false)],
|
||||
);
|
||||
let diags = diag_keys("sqlinsert into t (a, b) values (1, 'x')", &schema);
|
||||
let diags = diag_keys("insert into t (a, b) values (1, 'x')", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("is required")),
|
||||
"including `b` must not warn; got {diags:?}",
|
||||
@@ -4823,7 +4860,7 @@ mod tests {
|
||||
"t",
|
||||
&[("a", Type::Int, false, false), ("b", Type::Text, true, true)],
|
||||
);
|
||||
let diags = diag_keys("sqlinsert into t (a) values (1)", &schema);
|
||||
let diags = diag_keys("insert into t (a) values (1)", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("is required")),
|
||||
"a defaulted column must not warn; got {diags:?}",
|
||||
@@ -4838,7 +4875,7 @@ mod tests {
|
||||
"t",
|
||||
&[("id", Type::Serial, true, false), ("b", Type::Text, false, false)],
|
||||
);
|
||||
let diags = diag_keys("sqlinsert into t (b) values ('x')", &schema);
|
||||
let diags = diag_keys("insert into t (b) values ('x')", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("is required")),
|
||||
"auto-gen serial must not warn; got {diags:?}",
|
||||
@@ -4848,7 +4885,7 @@ mod tests {
|
||||
#[test]
|
||||
fn insert_arity_mismatch_single_row_fires() {
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
|
||||
let diags = diag_keys("sqlinsert into t (a, b) values (1, 2, 3)", &schema);
|
||||
let diags = diag_keys("insert into t (a, b) values (1, 2, 3)", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("value(s) are given")),
|
||||
"3 values for 2 columns should fire; got {diags:?}",
|
||||
@@ -4858,7 +4895,7 @@ mod tests {
|
||||
#[test]
|
||||
fn insert_arity_match_is_silent() {
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
|
||||
let diags = diag_keys("sqlinsert into t (a, b) values (1, 2)", &schema);
|
||||
let diags = diag_keys("insert into t (a, b) values (1, 2)", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("value(s) are given")),
|
||||
"matched arity must not fire; got {diags:?}",
|
||||
@@ -4871,7 +4908,7 @@ mod tests {
|
||||
// diagnostic; the matched rows stay silent.
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into t (a, b) values (1, 2), (3, 4, 5), (6), (7, 8)",
|
||||
"insert into t (a, b) values (1, 2), (3, 4, 5), (6), (7, 8)",
|
||||
&schema,
|
||||
);
|
||||
let n = diags.iter().filter(|d| d.contains("value(s) are given")).count();
|
||||
@@ -4885,7 +4922,7 @@ mod tests {
|
||||
// tuple mismatch still fires; the conflict target never does.
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into t (a, b) values (1, 2, 3) on conflict (a) do nothing",
|
||||
"insert into t (a, b) values (1, 2, 3) on conflict (a) do nothing",
|
||||
&schema,
|
||||
);
|
||||
let n = diags.iter().filter(|d| d.contains("value(s) are given")).count();
|
||||
@@ -4898,7 +4935,7 @@ mod tests {
|
||||
// silent (the conflict target isn't counted as a tuple).
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into t (a, b) values (1, 2) on conflict (a) do update set b = excluded.b",
|
||||
"insert into t (a, b) values (1, 2) on conflict (a) do update set b = excluded.b",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -4913,7 +4950,7 @@ mod tests {
|
||||
// the walk must stop there (same guard as ON CONFLICT) so the
|
||||
// RETURNING projection isn't mis-counted as a value tuple.
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
|
||||
let diags = diag_keys("sqlinsert into t (a, b) values (1, 2, 3) returning *", &schema);
|
||||
let diags = diag_keys("insert into t (a, b) values (1, 2, 3) returning *", &schema);
|
||||
let n = diags.iter().filter(|d| d.contains("value(s) are given")).count();
|
||||
assert_eq!(n, 1, "only the 3-value tuple is flagged; got {diags:?}");
|
||||
}
|
||||
@@ -4923,7 +4960,7 @@ mod tests {
|
||||
// A comma inside a function call (depth ≥ 2) is not a tuple
|
||||
// separator and must not inflate the value count.
|
||||
let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
|
||||
let diags = diag_keys("sqlinsert into t (a, b) values (1, coalesce(1, 2))", &schema);
|
||||
let diags = diag_keys("insert into t (a, b) values (1, coalesce(1, 2))", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("value(s) are given")),
|
||||
"two values (one a 2-arg call) match the 2-col list; got {diags:?}",
|
||||
@@ -4934,7 +4971,7 @@ mod tests {
|
||||
fn insert_select_arity_mismatch_fires() {
|
||||
// INSERT … SELECT: 1 listed column vs a 2-item projection.
|
||||
let schema = two_table_schema(); // a(id,name), b(id,total)
|
||||
let diags = diag_keys("sqlinsert into a (id) select id, total from b", &schema);
|
||||
let diags = diag_keys("insert into a (id) select id, total from b", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("value(s) are given")),
|
||||
"2-col projection into 1-col list should fire; got {diags:?}",
|
||||
@@ -4944,7 +4981,7 @@ mod tests {
|
||||
#[test]
|
||||
fn insert_select_arity_match_is_silent() {
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys("sqlinsert into a (id, name) select id, total from b", &schema);
|
||||
let diags = diag_keys("insert into a (id, name) select id, total from b", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("value(s) are given")),
|
||||
"matched projection arity must not fire; got {diags:?}",
|
||||
@@ -4955,7 +4992,7 @@ mod tests {
|
||||
fn auto_column_overridden_fires_on_explicit_serial() {
|
||||
// ADR-0033 §8.2: listing a serial column explicitly warns.
|
||||
let schema = schema_with("t", &[("id", Type::Serial), ("name", Type::Text)]);
|
||||
let diags = diag_keys("sqlinsert into t (id, name) values (5, 'x')", &schema);
|
||||
let diags = diag_keys("insert into t (id, name) values (5, 'x')", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("auto-generated")),
|
||||
"explicit serial value should warn; got {diags:?}",
|
||||
@@ -4965,7 +5002,7 @@ mod tests {
|
||||
#[test]
|
||||
fn auto_column_overridden_fires_on_explicit_shortid() {
|
||||
let schema = schema_with("t", &[("id", Type::ShortId), ("name", Type::Text)]);
|
||||
let diags = diag_keys("sqlinsert into t (id, name) values ('abc', 'x')", &schema);
|
||||
let diags = diag_keys("insert into t (id, name) values ('abc', 'x')", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("auto-generated")),
|
||||
"explicit shortid value should warn; got {diags:?}",
|
||||
@@ -4976,7 +5013,7 @@ mod tests {
|
||||
fn auto_column_overridden_silent_when_omitted() {
|
||||
// Negative: omitting the auto-gen column does not warn.
|
||||
let schema = schema_with("t", &[("id", Type::Serial), ("name", Type::Text)]);
|
||||
let diags = diag_keys("sqlinsert into t (name) values ('x')", &schema);
|
||||
let diags = diag_keys("insert into t (name) values ('x')", &schema);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("auto-generated")),
|
||||
"omitting the serial column must not warn; got {diags:?}",
|
||||
@@ -4990,7 +5027,7 @@ mod tests {
|
||||
// only the inserted `id` in the column list would.
|
||||
let schema = schema_with("t", &[("id", Type::Serial), ("name", Type::Text)]);
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into t (name) values ('x') on conflict (id) do nothing",
|
||||
"insert into t (name) values ('x') on conflict (id) do nothing",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -5005,7 +5042,7 @@ mod tests {
|
||||
// resolves to the target table's columns — no diagnostic.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into a (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
|
||||
"insert into a (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
|
||||
&schema,
|
||||
);
|
||||
assert!(diags.is_empty(), "excluded.name should resolve in DO UPDATE; got {diags:?}");
|
||||
@@ -5017,7 +5054,7 @@ mod tests {
|
||||
// `excluded` resolves there too (not just in the SET RHS).
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into a (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id = excluded.id",
|
||||
"insert into a (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id = excluded.id",
|
||||
&schema,
|
||||
);
|
||||
assert!(diags.is_empty(), "excluded in DO UPDATE WHERE should resolve; got {diags:?}");
|
||||
@@ -5029,7 +5066,7 @@ mod tests {
|
||||
// under it is still an unknown_column error.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into a (id, name) values (1, 'x') on conflict (id) do update set name = excluded.nosuch",
|
||||
"insert into a (id, name) values (1, 'x') on conflict (id) do update set name = excluded.nosuch",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -5044,7 +5081,7 @@ mod tests {
|
||||
// in scope) has no meaning and must be flagged.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into a (id, name) values (excluded.name, 'x')",
|
||||
"insert into a (id, name) values (excluded.name, 'x')",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -5060,7 +5097,7 @@ mod tests {
|
||||
// resolves). The byte-range scoping must distinguish them.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"sqlinsert into a (id, name) values (excluded.id, 'x') on conflict (id) do update set name = excluded.name",
|
||||
"insert into a (id, name) values (excluded.id, 'x') on conflict (id) do update set name = excluded.name",
|
||||
&schema,
|
||||
);
|
||||
// Exactly the VALUES-side `excluded.id` is flagged; the
|
||||
@@ -5083,7 +5120,7 @@ mod tests {
|
||||
// `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",
|
||||
"insert into b select nonexistent_col from a",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
@@ -5098,7 +5135,7 @@ mod tests {
|
||||
// ADR-0033 sub-phase 3e cross-cut: the schema-existence
|
||||
// pass fires on the SET assignment column.
|
||||
let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Text)]);
|
||||
let diags = diag_keys("sql_update t set nonexistent = 1 where id = 1", &schema);
|
||||
let diags = diag_keys("update t set nonexistent = 1 where id = 1", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such column")),
|
||||
"expected unknown_column on the SET column; got {diags:?}",
|
||||
@@ -5110,7 +5147,7 @@ mod tests {
|
||||
// ADR-0033 sub-phase 3e cross-cut: the predicate-warning
|
||||
// pass fires on `= NULL` in an UPDATE's WHERE.
|
||||
let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Int)]);
|
||||
let diags = diag_keys("sql_update t set v = 1 where v = NULL", &schema);
|
||||
let diags = diag_keys("update t set v = 1 where v = NULL", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("IS NULL")),
|
||||
"expected eq_null warning on the WHERE; got {diags:?}",
|
||||
@@ -5937,26 +5974,29 @@ mod dispatch_3a_tests {
|
||||
assert_eq!(cmd, Some(Command::App(AppCommand::Quit)));
|
||||
}
|
||||
|
||||
// ---- Exit-gate case 3: Simple + SQL-only input →
|
||||
// ValidationFailed advanced_mode.sql_in_simple ----------
|
||||
// ---- Exit-gate case 3: Simple + SQL-shaped input on a SHARED
|
||||
// word commits the DSL node (ADR-0033 Amendment 3) --------
|
||||
|
||||
#[test]
|
||||
fn simple_mode_sql_only_input_is_this_is_sql() {
|
||||
// Shared word, but the input matches only the SQL tail.
|
||||
fn simple_mode_shared_word_commits_dsl_even_for_sql_input() {
|
||||
// A shared word (DSL + SQL) in simple mode always commits its
|
||||
// DSL candidate, even when the input matches only the SQL
|
||||
// tail. The DSL grammar then rejects the SQL-only tail with a
|
||||
// normal parse error (a `Mismatch`), surfacing the actionable
|
||||
// DSL detail; the "(valid as SQL in advanced mode)" pointer is
|
||||
// added at the rendering layer, not here. "This is SQL" as a
|
||||
// dispatch outcome is reserved for entry words with no DSL
|
||||
// form (see `simple_mode_sql_only_entry_word_is_this_is_sql`).
|
||||
let cands = shared();
|
||||
match run_decide("smk sqltail", Mode::Simple, &cands) {
|
||||
Decision::ThisIsSql { primary } => assert_eq!(primary, "smk"),
|
||||
Decision::Commit { idx, .. } => {
|
||||
panic!("expected ThisIsSql, got Commit {{ idx: {idx} }}")
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
std::ptr::eq(committed_node("smk sqltail", Mode::Simple, &cands), &SMOKE_DSL),
|
||||
"simple mode must commit the DSL node for a shared word",
|
||||
);
|
||||
let (outcome, cmd) = dispatch("smk sqltail", Mode::Simple, &cands);
|
||||
match outcome {
|
||||
WalkOutcome::ValidationFailed { error, .. } => {
|
||||
assert_eq!(error.message_key, "advanced_mode.sql_in_simple");
|
||||
}
|
||||
other => panic!("expected ValidationFailed, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
matches!(outcome, WalkOutcome::Mismatch { .. }),
|
||||
"the DSL grammar rejects the SQL-only tail; got {outcome:?}",
|
||||
);
|
||||
assert_eq!(cmd, None);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user