grammar: SQL SELECT end-to-end (ADR-0030 Phase 1)
The first cut of advanced-mode SQL: a `select` line in advanced
mode parses, runs against the database, and renders its rows
through the existing data-table renderer; the same line in
simple mode lights up the precise "this is SQL" hint instead of
running.
Walker mode gate (ADR-0030 §2)
------------------------------
- `WalkContext` gains a `mode: Mode` field; `Mode` derives
`Default` (= `Simple`, matching the app's startup mode).
- `grammar::is_advanced_only` keys an advanced-only entry-word
set (Phase 1: just `select`). When the walker matches an
advanced-only entry word with `ctx.mode == Simple`, it
short-circuits to a `WalkOutcome::ValidationFailed` carrying
the `advanced_mode.sql_in_simple` catalog key — the input
highlights as a keyword, the validity indicator goes ERROR,
and the parse-error layer renders the "switch with `mode
advanced`, or prefix the line with `:`" hint.
- `parser::parse_command_with_schema_in_mode` (and the
schemaless `parse_command_in_mode`) threads the mode into
`WalkContext`; existing `parse_command*` entry points default
to `Mode::Advanced` (most permissive) so back-compat callers
see the full grammar.
- `App::submit` is unified: both modes route through
`dispatch_dsl(&effective_input, effective_mode)`, which now
parses with the line's effective mode. The placeholder
advanced-mode echo branch is gone.
Builder signature sweep (ADR-0031 §2)
-------------------------------------
- `CommandNode.ast_builder` gains a `source: &str` parameter,
forwarded by the walker. `build_select` reads it to put the
validated SQL text into `Command::Select`; the 21 existing
builders accept it as `_source`.
SQL `SELECT` (ADR-0030 §6, ADR-0031)
-------------------------------------
- New `Command::Select { sql: String }` variant. Every
exhaustive `match Command` updated (`verb`, `target_table`,
`build_translate_context`, `execute_command_typed`,
`typing_surface`'s label).
- `grammar::data::SELECT` `CommandNode`: projection (`*` or
`expr [as alias]` list), optional `FROM <table>`, optional
`WHERE`/`ORDER BY`/`LIMIT`, optional trailing `;`. The
expression slots reference the ADR-0031 fragment through
`Subgrammar(&sql_expr::SQL_OR_EXPR)`. The `FROM` table-name
slot carries a `reject_internal_table` validator that
refuses `__rdbms_*` references at parse time.
- The `FROM` clause is optional — `select 1`, `select upper('x')`
(zero-table constant/function-call SELECTs) work alongside
the single-table form. Standard SQL admits them and they are
the canonical learner probe.
- Implicit projection aliasing (`select a x`) is deliberately
unsupported — `from` is a keyword, the bare alias would be
ambiguous; only `select a as x` is admitted.
Worker / runtime
----------------
- `Request::RunSelect { sql, source, reply }` + a new
`Database::run_select` method. `do_run_select_request` runs
the prepared statement, collects rows into a `DataResult`
with `column_types: Vec<None>` (Phase-1 SELECT result columns
carry no playground type per ADR-0030 §6), and appends the
literal source line to `history.log` so replay re-runs it
(ADR-0030 §11).
- `runtime::execute_command_typed` gains a `Command::Select`
arm that calls `database.run_select(sql, src)` and maps to
`CommandOutcome::Query`, which flows into the existing
`AppEvent::DslDataSucceeded` → `render_data_table` path.
Catalog (ADR-0019)
------------------
- `advanced_mode.sql_in_simple` — the walker's gate message.
- `select.internal_table` — the `__rdbms_*` rejection.
- `parse.usage.select` — the parse-error usage template.
Tests
-----
Two `app::tests` cases that pinned the pre-ADR-0030 placeholder
echo are updated to pin the new dispatch contract — both verify
that the advanced-mode `select` (one persistent, one via the
`:` one-shot) produces `ExecuteDsl(Command::Select)` with the
submission's effective mode tagged on the echo. The matching
walking-skeleton test is updated likewise.
A separate follow-up commit lands the ambient mode-threading
(completion / live overlay / validity indicator) so simple-mode
users do not see SQL surfaced through Tab or the live error
overlay either — the dispatch-layer gate landed here is the
behavioural foundation that follow-up builds on. Integration
tests for the full end-to-end land in a third commit.
This commit is contained in:
@@ -283,6 +283,15 @@ pub enum Command {
|
||||
Explain {
|
||||
query: Box<Self>,
|
||||
},
|
||||
/// Run a SQL `SELECT` and render its result set (ADR-0030
|
||||
/// §6, ADR-0031). Advanced mode only. `sql` is the validated
|
||||
/// SQL statement text: a `SELECT` changes no schema, so it is
|
||||
/// carried and executed as text rather than lowered to a
|
||||
/// typed command (ADR-0030 §4). The walker has already
|
||||
/// confirmed it is in the supported subset.
|
||||
Select {
|
||||
sql: String,
|
||||
},
|
||||
/// App-lifecycle command (per ADR-0003). These work in both
|
||||
/// simple and advanced modes; the dispatcher branches on the
|
||||
/// `Command::App(...)` variant before mode-specific routing.
|
||||
@@ -577,6 +586,7 @@ impl Command {
|
||||
Self::ShowData { .. } => "show data",
|
||||
Self::Replay { .. } => "replay",
|
||||
Self::Explain { .. } => "explain",
|
||||
Self::Select { .. } => "select",
|
||||
Self::App(app) => match app {
|
||||
AppCommand::Quit => "quit",
|
||||
AppCommand::Help => "help",
|
||||
@@ -639,6 +649,11 @@ impl Command {
|
||||
// Explain forwards to the wrapped query — the table
|
||||
// the plan is about is the inner command's table.
|
||||
Self::Explain { query } => query.target_table(),
|
||||
// A SQL `SELECT` may read several tables (or none);
|
||||
// there is no single structure-target table. The
|
||||
// result renders as a data view, not a structure
|
||||
// view, so an empty target is correct here.
|
||||
Self::Select { .. } => "",
|
||||
// App commands aren't tied to schema entities — the
|
||||
// verb is the most identifying thing. The
|
||||
// display_subject override below provides a richer
|
||||
|
||||
+10
-10
@@ -110,19 +110,19 @@ const SAVE_AS_OPT: Node = Node::Optional(&SAVE_AS_WORD);
|
||||
|
||||
// --- AST builders --------------------------------------------------
|
||||
|
||||
const fn build_quit(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
const fn build_quit(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Quit))
|
||||
}
|
||||
|
||||
const fn build_help(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
const fn build_help(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Help))
|
||||
}
|
||||
|
||||
const fn build_rebuild(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Rebuild))
|
||||
}
|
||||
|
||||
fn build_save(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_save(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
if path.contains_word("as") {
|
||||
Ok(Command::App(AppCommand::SaveAs))
|
||||
} else {
|
||||
@@ -130,22 +130,22 @@ fn build_save(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
}
|
||||
}
|
||||
|
||||
const fn build_new(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
const fn build_new(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::New))
|
||||
}
|
||||
|
||||
const fn build_load(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
const fn build_load(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Load))
|
||||
}
|
||||
|
||||
fn build_export(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_export(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let bare = path
|
||||
.find(|i| matches!(i.kind, MatchedKind::BarePath))
|
||||
.map(|i| i.text.clone());
|
||||
Ok(Command::App(AppCommand::Export { path: bare }))
|
||||
}
|
||||
|
||||
fn build_import(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_import(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let bare_path = path
|
||||
.find(|i| matches!(i.kind, MatchedKind::BarePath))
|
||||
.map(|i| i.text.clone())
|
||||
@@ -159,7 +159,7 @@ fn build_import(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_mode(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_mode(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
// The Choice surfaces the matched value as either a `Word`
|
||||
// (known) or an `Ident` (unknown). The unknown branch's
|
||||
// validator always errors, so reaching the AST builder
|
||||
@@ -174,7 +174,7 @@ fn build_mode(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Mode { value }))
|
||||
}
|
||||
|
||||
fn build_messages(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_messages(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let value = if path.contains_word("short") {
|
||||
Some(MessagesValue::Short)
|
||||
} else if path.contains_word("verbose") {
|
||||
|
||||
+182
-11
@@ -20,6 +20,7 @@ use crate::dsl::command::{Command, Expr, RowFilter};
|
||||
use crate::dsl::grammar::{
|
||||
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
||||
shared::{column_value_list, current_column_value},
|
||||
sql_expr,
|
||||
};
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
use crate::dsl::value::Value;
|
||||
@@ -352,6 +353,154 @@ const EXPLAIN_CHOICES: &[Node] = &[
|
||||
];
|
||||
const EXPLAIN_SHAPE: Node = Node::Choice(EXPLAIN_CHOICES);
|
||||
|
||||
// =================================================================
|
||||
// select — SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031)
|
||||
// =================================================================
|
||||
//
|
||||
// Phase 1's single-table `SELECT`: a projection, a `FROM` table,
|
||||
// and optional `WHERE` / `ORDER BY` / `LIMIT`. The projection,
|
||||
// `WHERE` and `ORDER BY` expression slots reference the SQL
|
||||
// expression grammar (ADR-0031) through `Subgrammar`, so SQL gets
|
||||
// the same completion / highlighting / hints as the DSL for free.
|
||||
//
|
||||
// Advanced mode only — the walker's mode gate (ADR-0030 §2,
|
||||
// `grammar::is_advanced_only`) refuses `select` in simple mode
|
||||
// with the "this is SQL" hint, so this grammar is never reached
|
||||
// there.
|
||||
//
|
||||
// `JOIN`s, `GROUP BY` / `HAVING`, subqueries, `UNION`, CTEs, and
|
||||
// `OFFSET` are ADR-0030 Phase 2 ("`SELECT` — full"); implicit
|
||||
// column aliasing (`select a x`) and qualified `t.*` are out of
|
||||
// Phase 1 (see the inline notes).
|
||||
|
||||
/// A SQL expression slot — the ADR-0031 fragment as one node.
|
||||
const SQL_EXPR: Node = Node::Subgrammar(&sql_expr::SQL_OR_EXPR);
|
||||
|
||||
/// `as <alias>` — the explicit projection alias. Implicit
|
||||
/// aliasing (`select a x`) is not supported: a bare alias is
|
||||
/// ambiguous with the `from` keyword, whereas `as` is not.
|
||||
const SELECT_ALIAS_IDENT: Node = Node::Ident {
|
||||
source: IdentSource::NewName,
|
||||
role: "select_alias",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
};
|
||||
static SELECT_AS_ALIAS_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("as")),
|
||||
SELECT_ALIAS_IDENT,
|
||||
];
|
||||
static SELECT_AS_ALIAS: Node = Node::Seq(SELECT_AS_ALIAS_NODES);
|
||||
|
||||
/// A projection item: a SQL expression with an optional alias.
|
||||
static SELECT_PROJ_ITEM_NODES: &[Node] = &[
|
||||
SQL_EXPR,
|
||||
Node::Optional(&SELECT_AS_ALIAS),
|
||||
];
|
||||
static SELECT_PROJ_ITEM: Node = Node::Seq(SELECT_PROJ_ITEM_NODES);
|
||||
|
||||
/// `proj_item ( , proj_item )*`.
|
||||
const SELECT_PROJ_LIST: Node = Node::Repeated {
|
||||
inner: &SELECT_PROJ_ITEM,
|
||||
separator: Some(&Node::Punct(',')),
|
||||
min: 1,
|
||||
};
|
||||
|
||||
/// `projection := '*' | proj_item ( , proj_item )*`. (`t.*`
|
||||
/// qualified star is Phase 2 — it needs join scope.)
|
||||
const SELECT_PROJECTION_CHOICES: &[Node] = &[Node::Punct('*'), SELECT_PROJ_LIST];
|
||||
const SELECT_PROJECTION: Node = Node::Choice(SELECT_PROJECTION_CHOICES);
|
||||
|
||||
/// The `FROM` table. `writes_table` so the `WHERE` / `ORDER BY`
|
||||
/// expression column slots complete against this table; the
|
||||
/// validator rejects the internal `__rdbms_*` metadata tables
|
||||
/// (ADR-0030 §6).
|
||||
const SELECT_FROM_TABLE: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "table_name",
|
||||
validator: Some(reject_internal_table),
|
||||
highlight_override: None,
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
};
|
||||
|
||||
/// `where <sql_expr>`.
|
||||
static SELECT_WHERE_NODES: &[Node] = &[Node::Word(Word::keyword("where")), SQL_EXPR];
|
||||
static SELECT_WHERE: Node = Node::Seq(SELECT_WHERE_NODES);
|
||||
|
||||
/// `order by <item> ( , <item> )*`, each item a SQL expression
|
||||
/// with an optional `asc` / `desc`.
|
||||
const SELECT_SORT_DIR_CHOICES: &[Node] = &[
|
||||
Node::Word(Word::keyword("asc")),
|
||||
Node::Word(Word::keyword("desc")),
|
||||
];
|
||||
static SELECT_ORDER_ITEM_NODES: &[Node] = &[
|
||||
SQL_EXPR,
|
||||
Node::Optional(&Node::Choice(SELECT_SORT_DIR_CHOICES)),
|
||||
];
|
||||
static SELECT_ORDER_ITEM: Node = Node::Seq(SELECT_ORDER_ITEM_NODES);
|
||||
static SELECT_ORDER_BY_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("order")),
|
||||
Node::Word(Word::keyword("by")),
|
||||
Node::Repeated {
|
||||
inner: &SELECT_ORDER_ITEM,
|
||||
separator: Some(&Node::Punct(',')),
|
||||
min: 1,
|
||||
},
|
||||
];
|
||||
static SELECT_ORDER_BY: Node = Node::Seq(SELECT_ORDER_BY_NODES);
|
||||
|
||||
/// `limit <n>` — reuses the `show data` non-negative-integer
|
||||
/// validator (`OFFSET` is Phase 2).
|
||||
static SELECT_LIMIT_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("limit")),
|
||||
Node::NumberLit {
|
||||
validator: Some(LIMIT_VALIDATOR),
|
||||
},
|
||||
];
|
||||
static SELECT_LIMIT: Node = Node::Seq(SELECT_LIMIT_NODES);
|
||||
|
||||
/// `from <Table>` — the FROM clause is optional so that the
|
||||
/// zero-table form `select 1`, `select upper('x')` (a constant
|
||||
/// or function-call expression) is admitted alongside the
|
||||
/// single-table form. The expression slots resolve column
|
||||
/// completion against the FROM table when present; without it,
|
||||
/// the engine will reject any column references.
|
||||
static SELECT_FROM_CLAUSE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("from")),
|
||||
SELECT_FROM_TABLE,
|
||||
];
|
||||
static SELECT_FROM_CLAUSE: Node = Node::Seq(SELECT_FROM_CLAUSE_NODES);
|
||||
|
||||
const SELECT_NODES: &[Node] = &[
|
||||
SELECT_PROJECTION,
|
||||
Node::Optional(&SELECT_FROM_CLAUSE),
|
||||
Node::Optional(&SELECT_WHERE),
|
||||
Node::Optional(&SELECT_ORDER_BY),
|
||||
Node::Optional(&SELECT_LIMIT),
|
||||
// ADR-0030 §3: one statement per submission; a trailing `;`
|
||||
// is tolerated.
|
||||
Node::Optional(&Node::Punct(';')),
|
||||
];
|
||||
const SELECT_SHAPE: Node = Node::Seq(SELECT_NODES);
|
||||
|
||||
/// Reject a reference to an internal `__rdbms_*` metadata table
|
||||
/// in a `SELECT`'s `FROM` (ADR-0030 §6 — those tables are not in
|
||||
/// the query surface).
|
||||
fn reject_internal_table(name: &str) -> Result<(), ValidationError> {
|
||||
if name.to_ascii_lowercase().starts_with("__rdbms_") {
|
||||
Err(ValidationError {
|
||||
message_key: "select.internal_table",
|
||||
args: vec![("table", name.to_string())],
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// AST builders
|
||||
// =================================================================
|
||||
@@ -389,7 +538,7 @@ pub(crate) fn item_to_value(item: &MatchedItem) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_show(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let sub = path
|
||||
.items
|
||||
.iter()
|
||||
@@ -400,7 +549,7 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
.nth(1);
|
||||
let name = require_ident(path, "table_name")?;
|
||||
match sub {
|
||||
Some("data") => build_show_data(path),
|
||||
Some("data") => build_show_data(path, _source),
|
||||
Some("table") => Ok(Command::ShowTable { name }),
|
||||
_ => Err(ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
@@ -413,7 +562,7 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
/// (no positional `nth` lookups), so it serves both the
|
||||
/// standalone `show data` entry word and the `explain show
|
||||
/// data …` wrapper, where the entry-word offset shifts.
|
||||
fn build_show_data(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_show_data(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::ShowData {
|
||||
name: require_ident(path, "table_name")?,
|
||||
filter: build_show_filter(path)?,
|
||||
@@ -474,7 +623,7 @@ fn build_show_limit(path: &MatchedPath) -> Result<Option<u64>, ValidationError>
|
||||
})
|
||||
}
|
||||
|
||||
fn build_insert(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let table = require_ident(path, "table_name")?;
|
||||
|
||||
// Locate the second `values` keyword (the first is the
|
||||
@@ -654,7 +803,7 @@ fn collect_values_in_parens(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn build_update(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_update(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let table = require_ident(path, "table_name")?;
|
||||
let assignments = collect_assignments(path)?;
|
||||
let filter = collect_filter(path)?;
|
||||
@@ -723,7 +872,7 @@ fn collect_filter(path: &MatchedPath) -> Result<RowFilter, ValidationError> {
|
||||
)?))
|
||||
}
|
||||
|
||||
fn build_delete(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_delete(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let table = require_ident(path, "table_name")?;
|
||||
let filter = collect_filter(path)?;
|
||||
Ok(Command::Delete { table, filter })
|
||||
@@ -737,7 +886,7 @@ fn build_delete(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
/// `build_delete`), all of which are role-based and so are
|
||||
/// indifferent to the entry-word offset the `explain` prefix
|
||||
/// introduces.
|
||||
fn build_explain(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_explain(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let inner_word = path
|
||||
.items
|
||||
.iter()
|
||||
@@ -747,9 +896,9 @@ fn build_explain(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
})
|
||||
.nth(1);
|
||||
let inner = match inner_word {
|
||||
Some("show") => build_show_data(path)?,
|
||||
Some("update") => build_update(path)?,
|
||||
Some("delete") => build_delete(path)?,
|
||||
Some("show") => build_show_data(path, _source)?,
|
||||
Some("update") => build_update(path, _source)?,
|
||||
Some("delete") => build_delete(path, _source)?,
|
||||
_ => {
|
||||
return Err(ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
@@ -777,7 +926,7 @@ fn build_explain(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
const REPLAY_PATH_CHOICES: &[Node] = &[Node::StringLit, Node::BarePath];
|
||||
const REPLAY_PATH: Node = Node::Choice(REPLAY_PATH_CHOICES);
|
||||
|
||||
fn build_replay(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_replay(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let payload = path
|
||||
.items
|
||||
.iter()
|
||||
@@ -792,6 +941,18 @@ fn build_replay(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
Ok(Command::Replay { path: payload })
|
||||
}
|
||||
|
||||
/// `Command::Select` carries the validated SQL text verbatim
|
||||
/// (ADR-0030 §4/§6, ADR-0031 §2): a `SELECT` builds no AST — the
|
||||
/// walk has confirmed it is in the supported subset, and the
|
||||
/// worker runs the statement as text. `source` is the full
|
||||
/// submitted line; on a `Match` outcome the `SELECT` shape
|
||||
/// consumed all of it.
|
||||
fn build_select(_path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::Select {
|
||||
sql: source.trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// CommandNodes
|
||||
// =================================================================
|
||||
@@ -838,6 +999,16 @@ pub static EXPLAIN: CommandNode = CommandNode {
|
||||
help_id: Some("data.explain"),
|
||||
usage_ids: &["parse.usage.explain"],};
|
||||
|
||||
/// SQL `SELECT` (ADR-0030 §6, ADR-0031). Advanced mode only —
|
||||
/// gated by `grammar::is_advanced_only`. `help_id` is `None`
|
||||
/// until the `help sql` page lands (ADR-0030 Phase 6).
|
||||
pub static SELECT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("select"),
|
||||
shape: SELECT_SHAPE,
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
usage_ids: &["parse.usage.select"],};
|
||||
|
||||
// =================================================================
|
||||
// Tests — `explain` grammar (ADR-0028 §1)
|
||||
// =================================================================
|
||||
|
||||
+11
-11
@@ -522,7 +522,7 @@ fn parse_action(words: &[&'static str]) -> ReferentialAction {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_drop(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_drop(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
// Discriminate by the second word matched (the entry was
|
||||
// `drop`, the next Word is `table` / `column` / `relationship`).
|
||||
let sub = path
|
||||
@@ -566,7 +566,7 @@ fn build_drop(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
})
|
||||
}
|
||||
}
|
||||
Some("constraint") => build_drop_constraint(path),
|
||||
Some("constraint") => build_drop_constraint(path, _source),
|
||||
Some("relationship") => {
|
||||
// Endpoints form has `from` as the third Word.
|
||||
let has_from = path
|
||||
@@ -597,7 +597,7 @@ fn build_drop(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
// Second matched Word distinguishes column vs the `1:n
|
||||
// relationship` form. The `1` literal counts as a Word
|
||||
// (the walker records Literal matches as MatchedKind::Word
|
||||
@@ -631,13 +631,13 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
check,
|
||||
})
|
||||
}
|
||||
Some("1") => build_add_relationship(path),
|
||||
Some("1") => build_add_relationship(path, _source),
|
||||
Some("index") => Ok(Command::AddIndex {
|
||||
name: ident(path, "index_name").map(str::to_string),
|
||||
table: require_ident(path, "table_name")?,
|
||||
columns: collect_idents(path, "column_name"),
|
||||
}),
|
||||
Some("constraint") => build_add_constraint(path),
|
||||
Some("constraint") => build_add_constraint(path, _source),
|
||||
_ => Err(ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "unknown add subcommand".to_string())],
|
||||
@@ -645,7 +645,7 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_add_relationship(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_add_relationship(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
// Collect all referential-clause actions in matched order
|
||||
// and validate at-most-2 + not-repeated. The `on <delete|
|
||||
// update> <action>` sequence shows up as a run of Word
|
||||
@@ -715,7 +715,7 @@ fn build_add_relationship(path: &MatchedPath) -> Result<Command, ValidationError
|
||||
})
|
||||
}
|
||||
|
||||
fn build_rename_column(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_rename_column(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::RenameColumn {
|
||||
table: require_ident(path, "table_name")?,
|
||||
old: require_ident(path, "column_name")?,
|
||||
@@ -723,7 +723,7 @@ fn build_rename_column(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_change_column(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_change_column(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let ty_text = require_ident(path, "type")?;
|
||||
let ty = ty_text
|
||||
.parse::<crate::dsl::types::Type>()
|
||||
@@ -775,7 +775,7 @@ fn build_change_column(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
/// recovers it. The §9 redundancy and §5 dry-run checks are
|
||||
/// execution-time (the parser has no schema) and live in the
|
||||
/// database worker.
|
||||
fn build_add_constraint(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_add_constraint(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let (not_null, unique, default, check) = collect_column_constraints(path)?;
|
||||
let constraint = if not_null {
|
||||
Constraint::NotNull
|
||||
@@ -802,7 +802,7 @@ fn build_add_constraint(path: &MatchedPath) -> Result<Command, ValidationError>
|
||||
/// (ADR-0029 §2.2). `drop` names only the kind — the
|
||||
/// `DROP_CONSTRAINT_KIND` Choice is payload-free, so the kind
|
||||
/// is recovered from which keyword(s) the path matched.
|
||||
fn build_drop_constraint(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let words: Vec<&'static str> = path
|
||||
.items
|
||||
.iter()
|
||||
@@ -1113,7 +1113,7 @@ fn redundant_pk_constraint(column: &str, constraint: &str) -> ValidationError {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_create_table(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
let name = require_ident(path, "table_name")?;
|
||||
|
||||
// Walk the matched items, segmenting per column: a
|
||||
|
||||
+29
-1
@@ -380,7 +380,13 @@ pub struct CommandNode {
|
||||
/// rejections that are easier to express imperatively than
|
||||
/// as a per-node validator (Phase A: none — every app
|
||||
/// command's ast_builder is infallible).
|
||||
pub ast_builder: fn(&MatchedPath) -> Result<Command, ValidationError>,
|
||||
///
|
||||
/// `source` is the full input line being parsed. Most builders
|
||||
/// reconstruct the `Command` from the matched `MatchedPath`
|
||||
/// alone and ignore it; SQL builders whose `Command` carries
|
||||
/// the validated SQL text (ADR-0030 §4/§6, ADR-0031 §2) read
|
||||
/// it.
|
||||
pub ast_builder: fn(&MatchedPath, &str) -> Result<Command, ValidationError>,
|
||||
/// Catalog key (`help.<id>`) for this command's in-app
|
||||
/// `help` entry. Consumed by `App::note_help`, which
|
||||
/// iterates the REGISTRY and translates each `help_id` —
|
||||
@@ -486,8 +492,30 @@ pub static REGISTRY: &[&CommandNode] = &[
|
||||
&data::DELETE,
|
||||
&data::REPLAY,
|
||||
&data::EXPLAIN,
|
||||
&data::SELECT,
|
||||
];
|
||||
|
||||
/// Entry words for commands available only in advanced mode
|
||||
/// (ADR-0030 §2). Phase 1: `select`. In simple mode the walker
|
||||
/// gates these out — typing one yields the precise "this is SQL"
|
||||
/// hint rather than a normal parse or an "unknown command" error.
|
||||
///
|
||||
/// This is whole-command gating keyed on the entry word, which
|
||||
/// suffices while every SQL form is its own command. ADR-0030 §2's
|
||||
/// finer-grained per-`Choice`-branch tagging arrives with the
|
||||
/// shared DSL/SQL entry words (`create`, `insert`, …) in a later
|
||||
/// phase.
|
||||
const ADVANCED_ONLY_ENTRIES: &[&str] = &["select"];
|
||||
|
||||
/// Whether `entry` names an advanced-mode-only command (ADR-0030
|
||||
/// §2). Case-insensitive, matching keyword-matching elsewhere.
|
||||
#[must_use]
|
||||
pub fn is_advanced_only(entry: &str) -> bool {
|
||||
ADVANCED_ONLY_ENTRIES
|
||||
.iter()
|
||||
.any(|e| e.eq_ignore_ascii_case(entry))
|
||||
}
|
||||
|
||||
/// Look up a `CommandNode` by entry word, case-insensitively.
|
||||
///
|
||||
/// Used by the router to decide whether the walker owns this
|
||||
|
||||
+40
-3
@@ -13,6 +13,7 @@
|
||||
//! identifier-shape token isn't a registered entry word.
|
||||
|
||||
use crate::dsl::command::Command;
|
||||
use crate::mode::Mode;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
@@ -94,8 +95,13 @@ impl ParseError {
|
||||
/// `DynamicSubgrammar`) fall back to schema-unaware behaviour.
|
||||
/// Use `parse_command_with_schema` to enable typed value slots
|
||||
/// (ADR-0024 §Phase D).
|
||||
///
|
||||
/// Defaults to **advanced**-mode grammar (the full surface) —
|
||||
/// callers that need simple-mode gating (ADR-0030 §2) use
|
||||
/// [`parse_command_in_mode`] or
|
||||
/// [`parse_command_with_schema_in_mode`] instead.
|
||||
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
|
||||
parse_command_inner(input, None)
|
||||
parse_command_inner(input, None, Mode::Advanced)
|
||||
}
|
||||
|
||||
/// Schema-aware parse entry point (ADR-0024 §Phase D).
|
||||
@@ -104,21 +110,50 @@ pub fn parse_command(input: &str) -> Result<Command, ParseError> {
|
||||
/// the walker can populate `current_table` / `current_column`
|
||||
/// from existing entities and `DynamicSubgrammar` factories
|
||||
/// can unfold per-column typed value slots.
|
||||
///
|
||||
/// Defaults to **advanced**-mode grammar; for simple-mode
|
||||
/// gating use [`parse_command_with_schema_in_mode`].
|
||||
pub fn parse_command_with_schema(
|
||||
input: &str,
|
||||
schema: &crate::completion::SchemaCache,
|
||||
) -> Result<Command, ParseError> {
|
||||
parse_command_inner(input, Some(schema))
|
||||
parse_command_inner(input, Some(schema), Mode::Advanced)
|
||||
}
|
||||
|
||||
/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
|
||||
/// the walker gates SQL-only commands and produces the
|
||||
/// "this is SQL" hint instead of executing them.
|
||||
pub fn parse_command_in_mode(
|
||||
input: &str,
|
||||
mode: Mode,
|
||||
) -> Result<Command, ParseError> {
|
||||
parse_command_inner(input, None, mode)
|
||||
}
|
||||
|
||||
/// Schema-aware, mode-aware parse.
|
||||
///
|
||||
/// Combines ADR-0024 §Phase D (schema-aware typed slots) with
|
||||
/// ADR-0030 §2 (mode-gated SQL grammar). The execution path
|
||||
/// (`App::dispatch_dsl`) and the live overlay / completion /
|
||||
/// highlight call sites use this so simple-mode users do not
|
||||
/// see advanced-mode SQL surfaced.
|
||||
pub fn parse_command_with_schema_in_mode(
|
||||
input: &str,
|
||||
schema: &crate::completion::SchemaCache,
|
||||
mode: Mode,
|
||||
) -> Result<Command, ParseError> {
|
||||
parse_command_inner(input, Some(schema), mode)
|
||||
}
|
||||
|
||||
fn parse_command_inner(
|
||||
input: &str,
|
||||
schema: Option<&crate::completion::SchemaCache>,
|
||||
mode: Mode,
|
||||
) -> Result<Command, ParseError> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(ParseError::Empty);
|
||||
}
|
||||
if let Some(result) = try_walker_route(input, schema) {
|
||||
if let Some(result) = try_walker_route(input, schema, mode) {
|
||||
return result;
|
||||
}
|
||||
Err(unknown_command_error(input))
|
||||
@@ -157,11 +192,13 @@ fn unknown_command_error(source: &str) -> ParseError {
|
||||
fn try_walker_route(
|
||||
source: &str,
|
||||
schema: Option<&crate::completion::SchemaCache>,
|
||||
mode: Mode,
|
||||
) -> Option<Result<Command, ParseError>> {
|
||||
use crate::dsl::walker::{self, outcome::WalkBound};
|
||||
let mut ctx = schema.map_or_else(walker::context::WalkContext::new, |s| {
|
||||
walker::context::WalkContext::with_schema(s)
|
||||
});
|
||||
ctx.mode = mode;
|
||||
let (result, command) = walker::walk(source, WalkBound::EndOfInput, &mut ctx);
|
||||
let result = result?;
|
||||
Some(walker_outcome_to_parse_result(source, result, command))
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! generic value-literal slot.
|
||||
|
||||
use crate::completion::{SchemaCache, TableColumn};
|
||||
use crate::mode::Mode;
|
||||
|
||||
/// Per-walk state.
|
||||
///
|
||||
@@ -28,6 +29,13 @@ use crate::completion::{SchemaCache, TableColumn};
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WalkContext<'a> {
|
||||
pub schema: Option<&'a SchemaCache>,
|
||||
/// The input mode this walk runs under (ADR-0030 §2). In
|
||||
/// `Mode::Simple` the walker gates out SQL-only commands —
|
||||
/// an advanced-only entry word yields the "this is SQL"
|
||||
/// hint rather than a normal parse. Defaults to
|
||||
/// `Mode::Simple`; real call sites set it from the active
|
||||
/// `App` mode.
|
||||
pub mode: Mode,
|
||||
pub current_table: Option<String>,
|
||||
pub current_table_columns: Option<Vec<TableColumn>>,
|
||||
pub current_column: Option<TableColumn>,
|
||||
@@ -100,6 +108,7 @@ impl<'a> WalkContext<'a> {
|
||||
pub const fn with_schema(schema: &'a SchemaCache) -> Self {
|
||||
Self {
|
||||
schema: Some(schema),
|
||||
mode: Mode::Simple,
|
||||
current_table: None,
|
||||
current_table_columns: None,
|
||||
current_column: None,
|
||||
|
||||
+32
-1
@@ -865,6 +865,37 @@ pub fn walk<'a>(
|
||||
class: grammar::HighlightClass::Keyword,
|
||||
});
|
||||
|
||||
// Mode gate (ADR-0030 §2): an advanced-only command (a SQL
|
||||
// form) typed in simple mode is *recognised as SQL* and
|
||||
// yields a precise hint — "this is SQL; switch with `mode
|
||||
// advanced`, or prefix the line with `:`" — rather than
|
||||
// being walked normally or rejected as an unknown command.
|
||||
// The entry word stays highlighted as a keyword (it is one);
|
||||
// the input carries an ERROR verdict (it will not run here).
|
||||
if ctx.mode == crate::mode::Mode::Simple
|
||||
&& grammar::is_advanced_only(command_node.entry.primary)
|
||||
{
|
||||
return (
|
||||
Some(WalkResult {
|
||||
outcome: WalkOutcome::ValidationFailed {
|
||||
position: kw_start,
|
||||
error: crate::dsl::grammar::ValidationError {
|
||||
message_key: "advanced_mode.sql_in_simple",
|
||||
args: vec![(
|
||||
"command",
|
||||
command_node.entry.primary.to_string(),
|
||||
)],
|
||||
},
|
||||
},
|
||||
matched_path: path,
|
||||
per_byte_class: per_byte,
|
||||
diagnostics: Vec::new(),
|
||||
tail_expected: Vec::new(),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let mut tail_expected: Vec<Expectation> = Vec::new();
|
||||
let outcome = match walk_node(
|
||||
effective_source,
|
||||
@@ -937,7 +968,7 @@ pub fn walk<'a>(
|
||||
// the catalog wording correctly) rather than as a generic
|
||||
// "AST builder failed" fallback.
|
||||
let (final_outcome, cmd) = match outcome {
|
||||
WalkOutcome::Match { .. } => match (command_node.ast_builder)(&path) {
|
||||
WalkOutcome::Match { .. } => match (command_node.ast_builder)(&path, source) {
|
||||
Ok(c) => (outcome, Some(c)),
|
||||
Err(error) => (
|
||||
WalkOutcome::ValidationFailed {
|
||||
|
||||
Reference in New Issue
Block a user