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:
@@ -1270,6 +1270,192 @@ parity is preserved. The user confirmed this deferral.
|
||||
- The handoff-31 §4 "WHERE-byte-extraction is tractable for DELETE"
|
||||
heads-up is moot — no extraction happens.
|
||||
|
||||
## Amendment 3 — Command identity is intrinsic; execution-mode is side-channel (deferred) (2026-05-23)
|
||||
|
||||
This amendment **clarifies the command-identity model** that §2 and
|
||||
Amendment 1 implement, **corrects a false premise in the
|
||||
implementation plan's sub-phase 3j exit gate**, and **records a
|
||||
deferred follow-up** (the execution-time mode side-channel). It was
|
||||
written during sub-phase 3j — wiring the shared `insert` / `update` /
|
||||
`delete` entry words — after the dispatch model's interaction with the
|
||||
existing test suite surfaced a question about what a "command" *is* and
|
||||
how input mode relates to it. Recorded with explicit user approval
|
||||
before any 3j code landed.
|
||||
|
||||
### The model — a command is a mode-rooted grammar-path outcome
|
||||
|
||||
A command is the typed outcome of a grammar path **rooted at the input
|
||||
mode**: `simple → update → …` or `advanced → update → …`. Its identity
|
||||
is **intrinsic** — determined by *which grammar matched*, not by a mode
|
||||
flag carried on one shared command type. In Advanced mode the
|
||||
dispatcher (Amendment 1) tries the Advanced (SQL) candidate(s) first and
|
||||
**falls back to the Simple (DSL) candidate** when no Advanced branch
|
||||
recognizes a token; the fallback produces the **Simple** command.
|
||||
|
||||
Worked example (the `--all-rows` fall-through): `delete from T
|
||||
--all-rows` in Advanced mode — no Advanced (SQL) branch recognizes
|
||||
`--all-rows` (the SQL `DELETE` has no expression slot after the table
|
||||
to absorb it), so the path falls back to `simple → delete → … →
|
||||
all-rows` and yields `Command::Delete { filter: AllRows }` (the DSL
|
||||
command), exactly as it would in Simple mode. The mode rooted the path;
|
||||
the command that came out is the Simple one.
|
||||
|
||||
*Counter-example (no fall-through):* `update T set x = 42 --all-rows`
|
||||
in Advanced mode does **not** fall back — the SQL `UPDATE`'s
|
||||
`SET <expr>` greedily consumes `--all-rows` as the expression
|
||||
`42 - -all - rows` (with `all`/`rows` as column refs), so the SQL shape
|
||||
matches and `Command::SqlUpdate` is produced. Whether `--all-rows` is a
|
||||
DSL flag or part of a SQL expression is decided by which grammar wins,
|
||||
not by special-casing the token. (At execution this is harmless: the
|
||||
engine treats `--all-rows` as a SQL line comment, so the statement runs
|
||||
as `update T set x = 42` — all rows — the same effect as the DSL flag.)
|
||||
|
||||
### Simple mode commits the DSL candidate; an advanced-mode pointer combines
|
||||
|
||||
In **Simple mode** a shared entry word **always commits its DSL
|
||||
candidate**, so the user sees DSL completion and the *real* DSL error
|
||||
(with its position) — not a bare "this is SQL" that discards the
|
||||
actionable detail. A plain `ValidationFailed`/`Mismatch` "this is SQL"
|
||||
dispatch outcome is reserved for entry words that have **no** DSL form
|
||||
(`select` / `with`); for those the DSL surface has nothing to offer, so
|
||||
the simple-mode gate points at advanced mode (ADR-0030 §2).
|
||||
|
||||
To keep the SQL-discoverability the original §2 envisioned *without*
|
||||
losing the DSL fix, the rendering layer **combines** them: when a
|
||||
simple-mode line is a *definite* DSL error (not merely incomplete) and
|
||||
the same line would parse in advanced mode, the DSL error prose is
|
||||
suffixed with the `advanced_mode.also_valid_sql` pointer — e.g.
|
||||
`for \`Name\`: Type a quoted string … (valid as SQL in advanced mode —
|
||||
\`mode advanced\` or prefix \`:\`)`. The DSL detail and the mode hint
|
||||
coexist (`input_render::ambient_hint_in_mode` /
|
||||
`advanced_alternative_note`). Mid-typing (incomplete) input is not
|
||||
suffixed, to avoid noise during normal DSL entry. This supersedes the
|
||||
draft of this amendment's earlier suggestion that a SQL-shaped line in
|
||||
simple mode emits a *bare* "this is SQL" hint for shared words.
|
||||
|
||||
### Overlapping inputs produce two distinct commands — both are tested
|
||||
|
||||
For a **fully-overlapping** shape — `insert into T values (…)` is valid
|
||||
in both grammars — Simple mode yields `Command::Insert` (a typed AST,
|
||||
DSL execution) and Advanced mode yields `Command::SqlInsert` (validated
|
||||
text, SQL execution). This is **correct, not a defect**: the two
|
||||
commands *do the same thing but execute differently* (ADR-0030 §4 — the
|
||||
DSL lowers to a typed AST; SQL is grammar-as-text run verbatim). Because
|
||||
they are distinct commands, **each is tested in the mode that produces
|
||||
it** — DSL grammar tests run in Simple mode; the SQL command variants
|
||||
are tested in Advanced mode.
|
||||
|
||||
### Correction to the plan's 3j exit gate
|
||||
|
||||
The implementation plan's sub-phase 3j exit gate says "all existing DSL
|
||||
`INSERT`/`UPDATE`/`DELETE` tests still green — unmodified inputs,
|
||||
unmodified outputs", and its scope-out says "observable behaviour must
|
||||
be identical before and after 3j". That rested on a **false premise**:
|
||||
that the existing DSL DML tests run in Simple mode. They do not — the
|
||||
common helpers (`ok`/`err`/`parse`) call `parse_command`, which
|
||||
**defaults to Advanced mode**. Today that is harmless because
|
||||
`insert`/`update`/`delete` have no SQL competitor, so they route to the
|
||||
DSL command even in Advanced mode. Once they become **shared** entry
|
||||
words, §2's Advanced-mode **SQL-first** rule routes the overlap to the
|
||||
SQL command (`Command::Sql*`), changing the parsed variant those tests
|
||||
assert.
|
||||
|
||||
The corrected invariant: **Simple-mode behaviour is unchanged; Advanced
|
||||
mode is SQL-first per §2; the DSL grammar is tested in Simple mode (its
|
||||
canonical surface, ADR-0003); both command variants are tested in their
|
||||
producing mode.** No *production* behaviour regresses — the §6 (shortid
|
||||
auto-fill) and §7 (cascade summary) parity promises keep the two paths
|
||||
observably equivalent for overlapping inputs; only the
|
||||
implementation-internal `Command` variant differs, and only in Advanced
|
||||
mode.
|
||||
|
||||
### Replay uses the same parser, in advanced mode — and needs no per-line mode
|
||||
|
||||
A consequence worth stating explicitly, because it is easy to
|
||||
over-think (and a prior reading of this work did). **Replay parses
|
||||
each recorded line with the same schema-aware parser the interactive
|
||||
path uses, in advanced mode** (the full surface) — it skips nothing
|
||||
and simplifies nothing. The only difference from an interactive line
|
||||
is the mode argument: interactive uses the user's current mode,
|
||||
replay fixes it to advanced.
|
||||
|
||||
This is correct and complete **without** the per-line execution-mode
|
||||
side-channel below, because of the §6 (shortid auto-fill) and §7
|
||||
(cascade-summary) parity guarantees: a valid overlapping command
|
||||
produces *identical effects* whether it lowers to the DSL variant or
|
||||
the SQL variant. So replaying a log of valid commands in advanced
|
||||
mode is identical to typing those lines interactively in advanced
|
||||
mode — and, by parity, identical in effect to the simple-mode entry
|
||||
that originally produced them. (Replay only ever sees lines that
|
||||
already executed successfully: `history.log` is success-only, and
|
||||
ADR-0034's deferred journal replays `ok` lines only.)
|
||||
|
||||
The one observable nuance is purely in **error reporting for an
|
||||
invalid line** — which only arises from a hand-built script, never
|
||||
from a real journal. Such a line is still rejected and not applied;
|
||||
*where* the rejection lands just follows the grammar, exactly as
|
||||
interactively: an `insert … values …` line is SQL in advanced mode,
|
||||
so a wrong column-type value is rejected by the engine at execute
|
||||
time rather than by a DSL typed slot at parse time. This is **not**
|
||||
replay using a lesser parser — it is the same advanced-mode parse a
|
||||
user would get typing the line. Replay therefore does **not** depend
|
||||
on the deferred side-channel below.
|
||||
|
||||
### Execution-time mode is side-channel — deferred to its own ADR
|
||||
|
||||
Independent of command *identity*, every command should — at **execution
|
||||
time** — know which of three modes it ran under: `simple`, `advanced`,
|
||||
or `advanced-one-shot` (the `:` escape from Simple mode, ADR-0003), so
|
||||
execution can adjust **output** without changing **identity** (e.g. a
|
||||
Simple-mode `create table` echoing the generated SQL when run in
|
||||
Advanced mode, while staying silent in Simple mode).
|
||||
|
||||
Today this exists only as a **rendering** side-channel
|
||||
(`OutputLine.mode_at_submission`, consumed by the echo-line mode tag in
|
||||
`ui.rs`). The `Mode` enum is two-way (`Simple` / `Advanced`); the
|
||||
one-shot distinction is a transient "effective mode" collapsed at
|
||||
submission; and neither `Action::ExecuteDsl` nor the database worker
|
||||
carries any mode. Wiring an **execution-time** mode side-channel —
|
||||
widening `Mode` to the three-way distinction and threading it through
|
||||
the `Action` → worker interface — is **out of scope for Phase 3** and is
|
||||
**deferred to its own ADR** (user-confirmed). It is *not* required for
|
||||
Phase 3's dispatch to be correct: the routing and the `--all-rows`
|
||||
fall-through above are complete on the two-way mode. This amendment
|
||||
forward-references that future ADR so the requirement is not lost.
|
||||
|
||||
### Consequences of the amendment
|
||||
|
||||
- **No code or grammar change** from this amendment itself — it records
|
||||
the model that the dispatch (Amendment 1) already implements and
|
||||
corrects the plan's test-mode premise.
|
||||
- The 3j test migration **moves the DSL grammar / completion tests to
|
||||
Simple mode** (inputs and `Command::Insert`/`Update`/`Delete`
|
||||
assertions unchanged), **keeps the `--all-rows` fall-through tests in
|
||||
Advanced mode** (they validate the fallback to the Simple command),
|
||||
relies on the migrated `tests/sql_*.rs` for the SQL command variants
|
||||
in Advanced mode, and adds dispatcher routing tests
|
||||
(Advanced + structurally-ambiguous → SQL; Advanced + `--all-rows` →
|
||||
DSL fallback; Simple + a SQL-only *entry word* `select` → "this is
|
||||
SQL"; Simple + a shared word with a SQL-only *construct* → DSL error,
|
||||
carrying the combined pointer at the rendering layer).
|
||||
- The **combined pointer** lives in `input_render::ambient_hint_in_mode`
|
||||
(live typing) and `App::dispatch_dsl` (submit), both via the shared
|
||||
`advanced_alternative_note`, so a Simple-mode definite DSL error that
|
||||
would run as SQL gains the `advanced_mode.also_valid_sql` suffix in
|
||||
both surfaces (the submit path covers SQL constructs that surface
|
||||
only on submit, e.g. `delete … returning`). Found via the `/runda`
|
||||
round.
|
||||
- **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, so
|
||||
a simple-mode DML could touch the internal metadata tables.
|
||||
- The **execution-time mode side-channel + three-way `Mode`** is tracked
|
||||
as a future ADR; `OutputLine.mode_at_submission` remains the only mode
|
||||
side-channel until then. No structure built in 3j assumes mode is
|
||||
irrelevant to execution.
|
||||
|
||||
## See also
|
||||
|
||||
- ADR-0005 — the ten-type vocabulary INSERT works with.
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -120,6 +120,19 @@ handoff-14 cleanup; 449 after B2/C2.)
|
||||
one-shot advanced escape (with the prompt label updated). The
|
||||
`mode simple` / `mode advanced` command switches modes
|
||||
persistently.
|
||||
- [~] **M4** Execution-time mode side-channel — deferred, awaiting
|
||||
its own ADR (ADR-0033 Amendment 3). Every command should know, at
|
||||
execution time, which of three modes it ran under — `simple`,
|
||||
`advanced`, or `advanced-one-shot` (the `:` escape) — so execution
|
||||
can adjust *output* without changing command *identity* (e.g. a
|
||||
simple-mode `create table` echoing the generated SQL when run in
|
||||
advanced mode, silent in simple). Today only the *rendering*
|
||||
side-channel exists (`OutputLine.mode_at_submission`); the `Mode`
|
||||
enum is two-way, the one-shot distinction is collapsed at
|
||||
submission, and neither `Action::ExecuteDsl` nor the worker carries
|
||||
any mode. The work widens `Mode` to three-way and threads it
|
||||
through the `Action` → worker interface. Not required for Phase 3
|
||||
dispatch correctness; tracked here so it is not lost.
|
||||
|
||||
## App-level commands (per ADR-0003)
|
||||
|
||||
|
||||
+52
@@ -1187,6 +1187,20 @@ impl App {
|
||||
"parse.error",
|
||||
detail = parse_error_message(&err)
|
||||
));
|
||||
// ADR-0033 Amendment 3: combine the DSL error with a
|
||||
// pointer to advanced mode when the same line would
|
||||
// run as SQL there. Only in simple mode (a one-shot
|
||||
// `:` or persistent advanced submission uses the SQL
|
||||
// surface already). This mirrors the live hint and
|
||||
// covers SQL constructs that surface only on submit
|
||||
// (e.g. `delete … returning`, where the live hint
|
||||
// shows WHERE-completion rather than an error).
|
||||
if submission_mode == Mode::Simple
|
||||
&& let Some(note) =
|
||||
crate::input_render::advanced_alternative_note(input, &self.schema_cache)
|
||||
{
|
||||
self.note_error(note);
|
||||
}
|
||||
// ADR-0021 §2: append the usage block (if a
|
||||
// known command-entry keyword was consumed) or
|
||||
// the available-commands fallback (§5).
|
||||
@@ -2326,6 +2340,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_mode_submit_of_sql_construct_appends_advanced_pointer() {
|
||||
// ADR-0033 Amendment 3: submitting a line in simple mode that
|
||||
// fails as DSL but would run as SQL in advanced mode appends
|
||||
// the `advanced_mode.also_valid_sql` pointer to the parse
|
||||
// error — keeping the DSL detail and pointing at advanced
|
||||
// mode. Multi-row VALUES is a definite DSL error and valid SQL
|
||||
// (no schema needed).
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "insert into T values (1, 2), (3, 4)");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty(), "the bad line must not dispatch");
|
||||
let has_pointer = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.kind == OutputKind::Error && l.text.contains("advanced mode"));
|
||||
assert!(
|
||||
has_pointer,
|
||||
"expected the advanced-mode pointer on submit; output:\n{}",
|
||||
error_lines(&app),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_mode_submit_of_pure_dsl_error_has_no_advanced_pointer() {
|
||||
// A DSL error that is *not* valid SQL either (unknown command)
|
||||
// must not carry the advanced-mode pointer — there is nothing
|
||||
// to switch modes for.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "frobulate widgets");
|
||||
let _ = submit(&mut app);
|
||||
let has_pointer = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.text.contains("valid as SQL in advanced mode"));
|
||||
assert!(!has_pointer, "unknown command must not point at advanced mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_advanced_mode_dispatches_select_with_advanced_tag() {
|
||||
// The pre-ADR-0030 placeholder echoed any advanced-mode
|
||||
|
||||
+58
-18
@@ -973,6 +973,24 @@ pub fn invalid_ident_at_cursor(
|
||||
input: &str,
|
||||
cursor: usize,
|
||||
cache: &SchemaCache,
|
||||
) -> Option<InvalidIdent> {
|
||||
invalid_ident_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
|
||||
}
|
||||
|
||||
/// Mode-aware [`invalid_ident_at_cursor`].
|
||||
///
|
||||
/// The slot's expected set is computed in `mode`, so a simple-mode
|
||||
/// caller doesn't get the advanced (SQL) grammar's view of a shared
|
||||
/// `insert`/`update`/`delete` entry word — e.g. it won't flag `rows`
|
||||
/// as an "unknown column" in `update T … --all-rows`, which is a DSL
|
||||
/// flag in simple mode rather than the SQL expression `- -all - rows`
|
||||
/// (ADR-0033 Amendment 3).
|
||||
#[must_use]
|
||||
pub fn invalid_ident_at_cursor_in_mode(
|
||||
input: &str,
|
||||
cursor: usize,
|
||||
cache: &SchemaCache,
|
||||
mode: Mode,
|
||||
) -> Option<InvalidIdent> {
|
||||
let cursor = cursor.min(input.len());
|
||||
let bytes = input.as_bytes();
|
||||
@@ -1000,7 +1018,7 @@ pub fn invalid_ident_at_cursor(
|
||||
return None;
|
||||
}
|
||||
let leading = &input[..start];
|
||||
let expected = expected_at(leading, Mode::Advanced);
|
||||
let expected = expected_at(leading, mode);
|
||||
if expected.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1094,6 +1112,17 @@ mod tests {
|
||||
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
||||
}
|
||||
|
||||
/// Simple-mode completion candidates — the DSL surface
|
||||
/// (ADR-0003). Used by tests of DSL-only completion (the
|
||||
/// `--all-rows` flag, the DSL value-literal slots), which since
|
||||
/// sub-phase 3j must run in Simple mode: `insert`/`update`/
|
||||
/// `delete` are shared entry words (ADR-0033 Amendment 3) and
|
||||
/// Advanced mode surfaces the SQL grammar's completions instead.
|
||||
fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
|
||||
candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple)
|
||||
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
||||
}
|
||||
|
||||
fn cand_kinds_with(
|
||||
input: &str,
|
||||
cursor: usize,
|
||||
@@ -1178,14 +1207,17 @@ mod tests {
|
||||
// a `,` (more assignments), `where` (where clause),
|
||||
// or `--all-rows` (flag). Punctuation isn't surfaced;
|
||||
// `where` and `--all-rows` should appear.
|
||||
let cs = cands("update T set Name='hi' ", 23);
|
||||
// `--all-rows` is a DSL-only rail (Simple mode); in Advanced
|
||||
// mode `update`/`delete` route to the SQL grammar, which has
|
||||
// no such flag (ADR-0033 Amendment 3).
|
||||
let cs = cands_simple("update T set Name='hi' ", 23);
|
||||
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
|
||||
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_filter_position_offers_where_and_all_rows() {
|
||||
let cs = cands("delete from T ", 14);
|
||||
let cs = cands_simple("delete from T ", 14);
|
||||
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
|
||||
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
|
||||
}
|
||||
@@ -1195,12 +1227,18 @@ mod tests {
|
||||
// Hint-panel colouring distinguishes flags from
|
||||
// keywords (amber vs purple) — flags get their own
|
||||
// CandidateKind so the renderer can apply tok_flag.
|
||||
let kinds = candidates_at_cursor("delete from T ", 14, &SchemaCache::default())
|
||||
.expect("some completion")
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(|c| (c.text, c.kind))
|
||||
.collect::<Vec<_>>();
|
||||
// Simple mode: `--all-rows` is the DSL rail.
|
||||
let kinds = candidates_at_cursor_in_mode(
|
||||
"delete from T ",
|
||||
14,
|
||||
&SchemaCache::default(),
|
||||
Mode::Simple,
|
||||
)
|
||||
.expect("some completion")
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(|c| (c.text, c.kind))
|
||||
.collect::<Vec<_>>();
|
||||
let flag = kinds
|
||||
.iter()
|
||||
.find(|(t, _)| t == "--all-rows")
|
||||
@@ -1210,7 +1248,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn flag_candidates_filter_by_partial_prefix() {
|
||||
let cs = cands("delete from T --", 16);
|
||||
let cs = cands_simple("delete from T --", 16);
|
||||
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
|
||||
}
|
||||
|
||||
@@ -1327,7 +1365,9 @@ mod tests {
|
||||
// `true`, `false` as Tab candidates — actively
|
||||
// misleading at a slot where the user is more likely
|
||||
// entering a number / text / date. Suppress.
|
||||
let cs = cands("insert into T values (", 22);
|
||||
// DSL value-literal slot (Simple mode); in Advanced mode
|
||||
// `insert` routes to the SQL grammar (ADR-0033 Amendment 3).
|
||||
let cs = cands_simple("insert into T values (", 22);
|
||||
assert!(cs.is_empty(), "got misleading candidates {cs:?}");
|
||||
}
|
||||
|
||||
@@ -1337,15 +1377,15 @@ mod tests {
|
||||
// completion applies — `n` → `null`, `tr` → `true`,
|
||||
// `fa` → `false`.
|
||||
assert_eq!(
|
||||
cands("insert into T values (n", 23),
|
||||
cands_simple("insert into T values (n", 23),
|
||||
vec!["null".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
cands("insert into T values (tr", 24),
|
||||
cands_simple("insert into T values (tr", 24),
|
||||
vec!["true".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
cands("insert into T values (fa", 24),
|
||||
cands_simple("insert into T values (fa", 24),
|
||||
vec!["false".to_string()],
|
||||
);
|
||||
}
|
||||
@@ -1355,21 +1395,21 @@ mod tests {
|
||||
// Comma-separated value positions all hit the same slot
|
||||
// signature. `insert into T values (1, ` → expected:
|
||||
// null/true/false/number/string. Suppress.
|
||||
let cs = cands("insert into T values (1, ", 25);
|
||||
let cs = cands_simple("insert into T values (1, ", 25);
|
||||
assert!(cs.is_empty(), "got {cs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_set_value_slot_suppresses() {
|
||||
// `update T set col=` is also a value-literal slot.
|
||||
let cs = cands("update T set col=", 17);
|
||||
let cs = cands_simple("update T set col=", 17);
|
||||
assert!(cs.is_empty(), "got {cs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn where_value_slot_suppresses() {
|
||||
// `where col=` is also a value-literal slot.
|
||||
let cs = cands("delete from T where col=", 24);
|
||||
let cs = cands_simple("delete from T where col=", 24);
|
||||
assert!(cs.is_empty(), "got {cs:?}");
|
||||
}
|
||||
|
||||
@@ -2166,7 +2206,7 @@ mod tests {
|
||||
// ADR-0033 §9: `excluded.|` inside a DO UPDATE action
|
||||
// completes to the INSERT target table's columns.
|
||||
let cache = two_table_schema();
|
||||
let input = "sqlinsert into a (id, name) values (1, 'x') \
|
||||
let input = "insert into a (id, name) values (1, 'x') \
|
||||
on conflict (id) do update set name = excluded.";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
assert!(
|
||||
|
||||
@@ -9573,8 +9573,13 @@ mod tests {
|
||||
|
||||
/// Pull the `RowFilter` out of an `update` / `delete` parsed
|
||||
/// from DSL — the readable way to build a complex `Expr`.
|
||||
/// Parses in Simple mode: `update`/`delete` are shared entry
|
||||
/// words since sub-phase 3j (ADR-0033 Amendment 3), so the DSL
|
||||
/// `Command::Update`/`Delete` is only produced in Simple mode.
|
||||
fn parse_filter(dsl: &str) -> RowFilter {
|
||||
match crate::dsl::parser::parse_command(dsl).expect("filter parse") {
|
||||
match crate::dsl::parser::parse_command_in_mode(dsl, crate::mode::Mode::Simple)
|
||||
.expect("filter parse")
|
||||
{
|
||||
crate::dsl::command::Command::Update { filter, .. }
|
||||
| crate::dsl::command::Command::Delete { filter, .. } => filter,
|
||||
other => panic!("expected update/delete, got {other:?}"),
|
||||
@@ -9775,9 +9780,14 @@ mod tests {
|
||||
// --- explain / query plans (ADR-0028) -------------------
|
||||
|
||||
/// Parse a non-`explain` query command for use as the inner
|
||||
/// command of `explain_query_plan`.
|
||||
/// command of `explain_query_plan`. Simple mode: `explain`
|
||||
/// wraps the DSL `show data` / `update` / `delete` commands
|
||||
/// (ADR-0028; SQL DML is not explainable, ADR-0030 §13 OOS-2),
|
||||
/// and `update`/`delete` only yield the DSL variant in Simple
|
||||
/// mode (shared entry words since sub-phase 3j).
|
||||
fn parse_inner(dsl: &str) -> Command {
|
||||
crate::dsl::parser::parse_command(dsl).expect("inner command parse")
|
||||
crate::dsl::parser::parse_command_in_mode(dsl, crate::mode::Mode::Simple)
|
||||
.expect("inner command parse")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+58
-73
@@ -32,8 +32,14 @@ use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
|
||||
|
||||
const TABLE_NAME_EXISTING: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
// Reject `__rdbms_*` internal tables at the table-source slot
|
||||
// (ADR-0030 §6 — "every table-source slot"), matching the SQL
|
||||
// grammar's `reject_internal_table`. Without this, simple-mode DSL
|
||||
// data commands could read/write the internal metadata tables
|
||||
// even though advanced-mode SQL rejects them (ADR-0033
|
||||
// Amendment 3 / `/runda` finding B).
|
||||
role: "table_name",
|
||||
validator: None,
|
||||
validator: Some(sql_select::reject_internal_table),
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
@@ -49,8 +55,10 @@ writes_projection_alias: false,
|
||||
/// dispatch typed slots per column.
|
||||
const TABLE_NAME_INSERT: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
// Reject `__rdbms_*` internal tables (ADR-0030 §6; `/runda`
|
||||
// finding B) — see `TABLE_NAME_EXISTING`.
|
||||
role: "table_name",
|
||||
validator: None,
|
||||
validator: Some(sql_select::reject_internal_table),
|
||||
highlight_override: None,
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
@@ -224,8 +232,12 @@ const INSERT_SHAPE: Node = Node::Seq(INSERT_NODES);
|
||||
/// can resolve column types (Phase D).
|
||||
const TABLE_NAME_WRITES: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
// Reject `__rdbms_*` internal tables (ADR-0030 §6; `/runda`
|
||||
// finding B) — see `TABLE_NAME_EXISTING`. Shared by `update`,
|
||||
// `delete`, and `show data`, so all three reject the internal
|
||||
// metadata tables, matching the SQL grammar.
|
||||
role: "table_name",
|
||||
validator: None,
|
||||
validator: Some(sql_select::reject_internal_table),
|
||||
highlight_override: None,
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
@@ -856,14 +868,10 @@ fn build_select(_path: &MatchedPath, source: &str) -> Result<Command, Validation
|
||||
}
|
||||
|
||||
/// Build `Command::SqlInsert` from a validated SQL `INSERT`
|
||||
/// (ADR-0033 §1, sub-phase 3b). Extracts the target table from
|
||||
/// the matched path so the worker re-persists the right CSV.
|
||||
///
|
||||
/// Dev-scaffold detail: the entry word is `sqlinsert` (not valid
|
||||
/// SQL), so the statement is reconstructed as `insert` + the
|
||||
/// matched tail. Sub-phase 3j wires the real `insert` entry word,
|
||||
/// at which point this collapses to `source.trim()` like
|
||||
/// `build_select`.
|
||||
/// (ADR-0033 §1). Extracts the target table from the matched path
|
||||
/// so the worker re-persists the right CSV. `insert` is now the
|
||||
/// real (shared) entry word, so the validated `source` runs
|
||||
/// verbatim — like `build_select` (sub-phase 3j).
|
||||
fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||
let target_table = path
|
||||
.items
|
||||
@@ -938,13 +946,10 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Everything after the entry word is the `INTO …` tail; prefix
|
||||
// the real `insert` keyword for the engine.
|
||||
let tail = path
|
||||
.items
|
||||
.first()
|
||||
.map_or(source, |entry| &source[entry.span.1..]);
|
||||
let sql = format!("insert {}", tail.trim());
|
||||
// The entry word is the real `insert` keyword (sub-phase 3j),
|
||||
// so the validated line runs verbatim (grammar-as-text,
|
||||
// ADR-0030 §4) — no keyword reconstruction.
|
||||
let sql = source.trim().to_string();
|
||||
Ok(Command::SqlInsert {
|
||||
sql,
|
||||
target_table,
|
||||
@@ -966,13 +971,10 @@ fn path_has_returning(path: &MatchedPath) -> bool {
|
||||
}
|
||||
|
||||
/// Build `Command::SqlUpdate` from a validated SQL `UPDATE`
|
||||
/// (ADR-0033 §2, sub-phase 3e). Extracts the target table from the
|
||||
/// matched path so the worker re-persists the right CSV.
|
||||
///
|
||||
/// Dev-scaffold detail: the entry word is `sql_update` (not valid
|
||||
/// SQL), so the statement is reconstructed as `update` + the
|
||||
/// matched tail. Sub-phase 3j wires the real `update` entry word,
|
||||
/// at which point this collapses to `source.trim()`.
|
||||
/// (ADR-0033 §2). Extracts the target table from the matched path
|
||||
/// so the worker re-persists the right CSV. `update` is now the
|
||||
/// real (shared) entry word, so the validated `source` runs
|
||||
/// verbatim (sub-phase 3j).
|
||||
fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||
// The UPDATE target is the first `table_name` ident (it
|
||||
// precedes any table referenced inside a SET / WHERE subquery).
|
||||
@@ -986,11 +988,7 @@ fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tail = path
|
||||
.items
|
||||
.first()
|
||||
.map_or(source, |entry| &source[entry.span.1..]);
|
||||
let sql = format!("update {}", tail.trim());
|
||||
let sql = source.trim().to_string();
|
||||
Ok(Command::SqlUpdate {
|
||||
sql,
|
||||
target_table,
|
||||
@@ -999,17 +997,13 @@ fn build_sql_update(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
}
|
||||
|
||||
/// Build `Command::SqlDelete` from a validated SQL `DELETE`
|
||||
/// (ADR-0033 §1/§7, sub-phase 3f). Extracts the target table from
|
||||
/// the matched path so the worker re-persists the right CSV and
|
||||
/// snapshots the right inbound children for cascade diffing. No
|
||||
/// WHERE clause is captured — the worker executes the verbatim SQL
|
||||
/// and never inspects the predicate (Amendment 2).
|
||||
///
|
||||
/// Dev-scaffold detail: the entry word is `sql_delete` (not valid
|
||||
/// SQL), so the statement is reconstructed as `delete` + the matched
|
||||
/// tail (which opens at `from`). Sub-phase 3j wires the real
|
||||
/// `delete` entry word, at which point this collapses to
|
||||
/// `source.trim()`.
|
||||
/// (ADR-0033 §1/§7). Extracts the target table from the matched
|
||||
/// path so the worker re-persists the right CSV and snapshots the
|
||||
/// right inbound children for cascade diffing. No WHERE clause is
|
||||
/// captured — the worker executes the verbatim SQL and never
|
||||
/// inspects the predicate (Amendment 2). `delete` is now the real
|
||||
/// (shared) entry word, so the validated `source` runs verbatim
|
||||
/// (sub-phase 3j).
|
||||
fn build_sql_delete(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||
// The DELETE target is the first `table_name` ident (it precedes
|
||||
// any table referenced inside a WHERE subquery).
|
||||
@@ -1023,11 +1017,7 @@ fn build_sql_delete(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tail = path
|
||||
.items
|
||||
.first()
|
||||
.map_or(source, |entry| &source[entry.span.1..]);
|
||||
let sql = format!("delete {}", tail.trim());
|
||||
let sql = source.trim().to_string();
|
||||
Ok(Command::SqlDelete {
|
||||
sql,
|
||||
target_table,
|
||||
@@ -1110,51 +1100,46 @@ pub static WITH: CommandNode = CommandNode {
|
||||
help_id: None,
|
||||
usage_ids: &["parse.usage.select"],};
|
||||
|
||||
/// SQL `INSERT` development scaffold (ADR-0033 sub-phase 3b–3i).
|
||||
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
||||
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
|
||||
///
|
||||
/// Registered under the temporary entry word `sqlinsert` so the
|
||||
/// SQL INSERT grammar and execution path can be exercised in
|
||||
/// isolation, WITHOUT yet making `insert` a shared DSL/SQL entry
|
||||
/// word. Sharing `insert` is sub-phase 3j, which depends on
|
||||
/// `shortid` auto-fill (3d) so advanced-mode DSL inserts keep
|
||||
/// parity rather than regressing through an incomplete SQL path.
|
||||
/// This scaffold (entry word + reconstruction in `build_sql_insert`)
|
||||
/// is removed when 3j wires the real `insert` entry word.
|
||||
/// `insert` is a shared entry word: this `Advanced` SQL node and
|
||||
/// the `Simple` DSL [`INSERT`] node both register under `insert`.
|
||||
/// In Advanced mode the dispatcher (`walker::walk` / `decide`)
|
||||
/// tries this SQL node first and falls back to the DSL node when
|
||||
/// the SQL shape does not match; in Simple mode only the DSL node
|
||||
/// is reachable (Amendment 3 — command identity is the mode-rooted
|
||||
/// grammar-path outcome).
|
||||
pub static SQL_INSERT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("sqlinsert"),
|
||||
entry: Word::keyword("insert"),
|
||||
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
||||
ast_builder: build_sql_insert,
|
||||
help_id: None,
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
/// SQL `UPDATE` development scaffold (ADR-0033 sub-phase 3e).
|
||||
/// SQL `UPDATE` — the `Advanced` node of the shared `update` word.
|
||||
///
|
||||
/// Registered under the temporary entry word `sql_update` so the
|
||||
/// SQL UPDATE grammar and execution path can be exercised in
|
||||
/// isolation, WITHOUT yet making `update` a shared DSL/SQL entry
|
||||
/// word. Sharing `update` is sub-phase 3j. This scaffold (entry
|
||||
/// word + reconstruction in `build_sql_update`) is removed when 3j
|
||||
/// wires the real `update` entry word.
|
||||
/// ADR-0033 §2 / Amendment 1, sub-phase 3j. Pairs with the `Simple`
|
||||
/// DSL [`UPDATE`] node; dispatch is SQL-first / DSL-fallback in
|
||||
/// Advanced mode, DSL-only in Simple.
|
||||
pub static SQL_UPDATE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("sql_update"),
|
||||
entry: Word::keyword("update"),
|
||||
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||
ast_builder: build_sql_update,
|
||||
help_id: None,
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
/// SQL `DELETE` development scaffold (ADR-0033 sub-phase 3f).
|
||||
/// SQL `DELETE` — the `Advanced` node of the shared `delete` word.
|
||||
///
|
||||
/// Registered under the temporary entry word `sql_delete` so the
|
||||
/// SQL DELETE grammar and execution path (including cascade-summary
|
||||
/// parity) can be exercised in isolation, WITHOUT yet making
|
||||
/// `delete` a shared DSL/SQL entry word. Sharing `delete` is
|
||||
/// sub-phase 3j. This scaffold (entry word + reconstruction in
|
||||
/// `build_sql_delete`) is removed when 3j wires the real `delete`
|
||||
/// entry word.
|
||||
/// ADR-0033 §2 / Amendment 1, sub-phase 3j. Pairs with the `Simple`
|
||||
/// DSL [`DELETE`] node; dispatch is SQL-first / DSL-fallback in
|
||||
/// Advanced mode, DSL-only in Simple. In Advanced mode `delete from t
|
||||
/// --all-rows` falls back to the DSL node (the SQL shape has no
|
||||
/// `--all-rows`).
|
||||
pub static SQL_DELETE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("sql_delete"),
|
||||
entry: Word::keyword("delete"),
|
||||
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||
ast_builder: build_sql_delete,
|
||||
help_id: None,
|
||||
|
||||
@@ -573,17 +573,13 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&data::EXPLAIN, CommandCategory::Simple),
|
||||
(&data::SELECT, CommandCategory::Advanced),
|
||||
(&data::WITH, CommandCategory::Advanced),
|
||||
// SQL INSERT development scaffold (sub-phase 3b–3i); the
|
||||
// temporary `sqlinsert` entry word keeps it isolated from the
|
||||
// DSL `insert` word until 3j wires the shared entry.
|
||||
// Shared entry words (sub-phase 3j, ADR-0033 §2 / Amendment 1):
|
||||
// `insert` / `update` / `delete` each appear twice — the
|
||||
// `Simple` DSL node above and this `Advanced` SQL node. The
|
||||
// dispatcher tries the SQL node first in Advanced mode and falls
|
||||
// back to the DSL node when the SQL shape does not match.
|
||||
(&data::SQL_INSERT, CommandCategory::Advanced),
|
||||
// SQL UPDATE development scaffold (sub-phase 3e); the temporary
|
||||
// `sql_update` entry word keeps it isolated from the DSL
|
||||
// `update` word until 3j wires the shared entry.
|
||||
(&data::SQL_UPDATE, CommandCategory::Advanced),
|
||||
// SQL DELETE development scaffold (sub-phase 3f); the temporary
|
||||
// `sql_delete` entry word keeps it isolated from the DSL
|
||||
// `delete` word until 3j wires the shared entry.
|
||||
(&data::SQL_DELETE, CommandCategory::Advanced),
|
||||
];
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
//! plus every cascade-affected child (ADR-0030 §11). The shape here
|
||||
//! is the post-`DELETE` portion — the entry-word dispatch consumes
|
||||
//! the leading `DELETE` keyword before this shape walks, so the
|
||||
//! shape opens at `FROM` (mirroring `sql_update::SQL_UPDATE_SHAPE`,
|
||||
//! where the dev `sql_update` word stands in for `UPDATE`).
|
||||
//! shape opens at `FROM`. `delete` is a shared entry word
|
||||
//! (sub-phase 3j): this `Advanced` SQL shape and the `Simple` DSL
|
||||
//! delete node both register under `delete`.
|
||||
//!
|
||||
//! Scope (3f): `FROM <table> [ WHERE … ] [ ';' ]`, the `__rdbms_*`
|
||||
//! target rejection, and the shared `sql_expr` on the WHERE
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//! validated SQL text and re-persists the target table's CSV
|
||||
//! (ADR-0030 §11). The shape here is the post-`UPDATE` portion —
|
||||
//! the entry-word dispatch consumes the leading `UPDATE` keyword
|
||||
//! before this shape walks (mirroring `sql_insert::SQL_INSERT_SHAPE`,
|
||||
//! where the dev `sqlinsert` word stands in for `INSERT`).
|
||||
//! before this shape walks. `update` is a shared entry word
|
||||
//! (sub-phase 3j): this `Advanced` SQL shape and the `Simple` DSL
|
||||
//! update node both register under `update`.
|
||||
//!
|
||||
//! Scope (3e): `<table> SET assignment_list [ WHERE … ]`, the
|
||||
//! `__rdbms_*` target rejection, and the shared `sql_expr` on both
|
||||
|
||||
+154
-2
@@ -360,12 +360,23 @@ mod tests {
|
||||
use crate::dsl::value::Value;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
// These helpers parse in **Simple mode** — the DSL surface
|
||||
// (ADR-0003). The tests in this module exercise the DSL
|
||||
// grammar (`insert`/`update`/`delete` Forms A/B/C, the
|
||||
// `--all-rows` rail, DDL, app commands), all of which are
|
||||
// canonical in Simple mode. Since sub-phase 3j made
|
||||
// `insert`/`update`/`delete` shared entry words (ADR-0033 §2,
|
||||
// Amendment 3), parsing these in Advanced mode would route the
|
||||
// overlap to the SQL command variants; the SQL surface is
|
||||
// covered by `tests/sql_*.rs` instead. No SQL-only command
|
||||
// (`select`/`with`) is tested through these helpers.
|
||||
fn ok(input: &str) -> Command {
|
||||
parse_command(input).unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
|
||||
parse_command_in_mode(input, Mode::Simple)
|
||||
.unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
|
||||
}
|
||||
|
||||
fn err(input: &str) -> ParseError {
|
||||
parse_command(input).expect_err("expected parse error")
|
||||
parse_command_in_mode(input, Mode::Simple).expect_err("expected parse error")
|
||||
}
|
||||
|
||||
fn err_message(input: &str) -> String {
|
||||
@@ -1163,6 +1174,147 @@ mod tests {
|
||||
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// Sub-phase 3j — shared-entry-word dispatch (ADR-0033 §2,
|
||||
// Amendment 1 / Amendment 3).
|
||||
//
|
||||
// `insert` / `update` / `delete` are *shared* entry words: a
|
||||
// `Simple` DSL node and an `Advanced` SQL node both register
|
||||
// under each. A command's identity is the outcome of the
|
||||
// mode-rooted grammar path:
|
||||
// - Advanced mode tries the SQL shape first and falls back to
|
||||
// the DSL shape only when the SQL shape *structurally* can't
|
||||
// match (e.g. the DSL-only `--all-rows` flag). A content
|
||||
// rejection (a `__rdbms_*` target) on the SQL shape is
|
||||
// surfaced, never masked by the DSL fallback.
|
||||
// - Simple mode commits the DSL shape; it points the user at
|
||||
// advanced mode ("this is SQL") only when the input is
|
||||
// SQL-only (the DSL shape structurally mismatches and the SQL
|
||||
// shape matches — e.g. a `returning` tail). A DSL command
|
||||
// that is merely incomplete or has a bad value still commits
|
||||
// the DSL node so the user sees DSL completion / DSL errors.
|
||||
// The §6/§7 parity guarantees mean the two variants execute to
|
||||
// identical effects for an overlapping input.
|
||||
// =====================================================
|
||||
|
||||
#[test]
|
||||
fn advanced_ambiguous_insert_routes_to_sql() {
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("insert into Orders values (1, 2)", Mode::Advanced),
|
||||
Ok(Command::SqlInsert { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_ambiguous_update_routes_to_sql() {
|
||||
assert!(matches!(
|
||||
parse_command_in_mode(
|
||||
"update Orders set total = 0 where id = 1",
|
||||
Mode::Advanced,
|
||||
),
|
||||
Ok(Command::SqlUpdate { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_ambiguous_delete_routes_to_sql() {
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("delete from Orders where id = 1", Mode::Advanced),
|
||||
Ok(Command::SqlDelete { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_dsl_only_delete_falls_back_to_dsl() {
|
||||
// `--all-rows` is DSL-only; the SQL DELETE shape can't consume
|
||||
// the trailing flag, so dispatch falls back to the DSL node.
|
||||
assert_eq!(
|
||||
parse_command_in_mode("delete from Orders --all-rows", Mode::Advanced).unwrap(),
|
||||
Command::Delete {
|
||||
table: "Orders".to_string(),
|
||||
filter: RowFilter::AllRows,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_mode_data_commands_reject_internal_tables() {
|
||||
// ADR-0030 §6 ("every table-source slot") / `/runda` finding
|
||||
// B: the DSL data-command target slots reject `__rdbms_*`
|
||||
// internal tables in simple mode too — matching the SQL
|
||||
// grammar. Without this, simple-mode DML could read/write the
|
||||
// internal metadata tables while advanced-mode SQL rejected
|
||||
// them.
|
||||
for input in [
|
||||
"insert into __rdbms_playground_columns values (1)",
|
||||
"update __rdbms_playground_columns set x = 1 where id = 1",
|
||||
"delete from __rdbms_playground_columns where id = 1",
|
||||
"show data __rdbms_playground_columns",
|
||||
"show table __rdbms_playground_relationships",
|
||||
] {
|
||||
assert!(
|
||||
parse_command_in_mode(input, Mode::Simple).is_err(),
|
||||
"internal table must be rejected in simple mode: {input:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_internal_table_insert_is_rejected_not_fallen_back() {
|
||||
// The SQL insert's `reject_internal_table` rail must surface
|
||||
// even though the DSL insert node lacks it: a content
|
||||
// rejection commits the SQL candidate rather than falling
|
||||
// through to the DSL node that would accept it.
|
||||
assert!(
|
||||
parse_command_in_mode(
|
||||
"insert into __rdbms_playground_columns values (1)",
|
||||
Mode::Advanced,
|
||||
)
|
||||
.is_err(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_dsl_delete_stays_dsl() {
|
||||
assert_eq!(
|
||||
parse_command_in_mode("delete from Orders where id = 1", Mode::Simple).unwrap(),
|
||||
Command::Delete {
|
||||
table: "Orders".to_string(),
|
||||
filter: RowFilter::eq("id", Value::Number("1".to_string())),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_sql_only_entry_word_points_at_advanced_mode() {
|
||||
// A SQL-only *entry word* (`select`) has no DSL form, so
|
||||
// simple mode emits the "this is SQL" hint at the parse level
|
||||
// (ADR-0030 §2).
|
||||
match parse_command_in_mode("select Name from Orders", Mode::Simple) {
|
||||
Err(ParseError::Invalid { message, .. }) => assert!(
|
||||
message.contains("advanced"),
|
||||
"expected the this-is-SQL hint, got: {message}",
|
||||
),
|
||||
other => panic!("expected the this-is-SQL hint, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_shared_word_with_sql_construct_is_a_dsl_parse_error() {
|
||||
// `returning` is SQL-only, but `delete` is a *shared* entry
|
||||
// word, so simple mode commits the DSL shape and surfaces a
|
||||
// DSL parse error (ADR-0033 Amendment 3). The "(valid as SQL
|
||||
// in advanced mode)" pointer is added at the hint layer
|
||||
// (input_render), not in the parsed command/error here.
|
||||
assert!(matches!(
|
||||
parse_command_in_mode(
|
||||
"delete from Orders where id = 1 returning *",
|
||||
Mode::Simple,
|
||||
),
|
||||
Err(ParseError::Invalid { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_data_command() {
|
||||
assert_eq!(
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
|
||||
@@ -315,6 +315,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("advanced_mode.not_implemented", &["input"]),
|
||||
// ---- Advanced-mode SQL surface (ADR-0030) ----
|
||||
("advanced_mode.sql_in_simple", &["command"]),
|
||||
("advanced_mode.also_valid_sql", &[]),
|
||||
("select.internal_table", &["table"]),
|
||||
(
|
||||
"cli.invalid_value",
|
||||
|
||||
@@ -634,6 +634,10 @@ persistence:
|
||||
advanced_mode:
|
||||
not_implemented: "advanced mode SQL not implemented yet — echo: {input}"
|
||||
sql_in_simple: "`{command}` is SQL — available in advanced mode. Switch with `mode advanced`, or prefix the line with `:` to run it once."
|
||||
# Appended to a simple-mode DSL error when the same line would run
|
||||
# in advanced mode — keeps the actionable DSL fix and adds the mode
|
||||
# pointer (ADR-0033 Amendment 3).
|
||||
also_valid_sql: "(valid as SQL in advanced mode — `mode advanced` or prefix `:`)"
|
||||
|
||||
# ---- SQL SELECT (advanced mode; ADR-0030 / ADR-0031) ----------------
|
||||
select:
|
||||
|
||||
+132
-2
@@ -90,7 +90,9 @@ pub fn render_input_runs_in_mode(
|
||||
{
|
||||
overlay_error(&mut runs, pos, theme);
|
||||
}
|
||||
if let Some(inv) = crate::completion::invalid_ident_at_cursor(input, cursor_byte, cache) {
|
||||
if let Some(inv) =
|
||||
crate::completion::invalid_ident_at_cursor_in_mode(input, cursor_byte, cache, mode)
|
||||
{
|
||||
overlay_error(&mut runs, inv.range.0, theme);
|
||||
}
|
||||
// Schema-aware diagnostics (ADR-0027 §2): unknown table /
|
||||
@@ -167,6 +169,27 @@ pub fn classify_input_with_schema(
|
||||
classify_parse_result(parse_command_with_schema(input, cache))
|
||||
}
|
||||
|
||||
/// Mode-aware [`classify_input_with_schema`].
|
||||
///
|
||||
/// Walks the input in `mode` so the simple-mode DSL surface is
|
||||
/// classified against the DSL grammar rather than the advanced SQL
|
||||
/// grammar — relevant for the shared `insert`/`update`/`delete` entry
|
||||
/// words (ADR-0033 Amendment 3). The mode-less entry point keeps its
|
||||
/// advanced-mode behaviour.
|
||||
#[must_use]
|
||||
pub fn classify_input_with_schema_in_mode(
|
||||
input: &str,
|
||||
cache: &crate::completion::SchemaCache,
|
||||
mode: Mode,
|
||||
) -> InputState {
|
||||
if input.trim().is_empty() {
|
||||
return InputState::Empty;
|
||||
}
|
||||
classify_parse_result(crate::dsl::parser::parse_command_with_schema_in_mode(
|
||||
input, cache, mode,
|
||||
))
|
||||
}
|
||||
|
||||
fn classify_parse_result(
|
||||
result: Result<crate::dsl::Command, ParseError>,
|
||||
) -> InputState {
|
||||
@@ -239,6 +262,13 @@ pub fn ambient_hint(
|
||||
/// "this is SQL" gate. The simple-mode entry point [`ambient_hint`]
|
||||
/// forwards here with `Mode::Simple`.
|
||||
///
|
||||
/// In simple mode, when the line is a *definite* DSL error but the
|
||||
/// same line would parse in advanced mode, the DSL error prose is
|
||||
/// suffixed with the `advanced_mode.also_valid_sql` pointer — so the
|
||||
/// user keeps the actionable DSL fix *and* learns it would run as SQL
|
||||
/// in advanced mode (ADR-0033 Amendment 3). Mid-typing (incomplete)
|
||||
/// input is not suffixed, to avoid noise during normal DSL entry.
|
||||
///
|
||||
/// Returns `None` for empty input — caller falls back to
|
||||
/// `panel.hint_empty`.
|
||||
#[must_use]
|
||||
@@ -248,6 +278,58 @@ pub fn ambient_hint_in_mode(
|
||||
memo: Option<&crate::completion::LastCompletion>,
|
||||
cache: &crate::completion::SchemaCache,
|
||||
mode: Mode,
|
||||
) -> Option<AmbientHint> {
|
||||
let core = ambient_hint_core_in_mode(input, cursor, memo, cache, mode);
|
||||
// Combine: a simple-mode *definite* DSL error that would run in
|
||||
// advanced mode keeps its DSL prose and gains the mode pointer.
|
||||
// Skip the "command complete" prose — appending the pointer to a
|
||||
// "submit this" hint would be contradictory (and that prose can
|
||||
// come from the hint's schemaless fallback even when the
|
||||
// schema-aware classify is a definite error — a pre-existing
|
||||
// quirk this combine step deliberately does not amplify).
|
||||
if mode == Mode::Simple
|
||||
&& let Some(AmbientHint::Prose(message)) = &core
|
||||
&& *message != crate::t!("hint.ambient_complete")
|
||||
&& let Some(suffix) = advanced_alternative_note(input, cache)
|
||||
{
|
||||
return Some(AmbientHint::Prose(format!("{message} {suffix}")));
|
||||
}
|
||||
core
|
||||
}
|
||||
|
||||
/// The `advanced_mode.also_valid_sql` pointer string, or `None`.
|
||||
///
|
||||
/// Returns the pointer when a simple-mode line is a *definite* DSL
|
||||
/// error (not merely incomplete) yet parses in advanced mode. Used to
|
||||
/// combine the DSL fix with the mode hint — both while typing
|
||||
/// (`ambient_hint_in_mode`) and on submit (`App::dispatch_dsl`) — so
|
||||
/// the pointer reaches SQL constructs that surface only on submit,
|
||||
/// e.g. `delete … returning` (ADR-0033 Amendment 3).
|
||||
#[must_use]
|
||||
pub fn advanced_alternative_note(
|
||||
input: &str,
|
||||
cache: &crate::completion::SchemaCache,
|
||||
) -> Option<String> {
|
||||
let definite_dsl_error = matches!(
|
||||
classify_input_with_schema_in_mode(input, cache, Mode::Simple),
|
||||
InputState::DefiniteErrorAt(_)
|
||||
);
|
||||
if !definite_dsl_error {
|
||||
return None;
|
||||
}
|
||||
if parse_command_with_schema_in_mode(input, cache, Mode::Advanced).is_ok() {
|
||||
Some(crate::t!("advanced_mode.also_valid_sql"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn ambient_hint_core_in_mode(
|
||||
input: &str,
|
||||
cursor: usize,
|
||||
memo: Option<&crate::completion::LastCompletion>,
|
||||
cache: &crate::completion::SchemaCache,
|
||||
mode: Mode,
|
||||
) -> Option<AmbientHint> {
|
||||
if input.trim().is_empty() {
|
||||
return None;
|
||||
@@ -401,7 +483,8 @@ pub fn ambient_hint_in_mode(
|
||||
// Invalid identifier: cursor sits in a known-set slot but
|
||||
// the typed prefix matches nothing in the schema. (Stage
|
||||
// 8e / the user's #5.)
|
||||
if let Some(inv) = crate::completion::invalid_ident_at_cursor(input, cursor, cache) {
|
||||
if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
|
||||
{
|
||||
let kind = match inv.source {
|
||||
crate::dsl::grammar::IdentSource::Tables => "table",
|
||||
crate::dsl::grammar::IdentSource::Columns => "column",
|
||||
@@ -989,6 +1072,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_hint_combines_dsl_error_with_advanced_sql_pointer() {
|
||||
// ADR-0033 Amendment 3: in simple mode, a *definite* DSL
|
||||
// error whose line would run as SQL in advanced mode keeps
|
||||
// its actionable DSL prose AND gains the
|
||||
// `advanced_mode.also_valid_sql` pointer. `Customers(id
|
||||
// serial, Name, Email)`: DSL Form B auto-skips the serial
|
||||
// `id`, so three values is a definite DSL error — but the same
|
||||
// line is a valid SQL insert (all three columns).
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
|
||||
);
|
||||
let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
|
||||
match ambient_hint(input, input.len(), None, &cache) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
// The DSL detail survives …
|
||||
assert!(p.contains("Name"), "expected DSL slot detail, got: {p:?}");
|
||||
// … and the advanced-mode pointer is appended.
|
||||
assert!(
|
||||
p.contains("advanced mode"),
|
||||
"expected the advanced-mode pointer, got: {p:?}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected Prose, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_hint_does_not_add_pointer_for_a_valid_dsl_command() {
|
||||
// A valid simple-mode DSL command gets no advanced pointer —
|
||||
// it isn't an error, and there is nothing to switch modes for.
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Serial), ("Name", Type::Text)],
|
||||
);
|
||||
let input = "insert into Customers values ('Alice')";
|
||||
if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
|
||||
assert!(
|
||||
!p.contains("advanced mode"),
|
||||
"a valid DSL command must not carry the advanced pointer, got: {p:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_hint_at_insert_second_value_shows_text_prose() {
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
@@ -1644,6 +1644,13 @@ pub async fn run_replay(
|
||||
// (bad syntax / typed-slot reject) — report and stop
|
||||
// without dispatching.
|
||||
let schema = build_schema_cache(database).await;
|
||||
// Replay parses each line like an interactive submission and
|
||||
// executes the resulting command — in advanced mode (the full
|
||||
// surface). A bad value in a shared-entry-word DML line is
|
||||
// caught either at parse time (a DSL form's typed slot) or at
|
||||
// execute time (the engine's column-type enforcement); either
|
||||
// way the offending line fails and replay stops without
|
||||
// applying it.
|
||||
let command = match crate::dsl::parser::parse_command_with_schema(
|
||||
trimmed, &schema,
|
||||
) {
|
||||
|
||||
+27
-17
@@ -228,21 +228,31 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_rejects_typed_slot_violation_at_parse_time() {
|
||||
// Schema-aware replay (handoff-13 §2.1 fix): run_replay
|
||||
// re-snapshots the schema per line and parses with
|
||||
// parse_command_with_schema. So a wrong-type value in a
|
||||
// value list is caught at *parse* time during replay —
|
||||
// surfaced through the `replay.error_parse` wrapper ("parse
|
||||
// error …") — exactly as the interactive path would, rather
|
||||
// than only at bind time.
|
||||
fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
|
||||
// Replay parses each line with the SAME schema-aware parser the
|
||||
// interactive path uses, in **advanced mode** (the full surface),
|
||||
// and executes the result — so a replayed line behaves exactly as
|
||||
// if it had been typed interactively in advanced mode. Nothing is
|
||||
// skipped or simplified during replay (handoff-13 §2.1: the schema
|
||||
// is threaded so the parser is fully schema-aware).
|
||||
//
|
||||
// `'not a number'` (a string) lands in the int `count`
|
||||
// slot. The schemaless parser would accept it (a string is
|
||||
// a value literal) and only bind-time would reject; the
|
||||
// schema-aware parser rejects it at parse time. Asserting
|
||||
// the error went through the parse wrapper proves the
|
||||
// schema was threaded.
|
||||
// A real journal only ever contains commands that already executed
|
||||
// successfully (history.log is success-only; ADR-0034's deferred
|
||||
// journal replays `ok` lines only), so a wrong-type line like this
|
||||
// never arises from a genuine replay. It only arises from a
|
||||
// *hand-built* `.commands` script — the robustness case this test
|
||||
// exercises: replay must reject the bad line and stop, leaving
|
||||
// state intact, with the same error a user would see typing it.
|
||||
//
|
||||
// Where the rejection lands depends on the grammar the line
|
||||
// matches, exactly as interactively: `insert into T values (…)` is
|
||||
// SQL in advanced mode, and SQL defers column-type checking to the
|
||||
// engine, so `'not a number'` in the int `count` column is rejected
|
||||
// at **execute** time (the engine's column-type enforcement) rather
|
||||
// than at parse time. Either way the line fails and is not applied.
|
||||
// (Before sub-phase 3j, `insert` was a DSL-only entry word, so even
|
||||
// advanced-mode parsing hit the DSL typed-slot rail and this was a
|
||||
// parse-time rejection — ADR-0033 Amendment 3.)
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(
|
||||
@@ -261,12 +271,12 @@ fn replay_rejects_typed_slot_violation_at_parse_time() {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
error.contains("parse error"),
|
||||
"typed-slot violation should be caught at parse time, got: {error}",
|
||||
!error.is_empty(),
|
||||
"the rejected line must carry a reported error",
|
||||
);
|
||||
|
||||
// The earlier two lines stayed applied; the failing insert
|
||||
// did not run.
|
||||
// did not run — state is intact.
|
||||
let data_result = rt()
|
||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
||||
.expect("query_data");
|
||||
|
||||
+17
-17
@@ -82,7 +82,7 @@ fn run_delete(
|
||||
rt: &tokio::runtime::Runtime,
|
||||
input: &str,
|
||||
) -> Result<DeleteResult, DbError> {
|
||||
match parse_command(input).expect("parse sql_delete") {
|
||||
match parse_command(input).expect("parse delete") {
|
||||
Command::SqlDelete { sql, target_table, returning } => {
|
||||
rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning))
|
||||
}
|
||||
@@ -115,8 +115,8 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
|
||||
|
||||
#[test]
|
||||
fn parse_path_lowers_sql_delete_to_command() {
|
||||
let command = parse_command("sql_delete from Orders where id = 1")
|
||||
.expect("sql_delete parses in advanced mode");
|
||||
let command = parse_command("delete from Orders where id = 1")
|
||||
.expect("delete parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlDelete { sql, target_table, .. } => {
|
||||
assert_eq!(sql, "delete from Orders where id = 1");
|
||||
@@ -132,7 +132,7 @@ fn delete_with_where_persists() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t");
|
||||
let result = run_delete(&db, &rt, "sql_delete from t where id = 1").expect("delete runs");
|
||||
let result = run_delete(&db, &rt, "delete from t where id = 1").expect("delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row deleted");
|
||||
assert!(result.cascade.is_empty(), "no children, no cascade");
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
@@ -147,7 +147,7 @@ fn delete_without_where_runs_across_all_rows() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'a'), (2, 'b'), (3, 'c')", "t");
|
||||
let result = run_delete(&db, &rt, "sql_delete from t").expect("unfiltered delete runs");
|
||||
let result = run_delete(&db, &rt, "delete from t").expect("unfiltered delete runs");
|
||||
assert_eq!(result.rows_affected, 3, "all rows deleted");
|
||||
// Empty tables produce no CSV (CLAUDE.md persistence note), so the
|
||||
// file is either absent or has only a header — either way, no data.
|
||||
@@ -165,7 +165,7 @@ fn cascade_delete_reports_summary_and_repersists_child() {
|
||||
let rt = rt();
|
||||
cascade_fixture(&db, &rt);
|
||||
// Delete Alice (customer 1) — cascades to her two orders (10, 11).
|
||||
let result = run_delete(&db, &rt, "sql_delete from Customers where id = 1")
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
|
||||
.expect("cascading delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "one parent row deleted");
|
||||
assert_eq!(result.cascade.len(), 1, "one cascade relationship reported");
|
||||
@@ -193,7 +193,7 @@ fn cascade_parity_with_dsl() {
|
||||
|
||||
let (_p_sql, db_sql, _d_sql) = open_project_db();
|
||||
cascade_fixture(&db_sql, &rt);
|
||||
let sql_result = run_delete(&db_sql, &rt, "sql_delete from Customers where id = 1")
|
||||
let sql_result = run_delete(&db_sql, &rt, "delete from Customers where id = 1")
|
||||
.expect("SQL delete runs");
|
||||
|
||||
let (_p_dsl, db_dsl, _d_dsl) = open_project_db();
|
||||
@@ -223,7 +223,7 @@ fn r2_where_with_subquery() {
|
||||
let result = run_delete(
|
||||
&db,
|
||||
&rt,
|
||||
"sql_delete from Orders where CustId in (select id from Customers where Name = 'Alice')",
|
||||
"delete from Orders where CustId in (select id from Customers where Name = 'Alice')",
|
||||
)
|
||||
.expect("subquery-WHERE delete runs");
|
||||
assert_eq!(result.rows_affected, 2, "Alice's two orders deleted");
|
||||
@@ -250,7 +250,7 @@ fn r2_cascade_with_subquery_where() {
|
||||
let result = run_delete(
|
||||
&db,
|
||||
&rt,
|
||||
"sql_delete from Customers where id in (select CustId from Orders where id = 11)",
|
||||
"delete from Customers where id in (select CustId from Orders where id = 11)",
|
||||
)
|
||||
.expect("cascade + subquery-WHERE delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "Alice deleted");
|
||||
@@ -267,7 +267,7 @@ fn delete_appends_literal_line_to_history() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'x')", "t");
|
||||
let input = "sql_delete from t where id = 1";
|
||||
let input = "delete from t where id = 1";
|
||||
run_delete(&db, &rt, input).expect("delete runs");
|
||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log present");
|
||||
@@ -303,7 +303,7 @@ fn cascade_to_two_children_reports_both() {
|
||||
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1), (11, 1)", "Orders");
|
||||
seed(&db, &rt, "insert into Reviews (id, CustId) values (20, 1)", "Reviews");
|
||||
|
||||
let result = run_delete(&db, &rt, "sql_delete from Customers where id = 1")
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
|
||||
.expect("cascade-to-two delete runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
assert_eq!(result.cascade.len(), 2, "both cascade relationships reported");
|
||||
@@ -332,7 +332,7 @@ fn delete_childless_parent_reports_no_cascade() {
|
||||
cascade_fixture(&db, &rt);
|
||||
// Carol (3) exists with no orders; deleting her cascades nothing.
|
||||
seed(&db, &rt, "insert into Customers (id, Name) values (3, 'Carol')", "Customers");
|
||||
let result = run_delete(&db, &rt, "sql_delete from Customers where id = 3")
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 3")
|
||||
.expect("childless-parent delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "Carol deleted");
|
||||
assert!(result.cascade.is_empty(), "no children → no cascade effect reported");
|
||||
@@ -370,7 +370,7 @@ fn delete_violating_fk_fails_and_persists_nothing() {
|
||||
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers");
|
||||
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1)", "Orders");
|
||||
|
||||
let input = "sql_delete from Customers where id = 1";
|
||||
let input = "delete from Customers where id = 1";
|
||||
let result = run_delete(&db, &rt, input);
|
||||
assert!(result.is_err(), "delete of a referenced parent must be rejected");
|
||||
// Rolled back: Alice survives.
|
||||
@@ -406,7 +406,7 @@ fn self_referential_cascade_counts_only_cascaded_rows() {
|
||||
.expect("add self-referential relationship");
|
||||
seed(&db, &rt, "insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)", "T");
|
||||
let result =
|
||||
run_delete(&db, &rt, "sql_delete from T where id = 1").expect("self-ref delete runs");
|
||||
run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row matched the WHERE directly");
|
||||
assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once");
|
||||
assert_eq!(
|
||||
@@ -421,7 +421,7 @@ fn internal_target_table_rejected_at_parse() {
|
||||
// rejected at the target slot — the parse fails, the statement
|
||||
// never reaches the worker.
|
||||
assert!(
|
||||
parse_command("sql_delete from __rdbms_playground_columns").is_err(),
|
||||
parse_command("delete from __rdbms_playground_columns").is_err(),
|
||||
"internal table must be rejected at the DELETE target slot"
|
||||
);
|
||||
}
|
||||
@@ -436,7 +436,7 @@ fn delete_returning_yields_predelete_row() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t");
|
||||
let result = run_delete(&db, &rt, "sql_delete from t where id = 1 returning *")
|
||||
let result = run_delete(&db, &rt, "delete from t where id = 1 returning *")
|
||||
.expect("DELETE … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row deleted");
|
||||
// RETURNING yields the row as it was BEFORE deletion.
|
||||
@@ -454,7 +454,7 @@ fn delete_returning_with_cascade_surfaces_both() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
cascade_fixture(&db, &rt);
|
||||
let result = run_delete(&db, &rt, "sql_delete from Customers where id = 1 returning *")
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 1 returning *")
|
||||
.expect("cascading DELETE … RETURNING runs");
|
||||
assert_eq!(result.rows_affected, 1, "one parent row deleted");
|
||||
// RETURNING gave the deleted customer row.
|
||||
|
||||
+41
-41
@@ -222,8 +222,8 @@ fn failed_multi_row_insert_is_atomic() {
|
||||
fn parse_path_lowers_sqlinsert_scaffold_to_command() {
|
||||
// Advanced-mode parse of the dev scaffold reconstructs valid
|
||||
// `insert …` SQL and extracts the target table.
|
||||
let command = parse_command("sqlinsert into Orders (id, total) values (1, 99.5)")
|
||||
.expect("sqlinsert parses in advanced mode");
|
||||
let command = parse_command("insert into Orders (id, total) values (1, 99.5)")
|
||||
.expect("insert parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlInsert { sql, target_table, .. } => {
|
||||
assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)");
|
||||
@@ -235,7 +235,7 @@ fn parse_path_lowers_sqlinsert_scaffold_to_command() {
|
||||
|
||||
#[test]
|
||||
fn parse_path_rejects_internal_target_table() {
|
||||
let result = parse_command("sqlinsert into __rdbms_playground_columns values (1)");
|
||||
let result = parse_command("insert into __rdbms_playground_columns values (1)");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"an internal `__rdbms_*` target must be rejected: {result:?}",
|
||||
@@ -262,7 +262,7 @@ fn create_named(db: &Database, rt: &tokio::runtime::Runtime, name: &str) {
|
||||
|
||||
#[test]
|
||||
fn parse_path_lowers_insert_select_to_command() {
|
||||
let command = parse_command("sqlinsert into archive select * from source")
|
||||
let command = parse_command("insert into archive select * from source")
|
||||
.expect("INSERT … SELECT parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlInsert { sql, target_table, .. } => {
|
||||
@@ -277,7 +277,7 @@ fn parse_path_lowers_insert_select_to_command() {
|
||||
fn parse_path_lowers_with_prefixed_insert_select() {
|
||||
// R4: a WITH-prefixed SELECT row source lowers verbatim.
|
||||
let command = parse_command(
|
||||
"sqlinsert into archive with t as (select * from orders) select * from t",
|
||||
"insert into archive with t as (select * from orders) select * from t",
|
||||
)
|
||||
.expect("WITH-prefixed INSERT … SELECT parses");
|
||||
match command {
|
||||
@@ -436,7 +436,7 @@ fn run_sqlinsert(
|
||||
rt: &tokio::runtime::Runtime,
|
||||
input: &str,
|
||||
) -> Result<InsertResult, DbError> {
|
||||
match parse_command(input).expect("parse sqlinsert") {
|
||||
match parse_command(input).expect("parse insert") {
|
||||
Command::SqlInsert {
|
||||
sql,
|
||||
target_table,
|
||||
@@ -489,7 +489,7 @@ fn values_autofills_omitted_shortid_pk() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x')")
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
|
||||
.expect("auto-fill insert runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
let rows = csv_rows(&project, "t");
|
||||
@@ -506,7 +506,7 @@ fn values_multirow_autofills_distinct_shortids() {
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (label) values ('a'), ('b'), ('c')",
|
||||
"insert into t (label) values ('a'), ('b'), ('c')",
|
||||
)
|
||||
.expect("multi-row auto-fill runs");
|
||||
assert_eq!(result.rows_affected, 3);
|
||||
@@ -526,7 +526,7 @@ fn explicit_shortid_value_is_respected() {
|
||||
run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (id, label) values ('hardcoded', 'x')",
|
||||
"insert into t (id, label) values ('hardcoded', 'x')",
|
||||
)
|
||||
.expect("explicit-id insert runs");
|
||||
let rows = csv_rows(&project, "t");
|
||||
@@ -539,12 +539,12 @@ fn insert_select_autofills_distinct_shortids() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "source", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
create_cols(&db, &rt, "target", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into source (label) values ('a'), ('b')")
|
||||
run_sqlinsert(&db, &rt, "insert into source (label) values ('a'), ('b')")
|
||||
.expect("seed source");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into target (label) select label from source",
|
||||
"insert into target (label) select label from source",
|
||||
)
|
||||
.expect("INSERT … SELECT auto-fill runs");
|
||||
assert_eq!(result.rows_affected, 2);
|
||||
@@ -566,7 +566,7 @@ fn combined_serial_and_shortid_autofill() {
|
||||
&[("id", Type::Serial), ("code", Type::ShortId), ("name", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into t (name) values ('x')")
|
||||
run_sqlinsert(&db, &rt, "insert into t (name) values ('x')")
|
||||
.expect("combined auto-fill runs");
|
||||
let rows = csv_rows(&project, "t");
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
@@ -583,7 +583,7 @@ fn autofill_logs_original_source_not_rewritten_sql() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
let input = "sqlinsert into t (label) values ('x')";
|
||||
let input = "insert into t (label) values ('x')";
|
||||
run_sqlinsert(&db, &rt, input).expect("auto-fill insert runs");
|
||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log present");
|
||||
@@ -603,7 +603,7 @@ fn shortid_autofill_respects_mixed_case_column_name() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("MyId", Type::ShortId), ("label", Type::Text)], &["MyId"]);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x')")
|
||||
run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
|
||||
.expect("mixed-case shortid auto-fill runs");
|
||||
let rows = csv_rows(&project, "t");
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
@@ -623,7 +623,7 @@ fn two_shortids_pk_and_nonpk_both_autofill_distinctly() {
|
||||
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x'), ('y')")
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x'), ('y')")
|
||||
.expect("two-shortid auto-fill runs");
|
||||
assert_eq!(result.rows_affected, 2);
|
||||
let rows = csv_rows(&project, "t");
|
||||
@@ -650,7 +650,7 @@ fn two_shortids_one_provided_one_autofilled() {
|
||||
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into t (id, label) values ('myid', 'x')")
|
||||
run_sqlinsert(&db, &rt, "insert into t (id, label) values ('myid', 'x')")
|
||||
.expect("partial-shortid insert runs");
|
||||
let rows = csv_rows(&project, "t");
|
||||
assert_eq!(rows[0][0], "myid", "provided id preserved: {rows:?}");
|
||||
@@ -670,7 +670,7 @@ fn compound_pk_with_shortid_member_autofills() {
|
||||
&[("id", Type::ShortId), ("region", Type::Int), ("label", Type::Text)],
|
||||
&["id", "region"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into t (region, label) values (1, 'x')")
|
||||
run_sqlinsert(&db, &rt, "insert into t (region, label) values (1, 'x')")
|
||||
.expect("compound-pk insert runs");
|
||||
let rows = csv_rows(&project, "t");
|
||||
assert!(
|
||||
@@ -690,7 +690,7 @@ fn autofill_does_not_mask_arity_mismatch() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
let outcome = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('a', 'b')");
|
||||
let outcome = run_sqlinsert(&db, &rt, "insert into t (label) values ('a', 'b')");
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"arity mismatch must be rejected, not masked: {outcome:?}",
|
||||
@@ -707,8 +707,8 @@ fn autofill_insert_select_wider_projection_is_rejected() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "src", &[("a", Type::Text), ("b", Type::Text)], &["a"]);
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into src (a, b) values ('p', 'q')").expect("seed");
|
||||
let outcome = run_sqlinsert(&db, &rt, "sqlinsert into t (label) select a, b from src");
|
||||
run_sqlinsert(&db, &rt, "insert into src (a, b) values ('p', 'q')").expect("seed");
|
||||
let outcome = run_sqlinsert(&db, &rt, "insert into t (label) select a, b from src");
|
||||
assert!(outcome.is_err(), "wider projection must be rejected: {outcome:?}");
|
||||
assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
|
||||
}
|
||||
@@ -727,8 +727,8 @@ fn autofill_insert_select_narrower_projection_is_rejected() {
|
||||
&[("id", Type::ShortId), ("x", Type::Text), ("y", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into src (a) values ('p')").expect("seed");
|
||||
let outcome = run_sqlinsert(&db, &rt, "sqlinsert into t (x, y) select a from src");
|
||||
run_sqlinsert(&db, &rt, "insert into src (a) values ('p')").expect("seed");
|
||||
let outcome = run_sqlinsert(&db, &rt, "insert into t (x, y) select a from src");
|
||||
assert!(outcome.is_err(), "narrower projection must be rejected: {outcome:?}");
|
||||
assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
|
||||
}
|
||||
@@ -742,7 +742,7 @@ fn insert_returning_star_returns_inserted_row() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (id, b) values (1, 'Ada') returning *")
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (id, b) values (1, 'Ada') returning *")
|
||||
.expect("INSERT … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row inserted");
|
||||
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the inserted row");
|
||||
@@ -758,7 +758,7 @@ fn insert_multirow_returning_id_yields_distinct_rows() {
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (id, b) values (1, 'a'), (2, 'b'), (3, 'c') returning id",
|
||||
"insert into t (id, b) values (1, 'a'), (2, 'b'), (3, 'c') returning id",
|
||||
)
|
||||
.expect("multi-row INSERT … RETURNING id runs");
|
||||
assert_eq!(result.rows_affected, 3, "three rows inserted");
|
||||
@@ -777,7 +777,7 @@ fn insert_returning_autofills_shortid_and_returns_it() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (label) values ('x') returning *")
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x') returning *")
|
||||
.expect("auto-fill INSERT … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row inserted (RETURNING-counted)");
|
||||
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the row");
|
||||
@@ -796,7 +796,7 @@ fn insert_returning_recovers_bare_column_type() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (id, active) values (1, true) returning active")
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (id, active) values (1, true) returning active")
|
||||
.expect("INSERT … RETURNING active runs");
|
||||
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered");
|
||||
assert_eq!(result.data.rows[0][0], Some("true".to_string()), "rendered as the bool word");
|
||||
@@ -809,7 +809,7 @@ fn insert_returning_computed_expression_is_typeless() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("n", Type::Int)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into t (id, n) values (1, 5) returning n + 1")
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (id, n) values (1, 5) returning n + 1")
|
||||
.expect("INSERT … RETURNING <expr> runs");
|
||||
assert_eq!(result.data.column_types, vec![None], "computed projection is typeless");
|
||||
assert_eq!(result.data.rows[0][0], Some("6".to_string()), "engine evaluated n + 1");
|
||||
@@ -839,7 +839,7 @@ fn insert_returning_recovers_multiple_bare_column_types() {
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (id, txt, amount, ratio, flag) values (1, 'a', 9.50, 1.5, true) returning id, txt, amount, ratio, flag",
|
||||
"insert into t (id, txt, amount, ratio, flag) values (1, 'a', 9.50, 1.5, true) returning id, txt, amount, ratio, flag",
|
||||
)
|
||||
.expect("INSERT … RETURNING <cols> runs");
|
||||
assert_eq!(
|
||||
@@ -867,7 +867,7 @@ fn multirow_autofill_returning_yields_distinct_generated_ids() {
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (label) values ('a'), ('b'), ('c') returning *",
|
||||
"insert into t (label) values ('a'), ('b'), ('c') returning *",
|
||||
)
|
||||
.expect("multi-row auto-fill INSERT … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 3, "three rows inserted");
|
||||
@@ -888,8 +888,8 @@ fn insert_select_returning_executes_and_returns_rows() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "src", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
create_cols(&db, &rt, "dst", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into src (id, b) values (1, 'x'), (2, 'y')").expect("seed src");
|
||||
let result = run_sqlinsert(&db, &rt, "sqlinsert into dst select * from src returning id, b")
|
||||
run_sqlinsert(&db, &rt, "insert into src (id, b) values (1, 'x'), (2, 'y')").expect("seed src");
|
||||
let result = run_sqlinsert(&db, &rt, "insert into dst select * from src returning id, b")
|
||||
.expect("INSERT … SELECT … RETURNING runs");
|
||||
assert_eq!(result.rows_affected, 2, "two rows copied");
|
||||
assert_eq!(result.data.rows.len(), 2, "RETURNING yielded both inserted rows");
|
||||
@@ -909,7 +909,7 @@ fn conflict_target_columns_excluded_from_listed_columns() {
|
||||
// listed_columns (which drives shortid auto-fill) must NOT pick
|
||||
// up the conflict-target columns. If it did, an omitted shortid
|
||||
// would look "listed" and auto-fill would wrongly skip.
|
||||
match parse_command("sqlinsert into t (name) values ('x') on conflict (id) do nothing")
|
||||
match parse_command("insert into t (name) values ('x') on conflict (id) do nothing")
|
||||
.expect("parse upsert")
|
||||
{
|
||||
Command::SqlInsert { listed_columns, .. } => {
|
||||
@@ -946,11 +946,11 @@ fn autofill_upsert_real_conflict_preserves_clause_and_excluded() {
|
||||
None,
|
||||
))
|
||||
.expect("create table with shortid pk + unique code");
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into t (code, label) values ('A', 'first')").expect("seed");
|
||||
run_sqlinsert(&db, &rt, "insert into t (code, label) values ('A', 'first')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label",
|
||||
"insert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label",
|
||||
)
|
||||
.expect("auto-filled UPSERT with a real conflict (clause preserved)");
|
||||
assert_eq!(result.rows_affected, 1, "the conflicting row was updated, not inserted");
|
||||
@@ -965,11 +965,11 @@ fn on_conflict_do_nothing_keeps_existing_row() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (id, name) values (1, 'new') on conflict (id) do nothing",
|
||||
"insert into t (id, name) values (1, 'new') on conflict (id) do nothing",
|
||||
)
|
||||
.expect("ON CONFLICT DO NOTHING runs");
|
||||
assert_eq!(result.rows_affected, 0, "conflicting row left untouched");
|
||||
@@ -983,11 +983,11 @@ fn on_conflict_do_update_applies_excluded() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name",
|
||||
"insert into t (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name",
|
||||
)
|
||||
.expect("ON CONFLICT DO UPDATE runs");
|
||||
assert_eq!(result.rows_affected, 1, "the conflicting row was updated");
|
||||
@@ -1001,11 +1001,11 @@ fn on_conflict_do_nothing_without_target() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||||
run_sqlinsert(&db, &rt, "sqlinsert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (id, name) values (1, 'x') on conflict do nothing",
|
||||
"insert into t (id, name) values (1, 'x') on conflict do nothing",
|
||||
)
|
||||
.expect("ON CONFLICT (no target) DO NOTHING runs");
|
||||
assert_eq!(result.rows_affected, 0, "any-conflict do-nothing absorbed the duplicate");
|
||||
@@ -1026,7 +1026,7 @@ fn autofill_preserves_on_conflict_clause() {
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"sqlinsert into t (label) values ('x') on conflict (id) do nothing",
|
||||
"insert into t (label) values ('x') on conflict (id) do nothing",
|
||||
)
|
||||
.expect("auto-fill INSERT with ON CONFLICT runs (clause preserved)");
|
||||
assert_eq!(result.rows_affected, 1, "row inserted with a generated id");
|
||||
|
||||
+13
-13
@@ -69,7 +69,7 @@ fn run_update(
|
||||
rt: &tokio::runtime::Runtime,
|
||||
input: &str,
|
||||
) -> Result<UpdateResult, DbError> {
|
||||
match parse_command(input).expect("parse sql_update") {
|
||||
match parse_command(input).expect("parse update") {
|
||||
Command::SqlUpdate { sql, target_table, returning } => {
|
||||
rt.block_on(db.run_sql_update(sql, Some(input.to_string()), target_table, returning))
|
||||
}
|
||||
@@ -79,8 +79,8 @@ fn run_update(
|
||||
|
||||
#[test]
|
||||
fn parse_path_lowers_sql_update_to_command() {
|
||||
let command = parse_command("sql_update Orders set total = 0 where id = 1")
|
||||
.expect("sql_update parses in advanced mode");
|
||||
let command = parse_command("update Orders set total = 0 where id = 1")
|
||||
.expect("update parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlUpdate { sql, target_table, .. } => {
|
||||
assert_eq!(sql, "update Orders set total = 0 where id = 1");
|
||||
@@ -96,7 +96,7 @@ fn single_column_update_with_where_persists() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'old'), (2, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set v = 'new' where id = 1")
|
||||
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1")
|
||||
.expect("update runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row updated");
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
@@ -117,7 +117,7 @@ fn multi_column_update_persists() {
|
||||
&["id"],
|
||||
);
|
||||
seed(&db, &rt, "insert into t (id, a, b) values (1, 0, 'x')", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set a = 9, b = 'y' where id = 1")
|
||||
let result = run_update(&db, &rt, "update t set a = 9, b = 'y' where id = 1")
|
||||
.expect("multi-col update runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
@@ -131,7 +131,7 @@ fn update_without_where_runs_across_all_rows() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, active) values (1, true), (2, true)", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set active = false")
|
||||
let result = run_update(&db, &rt, "update t set active = false")
|
||||
.expect("unfiltered update runs");
|
||||
assert_eq!(result.rows_affected, 2, "all rows updated");
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
@@ -150,7 +150,7 @@ fn update_with_sql_expr_in_set() {
|
||||
&["id"],
|
||||
);
|
||||
seed(&db, &rt, "insert into t (id, price, qty, total) values (1, 6, 7, 0)", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set total = price * qty where id = 1")
|
||||
let result = run_update(&db, &rt, "update t set total = price * qty where id = 1")
|
||||
.expect("expression update runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
@@ -169,7 +169,7 @@ fn update_with_subquery_in_set() {
|
||||
let result = run_update(
|
||||
&db,
|
||||
&rt,
|
||||
"sql_update t set v = (select max(n) from other) where id = 1",
|
||||
"update t set v = (select max(n) from other) where id = 1",
|
||||
)
|
||||
.expect("subquery-set update runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
@@ -185,7 +185,7 @@ fn update_matching_no_rows_is_ok() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set v = 'x' where id = 999")
|
||||
let result = run_update(&db, &rt, "update t set v = 'x' where id = 999")
|
||||
.expect("no-match update is a success");
|
||||
assert_eq!(result.rows_affected, 0, "no rows matched");
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
@@ -198,7 +198,7 @@ fn update_appends_literal_line_to_history() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'old')", "t");
|
||||
let input = "sql_update t set v = 'new' where id = 1";
|
||||
let input = "update t set v = 'new' where id = 1";
|
||||
run_update(&db, &rt, input).expect("update runs");
|
||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log present");
|
||||
@@ -215,7 +215,7 @@ fn update_returning_yields_modified_columns() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'old'), (2, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set v = 'new' where id = 1 returning id, v")
|
||||
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1 returning id, v")
|
||||
.expect("UPDATE … RETURNING runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row updated");
|
||||
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()]);
|
||||
@@ -230,7 +230,7 @@ fn update_returning_recovers_bare_column_type() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, active) values (1, false)", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set active = true where id = 1 returning active")
|
||||
let result = run_update(&db, &rt, "update t set active = true where id = 1 returning active")
|
||||
.expect("UPDATE … RETURNING active runs");
|
||||
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered");
|
||||
assert_eq!(result.data.rows[0][0], Some("true".to_string()));
|
||||
@@ -246,7 +246,7 @@ fn update_returning_matching_no_rows_is_ok_and_empty() {
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "sql_update t set v = 'x' where id = 999 returning id, v")
|
||||
let result = run_update(&db, &rt, "update t set v = 'x' where id = 999 returning id, v")
|
||||
.expect("no-match UPDATE … RETURNING is a success");
|
||||
assert_eq!(result.rows_affected, 0, "no rows matched");
|
||||
assert!(result.data.rows.is_empty(), "no rows returned");
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
#![allow(dead_code, unreachable_pub)]
|
||||
|
||||
use rdbms_playground::completion::{
|
||||
Completion, SchemaCache, TableColumn, candidates_at_cursor,
|
||||
Completion, SchemaCache, TableColumn, candidates_at_cursor_in_mode,
|
||||
};
|
||||
use rdbms_playground::dsl::parser::parse_command_with_schema;
|
||||
use rdbms_playground::dsl::parser::parse_command_with_schema_in_mode;
|
||||
use rdbms_playground::dsl::types::Type;
|
||||
use rdbms_playground::input_render::{
|
||||
AmbientHint, InputState, ambient_hint, classify_input_with_schema,
|
||||
AmbientHint, InputState, ambient_hint_in_mode, classify_input_with_schema_in_mode,
|
||||
};
|
||||
use rdbms_playground::mode::Mode;
|
||||
|
||||
pub mod insert_form_a;
|
||||
pub mod insert_form_b;
|
||||
@@ -174,11 +175,24 @@ pub struct Assessment {
|
||||
}
|
||||
|
||||
/// Assess the typing surface at the given cell.
|
||||
///
|
||||
/// The whole typing-surface matrix exercises the **DSL** surface
|
||||
/// (insert Forms A/B/C, `update`/`delete` with `where` / `--all-rows`,
|
||||
/// the DSL expression grammar, DDL, app commands) — which is the
|
||||
/// **Simple-mode** surface (ADR-0003). So every facet here is computed
|
||||
/// in Simple mode, and consistently so: `ambient_hint` already
|
||||
/// defaults to Simple, and the others are pinned to Simple via their
|
||||
/// `*_in_mode` variants. This matters since sub-phase 3j made
|
||||
/// `insert`/`update`/`delete` shared entry words (ADR-0033
|
||||
/// Amendment 3): in Advanced mode those route to the SQL grammar
|
||||
/// (different completion / hints / parse), whereas the DSL forms this
|
||||
/// matrix documents live in Simple mode. The SQL surface is covered
|
||||
/// by `tests/sql_*.rs` and the advanced-mode walker diagnostics.
|
||||
pub fn assess(input: &str, cursor: usize, schema: &SchemaCache) -> Assessment {
|
||||
let state = classify_input_with_schema(input, schema);
|
||||
let hint = ambient_hint(input, cursor, None, schema);
|
||||
let completion = candidates_at_cursor(input, cursor, schema);
|
||||
let parse_result = match parse_command_with_schema(input, schema) {
|
||||
let state = classify_input_with_schema_in_mode(input, schema, Mode::Simple);
|
||||
let hint = ambient_hint_in_mode(input, cursor, None, schema, Mode::Simple);
|
||||
let completion = candidates_at_cursor_in_mode(input, cursor, schema, Mode::Simple);
|
||||
let parse_result = match parse_command_with_schema_in_mode(input, schema, Mode::Simple) {
|
||||
Ok(cmd) => Ok(command_kind_label(&cmd)),
|
||||
Err(e) => Err(parse_error_label(&e)),
|
||||
};
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ Assessment {
|
||||
),
|
||||
hint: Some(
|
||||
Prose(
|
||||
"for `Name`: Type a quoted string (e.g. 'Alice') or null (`id` auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.)",
|
||||
"for `Name`: Type a quoted string (e.g. 'Alice') or null (`id` auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.) (valid as SQL in advanced mode — `mode advanced` or prefix `:`)",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
|
||||
Reference in New Issue
Block a user