feat: ADR-0036 Phase 3b — live typed-slot hints + highlighting for INSERT VALUES
Give each positional INSERT VALUES position its column identity so a lone
literal gets the column-typed slot (live per-column hint + mismatch
highlight) and any expression falls through to sql_expr — completing the
typed-DML-values feature for the INSERT surface (single/multi-row, Form A
and Form B).
New zero-width Node::SetColumn(&TableColumn) primitive establishes the
active column for the value position that follows (sets current_column +
pending_value_column, like an Ident{writes_column} but without consuming
input); a DynamicSubgrammar emits SetColumn(col) + the shared SET_VALUE
per position. Column mapping mirrors do_sql_insert: Form A → listed
columns; Form B → all columns in declaration order (advanced-mode Form B
auto-fills nothing; an omitted shortid in Form A is auto-filled and has no
VALUES position).
Reconcile with the per-tuple arity diagnostic (ADR-0033 §8.1): a
fixed-length typed Seq would reject wrong-arity tuples and suppress that
post-walk diagnostic, so the tuple value list is an arity-gating lookahead
— a correct-arity tuple uses the typed Seq; a wrong-arity tuple keeps the
type-blind sql_expr repeat so §8.1 fires unchanged. Correct-arity tuples
get full live feedback, including a wrong-kind literal like 'text' into an
int column.
Records ADR-0036 Amendment 1 (Phase 3b detail + the arity reconciliation);
ADR-0036 is now fully implemented.
Tests: 1947 passing (+8), 0 failed, 0 skipped, 1 ignored; clippy clean.
This commit is contained in:
+156
-1
@@ -24,7 +24,9 @@ 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::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;
|
||||
@@ -1217,3 +1219,156 @@ fn advanced_upsert_do_update_set_offers_typed_slot_hint() {
|
||||
"text-column hint says `quoted string`: {prose:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// ADR-0036 Phase 3b — live typed-slot hints + highlighting for the
|
||||
// INSERT `VALUES (…)` positions (per-position column mapping via the
|
||||
// `Node::SetColumn` primitive; boundary-aware lookahead per position).
|
||||
// =================================================================
|
||||
|
||||
/// Build a `SchemaCache` for the advanced-mode typing-surface tests.
|
||||
fn vschema(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
|
||||
}
|
||||
|
||||
fn prose_at(input: &str, schema: &SchemaCache) -> String {
|
||||
let hint = ambient_hint_in_mode(input, input.len(), None, schema, Mode::Advanced);
|
||||
match hint {
|
||||
Some(AmbientHint::Prose(p)) => p,
|
||||
other => panic!("expected a Prose hint for {input:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_insert_form_a_value_offers_typed_slot_hint() {
|
||||
// Form A (explicit column list): the value position maps to the
|
||||
// user-listed column, so the hint is that column's typed prose.
|
||||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||
let prose = prose_at("insert into Things (note) values (", &schema);
|
||||
assert!(prose.contains("note"), "names listed column `note`: {prose:?}");
|
||||
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_insert_form_b_value_maps_first_column() {
|
||||
// Form B (no column list): positions map to ALL columns in
|
||||
// declaration order, so the first position is the first column.
|
||||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||
let prose = prose_at("insert into Things values (", &schema);
|
||||
assert!(prose.contains("k"), "names first column `k`: {prose:?}");
|
||||
assert!(prose.contains("integer"), "int-column prose: {prose:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_insert_second_position_hints_second_column() {
|
||||
// Per-position mapping advances: after the first value + comma, the
|
||||
// hint is the SECOND column's typed prose.
|
||||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||
let prose = prose_at("insert into Things (k, note) values (5, ", &schema);
|
||||
assert!(prose.contains("note"), "second position names `note`: {prose:?}");
|
||||
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_insert_value_int_mismatch_is_caught_live() {
|
||||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||
let bad = classify_input_with_schema_in_mode(
|
||||
"insert into Things (k) values (3.14)",
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(!matches!(bad, InputState::Valid), "decimal into int rejected live: {bad:?}");
|
||||
let ok = classify_input_with_schema_in_mode(
|
||||
"insert into Things (k) values (5)",
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(matches!(ok, InputState::Valid), "valid int literal parses: {ok:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_insert_string_into_int_is_caught_live() {
|
||||
// The Option-A win over the structural fallback: a wrong-KIND lone
|
||||
// literal (a string into an int column) is rejected WHILE TYPING,
|
||||
// not only at execution.
|
||||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||
let bad = classify_input_with_schema_in_mode(
|
||||
"insert into Things (k) values ('text')",
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(!matches!(bad, InputState::Valid), "string into int rejected live: {bad:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_insert_multi_row_typed_and_mismatch_caught() {
|
||||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||
let ok = classify_input_with_schema_in_mode(
|
||||
"insert into Things (k, note) values (1, 'a'), (2, 'b')",
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(matches!(ok, InputState::Valid), "well-formed multi-row parses: {ok:?}");
|
||||
let bad = classify_input_with_schema_in_mode(
|
||||
"insert into Things (k, note) values (1, 'a'), (3.14, 'b')",
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(
|
||||
!matches!(bad, InputState::Valid),
|
||||
"a mismatch in the second row is caught: {bad:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_insert_form_b_maps_all_columns_including_serial() {
|
||||
// SQL Form B supplies a value for EVERY column (no auto-fill), so
|
||||
// the position count = all columns, and a serial column's position
|
||||
// takes an int literal (unlike the DSL, which omits auto-gen cols).
|
||||
let schema = vschema(&[(
|
||||
"Customers",
|
||||
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
|
||||
)]);
|
||||
let state = classify_input_with_schema_in_mode(
|
||||
"insert into Customers values (1, 'Bob', 'b@c')",
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(matches!(state, InputState::Valid), "Form B maps all 3 columns: {state:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_insert_value_expressions_still_parse_via_sql_expr() {
|
||||
// Regression guard: a non-lone-literal value position (arithmetic,
|
||||
// literal-prefixed, function call, signed number) falls through to
|
||||
// sql_expr unchanged — the typed slot must not steal it.
|
||||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||
for input in [
|
||||
"insert into Things (k) values (1 + 2)",
|
||||
"insert into Things (k, note) values (5, upper(note))",
|
||||
"insert into Things (k) values (-5)",
|
||||
"insert into Things (k) values ((select 1))",
|
||||
] {
|
||||
let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
|
||||
assert!(matches!(state, InputState::Valid), "{input:?} must parse: {state:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user