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:
+27
-2
@@ -139,8 +139,15 @@ pub enum Command {
|
||||
filter: RowFilter,
|
||||
},
|
||||
/// Render the rows of a table as a data view in the output.
|
||||
/// An optional `where` filters rows; an optional `limit`
|
||||
/// caps the row count (ADR-0026 §5). When `limit` is set the
|
||||
/// query is implicitly ordered by the table's primary key,
|
||||
/// so `limit n` is a stable "first n by primary key" rather
|
||||
/// than an arbitrary subset.
|
||||
ShowData {
|
||||
name: String,
|
||||
filter: Option<Expr>,
|
||||
limit: Option<u64>,
|
||||
},
|
||||
/// Replay a sequence of DSL commands from a file. Each line
|
||||
/// is parsed and dispatched through the same pipeline as
|
||||
@@ -235,10 +242,28 @@ pub enum ChangeColumnMode {
|
||||
/// `--all-rows` flag opt-in for unfiltered operations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RowFilter {
|
||||
Where { column: String, value: Value },
|
||||
/// Operate on rows matching this WHERE expression
|
||||
/// (ADR-0026 — a full boolean expression, not just a single
|
||||
/// equality).
|
||||
Where(Expr),
|
||||
AllRows,
|
||||
}
|
||||
|
||||
impl RowFilter {
|
||||
/// Build a `Where` filter for a single `column = value`
|
||||
/// equality. The pre-ADR-0026 grammar produced exactly this
|
||||
/// shape; the constructor stays as a convenience for
|
||||
/// callers and tests that only need simple equality.
|
||||
#[must_use]
|
||||
pub fn eq(column: impl Into<String>, value: Value) -> Self {
|
||||
Self::Where(Expr::Predicate(Predicate::Compare {
|
||||
left: Operand::Column(column.into()),
|
||||
op: CompareOp::Eq,
|
||||
right: Operand::Literal(value),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A complex WHERE expression (ADR-0026 §4).
|
||||
///
|
||||
/// Built by `grammar::expr::build_expr` from the flat
|
||||
@@ -414,7 +439,7 @@ impl Command {
|
||||
Self::CreateTable { name, .. }
|
||||
| Self::DropTable { name }
|
||||
| Self::ShowTable { name }
|
||||
| Self::ShowData { name } => name,
|
||||
| Self::ShowData { name, .. } => name,
|
||||
Self::AddColumn { table, .. }
|
||||
| Self::DropColumn { table, .. }
|
||||
| Self::RenameColumn { table, .. }
|
||||
|
||||
+109
-50
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@ pub mod walker;
|
||||
|
||||
pub use action::ReferentialAction;
|
||||
pub use command::{
|
||||
AppCommand, ChangeColumnMode, ColumnSpec, Command, IndexSelector, MessagesValue,
|
||||
ModeValue, RelationshipSelector, RowFilter,
|
||||
AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, Expr, IndexSelector,
|
||||
MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
|
||||
};
|
||||
pub use parser::{ParseError, parse_command};
|
||||
pub use types::Type;
|
||||
|
||||
+6
-13
@@ -1039,10 +1039,7 @@ mod tests {
|
||||
Command::Update {
|
||||
table: "Customers".to_string(),
|
||||
assignments: vec![("Name".to_string(), Value::Text("Alice".to_string()))],
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
filter: RowFilter::eq("id", Value::Number("1".to_string())),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1057,10 +1054,7 @@ mod tests {
|
||||
("Name".to_string(), Value::Text("Alice".to_string())),
|
||||
("Email".to_string(), Value::Text("a@b.com".to_string())),
|
||||
],
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
filter: RowFilter::eq("id", Value::Number("1".to_string())),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1089,10 +1083,7 @@ mod tests {
|
||||
ok("delete from Customers where id=1"),
|
||||
Command::Delete {
|
||||
table: "Customers".to_string(),
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
filter: RowFilter::eq("id", Value::Number("1".to_string())),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1119,7 +1110,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
ok("show data Customers"),
|
||||
Command::ShowData {
|
||||
name: "Customers".to_string()
|
||||
name: "Customers".to_string(),
|
||||
filter: None,
|
||||
limit: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+78
-26
@@ -229,6 +229,15 @@ pub struct CompletionProbe {
|
||||
/// earlier in the walk). `None` when the walker is
|
||||
/// schemaless or the table didn't resolve.
|
||||
pub current_table_columns: Option<Vec<crate::completion::TableColumn>>,
|
||||
/// The grammar-declared `HintMode` at the cursor's slot
|
||||
/// (`Node::Hinted`), if any. A `ProseOnly` slot tells the
|
||||
/// completion engine to suppress its keyword candidates —
|
||||
/// the node-attached signal that supersedes the
|
||||
/// expected-set signature heuristic where the grammar
|
||||
/// explicitly marks a slot prose-only (e.g. the
|
||||
/// WHERE-expression operand, which also accepts a column
|
||||
/// reference — ADR-0026 §8).
|
||||
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
|
||||
}
|
||||
|
||||
/// Run a schema-aware walk and report the completion-engine's
|
||||
@@ -247,6 +256,7 @@ pub fn completion_probe(
|
||||
.map(|c| outcome::Expectation::Word(c.entry.primary))
|
||||
.collect(),
|
||||
current_table_columns: None,
|
||||
pending_hint_mode: None,
|
||||
};
|
||||
}
|
||||
let mut ctx = context::WalkContext::with_schema(schema);
|
||||
@@ -258,6 +268,7 @@ pub fn completion_probe(
|
||||
.map(|c| outcome::Expectation::Word(c.entry.primary))
|
||||
.collect(),
|
||||
current_table_columns: None,
|
||||
pending_hint_mode: None,
|
||||
};
|
||||
};
|
||||
let expected = match result.outcome {
|
||||
@@ -276,6 +287,7 @@ pub fn completion_probe(
|
||||
CompletionProbe {
|
||||
expected,
|
||||
current_table_columns: ctx.current_table_columns,
|
||||
pending_hint_mode: ctx.pending_hint_mode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1155,7 +1167,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
parse("show data Customers").unwrap(),
|
||||
Command::ShowData {
|
||||
name: "Customers".to_string()
|
||||
name: "Customers".to_string(),
|
||||
filter: None,
|
||||
limit: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1170,6 +1184,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_parses_show_data_with_where_and_limit() {
|
||||
// ADR-0026 §5: `show data` gains an optional `where`
|
||||
// and an optional `limit <n>`.
|
||||
match parse("show data Customers where id=1 limit 10").unwrap() {
|
||||
Command::ShowData {
|
||||
name,
|
||||
filter: Some(_),
|
||||
limit: Some(10),
|
||||
} => assert_eq!(name, "Customers"),
|
||||
other => panic!("expected ShowData with filter + limit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_parses_show_data_with_limit_only() {
|
||||
assert!(matches!(
|
||||
parse("show data Customers limit 5").unwrap(),
|
||||
Command::ShowData {
|
||||
filter: None,
|
||||
limit: Some(5),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_parses_update_with_complex_where() {
|
||||
// The WHERE is a full boolean expression, not a single
|
||||
// equality (ADR-0026).
|
||||
match parse("update T set Active=true where Age>30 and Name like 'A%'")
|
||||
.unwrap()
|
||||
{
|
||||
Command::Update {
|
||||
filter: RowFilter::Where(crate::dsl::Expr::And(terms)),
|
||||
..
|
||||
} => assert_eq!(terms.len(), 2, "two AND-ed predicates"),
|
||||
other => panic!("expected Update with And-expression filter, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_parses_delete_with_or_where() {
|
||||
assert!(matches!(
|
||||
parse("delete from T where id=1 or id=2").unwrap(),
|
||||
Command::Delete {
|
||||
filter: RowFilter::Where(crate::dsl::Expr::Or(_)),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_parses_insert_with_explicit_column_list() {
|
||||
assert_eq!(
|
||||
@@ -1233,10 +1299,7 @@ mod tests {
|
||||
Command::Update {
|
||||
table: "Customers".to_string(),
|
||||
assignments: vec![("Email".to_string(), Value::Text("new@b.c".to_string()))],
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
filter: RowFilter::eq("id", Value::Number("1".to_string())),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1251,10 +1314,7 @@ mod tests {
|
||||
("Email".to_string(), Value::Text("a@b.c".to_string())),
|
||||
("Name".to_string(), Value::Text("Alice".to_string())),
|
||||
],
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
filter: RowFilter::eq("id", Value::Number("1".to_string())),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1277,10 +1337,7 @@ mod tests {
|
||||
parse("delete from Customers where id=42").unwrap(),
|
||||
Command::Delete {
|
||||
table: "Customers".to_string(),
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("42".to_string()),
|
||||
},
|
||||
filter: RowFilter::eq("id", Value::Number("42".to_string())),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1708,20 +1765,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_d_delete_where_rejects_decimal_at_int_column() {
|
||||
// `where id=3.14` — id is Int; the typed slot rejects.
|
||||
fn phase_d_delete_where_permits_decimal_at_int_column() {
|
||||
// ADR-0026 §7: a type-mismatched WHERE comparison is
|
||||
// flagged in the editor but never blocks. `id` is Int
|
||||
// and `3.14` is not — yet the command still parses and
|
||||
// would run (this relaxes the pre-ADR-0026 rejection).
|
||||
let schema = schema_with("T", &[("id", Type::Int)]);
|
||||
let err = parse_command_with_schema("delete from T where id=3.14", &schema)
|
||||
.expect_err("should reject");
|
||||
match err {
|
||||
crate::dsl::ParseError::Invalid { message, .. } => {
|
||||
assert!(
|
||||
message.contains("integer") || message.contains("3.14"),
|
||||
"got: {message}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Invalid, got {other:?}"),
|
||||
}
|
||||
let cmd = parse_command_with_schema("delete from T where id=3.14", &schema)
|
||||
.expect("type-mismatched WHERE comparisons are permissive");
|
||||
assert!(matches!(cmd, crate::dsl::Command::Delete { .. }), "got {cmd:?}");
|
||||
}
|
||||
|
||||
// ---- Typed-slot HintMode (Phase D + HintMode dispatch) ----
|
||||
|
||||
Reference in New Issue
Block a user