Phase D: insert value list mirrors do_insert's user_cols contract
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 <T> (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 <T> 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 <T> (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<Vec<String>>\` — 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.
This commit is contained in:
@@ -52,6 +52,7 @@ const IMPORT_AS_TARGET: Node = Node::Seq(&[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
|
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
|
||||||
@@ -76,6 +77,7 @@ const MODE_CHOICES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
|
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
|
||||||
@@ -90,6 +92,7 @@ const MESSAGES_CHOICES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
|
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Table-name slot variant that populates
|
/// Table-name slot variant that populates
|
||||||
@@ -48,6 +49,7 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: true,
|
writes_table: true,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// `value_literal` — null / true / false / number / string. The
|
// `value_literal` — null / true / false / number / string. The
|
||||||
@@ -108,6 +110,14 @@ const INSERT_PAREN_ITEM_CHOICES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
// Form A signal: when the user lists explicit columns
|
||||||
|
// in `insert into <T> (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);
|
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,
|
highlight_override: None,
|
||||||
writes_table: true,
|
writes_table: true,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Column-name slot in `set col = …` — resolves the column's
|
/// Column-name slot in `set col = …` — resolves the column's
|
||||||
@@ -185,6 +196,7 @@ const SET_COLUMN: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: true,
|
writes_column: true,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Column-name slot in `where col = …` — same writes-column
|
/// Column-name slot in `where col = …` — same writes-column
|
||||||
@@ -196,6 +208,7 @@ const FILTER_COLUMN: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: true,
|
writes_column: true,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Value slot resolved at walk time from
|
/// Value slot resolved at walk time from
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const TABLE_NAME_NEW: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABLE_NAME_EXISTING: Node = Node::Ident {
|
const TABLE_NAME_EXISTING: Node = Node::Ident {
|
||||||
@@ -40,6 +41,7 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMN_NAME: Node = Node::Ident {
|
const COLUMN_NAME: Node = Node::Ident {
|
||||||
@@ -49,6 +51,7 @@ const COLUMN_NAME: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMN_NAME_NEW: Node = Node::Ident {
|
const COLUMN_NAME_NEW: Node = Node::Ident {
|
||||||
@@ -58,6 +61,7 @@ const COLUMN_NAME_NEW: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const RELATIONSHIP_NAME: Node = Node::Ident {
|
const RELATIONSHIP_NAME: Node = Node::Ident {
|
||||||
@@ -67,6 +71,7 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const RELATIONSHIP_NAME_NEW: Node = Node::Ident {
|
const RELATIONSHIP_NAME_NEW: Node = Node::Ident {
|
||||||
@@ -76,6 +81,7 @@ const RELATIONSHIP_NAME_NEW: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// `[to]` and `[table]` connectives.
|
// `[to]` and `[table]` connectives.
|
||||||
@@ -120,6 +126,7 @@ const DR_PARENT_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
Node::Punct('.'),
|
Node::Punct('.'),
|
||||||
Node::Ident {
|
Node::Ident {
|
||||||
@@ -129,6 +136,7 @@ const DR_PARENT_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
|
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
|
||||||
@@ -141,6 +149,7 @@ const DR_CHILD_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
Node::Punct('.'),
|
Node::Punct('.'),
|
||||||
Node::Ident {
|
Node::Ident {
|
||||||
@@ -150,6 +159,7 @@ const DR_CHILD_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
|
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
|
||||||
@@ -210,6 +220,7 @@ const AR_PARENT_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
Node::Punct('.'),
|
Node::Punct('.'),
|
||||||
Node::Ident {
|
Node::Ident {
|
||||||
@@ -219,6 +230,7 @@ const AR_PARENT_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
|
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
|
||||||
@@ -231,6 +243,7 @@ const AR_CHILD_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
Node::Punct('.'),
|
Node::Punct('.'),
|
||||||
Node::Ident {
|
Node::Ident {
|
||||||
@@ -240,6 +253,7 @@ const AR_CHILD_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
|
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
|
||||||
@@ -293,6 +307,7 @@ const RENAME_COLUMN_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
|
const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
|
||||||
@@ -627,6 +642,7 @@ const COL_SPEC_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
Node::Punct(':'),
|
Node::Punct(':'),
|
||||||
Node::Ident {
|
Node::Ident {
|
||||||
@@ -636,6 +652,7 @@ const COL_SPEC_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES);
|
const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES);
|
||||||
|
|||||||
@@ -233,6 +233,14 @@ pub enum Node {
|
|||||||
highlight_override: Option<HighlightClass>,
|
highlight_override: Option<HighlightClass>,
|
||||||
writes_table: bool,
|
writes_table: bool,
|
||||||
writes_column: bool,
|
writes_column: bool,
|
||||||
|
/// Append the matched text to
|
||||||
|
/// `WalkContext::user_listed_columns` (Phase D). Used by
|
||||||
|
/// the `insert into <T> (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
|
/// A number literal. The optional `validator` runs against
|
||||||
/// the matched text (used by Phase D value slots to enforce
|
/// the matched text (used by Phase D value slots to enforce
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! actions; Phase D extends with `where_clause`,
|
//! actions; Phase D extends with `where_clause`,
|
||||||
//! `column_value_list`, and the typed value slots.
|
//! `column_value_list`, and the typed value slots.
|
||||||
|
|
||||||
|
use crate::completion::TableColumn;
|
||||||
use crate::dsl::grammar::{
|
use crate::dsl::grammar::{
|
||||||
IdentSource, IdentValidator, Node, NumberValidator, ValidationError, Word,
|
IdentSource, IdentValidator, Node, NumberValidator, ValidationError, Word,
|
||||||
};
|
};
|
||||||
@@ -51,6 +52,7 @@ pub const TYPE_SLOT: Node = Node::Ident {
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Qualified column reference (`<Table>.<Column>`) --------------
|
// --- Qualified column reference (`<Table>.<Column>`) --------------
|
||||||
@@ -63,6 +65,7 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
Node::Punct('.'),
|
Node::Punct('.'),
|
||||||
Node::Ident {
|
Node::Ident {
|
||||||
@@ -72,6 +75,7 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
|
|||||||
highlight_override: None,
|
highlight_override: None,
|
||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES);
|
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
|
/// `Repeated(VALUE_LITERAL, ',', 1)` shape so existing
|
||||||
/// callers/tests continue to work.
|
/// callers/tests continue to work.
|
||||||
pub fn column_value_list(ctx: &WalkContext) -> Node {
|
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;
|
return FALLBACK_VALUE_LIST;
|
||||||
};
|
};
|
||||||
if cols.is_empty() {
|
if table_cols.is_empty() {
|
||||||
return FALLBACK_VALUE_LIST;
|
return FALLBACK_VALUE_LIST;
|
||||||
}
|
}
|
||||||
// Build a Seq of typed slots interleaved with commas.
|
// Three dispatch shapes (ADR-0024 §Phase D §column_value_list,
|
||||||
// Each slot embeds its column name so the hint resolver
|
// matching `db::do_insert`'s user_cols logic):
|
||||||
// can mention the column by name ("for `Email`: Type a
|
//
|
||||||
// quoted string …").
|
// 1. Form A — user listed explicit columns
|
||||||
let mut children: Vec<Node> = Vec::with_capacity(cols.len() * 2);
|
// (`insert into T (col1, col2, …) values (…)`): one slot
|
||||||
for (i, col) in cols.iter().enumerate() {
|
// 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<Node> = Vec::with_capacity(target_cols.len() * 2);
|
||||||
|
for (i, col) in target_cols.iter().enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
children.push(Node::Punct(','));
|
children.push(Node::Punct(','));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,20 @@ pub struct WalkContext<'a> {
|
|||||||
/// Cleared on successful inner match alongside
|
/// Cleared on successful inner match alongside
|
||||||
/// `pending_value_type`.
|
/// `pending_value_type`.
|
||||||
pub pending_value_column: Option<String>,
|
pub pending_value_column: Option<String>,
|
||||||
|
/// The columns the user explicitly listed in
|
||||||
|
/// `insert into <T> (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<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> WalkContext<'a> {
|
impl<'a> WalkContext<'a> {
|
||||||
@@ -77,6 +91,7 @@ impl<'a> WalkContext<'a> {
|
|||||||
current_column: None,
|
current_column: None,
|
||||||
pending_value_type: None,
|
pending_value_type: None,
|
||||||
pending_value_column: None,
|
pending_value_column: None,
|
||||||
|
user_listed_columns: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ pub fn walk_node(
|
|||||||
highlight_override: _,
|
highlight_override: _,
|
||||||
writes_table,
|
writes_table,
|
||||||
writes_column,
|
writes_column,
|
||||||
|
writes_user_listed_column,
|
||||||
} => walk_ident(
|
} => walk_ident(
|
||||||
source,
|
source,
|
||||||
pos,
|
pos,
|
||||||
@@ -102,6 +103,7 @@ pub fn walk_node(
|
|||||||
*validator,
|
*validator,
|
||||||
*writes_table,
|
*writes_table,
|
||||||
*writes_column,
|
*writes_column,
|
||||||
|
*writes_user_listed_column,
|
||||||
ctx,
|
ctx,
|
||||||
path,
|
path,
|
||||||
per_byte,
|
per_byte,
|
||||||
@@ -241,6 +243,7 @@ fn walk_ident(
|
|||||||
validator: Option<crate::dsl::grammar::IdentValidator>,
|
validator: Option<crate::dsl::grammar::IdentValidator>,
|
||||||
writes_table: bool,
|
writes_table: bool,
|
||||||
writes_column: bool,
|
writes_column: bool,
|
||||||
|
writes_user_listed_column: bool,
|
||||||
ctx: &mut WalkContext,
|
ctx: &mut WalkContext,
|
||||||
path: &mut MatchedPath,
|
path: &mut MatchedPath,
|
||||||
per_byte: &mut Vec<ByteClass>,
|
per_byte: &mut Vec<ByteClass>,
|
||||||
@@ -289,6 +292,28 @@ fn walk_ident(
|
|||||||
.map(|c| c.name.clone())
|
.map(|c| c.name.clone())
|
||||||
.or_else(|| Some(text.clone()));
|
.or_else(|| Some(text.clone()));
|
||||||
}
|
}
|
||||||
|
if writes_user_listed_column
|
||||||
|
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
|
||||||
|
{
|
||||||
|
// Form A: `insert into <T> (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 {
|
path.push(MatchedItem {
|
||||||
kind: MatchedKind::Ident { role },
|
kind: MatchedKind::Ident { role },
|
||||||
text,
|
text,
|
||||||
|
|||||||
+111
-8
@@ -1394,20 +1394,91 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn phase_d_insert_with_schema_accepts_typed_values_per_column() {
|
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(
|
let schema = schema_with(
|
||||||
"Customers",
|
"Customers",
|
||||||
&[("id", Type::Serial), ("Name", Type::Text), ("Active", Type::Bool)],
|
&[("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(
|
let cmd = parse_command_with_schema(
|
||||||
"insert into Customers values (1, 'Alice', true)",
|
"insert into Customers values ('Alice', true)",
|
||||||
&schema,
|
&schema,
|
||||||
)
|
)
|
||||||
.expect("parse");
|
.expect("parse");
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::Insert { table, values, .. } => {
|
Command::Insert { table, values, .. } => {
|
||||||
assert_eq!(table, "Customers");
|
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 <T> 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:?}"),
|
other => panic!("expected Insert, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -1654,9 +1725,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typed_hint_for_each_type_routes_to_correct_catalog_key() {
|
fn typed_hint_for_each_user_settable_type_routes_via_form_b() {
|
||||||
// Confirm each Type maps to its expected catalog key
|
// Form B (`insert into T values (…)`) excludes auto-
|
||||||
// via insert at a single-column table.
|
// generated columns from the value list — so only the
|
||||||
|
// user-settable types appear at this position.
|
||||||
for (ty, key) in [
|
for (ty, key) in [
|
||||||
(Type::Int, "hint.value_slot_int"),
|
(Type::Int, "hint.value_slot_int"),
|
||||||
(Type::Real, "hint.value_slot_real"),
|
(Type::Real, "hint.value_slot_real"),
|
||||||
@@ -1666,8 +1738,6 @@ mod tests {
|
|||||||
(Type::Date, "hint.value_slot_date"),
|
(Type::Date, "hint.value_slot_date"),
|
||||||
(Type::DateTime, "hint.value_slot_datetime"),
|
(Type::DateTime, "hint.value_slot_datetime"),
|
||||||
(Type::Blob, "hint.value_slot_blob"),
|
(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 schema = schema_with("T", &[("c", ty)]);
|
||||||
let mode = hint_mode_at_input_with_schema("insert into T values (", &schema);
|
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]
|
#[test]
|
||||||
fn phase_d_update_multi_assignment_uses_per_column_types() {
|
fn phase_d_update_multi_assignment_uses_per_column_types() {
|
||||||
let schema = schema_with(
|
let schema = schema_with(
|
||||||
|
|||||||
@@ -301,8 +301,13 @@ hint:
|
|||||||
value_slot_date: "Type a quoted date as 'YYYY-MM-DD' or null"
|
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_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_blob: "Type a quoted blob literal or null"
|
||||||
value_slot_serial: "Type an integer (or omit to auto-generate) or null"
|
# Serial / shortid in `values (…)` form: the user must enter
|
||||||
value_slot_shortid: "Type a quoted shortid (or omit to auto-generate) or null"
|
# 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
|
# Wrapper that prefixes a per-column-type slot hint with the
|
||||||
# actual column name so the user sees "for `Email`: Type a
|
# actual column name so the user sees "for `Email`: Type a
|
||||||
# quoted string …" instead of the generic type prose.
|
# quoted string …" instead of the generic type prose.
|
||||||
|
|||||||
Reference in New Issue
Block a user