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
+120
View File
@@ -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:?}"
);
}
}