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:
@@ -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