From b3f1a20652e66f72cd19f48a6260cd8c8db1a255 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 15 May 2026 18:45:47 +0000 Subject: [PATCH] Phase D: insert value list mirrors do_insert's user_cols contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: hint at \`insert into Customers values (\` for a Customers table with id:serial PK suggested typing an integer for \`id\`, but the dispatch path (\`db::do_insert\`) deliberately doesn't accept user-supplied values for auto-generated columns in Form B. The grammar prompted for a value the dispatch would refuse. The fix aligns Phase D's \`column_value_list\` dynamic sub-grammar with do_insert's three forms (ADR-0014 + ADR-0018 §3): - **Form A** \`insert into (col1, col2, …) values (…)\` — user explicitly lists columns. Slot list mirrors that selection; serial / shortid columns CAN appear if the user lists them. - **Form B** \`insert into values (…)\` — bare values. Slot list = non-auto-generated columns of the table in declaration order. Serial / shortid get auto-filled by the dispatch; the grammar doesn't prompt for them. - **Form C** \`insert into (v1, v2, …)\` — bare value list. Not affected by this change (column_value_list isn't on this path; Form C's literals route through the schemaless INSERT_PAREN_LIST). Implementation: \`WalkContext.user_listed_columns: Option>\` — when \`Some\`, signals Form A; \`None\` is Form B. Populated by walking the first paren's column-list idents. \`Node::Ident.writes_user_listed_column: bool\` — new field; \`true\` on the INSERT_PAREN_ITEM's Ident child. When the walker matches that ident in Form A, it appends the schema-canonical column name (case-corrected against the schema) to user_listed_columns. \`column_value_list\` factory: - If user_listed_columns is Some → resolve each name from the schema; one typed slot per listed column. - Else → filter current_table_columns to non-auto-generated; one typed slot per remaining column. - Empty result → fall back to the schemaless value-literal list (a serial-only table in Form B has nothing for the user to type). Tests: - New \`phase_d_insert_form_b_skips_serial_column\` confirms the bug: \`insert into Customers values (1, 'Alice')\` against a Customers with serial id rejects at parse time (Form B expects 1 value for Name, not 2). - New \`phase_d_insert_form_a_accepts_serial_when_listed\` confirms \`insert into Customers (id, Name) values (1, 'Alice')\` works. - New \`phase_d_insert_form_a_filters_to_user_listed_columns\` confirms partial Form A (\`(Name) values ('Alice')\`). - Updated \`phase_d_insert_with_schema_accepts_typed_values_per_column\` to match the new Form B contract (2 user-typed values, not 3). - Updated typed-hint test matrix split into form-B (8 types) and form-A (serial / shortid). - New \`typed_hint_form_b_skips_serial_column_to_generic_or_text_neighbor\` pins the fallback behavior for a serial-only table. For the user: \`insert into Customers values (\` for a Customers with \`(id:serial, Name:text, Email:text)\` now hints \`for \`Name\`: Type a quoted string …\` (skipping id entirely) and accepts exactly 2 values. To set the serial explicitly, use Form A: \`insert into Customers (id, Name, Email) values (1, 'Alice', 'a@b.c')\`. Tests: 851 passing, 0 failing, 1 ignored. Clippy clean. --- src/dsl/grammar/app.rs | 3 + src/dsl/grammar/data.rs | 13 ++++ src/dsl/grammar/ddl.rs | 17 +++++ src/dsl/grammar/mod.rs | 8 +++ src/dsl/grammar/shared.rs | 58 +++++++++++++--- src/dsl/walker/context.rs | 15 ++++ src/dsl/walker/driver.rs | 25 +++++++ src/dsl/walker/mod.rs | 119 +++++++++++++++++++++++++++++--- src/friendly/strings/en-US.yaml | 9 ++- 9 files changed, 249 insertions(+), 18 deletions(-) diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index 38f8f6d..b780066 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -52,6 +52,7 @@ const IMPORT_AS_TARGET: Node = Node::Seq(&[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]); const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET); @@ -76,6 +77,7 @@ const MODE_CHOICES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]; const MODE_VALUE: Node = Node::Choice(MODE_CHOICES); @@ -90,6 +92,7 @@ const MESSAGES_CHOICES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]; const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES); diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 242f3d2..0439063 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -35,6 +35,7 @@ const TABLE_NAME_EXISTING: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }; /// Table-name slot variant that populates @@ -48,6 +49,7 @@ const TABLE_NAME_INSERT: Node = Node::Ident { highlight_override: None, writes_table: true, writes_column: false, + writes_user_listed_column: false, }; // `value_literal` — null / true / false / number / string. The @@ -108,6 +110,14 @@ const INSERT_PAREN_ITEM_CHOICES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + // Form A signal: when the user lists explicit columns + // in `insert into (col1, col2, …)`, the walker + // appends each matched name to + // `WalkContext::user_listed_columns`. The inner + // `values (…)` slot list then mirrors that user + // selection instead of the auto-filtered default + // (ADR-0024 §Phase D §column_value_list). + writes_user_listed_column: true, }, ]; const INSERT_PAREN_ITEM: Node = Node::Choice(INSERT_PAREN_ITEM_CHOICES); @@ -173,6 +183,7 @@ const TABLE_NAME_WRITES: Node = Node::Ident { highlight_override: None, writes_table: true, writes_column: false, + writes_user_listed_column: false, }; /// Column-name slot in `set col = …` — resolves the column's @@ -185,6 +196,7 @@ const SET_COLUMN: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: true, + writes_user_listed_column: false, }; /// Column-name slot in `where col = …` — same writes-column @@ -196,6 +208,7 @@ const FILTER_COLUMN: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: true, + writes_user_listed_column: false, }; /// Value slot resolved at walk time from diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 1ef950d..7d06373 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -31,6 +31,7 @@ const TABLE_NAME_NEW: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }; const TABLE_NAME_EXISTING: Node = Node::Ident { @@ -40,6 +41,7 @@ const TABLE_NAME_EXISTING: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }; const COLUMN_NAME: Node = Node::Ident { @@ -49,6 +51,7 @@ const COLUMN_NAME: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }; const COLUMN_NAME_NEW: Node = Node::Ident { @@ -58,6 +61,7 @@ const COLUMN_NAME_NEW: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }; const RELATIONSHIP_NAME: Node = Node::Ident { @@ -67,6 +71,7 @@ const RELATIONSHIP_NAME: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }; const RELATIONSHIP_NAME_NEW: Node = Node::Ident { @@ -76,6 +81,7 @@ const RELATIONSHIP_NAME_NEW: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }; // `[to]` and `[table]` connectives. @@ -120,6 +126,7 @@ const DR_PARENT_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, Node::Punct('.'), Node::Ident { @@ -129,6 +136,7 @@ const DR_PARENT_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]; const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES); @@ -141,6 +149,7 @@ const DR_CHILD_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, Node::Punct('.'), Node::Ident { @@ -150,6 +159,7 @@ const DR_CHILD_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]; const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES); @@ -210,6 +220,7 @@ const AR_PARENT_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, Node::Punct('.'), Node::Ident { @@ -219,6 +230,7 @@ const AR_PARENT_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]; const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES); @@ -231,6 +243,7 @@ const AR_CHILD_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, Node::Punct('.'), Node::Ident { @@ -240,6 +253,7 @@ const AR_CHILD_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]; const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES); @@ -293,6 +307,7 @@ const RENAME_COLUMN_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]; const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES); @@ -627,6 +642,7 @@ const COL_SPEC_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, Node::Punct(':'), Node::Ident { @@ -636,6 +652,7 @@ const COL_SPEC_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]; const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES); diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index d2e8449..b251641 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -233,6 +233,14 @@ pub enum Node { highlight_override: Option, writes_table: bool, writes_column: bool, + /// Append the matched text to + /// `WalkContext::user_listed_columns` (Phase D). Used by + /// the `insert into (col1, col2, …)` column-list + /// idents — when the walker sees these, the form is + /// "Form A" and the inner values slot list mirrors the + /// user's explicit selection instead of the + /// auto-filtered schema default. + writes_user_listed_column: bool, }, /// A number literal. The optional `validator` runs against /// the matched text (used by Phase D value slots to enforce diff --git a/src/dsl/grammar/shared.rs b/src/dsl/grammar/shared.rs index 1478184..4c6d185 100644 --- a/src/dsl/grammar/shared.rs +++ b/src/dsl/grammar/shared.rs @@ -5,6 +5,7 @@ //! actions; Phase D extends with `where_clause`, //! `column_value_list`, and the typed value slots. +use crate::completion::TableColumn; use crate::dsl::grammar::{ IdentSource, IdentValidator, Node, NumberValidator, ValidationError, Word, }; @@ -51,6 +52,7 @@ pub const TYPE_SLOT: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }; // --- Qualified column reference (`.`) -------------- @@ -63,6 +65,7 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, Node::Punct('.'), Node::Ident { @@ -72,6 +75,7 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[ highlight_override: None, writes_table: false, writes_column: false, + writes_user_listed_column: false, }, ]; pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES); @@ -373,18 +377,56 @@ pub fn current_column_value(ctx: &WalkContext) -> Node { /// `Repeated(VALUE_LITERAL, ',', 1)` shape so existing /// callers/tests continue to work. pub fn column_value_list(ctx: &WalkContext) -> Node { - let Some(cols) = ctx.current_table_columns.as_ref() else { + let Some(table_cols) = ctx.current_table_columns.as_ref() else { return FALLBACK_VALUE_LIST; }; - if cols.is_empty() { + if table_cols.is_empty() { return FALLBACK_VALUE_LIST; } - // Build a Seq of typed slots interleaved with commas. - // Each slot embeds its column name so the hint resolver - // can mention the column by name ("for `Email`: Type a - // quoted string …"). - let mut children: Vec = Vec::with_capacity(cols.len() * 2); - for (i, col) in cols.iter().enumerate() { + // Three dispatch shapes (ADR-0024 §Phase D §column_value_list, + // matching `db::do_insert`'s user_cols logic): + // + // 1. Form A — user listed explicit columns + // (`insert into T (col1, col2, …) values (…)`): one slot + // per listed column, in the user's order, types resolved + // from the schema. + // 2. Form B — bare values keyword + // (`insert into T values (…)`): one slot per non-auto- + // generated column of T, in declaration order. Serial / + // shortid columns are skipped because the dispatch path + // auto-fills them (ADR-0018 §3). + // 3. Schemaless / fallback: the generic value-literal list. + let target_cols: Vec<&TableColumn> = ctx.user_listed_columns.as_ref().map_or_else( + || { + // Form B — exclude auto-generated columns. + table_cols + .iter() + .filter(|c| !matches!(c.user_type, Type::Serial | Type::ShortId)) + .collect() + }, + |user_listed| { + // Form A — resolve each listed name from the schema. + // Names the schema doesn't know about silently drop; + // the bind-time path catches unknown columns. + user_listed + .iter() + .filter_map(|name| { + table_cols + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + }) + .collect() + }, + ); + if target_cols.is_empty() { + return FALLBACK_VALUE_LIST; + } + // Build a Seq of typed slots interleaved with commas. Each + // slot embeds its column name so the hint resolver can + // mention the column by name ("for `Email`: Type a quoted + // string …"). + let mut children: Vec = Vec::with_capacity(target_cols.len() * 2); + for (i, col) in target_cols.iter().enumerate() { if i > 0 { children.push(Node::Punct(',')); } diff --git a/src/dsl/walker/context.rs b/src/dsl/walker/context.rs index f474e4c..bdcabfb 100644 --- a/src/dsl/walker/context.rs +++ b/src/dsl/walker/context.rs @@ -54,6 +54,20 @@ pub struct WalkContext<'a> { /// Cleared on successful inner match alongside /// `pending_value_type`. pub pending_value_column: Option, + /// The columns the user explicitly listed in + /// `insert into (col1, col2, …) values (…)` (Form A), + /// in declaration order. + /// + /// Populated as each ident-shape token in the leading paren + /// matches an `Ident` node with `writes_user_listed_column: + /// true`. `None` (default) means no explicit list was + /// observed — the inner `values (…)` slot list then + /// defaults to "every non-auto-generated column of the + /// current table" (Form B `insert into T values (…)` + /// behavior; ADR-0018 §3 — auto-generated columns are + /// skipped from the value list because the dispatch path + /// auto-fills them). + pub user_listed_columns: Option>, } impl<'a> WalkContext<'a> { @@ -77,6 +91,7 @@ impl<'a> WalkContext<'a> { current_column: None, pending_value_type: None, pending_value_column: None, + user_listed_columns: None, } } } diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index 48f6f6c..dcb83ec 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -94,6 +94,7 @@ pub fn walk_node( highlight_override: _, writes_table, writes_column, + writes_user_listed_column, } => walk_ident( source, pos, @@ -102,6 +103,7 @@ pub fn walk_node( *validator, *writes_table, *writes_column, + *writes_user_listed_column, ctx, path, per_byte, @@ -241,6 +243,7 @@ fn walk_ident( validator: Option, writes_table: bool, writes_column: bool, + writes_user_listed_column: bool, ctx: &mut WalkContext, path: &mut MatchedPath, per_byte: &mut Vec, @@ -289,6 +292,28 @@ fn walk_ident( .map(|c| c.name.clone()) .or_else(|| Some(text.clone())); } + if writes_user_listed_column + && matches!(src, crate::dsl::grammar::IdentSource::Columns) + { + // Form A: `insert into (col1, col2, …)`. Append the + // matched column name to user_listed_columns so the + // inner `values (…)` slot list mirrors the user's + // explicit selection. Schema-canonical name wins over + // user's spelling so downstream lookups (typed slot + // dispatch, hint rendering) are consistent. + let canonical = ctx + .current_table_columns + .as_ref() + .and_then(|cols| { + cols.iter() + .find(|c| c.name.eq_ignore_ascii_case(&text)) + .map(|c| c.name.clone()) + }) + .unwrap_or_else(|| text.clone()); + ctx.user_listed_columns + .get_or_insert_with(Vec::new) + .push(canonical); + } path.push(MatchedItem { kind: MatchedKind::Ident { role }, text, diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 21ffbdd..3421e3d 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -1394,20 +1394,91 @@ mod tests { #[test] fn phase_d_insert_with_schema_accepts_typed_values_per_column() { + // Form B: the grammar dispatches one slot per + // non-auto-generated column — the serial `id` is + // skipped because the dispatch path (`db::do_insert`) + // auto-fills it (ADR-0018 §3). let schema = schema_with( "Customers", &[("id", Type::Serial), ("Name", Type::Text), ("Active", Type::Bool)], ); - // 3 columns: int, text, bool. Each value matches its slot. + // 2 user-typed values: Name (text), Active (bool). let cmd = parse_command_with_schema( - "insert into Customers values (1, 'Alice', true)", + "insert into Customers values ('Alice', true)", &schema, ) .expect("parse"); match cmd { Command::Insert { table, values, .. } => { assert_eq!(table, "Customers"); - assert_eq!(values.len(), 3); + assert_eq!(values.len(), 2); + } + other => panic!("expected Insert, got {other:?}"), + } + } + + #[test] + fn phase_d_insert_form_b_skips_serial_column() { + // Form B: `insert into values (…)` excludes + // auto-generated columns from the value list. Supplying + // a value for the serial column is a count mismatch. + let schema = schema_with( + "Customers", + &[("id", Type::Serial), ("Name", Type::Text)], + ); + // Two values where Form B expects one (Name only): + let err = parse_command_with_schema( + "insert into Customers values (1, 'Alice')", + &schema, + ) + .expect_err("Form B should reject user-supplied serial"); + match err { + crate::dsl::ParseError::Invalid { .. } => {} + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn phase_d_insert_form_a_accepts_serial_when_listed() { + // Form A: user explicitly lists `id`. The dispatch path + // accepts user-supplied serial values when they're in + // the explicit column list; the grammar mirrors that. + let schema = schema_with( + "Customers", + &[("id", Type::Serial), ("Name", Type::Text)], + ); + let cmd = parse_command_with_schema( + "insert into Customers (id, Name) values (1, 'Alice')", + &schema, + ) + .expect("parse"); + match cmd { + Command::Insert { columns, values, .. } => { + assert_eq!(columns.as_deref(), Some(&["id".to_string(), "Name".to_string()][..])); + assert_eq!(values.len(), 2); + } + other => panic!("expected Insert, got {other:?}"), + } + } + + #[test] + fn phase_d_insert_form_a_filters_to_user_listed_columns() { + // Form A: listing only Name should accept exactly one + // value (for Name), even though the table has more + // columns. + let schema = schema_with( + "Customers", + &[("id", Type::Serial), ("Name", Type::Text), ("Active", Type::Bool)], + ); + let cmd = parse_command_with_schema( + "insert into Customers (Name) values ('Alice')", + &schema, + ) + .expect("parse"); + match cmd { + Command::Insert { columns, values, .. } => { + assert_eq!(columns.as_deref(), Some(&["Name".to_string()][..])); + assert_eq!(values.len(), 1); } other => panic!("expected Insert, got {other:?}"), } @@ -1654,9 +1725,10 @@ mod tests { } #[test] - fn typed_hint_for_each_type_routes_to_correct_catalog_key() { - // Confirm each Type maps to its expected catalog key - // via insert at a single-column table. + fn typed_hint_for_each_user_settable_type_routes_via_form_b() { + // Form B (`insert into T values (…)`) excludes auto- + // generated columns from the value list — so only the + // user-settable types appear at this position. for (ty, key) in [ (Type::Int, "hint.value_slot_int"), (Type::Real, "hint.value_slot_real"), @@ -1666,8 +1738,6 @@ mod tests { (Type::Date, "hint.value_slot_date"), (Type::DateTime, "hint.value_slot_datetime"), (Type::Blob, "hint.value_slot_blob"), - (Type::Serial, "hint.value_slot_serial"), - (Type::ShortId, "hint.value_slot_shortid"), ] { let schema = schema_with("T", &[("c", ty)]); let mode = hint_mode_at_input_with_schema("insert into T values (", &schema); @@ -1678,6 +1748,39 @@ mod tests { } } + #[test] + fn typed_hint_for_auto_generated_types_routes_via_form_a() { + // Serial / shortid columns can be set by the user only + // in Form A (`insert into T (col) values (…)`) — Form B + // skips them because the dispatch path auto-fills. + for (ty, key) in [ + (Type::Serial, "hint.value_slot_serial"), + (Type::ShortId, "hint.value_slot_shortid"), + ] { + let schema = schema_with("T", &[("c", ty)]); + let mode = + hint_mode_at_input_with_schema("insert into T (c) values (", &schema); + assert!( + matches!(mode, Some(HintMode::ProseOnly(k)) if k == key), + "expected ProseOnly({key}) for type {ty:?}, got {mode:?}", + ); + } + } + + #[test] + fn typed_hint_form_b_skips_serial_column_to_generic_or_text_neighbor() { + // A serial-only table in Form B has nothing for the user + // to type — column_value_list returns the schemaless + // fallback, so the hint at the first value position is + // the generic value-literal prose. + let schema = schema_with("T", &[("id", Type::Serial)]); + let mode = hint_mode_at_input_with_schema("insert into T values (", &schema); + assert!( + matches!(mode, Some(HintMode::ProseOnly("hint.value_literal_slot"))), + "got {mode:?}", + ); + } + #[test] fn phase_d_update_multi_assignment_uses_per_column_types() { let schema = schema_with( diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index b3d23b2..4237b4d 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -301,8 +301,13 @@ hint: value_slot_date: "Type a quoted date as 'YYYY-MM-DD' or null" value_slot_datetime: "Type a quoted datetime as 'YYYY-MM-DD HH:MM:SS' or null" value_slot_blob: "Type a quoted blob literal or null" - value_slot_serial: "Type an integer (or omit to auto-generate) or null" - value_slot_shortid: "Type a quoted shortid (or omit to auto-generate) or null" + # Serial / shortid in `values (…)` form: the user must enter + # something at this position (no "skip column" syntax). `null` + # is the auto-fill path (ADR-0018: serial / shortid columns + # auto-fill null cells on insert), so the prose leads with + # null and offers an explicit value as the alternative. + value_slot_serial: "Type null to auto-generate, or an explicit integer" + value_slot_shortid: "Type null to auto-generate, or a quoted shortid" # Wrapper that prefixes a per-column-type slot hint with the # actual column name so the user sees "for `Email`: Type a # quoted string …" instead of the generic type prose.