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:
+67
-29
@@ -990,27 +990,12 @@ impl App {
|
||||
return self.dispatch_app_command(app_cmd, &effective_input);
|
||||
}
|
||||
|
||||
// For everything else: dispatch by effective mode.
|
||||
match effective_mode {
|
||||
Mode::Simple => self.dispatch_dsl(&effective_input, effective_mode),
|
||||
Mode::Advanced => {
|
||||
// SQL handling is not implemented yet; show a placeholder
|
||||
// until the advanced-mode SQL path lands. Once it does,
|
||||
// this branch parses with sqlparser-rs and dispatches
|
||||
// analogously to the DSL path below.
|
||||
self.note_system(crate::t!(
|
||||
"advanced_mode.not_implemented",
|
||||
input = effective_input
|
||||
));
|
||||
self.push_output(OutputLine {
|
||||
text: effective_input,
|
||||
kind: OutputKind::Echo,
|
||||
mode_at_submission: effective_mode,
|
||||
styled_runs: None,
|
||||
});
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
// For everything else: unified dispatch. `dispatch_dsl`
|
||||
// parses with `effective_mode` (ADR-0030 §2), so a SQL
|
||||
// form in advanced mode runs and a SQL form in simple
|
||||
// mode yields the precise "this is SQL" hint through the
|
||||
// walker's mode gate — no separate placeholder branch.
|
||||
self.dispatch_dsl(&effective_input, effective_mode)
|
||||
}
|
||||
|
||||
/// Dispatch a parsed app-lifecycle command. Works in both
|
||||
@@ -1091,7 +1076,16 @@ impl App {
|
||||
// value slots (insert-into-T-values-…) dispatch on the
|
||||
// column's actual user-facing type instead of accepting
|
||||
// any literal at bind time.
|
||||
match crate::dsl::parser::parse_command_with_schema(input, &self.schema_cache) {
|
||||
//
|
||||
// ADR-0030 §2: parse with the submission's effective
|
||||
// mode so the walker gates SQL-only forms — simple-mode
|
||||
// `select` returns the "this is SQL" hint as a normal
|
||||
// parse error and is rendered through the Err arm below.
|
||||
match crate::dsl::parser::parse_command_with_schema_in_mode(
|
||||
input,
|
||||
&self.schema_cache,
|
||||
submission_mode,
|
||||
) {
|
||||
Ok(Command::Replay { path }) => {
|
||||
// `replay` is parsed as a DSL command for the
|
||||
// sake of grammar uniformity, but its execution
|
||||
@@ -1443,6 +1437,10 @@ impl App {
|
||||
C::ShowData { name, .. } | C::ShowTable { name } => {
|
||||
(Operation::Query, Some(name.as_str()), None)
|
||||
}
|
||||
// A SQL `SELECT` carries only its statement text —
|
||||
// no single table name to fall back on. A query
|
||||
// failure routes through `Operation::Query`.
|
||||
C::Select { .. } => (Operation::Query, None, None),
|
||||
C::Replay { .. } => (Operation::Replay, None, None),
|
||||
// An `explain` failure (e.g. unknown table) is best
|
||||
// described by the wrapped query it failed to plan.
|
||||
@@ -2270,19 +2268,40 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_advanced_mode_echoes_with_advanced_tag() {
|
||||
fn enter_in_advanced_mode_dispatches_select_with_advanced_tag() {
|
||||
// The pre-ADR-0030 placeholder echoed any advanced-mode
|
||||
// input back unexecuted; with the SQL surface live, a
|
||||
// `select` in advanced mode runs through `dispatch_dsl`
|
||||
// exactly like a DSL command, producing the standard
|
||||
// `running: …` echo and an `ExecuteDsl(Command::Select)`
|
||||
// action. The mode-tag invariant — that the echo carries
|
||||
// the submission's effective mode — is what this test
|
||||
// pins down.
|
||||
let mut app = App::new();
|
||||
app.mode = Mode::Advanced;
|
||||
type_str(&mut app, "select 1");
|
||||
submit(&mut app);
|
||||
// We expect a placeholder system line plus the echoed line.
|
||||
let actions = submit(&mut app);
|
||||
let echoed = app
|
||||
.output
|
||||
.iter()
|
||||
.rfind(|l| l.kind == OutputKind::Echo)
|
||||
.unwrap();
|
||||
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
|
||||
assert_eq!(echoed.text, "select 1");
|
||||
assert!(
|
||||
echoed.text.contains("select 1"),
|
||||
"echo line carries the input: {:?}",
|
||||
echoed.text,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
actions.as_slice(),
|
||||
[Action::ExecuteDsl {
|
||||
command: Command::Select { .. },
|
||||
..
|
||||
}],
|
||||
),
|
||||
"advanced-mode `select` should produce ExecuteDsl(Select); got {actions:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2326,17 +2345,36 @@ mod tests {
|
||||
fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, ":select 1");
|
||||
submit(&mut app);
|
||||
let actions = submit(&mut app);
|
||||
// The persistent mode is unchanged.
|
||||
assert_eq!(app.mode, Mode::Simple);
|
||||
// The advanced echo line is present.
|
||||
let echoed = app
|
||||
.output
|
||||
.iter()
|
||||
.rfind(|l| l.kind == OutputKind::Echo)
|
||||
.unwrap();
|
||||
// The line ran under the one-shot effective mode, so
|
||||
// the echo carries the Advanced tag…
|
||||
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
|
||||
assert_eq!(echoed.text, "select 1");
|
||||
// …and the `:` is stripped before dispatch (the SQL
|
||||
// executed is `select 1`, not `:select 1`).
|
||||
assert!(
|
||||
echoed.text.contains("select 1") && !echoed.text.contains(":select"),
|
||||
"echo carries the stripped input: {:?}",
|
||||
echoed.text,
|
||||
);
|
||||
// The one-shot dispatched the SELECT through the same
|
||||
// path as a persistent-advanced submission.
|
||||
assert!(
|
||||
matches!(
|
||||
actions.as_slice(),
|
||||
[Action::ExecuteDsl {
|
||||
command: Command::Select { .. },
|
||||
..
|
||||
}],
|
||||
),
|
||||
"`:select 1` should produce ExecuteDsl(Select); got {actions:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -567,6 +567,19 @@ enum Request {
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<DataResult, DbError>>,
|
||||
},
|
||||
/// Run a SQL `SELECT` typed by the user in advanced mode
|
||||
/// (ADR-0030 §6, ADR-0031). The grammar walker has already
|
||||
/// validated `sql` is in the supported subset; the worker
|
||||
/// prepares and runs the statement and returns the rows as
|
||||
/// a [`DataResult`] (with no playground type information per
|
||||
/// ADR-0030 §6 — computed columns render with neutral
|
||||
/// alignment). `source` is the literal submitted line,
|
||||
/// appended to `history.log` for replay (ADR-0030 §11).
|
||||
RunSelect {
|
||||
sql: String,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<DataResult, DbError>>,
|
||||
},
|
||||
/// Capture the query plan for an explainable command via
|
||||
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
|
||||
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
|
||||
@@ -1010,6 +1023,20 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Run a validated SQL `SELECT` and return the rows
|
||||
/// (ADR-0030 §6, ADR-0031). `sql` is the grammar-validated
|
||||
/// statement text; `source` is the literal submitted line
|
||||
/// for `history.log`.
|
||||
pub async fn run_select(
|
||||
&self,
|
||||
sql: String,
|
||||
source: Option<String>,
|
||||
) -> Result<DataResult, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::RunSelect { sql, source, reply }).await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Capture the query plan for an explainable command
|
||||
/// (ADR-0028 §2). The wrapped command is not executed —
|
||||
/// `EXPLAIN QUERY PLAN` only inspects how the engine would
|
||||
@@ -1447,6 +1474,14 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
||||
limit,
|
||||
));
|
||||
}
|
||||
Request::RunSelect { sql, source, reply } => {
|
||||
let _ = reply.send(do_run_select_request(
|
||||
conn,
|
||||
persistence,
|
||||
source.as_deref(),
|
||||
&sql,
|
||||
));
|
||||
}
|
||||
Request::RebuildFromText {
|
||||
project_path,
|
||||
source,
|
||||
@@ -5645,6 +5680,69 @@ fn do_query_data_request(
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Worker handler for `Request::RunSelect` (ADR-0030 §6,
|
||||
/// ADR-0031). Mirrors `do_query_data_request`: run the
|
||||
/// statement, append the literal line to `history.log` so a
|
||||
/// replay re-runs it (ADR-0030 §11).
|
||||
fn do_run_select_request(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
sql: &str,
|
||||
) -> Result<DataResult, DbError> {
|
||||
let data = do_run_select(conn, sql)?;
|
||||
if let (Some(p), Some(text)) = (persistence, source) {
|
||||
p.append_history(text)
|
||||
.map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Execute a grammar-validated SQL `SELECT` and collect its
|
||||
/// rows into a [`DataResult`] (ADR-0030 §6).
|
||||
///
|
||||
/// All result columns are reported with `column_types = None` —
|
||||
/// a SELECT result has no playground type unless we resolve
|
||||
/// each output column back to its source column, which is a
|
||||
/// Phase-2 (full `SELECT`) concern. The DSL data-table renderer
|
||||
/// (ADR-0016) renders typeless columns with neutral alignment.
|
||||
fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
|
||||
debug!(sql = %sql, "run_select");
|
||||
let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
|
||||
let column_names: Vec<String> = stmt
|
||||
.column_names()
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let col_count = column_names.len();
|
||||
let column_types: Vec<Option<Type>> = vec![None; col_count];
|
||||
let rows_iter = stmt
|
||||
.query_map([], |row| {
|
||||
let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(col_count);
|
||||
for i in 0..col_count {
|
||||
cells.push(row.get(i)?);
|
||||
}
|
||||
Ok(cells)
|
||||
})
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
|
||||
for r in rows_iter {
|
||||
let cells = r.map_err(DbError::from_rusqlite)?;
|
||||
rows.push(
|
||||
cells
|
||||
.into_iter()
|
||||
.map(|v| format_cell(v, None))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
Ok(DataResult {
|
||||
table_name: String::new(),
|
||||
columns: column_names,
|
||||
column_types,
|
||||
rows,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the parameterised `SELECT … FROM …` statement for a
|
||||
/// `show data` query (ADR-0026 §5–§6). Separated from
|
||||
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -240,6 +240,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.rebuild", &[]),
|
||||
("parse.usage.replay", &[]),
|
||||
("parse.usage.save", &[]),
|
||||
("parse.usage.select", &[]),
|
||||
("parse.usage.show_data", &[]),
|
||||
("parse.usage.show_table", &[]),
|
||||
("parse.usage.update", &[]),
|
||||
@@ -293,6 +294,9 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("project.switched_ok", &["display_name"]),
|
||||
// ---- Advanced-mode placeholder ----
|
||||
("advanced_mode.not_implemented", &["input"]),
|
||||
// ---- Advanced-mode SQL surface (ADR-0030) ----
|
||||
("advanced_mode.sql_in_simple", &["command"]),
|
||||
("select.internal_table", &["table"]),
|
||||
(
|
||||
"cli.invalid_value",
|
||||
&["flag", "value", "expected"],
|
||||
|
||||
@@ -462,6 +462,8 @@ parse:
|
||||
explain update <Table> set <col>=<value>[, ...] (where <expr> | --all-rows)
|
||||
explain delete from <Table> (where <expr> | --all-rows)
|
||||
replay: "replay <path> | replay '<path with spaces>'"
|
||||
# SQL `SELECT` (advanced mode; ADR-0030 / ADR-0031).
|
||||
select: "select (* | <expr>[ as <alias>][, ...]) from <Table> [where <expr>] [order by <expr>[ asc|desc][, ...]] [limit <n>]"
|
||||
# App-lifecycle commands (per ADR-0003, surfaced through
|
||||
# the parser so they participate in usage templates +
|
||||
# completion). Templates here describe the surface
|
||||
@@ -602,9 +604,14 @@ persistence:
|
||||
bad_output: "migrator produced an unparseable result: {detail}"
|
||||
io: "io error during migration on `{path}`: {source}"
|
||||
|
||||
# ---- Advanced-mode placeholder until SQL parser lands (Q1) ----------
|
||||
# ---- Advanced-mode SQL surface (ADR-0030) ---------------------------
|
||||
advanced_mode:
|
||||
not_implemented: "advanced mode SQL not implemented yet — echo: {input}"
|
||||
sql_in_simple: "`{command}` is SQL — available in advanced mode. Switch with `mode advanced`, or prefix the line with `:` to run it once."
|
||||
|
||||
# ---- SQL SELECT (advanced mode; ADR-0030 / ADR-0031) ----------------
|
||||
select:
|
||||
internal_table: "`{table}` is an internal system table and cannot be queried."
|
||||
|
||||
# ---- Persistence-fatal banner (ADR-0015 §8) -------------------------
|
||||
fatal:
|
||||
|
||||
+5
-1
@@ -7,8 +7,12 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Mode {
|
||||
/// The teaching DSL only — the app's startup mode (ADR-0003)
|
||||
/// and the walker's default view: SQL-only grammar is gated
|
||||
/// out (ADR-0030 §2).
|
||||
#[default]
|
||||
Simple,
|
||||
Advanced,
|
||||
}
|
||||
|
||||
@@ -1874,6 +1874,13 @@ async fn execute_command_typed(
|
||||
.query_data(name, filter, limit, src)
|
||||
.await
|
||||
.map(CommandOutcome::Query),
|
||||
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
|
||||
// The grammar walker has already validated `sql` is in
|
||||
// the supported subset; the worker runs it as text.
|
||||
Command::Select { sql } => database
|
||||
.run_select(sql, src)
|
||||
.await
|
||||
.map(CommandOutcome::Query),
|
||||
// `EXPLAIN QUERY PLAN` never executes the wrapped
|
||||
// statement (ADR-0028 §2), so explaining a destructive
|
||||
// command is safe. `src` is unused here — explain is a
|
||||
|
||||
Reference in New Issue
Block a user