feat: ADR-0036 Phase 3a — live typed-slot hints + highlighting for SQL SET values

Wire the DSL's column-typed value slots into the advanced-mode SQL
UPDATE/UPSERT `SET col = <rhs>` value position so a learner gets the same
per-column hint ("for `Email`: type a quoted string") and live numeric-
shape mismatch highlight the simple-mode DSL gives.

Discriminate literal-vs-expression with a boundary-aware lookahead
(shared::SET_VALUE), NOT the naive `Choice(typed-slot, sql_expr)` the ADR
originally sketched: the walker's Choice is first-match-wins with no
backtrack, so a typed slot would greedily match the leading `1` of `1 + 2`
and commit, regressing valid SQL (e.g. the existing `values (1, 1 + 2)`
test). The lookahead peeks the whole value position: a literal routes to
the typed slot only when it fills the position up to the next
`,`/`)`/`;`/`where`/`returning`/end; everything else falls through to the
full sql_expr grammar unchanged. The SET column ident gets
`writes_column: true` so `current_column` drives the slot + hint.

Scope: Phase 3a covers UPDATE's assignment list and INSERT's ON CONFLICT
DO UPDATE SET. Phase 3b (INSERT VALUES — needs a per-position grammar
restructure + multi-row) is deferred. Records ADR-0036 Amendment 1 with
the mechanism correction + the 3a/3b split.

Tests: 1939 passing (+5), 0 failed, 0 skipped, 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-26 22:48:46 +00:00
parent 8c3b13b313
commit 49ea03b0d5
7 changed files with 376 additions and 32 deletions
+80 -9
View File
@@ -16,8 +16,12 @@ validation + offending-value retention; the same capture-at-parse technique
on the SET assignment list — `capture_set_literals` in `data.rs` on the SET assignment list — `capture_set_literals` in `data.rs`
classifying each top-level RHS literal-vs-expression, validating literals in classifying each top-level RHS literal-vs-expression, validating literals in
`do_sql_update`, and reading them in `user_value_for_column`; `WHERE` is not `do_sql_update`, and reading them in `user_value_for_column`; `WHERE` is not
validated, execution stays verbatim). Phase 3 (completion validated, execution stays verbatim). **Phase 3a implemented 2026-05-26**
hinting/highlighting — the only part needing a grammar change) pending. — live typed-slot hints + numeric-shape highlighting for advanced-mode
`UPDATE`/UPSERT `SET col = <literal>` value positions, via a
**boundary-aware lookahead** (not the naive `Choice` this ADR originally
sketched in §5 — see **Amendment 1**). Phase 3b (`INSERT … VALUES` typed
slots — needs a per-position grammar restructure + multi-row) pending.
**Augments** **ADR-0030 §4** and **ADR-0033 §10** — it does **not** **Augments** **ADR-0030 §4** and **ADR-0033 §10** — it does **not**
supersede them and does **not** change the execution model. Advanced-mode supersede them and does **not** change the execution model. Advanced-mode
@@ -265,13 +269,17 @@ execution), only its `Result` is used.
verbatim update; `user_value_for_column` reads them so a constraint error verbatim update; `user_value_for_column` reads them so a constraint error
names the offending value. `WHERE` is deliberately not validated (§2). names the offending value. `WHERE` is deliberately not validated (§2).
- **Phase 3 — completion hinting / highlighting.** This is the *only* - **Phase 3 — completion hinting / highlighting.** This is the *only*
part that needs a grammar change: a `Choice(typed-literal-slot, part that needs a grammar change: a typed-literal slot vs `sql_expr` at
sql_expr)` at each value position (reusing the DSL's live each value position (reusing the DSL's live `column_value_list` /
`column_value_list` / `TypedValueSlot`s — `data.rs:141`/`189`/`269`), `TypedValueSlot`s — `data.rs:141`/`189`/`269`), so the column type
so the column type drives a live hint and a mismatch highlights while drives a live hint and a mismatch highlights while typing. When Phase 3
typing. When Phase 3 lands, the typed slot supersedes Phase 1's lands, the typed slot supersedes Phase 1/2's classification of literals
classification of literals (the validation/enrichment built on top is (the validation/enrichment built on top is unaffected — that is the
unaffected — that is the only throwaway, by design). only throwaway, by design). **The literal-vs-expression discriminator
is a boundary-aware *lookahead*, not a naive `Choice(typed-slot,
sql_expr)` — see Amendment 1, which corrects this section's mechanism
and splits Phase 3 into 3a (`SET`, implemented 2026-05-26) and 3b
(`VALUES`, pending).**
### 6. Non-goals ### 6. Non-goals
@@ -315,6 +323,69 @@ execution), only its `Result` is used.
validation/enrichment built on it is permanent; only the detection is validation/enrichment built on it is permanent; only the detection is
provisional — a deliberate, documented small throwaway. provisional — a deliberate, documented small throwaway.
## Amendment 1 — Phase 3 mechanism is a boundary-aware lookahead, not a naive `Choice`; Phase 3 split into 3a/3b (2026-05-26)
**Status:** Accepted (agreed with the user in conversation, 2026-05-26).
**Phase 3a implemented the same day.**
§5 Phase 3 sketched the mechanism as "a `Choice(typed-literal-slot,
sql_expr)` at each value position." Implementation found that sketch is
**wrong as written** and would regress valid SQL, so it is corrected here.
**Why the naive `Choice` is broken.** The walker's `Node::Choice` is
first-match-wins with **no cross-branch backtracking** once a branch has
committed a `Matched` (a later failure in the enclosing `Seq` does not
re-enter the Choice). At, say, an `int` column, the value `1 + 2`:
- Branch 0 (the typed slot) matches just the `1` and commits, leaving
`+ 2` dangling — the enclosing tuple/assignment then fails on `+`.
- The Choice never falls through to `sql_expr`, so a **valid, currently
parsing SQL expression is rejected**.
This is not hypothetical: `tests/sql_insert.rs::sql_insert_expression_value_is_not_validated_and_runs`
exercises exactly `values (1, 1 + 2)`. Putting `sql_expr` first instead
makes the typed slot unreachable (sql_expr matches bare literals too),
defeating the purpose. So the discriminator must know whether a literal
**fills the whole value position** before choosing the typed slot.
**The correction.** Discriminate by a **boundary-aware lookahead**
(`shared::SET_VALUE` → `set_value_node`): peek the value position and
route to the column-typed slot only when it is empty, a partial string
still being typed, or a single complete literal token whose next token is
a position boundary (`,` / `)` / `;` / `where` / `returning` / end);
otherwise route to `Subgrammar(sql_expr)`. The empty case still routes to
the slot so the per-column hint shows while the cursor sits right after
`=`. A leading sign folds into the literal (the slot's `NumberLit` uses
the same `consume_number_literal` that eats a `-`), so signed literals get
typed treatment too. `Node::Lookahead` already exists and is used the same
way by `insert_first_paren` (`data.rs`). The validation/enrichment from
Phases 12 is unchanged; only the *live-feedback detection* uses this
lookahead — consistent with §5's note that Phase 3's detection is the one
deliberate throwaway.
**Phase 3 split into 3a + 3b.** The two halves differ structurally:
- **Phase 3a (implemented) — `UPDATE` / UPSERT `SET col = <rhs>`.** Low
risk: the preceding `SET` column ident gets `writes_column: true` so
`current_column` (and the `pending_value_column` hint framing) is set
per assignment; the RHS becomes `shared::SET_VALUE`. Covers both
`sql_update`'s assignment list and `sql_insert`'s `ON CONFLICT … DO
UPDATE SET`. Mismatch examples now caught **live** (e.g. `set k = 3.14`
at an `int` column), matching what simple mode already does — earlier,
better feedback than Phase 2's execution-time catch.
- **Phase 3b (pending) — `INSERT … VALUES (…)`.** Harder: the values list
is `Repeated(VALUE_EXPR)` with **no per-position column identity**, and
multi-row `values (..),(..)` must be handled. It needs the DSL-style
per-position restructure (a `DynamicSubgrammar` emitting one
boundary-aware position per column), tracked as its own step.
**Known limitation (both phases, matches the DSL).** `date` / `shortid` /
`datetime` **format** is still not validated at parse — those slots accept
any quoted string; the format is checked at bind/execution time (Phase 2).
So the live highlight catches *numeric-shape* mismatches (`int`/`decimal`/
`bool`), not malformed dates. The column-type **hint** still shows for
every type.
## See also ## See also
- ADR-0030 §4 / ADR-0033 §10 — the execute-path this ADR **augments** - ADR-0030 §4 / ADR-0033 §10 — the execute-path this ADR **augments**
+1 -1
View File
File diff suppressed because one or more lines are too long
+111
View File
@@ -456,3 +456,114 @@ pub fn column_value_list(ctx: &WalkContext) -> Node {
} }
Node::Seq(Box::leak(children.into_boxed_slice())) Node::Seq(Box::leak(children.into_boxed_slice()))
} }
// =================================================================
// Advanced-mode SQL `SET col = <rhs>` value slot (ADR-0036 Phase 3a)
// =================================================================
//
// A SQL UPDATE / UPSERT `SET col = <rhs>` value position routes a
// *lone literal* (a string / number / `null` / `true` / `false` that
// fills the whole position up to the next `,` / `where` / `returning`
// / `;` / end-of-input) to the column-typed slot — so the learner gets
// the same per-column hint + numeric-shape highlight the DSL gives —
// and routes anything else (an expression: arithmetic, a literal-
// prefixed form like `1 + 2`, a function call, a scalar subquery, a
// column / `excluded` reference) to the full `sql_expr` grammar,
// unchanged (ADR-0030 §4).
//
// Discrimination is by *lookahead*, NOT `Choice([typed_slot,
// sql_expr])`. A naive Choice would let the typed slot greedily match
// the leading literal of `1 + 2` and commit, leaving `+ 2` dangling —
// the walker's `Choice` is first-match-wins with no cross-branch
// backtrack — turning a valid expression into a parse error. The
// lookahead peeks the whole value position first, so a literal routes
// to the typed slot only when it is the *entire* value (ADR-0036
// Amendment 1).
/// Lookahead factory for a SQL `SET col = <rhs>` value position
/// (ADR-0036 Phase 3a). Routes a lone literal to the column-typed slot
/// (live hint + highlight) and everything else to `sql_expr`.
fn set_value_node(_ctx: &WalkContext, source: &str, pos: usize) -> Node {
if set_rhs_is_lone_literal(source, pos) {
// The column type was resolved into `current_column` by the
// preceding `SET` column ident (`writes_column: true`), so the
// typed slot drives the per-column hint + highlight.
Node::DynamicSubgrammar(current_column_value)
} else {
// An expression — the engine evaluates it; keep the full
// `sql_expr` surface. Returned from a `fn` (not a `const`) so
// the reference doesn't enter the sql_expr ⇄ sql_select
// const-evaluation cycle (see the matching note in `data.rs`).
Node::Subgrammar(&crate::dsl::grammar::sql_expr::SQL_OR_EXPR)
}
}
/// The SQL `SET col = <rhs>` value slot (ADR-0036 Phase 3a). Shared by
/// `sql_update`'s assignment list and the `sql_insert` UPSERT
/// `DO UPDATE SET`.
pub const SET_VALUE: Node = Node::Lookahead(set_value_node);
/// True when the `SET` RHS starting at `pos` is empty, a partial string
/// still being typed, or exactly one complete literal token that fills
/// the value position (the next token is a position boundary). Such
/// positions route to the column-typed slot so its hint/highlight fire;
/// everything else is an expression and routes to `sql_expr`.
///
/// An empty RHS counts as lone-literal so the typed-slot hint shows
/// while the cursor sits right after `=`. A signed number (`-5`) counts
/// too: `consume_number_literal` — the same consumer the slot's
/// `NumberLit` uses — folds the leading sign into the literal, so the
/// slot can match it.
fn set_rhs_is_lone_literal(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{
consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
};
let p = skip_whitespace(source, pos);
if p >= source.len() {
return true; // empty RHS — show the typed-slot hint
}
// A string literal — single-quoted (SQL strings). Complete (a
// boundary must follow) or a partial one still being typed (route
// to the slot so the hint persists while typing).
if source.as_bytes()[p] == b'\'' {
return match consume_string_literal(source, p) {
Some(((_, end), _)) => next_is_set_boundary(source, end),
None => true, // unterminated string, mid-typing
};
}
// A number literal (incl. a leading sign) that fills the position.
if let Some((_, end)) = consume_number_literal(source, p) {
return next_is_set_boundary(source, end);
}
// `null` / `true` / `false` filling the position.
if let Some((s, e)) = consume_ident(source, p) {
let word = &source[s..e];
if word.eq_ignore_ascii_case("null")
|| word.eq_ignore_ascii_case("true")
|| word.eq_ignore_ascii_case("false")
{
return next_is_set_boundary(source, e);
}
return false; // any other identifier → column ref / function → expression
}
false // punctuation / sign-then-space / `(` → expression
}
/// True when the next non-whitespace token after `pos` ends a `SET`
/// value position: `,` (next assignment), `)` / `;` / end-of-input, or
/// a `where` / `returning` clause keyword.
fn next_is_set_boundary(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let p = skip_whitespace(source, pos);
let Some(b) = source.as_bytes().get(p) else {
return true; // end of input
};
if *b == b',' || *b == b';' || *b == b')' {
return true;
}
if let Some((s, e)) = consume_ident(source, p) {
let word = &source[s..e];
return word.eq_ignore_ascii_case("where") || word.eq_ignore_ascii_case("returning");
}
false
}
+13 -11
View File
@@ -13,6 +13,7 @@
//! (3g), and `ON CONFLICT … ` UPSERT (3h) land in later //! (3g), and `ON CONFLICT … ` UPSERT (3h) land in later
//! sub-phases. //! sub-phases.
use crate::dsl::grammar::shared::SET_VALUE;
use crate::dsl::grammar::sql_expr; use crate::dsl::grammar::sql_expr;
use crate::dsl::grammar::sql_select::{ use crate::dsl::grammar::sql_select::{
RETURNING_CLAUSE, SQL_SELECT_COMPOUND, WHERE_CLAUSE, reject_internal_table, RETURNING_CLAUSE, SQL_SELECT_COMPOUND, WHERE_CLAUSE, reject_internal_table,
@@ -146,30 +147,31 @@ const OPTIONAL_CONFLICT_TARGET: Node = Node::Optional(&Node::Seq(CONFLICT_TARGET
/// The column on the left of one `DO UPDATE SET col = expr` /// The column on the left of one `DO UPDATE SET col = expr`
/// assignment. Mirrors `sql_update`'s `ASSIGN_COLUMN` shape (same /// assignment. Mirrors `sql_update`'s `ASSIGN_COLUMN` shape (same
/// `update_set_column` role so it gets the same column completion / /// `update_set_column` role so it gets the same column completion /
/// diagnostics against the target table). /// diagnostics against the target table). `writes_column: true`
/// resolves the column type into `current_column` so the RHS
/// `SET_VALUE` lookahead can dispatch the typed slot for a lone
/// literal (ADR-0036 Phase 3a).
const UPSERT_SET_COLUMN: Node = Node::Ident { const UPSERT_SET_COLUMN: Node = Node::Ident {
source: IdentSource::Columns, source: IdentSource::Columns,
role: "update_set_column", role: "update_set_column",
validator: None, validator: None,
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: false,
writes_column: false, writes_column: true,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
/// `column '=' sql_expr` — the RHS reuses the shared expression /// `column '=' <value>` — the RHS is the boundary-aware `SET_VALUE`
/// grammar (ADR-0031), so `excluded.col`, literals, operators, /// slot (ADR-0036 Phase 3a), shared with `sql_update`: a lone literal
/// `CASE`, and function calls are all admitted. `excluded` is the /// routes to the column-typed slot (live hint + highlight) while an
/// would-have-been-inserted row (ADR-0033 §9); it parses as a /// expression — `excluded.col`, operators, `CASE`, function calls
/// falls through to the full `sql_expr` grammar (ADR-0031). `excluded`
/// is the would-have-been-inserted row (ADR-0033 §9); it parses as a
/// qualified ref via `sql_expr` and the engine resolves it. /// qualified ref via `sql_expr` and the engine resolves it.
static UPSERT_ASSIGNMENT_NODES: &[Node] = &[ static UPSERT_ASSIGNMENT_NODES: &[Node] = &[UPSERT_SET_COLUMN, Node::Punct('='), SET_VALUE];
UPSERT_SET_COLUMN,
Node::Punct('='),
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
];
static UPSERT_ASSIGNMENT: Node = Node::Seq(UPSERT_ASSIGNMENT_NODES); static UPSERT_ASSIGNMENT: Node = Node::Seq(UPSERT_ASSIGNMENT_NODES);
// `const` — used by value in `DO_UPDATE_NODES` (static-vs-const // `const` — used by value in `DO_UPDATE_NODES` (static-vs-const
// rule: a `Node` referenced by value in a `static [...]` must be // rule: a `Node` referenced by value in a `static [...]` must be
+16 -11
View File
@@ -15,7 +15,7 @@
//! `--all-rows` rail — a SQL `UPDATE` without `WHERE` runs as //! `--all-rows` rail — a SQL `UPDATE` without `WHERE` runs as
//! written (ADR-0030 §12). `RETURNING` (3g) lands later. //! written (ADR-0030 §12). `RETURNING` (3g) lands later.
use crate::dsl::grammar::sql_expr; use crate::dsl::grammar::shared::SET_VALUE;
use crate::dsl::grammar::sql_select::{RETURNING_CLAUSE, WHERE_CLAUSE, reject_internal_table}; use crate::dsl::grammar::sql_select::{RETURNING_CLAUSE, WHERE_CLAUSE, reject_internal_table};
use crate::dsl::grammar::{IdentSource, Node, Word}; use crate::dsl::grammar::{IdentSource, Node, Word};
@@ -44,28 +44,33 @@ const TARGET_TABLE: Node = Node::Ident {
}; };
/// The column on the left of one `SET col = expr` assignment. /// The column on the left of one `SET col = expr` assignment.
///
/// `writes_column: true` resolves the column's type into
/// `current_column` (and frames the value-slot hint via
/// `pending_value_column`), so the RHS `SET_VALUE` lookahead can
/// dispatch the column-typed slot for a lone literal (ADR-0036
/// Phase 3a).
const ASSIGN_COLUMN: Node = Node::Ident { const ASSIGN_COLUMN: Node = Node::Ident {
source: IdentSource::Columns, source: IdentSource::Columns,
role: "update_set_column", role: "update_set_column",
validator: None, validator: None,
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: false,
writes_column: false, writes_column: true,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
/// `column_name '=' sql_expr` — the RHS reuses the shared /// `column_name '=' <value>` — the RHS is the boundary-aware
/// expression grammar (ADR-0031), so literals, operators, `CASE`, /// `SET_VALUE` slot (ADR-0036 Phase 3a): a lone literal routes to the
/// function calls, and scalar subqueries are all admitted; the /// column-typed slot (live per-column hint + numeric-shape highlight,
/// engine evaluates them at execution time. /// shared with the DSL), while any expression — arithmetic, a
static ASSIGNMENT_NODES: &[Node] = &[ /// literal-prefixed form, `CASE`, function calls, scalar subqueries —
ASSIGN_COLUMN, /// falls through to the full `sql_expr` grammar (ADR-0031), which the
Node::Punct('='), /// engine evaluates at execution time.
Node::Subgrammar(&sql_expr::SQL_OR_EXPR), static ASSIGNMENT_NODES: &[Node] = &[ASSIGN_COLUMN, Node::Punct('='), SET_VALUE];
];
static ASSIGNMENT: Node = Node::Seq(ASSIGNMENT_NODES); static ASSIGNMENT: Node = Node::Seq(ASSIGNMENT_NODES);
/// `assignment ( ',' assignment )*`. /// `assignment ( ',' assignment )*`.
+35
View File
@@ -20,9 +20,12 @@
//! worker-level tests call `db.run_sql_insert` directly with the //! worker-level tests call `db.run_sql_insert` directly with the
//! real reconstructed SQL. //! real reconstructed SQL.
use rdbms_playground::completion::{SchemaCache, TableColumn};
use rdbms_playground::db::{Database, DbError, InsertResult}; use rdbms_playground::db::{Database, DbError, InsertResult};
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command}; use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
use rdbms_playground::event::AppEvent; use rdbms_playground::event::AppEvent;
use rdbms_playground::input_render::{AmbientHint, ambient_hint_in_mode};
use rdbms_playground::mode::Mode;
use rdbms_playground::persistence::Persistence; use rdbms_playground::persistence::Persistence;
use rdbms_playground::project; use rdbms_playground::project;
use rdbms_playground::runtime::run_replay; use rdbms_playground::runtime::run_replay;
@@ -1182,3 +1185,35 @@ fn sql_insert_natural_order_validates_against_schema_columns() {
"natural-order insert validates the date against column `d`; events: {events:?}" "natural-order insert validates the date against column `d`; events: {events:?}"
); );
} }
// =================================================================
// ADR-0036 Phase 3a — live typed-slot hint for the UPSERT
// `ON CONFLICT … DO UPDATE SET col = <rhs>` value position.
// =================================================================
#[test]
fn advanced_upsert_do_update_set_offers_typed_slot_hint() {
// ADR-0036 Phase 3a: the `DO UPDATE SET col = ` value position
// shares the SQL UPDATE `SET` treatment, so it drives the same
// column-typed slot hint (boundary-aware lookahead → typed slot).
let mut cache = SchemaCache::default();
let cols = vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "Name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
];
cache.tables.push("Customers".to_string());
cache.columns.push("id".to_string());
cache.columns.push("Name".to_string());
cache.table_columns.insert("Customers".to_string(), cols);
let input = "insert into Customers (id, Name) values (1, 'x') on conflict (id) do update set Name=";
let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced);
let Some(AmbientHint::Prose(prose)) = hint else {
panic!("expected a Prose hint at the UPSERT SET value slot, got {hint:?}");
};
assert!(prose.contains("Name"), "hint names the column `Name`: {prose:?}");
assert!(
prose.contains("quoted string"),
"text-column hint says `quoted string`: {prose:?}"
);
}
+120
View File
@@ -7,9 +7,14 @@
//! append `history.log`). A SQL `UPDATE` without `WHERE` runs //! append `history.log`). A SQL `UPDATE` without `WHERE` runs
//! across all rows with no rail (ADR-0030 §12). //! across all rows with no rail (ADR-0030 §12).
use rdbms_playground::completion::{SchemaCache, TableColumn};
use rdbms_playground::db::{Database, DbError, UpdateResult}; use rdbms_playground::db::{Database, DbError, UpdateResult};
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command}; use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
use rdbms_playground::event::AppEvent; use rdbms_playground::event::AppEvent;
use rdbms_playground::input_render::{
AmbientHint, InputState, ambient_hint_in_mode, classify_input_with_schema_in_mode,
};
use rdbms_playground::mode::Mode;
use rdbms_playground::persistence::Persistence; use rdbms_playground::persistence::Persistence;
use rdbms_playground::project; use rdbms_playground::project;
use rdbms_playground::runtime::run_replay; use rdbms_playground::runtime::run_replay;
@@ -368,3 +373,118 @@ fn update_returning_matching_no_rows_is_ok_and_empty() {
assert!(result.data.rows.is_empty(), "no rows returned"); assert!(result.data.rows.is_empty(), "no rows returned");
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()], "columns still present"); assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()], "columns still present");
} }
// =================================================================
// ADR-0036 Phase 3a — live typed-slot hints + highlighting for
// advanced-mode `SET col = <rhs>` (boundary-aware lookahead).
// =================================================================
/// Build a `SchemaCache` for the advanced-mode typing-surface tests
/// (mirrors `tests/typing_surface`'s `build_schema`).
fn schema_cache(tables: &[(&str, &[(&str, Type)])]) -> SchemaCache {
let mut cache = SchemaCache::default();
for (table, cols) in tables {
let table_cols: Vec<TableColumn> = cols
.iter()
.map(|(n, t)| TableColumn {
name: (*n).to_string(),
user_type: *t,
not_null: false,
has_default: false,
})
.collect();
cache.tables.push((*table).to_string());
for c in &table_cols {
if !cache.columns.contains(&c.name) {
cache.columns.push(c.name.clone());
}
}
cache.table_columns.insert((*table).to_string(), table_cols);
}
cache
}
#[test]
fn advanced_update_set_value_offers_typed_slot_hint_for_column() {
// ADR-0036 Phase 3a: at a `SET col = ` value position the
// advanced-mode SQL UPDATE now drives the same column-typed slot
// hint the DSL gives — "for `Email`: type a quoted string …" —
// instead of the type-blind sql_expr surface.
let schema = schema_cache(&[(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
)]);
let input = "update Customers set Email=";
let hint = ambient_hint_in_mode(input, input.len(), None, &schema, Mode::Advanced);
let Some(AmbientHint::Prose(prose)) = hint else {
panic!("expected a Prose hint at the typed value slot, got {hint:?}");
};
assert!(prose.contains("Email"), "hint names the column `Email`: {prose:?}");
assert!(
prose.contains("quoted string"),
"text-column hint says `quoted string`: {prose:?}"
);
}
#[test]
fn advanced_update_set_date_value_hint_says_yyyy_mm_dd() {
let schema = schema_cache(&[("Things", &[("k", Type::Int), ("dt", Type::Date)])]);
let input = "update Things set dt=";
let hint = ambient_hint_in_mode(input, input.len(), None, &schema, Mode::Advanced);
let Some(AmbientHint::Prose(prose)) = hint else {
panic!("expected a Prose hint at the date value slot, got {hint:?}");
};
assert!(
prose.contains("YYYY-MM-DD"),
"date-column hint references the YYYY-MM-DD format: {prose:?}"
);
}
#[test]
fn advanced_update_set_int_value_type_mismatch_is_caught_live() {
// A decimal literal at an `int` column now fails to parse in
// advanced mode (the typed slot's integer validator fires while
// typing) — previously the verbatim sql_expr surface accepted it
// and only Phase 2's execution-time validation caught it.
let schema = schema_cache(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
let bad = classify_input_with_schema_in_mode(
"update Things set k = 3.14 where k = 0",
&schema,
Mode::Advanced,
);
assert!(
!matches!(bad, InputState::Valid),
"a decimal at an int column is rejected live (typed slot), got {bad:?}"
);
// A well-formed integer literal still parses cleanly.
let ok = classify_input_with_schema_in_mode(
"update Things set k = 5 where k = 0",
&schema,
Mode::Advanced,
);
assert!(matches!(ok, InputState::Valid), "a valid int literal parses: {ok:?}");
}
#[test]
fn advanced_update_set_expression_still_parses_via_sql_expr() {
// Regression guard: the boundary-aware lookahead must fall through
// to sql_expr for anything that is not a lone literal — arithmetic,
// a literal-prefixed expression, a function call, a scalar subquery.
// None of these may be stolen by the typed slot.
let schema = schema_cache(&[
("Things", &[("k", Type::Int), ("note", Type::Text)]),
("other", &[("n", Type::Int)]),
]);
for input in [
"update Things set k = 3 + 2 where k = 0", // literal-prefixed expression
"update Things set k = (select max(n) from other) where k = 0", // scalar subquery
"update Things set note = upper(note) where k = 0", // function call
"update Things set k = -5 where k = 0", // signed number → sql_expr
] {
let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
assert!(
matches!(state, InputState::Valid),
"{input:?} must still parse via sql_expr, got {state:?}"
);
}
}