WHERE expressions: wire into update/delete/show data + SQL gen (ADR-0026 steps 3-4)

Wires the stratified WHERE-expression fragment into the three
filter commands and compiles the resulting Expr to SQL.

Grammar (data.rs): the `update` / `delete` `where` clause is
now the expression fragment (`Subgrammar(&expr::OR_EXPR)`) in
place of the single `col = val` slot; `show data` gains an
optional `where <expr>` and an optional `limit <n>` (a
non-negative integer, validated at parse time). The
expression's right-hand operands are a schema-aware
`DynamicSubgrammar` so the hint panel still narrows to the
left column's type (ADR-0026 §8) — but the inner grammar is
permissive: a type-mismatched literal still parses (§7).

AST: `RowFilter::Where{column,value}` -> `RowFilter::Where(Expr)`;
`ShowData` gains `filter: Option<Expr>` and `limit: Option<u64>`.
A `RowFilter::eq` convenience constructor keeps simple-equality
call sites and tests readable.

SQL (db.rs): `compile_expr` lowers an `Expr` to a
parameterised WHERE — every literal a `?` placeholder,
identifiers `quote_ident`-quoted, `<>` for inequality. A
literal compared against a column binds through that column's
type where compatible and falls back to its syntactic shape on
a mismatch (§7 — permissive). `show data ... limit n` emits
`LIMIT ?` with an implicit primary-key `ORDER BY`, so it is a
stable "first n by primary key".

completion.rs: `invalid_ident_at_cursor` no longer mis-flags a
digit-led literal (`1`) as an unknown column now that the
WHERE operand slot also accepts a column reference; a
`ProseOnly` slot suppresses keyword candidates even when the
expected set also carries a column ident.

11 db integration tests cover AND / OR / NOT, BETWEEN, IN,
LIKE, filtered `show data`, and limit ordering; walker and
expr unit tests cover the parse surface. Type-mismatch /
`= NULL` diagnostic flagging (§7 highlight + hint) is the
remaining ADR-0026 piece.
This commit is contained in:
claude@clouddev1
2026-05-18 23:12:33 +00:00
parent 59e6a541bf
commit f75f71bbe4
22 changed files with 1013 additions and 193 deletions
+109 -50
View File
@@ -16,9 +16,9 @@
//! ready to consume them when the schema reference flows
//! through `parse_command`.
use crate::dsl::command::{Command, RowFilter};
use crate::dsl::command::{Command, Expr, RowFilter};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, ValidationError, Word,
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{column_value_list, current_column_value},
};
use crate::dsl::walker::context::WalkContext;
@@ -59,7 +59,11 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
const SHOW_DATA_NODES: &[Node] = &[
Node::Word(Word::keyword("data")),
TABLE_NAME_EXISTING,
// `writes_table` so the optional `where` expression's
// column slots resolve against this table for completion.
TABLE_NAME_WRITES,
Node::Optional(&WHERE_CLAUSE),
Node::Optional(&LIMIT_CLAUSE),
];
const SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES);
@@ -231,18 +235,6 @@ const SET_COLUMN: Node = Node::Ident {
writes_user_listed_column: false,
};
/// Column-name slot in `where col = …` — same writes-column
/// semantics as SET_COLUMN, distinct role for the AST builder.
const FILTER_COLUMN: Node = Node::Ident {
source: IdentSource::Columns,
role: "filter_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
};
/// Value slot resolved at walk time from
/// `WalkContext::current_column`. Falls back to the schemaless
/// value-literal choice when no current_column is bound.
@@ -260,17 +252,45 @@ const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
min: 1,
};
/// `where <expr>` — the complex WHERE-expression fragment
/// (ADR-0026). The grammar tier is defined once in
/// `grammar::expr` and reached here through `Subgrammar`.
const WHERE_CLAUSE_NODES: &[Node] = &[
Node::Word(Word::keyword("where")),
FILTER_COLUMN,
Node::Punct('='),
PER_COLUMN_VALUE,
Node::Subgrammar(&expr::OR_EXPR),
];
const WHERE_CLAUSE: Node = Node::Seq(WHERE_CLAUSE_NODES);
const FILTER_CHOICES: &[Node] = &[WHERE_CLAUSE, Node::Flag("all-rows")];
const FILTER_CLAUSE: Node = Node::Choice(FILTER_CHOICES);
/// `limit <n>` — `<n>` is a non-negative integer; the
/// validator rejects fractional / negative literals at parse
/// time (ADR-0026 §5).
fn validate_limit_count(value: &str) -> Result<(), ValidationError> {
if value.parse::<u64>().is_ok() {
Ok(())
} else {
Err(ValidationError {
message_key: "parse.custom.bind_type_mismatch",
args: vec![
("found", value.to_string()),
("expected", "non-negative integer".to_string()),
],
})
}
}
const LIMIT_VALIDATOR: NumberValidator = validate_limit_count;
/// `limit <n>` clause, optional on `show data` (ADR-0026 §5).
const LIMIT_CLAUSE_NODES: &[Node] = &[
Node::Word(Word::keyword("limit")),
Node::NumberLit {
validator: Some(LIMIT_VALIDATOR),
},
];
const LIMIT_CLAUSE: Node = Node::Seq(LIMIT_CLAUSE_NODES);
const UPDATE_NODES: &[Node] = &[
TABLE_NAME_WRITES,
Node::Word(Word::keyword("set")),
@@ -335,7 +355,11 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
.nth(1);
let name = require_ident(path, "table_name")?;
match sub {
Some("data") => Ok(Command::ShowData { name }),
Some("data") => Ok(Command::ShowData {
name,
filter: build_show_filter(path)?,
limit: build_show_limit(path)?,
}),
Some("table") => Ok(Command::ShowTable { name }),
_ => Err(ValidationError {
message_key: "parse.error_wrapper",
@@ -344,6 +368,59 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
}
}
/// The optional `where <expr>` of a `show data`. The expression
/// terminals run from just past `Word("where")` to the start of
/// the `limit` clause (or the end of the path) — neither the
/// `limit` keyword nor any expression keyword collide, so the
/// slice is exact.
fn build_show_filter(path: &MatchedPath) -> Result<Option<Expr>, ValidationError> {
let Some(where_idx) = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Word("where")))
else {
return Ok(None);
};
let end = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Word("limit")))
.unwrap_or(path.items.len());
Ok(Some(expr::build_expr(&path.items[where_idx + 1..end])?))
}
/// The optional `limit <n>` of a `show data`. The grammar's
/// `LIMIT_VALIDATOR` already constrained `<n>` to a
/// non-negative integer, so the parse here cannot realistically
/// fail.
fn build_show_limit(path: &MatchedPath) -> Result<Option<u64>, ValidationError> {
let Some(limit_idx) = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Word("limit")))
else {
return Ok(None);
};
let count = path
.items
.get(limit_idx + 1)
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing limit count".to_string())],
})?;
count
.text
.parse::<u64>()
.map(Some)
.map_err(|_| ValidationError {
message_key: "parse.custom.bind_type_mismatch",
args: vec![
("found", count.text.clone()),
("expected", "non-negative integer".to_string()),
],
})
}
fn build_insert(path: &MatchedPath) -> Result<Command, ValidationError> {
let table = require_ident(path, "table_name")?;
@@ -575,37 +652,19 @@ fn collect_filter(path: &MatchedPath) -> Result<RowFilter, ValidationError> {
{
return Ok(RowFilter::AllRows);
}
// Walk for filter_column ident, then `=`, then value.
let mut iter = path.items.iter();
while let Some(item) = iter.next() {
if matches!(
item.kind,
MatchedKind::Ident {
role: "filter_column"
}
) {
let column = item.text.clone();
// Skip until `=`.
for next in iter.by_ref() {
if matches!(next.kind, MatchedKind::Punct('=')) {
break;
}
}
let value_item = iter.next().ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing where value".to_string())],
})?;
let value = item_to_value(value_item).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "expected value literal".to_string())],
})?;
return Ok(RowFilter::Where { column, value });
}
}
Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing where or --all-rows".to_string())],
})
let where_idx = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Word("where")))
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing where or --all-rows".to_string())],
})?;
// `where` is the last clause of update / delete, so every
// terminal after it belongs to the expression.
Ok(RowFilter::Where(expr::build_expr(
&path.items[where_idx + 1..],
)?))
}
fn build_delete(path: &MatchedPath) -> Result<Command, ValidationError> {
+59 -9
View File
@@ -53,8 +53,9 @@
//! `build_expr` owns the tree shape.
use crate::dsl::command::{CompareOp, Expr, Operand, Predicate};
use crate::dsl::grammar::{IdentSource, Node, ValidationError, Word};
use crate::dsl::grammar::{HintMode, IdentSource, Node, ValidationError, Word};
use crate::dsl::value::Value;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind};
// =================================================================
@@ -64,19 +65,27 @@ use crate::dsl::walker::outcome::{MatchedItem, MatchedKind};
/// A column reference inside an expression. The `expr_column`
/// role lets [`build_expr`] (and the command AST builders) tell
/// an expression column apart from other identifier slots.
///
/// `writes_column` so that, once a predicate's left operand
/// resolves to a known column, the walker records it in
/// `WalkContext::current_column` — the right operand's
/// schema-aware slot (`where_rhs_operand`) reads it to narrow
/// the hint panel to that column's type (ADR-0026 §8).
const EXPR_COLUMN: Node = Node::Ident {
source: IdentSource::Columns,
role: "expr_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_column: true,
writes_user_listed_column: false,
};
/// Operand alternatives. The literal keywords (`null` / `true`
/// / `false`) come before the column slot so they parse as
/// literals; any other identifier is a column reference.
/// literals; any other identifier is a column reference. No
/// validators — a type-mismatched literal in a comparison is
/// flagged in the editor but still parses (ADR-0026 §7).
static OPERAND_CHOICES: &[Node] = &[
Node::Word(Word::keyword("null")),
Node::Word(Word::keyword("true")),
@@ -86,6 +95,45 @@ static OPERAND_CHOICES: &[Node] = &[
EXPR_COLUMN,
];
/// The operand alternatives as a single node — the permissive
/// inner of the schema-aware right-hand operand slot.
static OPERAND_NODE: Node = Node::Choice(OPERAND_CHOICES);
/// The right-hand operand of a predicate — the comparison RHS,
/// a `LIKE` pattern, the `BETWEEN` bounds, the `IN` items —
/// resolved at walk time (ADR-0026 §8).
///
/// When the predicate's left operand resolved to a known
/// column, this wraps the operand in a `TypedValueSlot` keyed
/// on that column's user-facing type, so the hint panel
/// narrows to the column's type and names the column.
/// Otherwise a generic value-literal `Hinted` slot. Either way
/// the inner grammar is the *permissive* operand choice
/// (`OPERAND_NODE`) — a type-mismatched literal still matches
/// (ADR-0026 §7); the mismatch is an editor flag, never a
/// parse failure.
fn where_rhs_operand(ctx: &WalkContext) -> Node {
ctx.current_column.as_ref().map_or_else(
|| Node::Hinted {
mode: HintMode::ProseOnly("hint.value_literal_slot"),
inner: &OPERAND_NODE,
},
|col| {
// `Box::leak` mirrors `shared::slot_for_column` —
// the leak is per distinct column (the walker
// memoizes `DynamicSubgrammar` resolution on
// `current_column`), not per keystroke.
let leaked: &'static str =
Box::leak(col.name.clone().into_boxed_str());
Node::TypedValueSlot {
ty: col.user_type,
column_name: Some(leaked),
inner: &OPERAND_NODE,
}
},
)
}
// =================================================================
// cmp_op := <= | <> | >= | != | < | > | =
// =================================================================
@@ -110,10 +158,12 @@ static CMP_OP_CHOICES: &[Node] = &[
// predicate_tail branches
// =================================================================
/// `cmp_op operand`.
/// `cmp_op operand`. The right operand is the schema-aware
/// `where_rhs_operand` so the hint panel can narrow to the
/// left column's type.
static COMPARE_FORM_NODES: &[Node] = &[
Node::Choice(CMP_OP_CHOICES),
Node::Choice(OPERAND_CHOICES),
Node::DynamicSubgrammar(where_rhs_operand),
];
/// `IS [NOT] NULL`.
@@ -126,7 +176,7 @@ static IS_NULL_NODES: &[Node] = &[
/// `LIKE operand`.
static LIKE_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("like")),
Node::Choice(OPERAND_CHOICES),
Node::DynamicSubgrammar(where_rhs_operand),
];
/// `BETWEEN operand AND operand`. The inner `and` is consumed
@@ -134,9 +184,9 @@ static LIKE_FORM_NODES: &[Node] = &[
/// connective.
static BETWEEN_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("between")),
Node::Choice(OPERAND_CHOICES),
Node::DynamicSubgrammar(where_rhs_operand),
Node::Word(Word::keyword("and")),
Node::Choice(OPERAND_CHOICES),
Node::DynamicSubgrammar(where_rhs_operand),
];
/// `IN ( operand [, operand]* )`.
@@ -144,7 +194,7 @@ static IN_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("in")),
Node::Punct('('),
Node::Repeated {
inner: &Node::Choice(OPERAND_CHOICES),
inner: &Node::DynamicSubgrammar(where_rhs_operand),
separator: Some(&Node::Punct(',')),
min: 1,
},