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:
@@ -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` —
|
||||
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
|
||||
validated, execution stays verbatim). Phase 3 (completion
|
||||
hinting/highlighting — the only part needing a grammar change) pending.
|
||||
validated, execution stays verbatim). **Phase 3a implemented 2026-05-26**
|
||||
— 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**
|
||||
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
|
||||
names the offending value. `WHERE` is deliberately not validated (§2).
|
||||
- **Phase 3 — completion hinting / highlighting.** This is the *only*
|
||||
part that needs a grammar change: a `Choice(typed-literal-slot,
|
||||
sql_expr)` at each value position (reusing the DSL's live
|
||||
`column_value_list` / `TypedValueSlot`s — `data.rs:141`/`189`/`269`),
|
||||
so the column type drives a live hint and a mismatch highlights while
|
||||
typing. When Phase 3 lands, the typed slot supersedes Phase 1's
|
||||
classification of literals (the validation/enrichment built on top is
|
||||
unaffected — that is the only throwaway, by design).
|
||||
part that needs a grammar change: a typed-literal slot vs `sql_expr` at
|
||||
each value position (reusing the DSL's live `column_value_list` /
|
||||
`TypedValueSlot`s — `data.rs:141`/`189`/`269`), so the column type
|
||||
drives a live hint and a mismatch highlights while typing. When Phase 3
|
||||
lands, the typed slot supersedes Phase 1/2's classification of literals
|
||||
(the validation/enrichment built on top is unaffected — that is the
|
||||
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
|
||||
|
||||
@@ -315,6 +323,69 @@ execution), only its `Result` is used.
|
||||
validation/enrichment built on it is permanent; only the detection is
|
||||
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 1–2 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
|
||||
|
||||
- ADR-0030 §4 / ADR-0033 §10 — the execute-path this ADR **augments**
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -456,3 +456,114 @@ pub fn column_value_list(ctx: &WalkContext) -> Node {
|
||||
}
|
||||
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,6 +13,7 @@
|
||||
//! (3g), and `ON CONFLICT … ` UPSERT (3h) land in later
|
||||
//! sub-phases.
|
||||
|
||||
use crate::dsl::grammar::shared::SET_VALUE;
|
||||
use crate::dsl::grammar::sql_expr;
|
||||
use crate::dsl::grammar::sql_select::{
|
||||
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`
|
||||
/// assignment. Mirrors `sql_update`'s `ASSIGN_COLUMN` shape (same
|
||||
/// `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 {
|
||||
source: IdentSource::Columns,
|
||||
role: "update_set_column",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_column: true,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
/// `column '=' sql_expr` — the RHS reuses the shared expression
|
||||
/// grammar (ADR-0031), so `excluded.col`, literals, operators,
|
||||
/// `CASE`, and function calls are all admitted. `excluded` is the
|
||||
/// would-have-been-inserted row (ADR-0033 §9); it parses as a
|
||||
/// `column '=' <value>` — the RHS is the boundary-aware `SET_VALUE`
|
||||
/// slot (ADR-0036 Phase 3a), shared with `sql_update`: a lone literal
|
||||
/// routes to the column-typed slot (live hint + highlight) while an
|
||||
/// 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.
|
||||
static UPSERT_ASSIGNMENT_NODES: &[Node] = &[
|
||||
UPSERT_SET_COLUMN,
|
||||
Node::Punct('='),
|
||||
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
|
||||
];
|
||||
static UPSERT_ASSIGNMENT_NODES: &[Node] = &[UPSERT_SET_COLUMN, Node::Punct('='), SET_VALUE];
|
||||
static UPSERT_ASSIGNMENT: Node = Node::Seq(UPSERT_ASSIGNMENT_NODES);
|
||||
// `const` — used by value in `DO_UPDATE_NODES` (static-vs-const
|
||||
// rule: a `Node` referenced by value in a `static [...]` must be
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! `--all-rows` rail — a SQL `UPDATE` without `WHERE` runs as
|
||||
//! 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::{IdentSource, Node, Word};
|
||||
|
||||
@@ -44,28 +44,33 @@ const TARGET_TABLE: Node = Node::Ident {
|
||||
};
|
||||
|
||||
/// 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 {
|
||||
source: IdentSource::Columns,
|
||||
role: "update_set_column",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_column: true,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
/// `column_name '=' sql_expr` — the RHS reuses the shared
|
||||
/// expression grammar (ADR-0031), so literals, operators, `CASE`,
|
||||
/// function calls, and scalar subqueries are all admitted; the
|
||||
/// engine evaluates them at execution time.
|
||||
static ASSIGNMENT_NODES: &[Node] = &[
|
||||
ASSIGN_COLUMN,
|
||||
Node::Punct('='),
|
||||
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
|
||||
];
|
||||
/// `column_name '=' <value>` — the RHS is the boundary-aware
|
||||
/// `SET_VALUE` slot (ADR-0036 Phase 3a): a lone literal routes to the
|
||||
/// column-typed slot (live per-column hint + numeric-shape highlight,
|
||||
/// shared with the DSL), while any expression — arithmetic, a
|
||||
/// literal-prefixed form, `CASE`, function calls, scalar subqueries —
|
||||
/// falls through to the full `sql_expr` grammar (ADR-0031), which the
|
||||
/// engine evaluates at execution time.
|
||||
static ASSIGNMENT_NODES: &[Node] = &[ASSIGN_COLUMN, Node::Punct('='), SET_VALUE];
|
||||
static ASSIGNMENT: Node = Node::Seq(ASSIGNMENT_NODES);
|
||||
|
||||
/// `assignment ( ',' assignment )*`.
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
//! worker-level tests call `db.run_sql_insert` directly with the
|
||||
//! real reconstructed SQL.
|
||||
|
||||
use rdbms_playground::completion::{SchemaCache, TableColumn};
|
||||
use rdbms_playground::db::{Database, DbError, InsertResult};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
|
||||
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::project;
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@
|
||||
//! append `history.log`). A SQL `UPDATE` without `WHERE` runs
|
||||
//! 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::dsl::{ColumnSpec, Command, Type, Value, parse_command};
|
||||
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::project;
|
||||
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_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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user