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:
claude@clouddev1
2026-05-19 21:46:56 +00:00
parent c93f9394f5
commit 6369066fe4
16 changed files with 527 additions and 71 deletions
+67 -29
View File
@@ -990,27 +990,12 @@ impl App {
return self.dispatch_app_command(app_cmd, &effective_input); return self.dispatch_app_command(app_cmd, &effective_input);
} }
// For everything else: dispatch by effective mode. // For everything else: unified dispatch. `dispatch_dsl`
match effective_mode { // parses with `effective_mode` (ADR-0030 §2), so a SQL
Mode::Simple => self.dispatch_dsl(&effective_input, effective_mode), // form in advanced mode runs and a SQL form in simple
Mode::Advanced => { // mode yields the precise "this is SQL" hint through the
// SQL handling is not implemented yet; show a placeholder // walker's mode gate — no separate placeholder branch.
// until the advanced-mode SQL path lands. Once it does, self.dispatch_dsl(&effective_input, effective_mode)
// 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()
}
}
} }
/// Dispatch a parsed app-lifecycle command. Works in both /// Dispatch a parsed app-lifecycle command. Works in both
@@ -1091,7 +1076,16 @@ impl App {
// value slots (insert-into-T-values-…) dispatch on the // value slots (insert-into-T-values-…) dispatch on the
// column's actual user-facing type instead of accepting // column's actual user-facing type instead of accepting
// any literal at bind time. // 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 }) => { Ok(Command::Replay { path }) => {
// `replay` is parsed as a DSL command for the // `replay` is parsed as a DSL command for the
// sake of grammar uniformity, but its execution // sake of grammar uniformity, but its execution
@@ -1443,6 +1437,10 @@ impl App {
C::ShowData { name, .. } | C::ShowTable { name } => { C::ShowData { name, .. } | C::ShowTable { name } => {
(Operation::Query, Some(name.as_str()), None) (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), C::Replay { .. } => (Operation::Replay, None, None),
// An `explain` failure (e.g. unknown table) is best // An `explain` failure (e.g. unknown table) is best
// described by the wrapped query it failed to plan. // described by the wrapped query it failed to plan.
@@ -2270,19 +2268,40 @@ mod tests {
} }
#[test] #[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(); let mut app = App::new();
app.mode = Mode::Advanced; app.mode = Mode::Advanced;
type_str(&mut app, "select 1"); type_str(&mut app, "select 1");
submit(&mut app); let actions = submit(&mut app);
// We expect a placeholder system line plus the echoed line.
let echoed = app let echoed = app
.output .output
.iter() .iter()
.rfind(|l| l.kind == OutputKind::Echo) .rfind(|l| l.kind == OutputKind::Echo)
.unwrap(); .unwrap();
assert_eq!(echoed.mode_at_submission, Mode::Advanced); 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] #[test]
@@ -2326,17 +2345,36 @@ mod tests {
fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() { fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() {
let mut app = App::new(); let mut app = App::new();
type_str(&mut app, ":select 1"); type_str(&mut app, ":select 1");
submit(&mut app); let actions = submit(&mut app);
// The persistent mode is unchanged. // The persistent mode is unchanged.
assert_eq!(app.mode, Mode::Simple); assert_eq!(app.mode, Mode::Simple);
// The advanced echo line is present.
let echoed = app let echoed = app
.output .output
.iter() .iter()
.rfind(|l| l.kind == OutputKind::Echo) .rfind(|l| l.kind == OutputKind::Echo)
.unwrap(); .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.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] #[test]
+98
View File
@@ -567,6 +567,19 @@ enum Request {
source: Option<String>, source: Option<String>,
reply: oneshot::Sender<Result<DataResult, DbError>>, 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 /// Capture the query plan for an explainable command via
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner /// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN` /// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
@@ -1010,6 +1023,20 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)? 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 /// Capture the query plan for an explainable command
/// (ADR-0028 §2). The wrapped command is not executed — /// (ADR-0028 §2). The wrapped command is not executed —
/// `EXPLAIN QUERY PLAN` only inspects how the engine would /// `EXPLAIN QUERY PLAN` only inspects how the engine would
@@ -1447,6 +1474,14 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
limit, limit,
)); ));
} }
Request::RunSelect { sql, source, reply } => {
let _ = reply.send(do_run_select_request(
conn,
persistence,
source.as_deref(),
&sql,
));
}
Request::RebuildFromText { Request::RebuildFromText {
project_path, project_path,
source, source,
@@ -5645,6 +5680,69 @@ fn do_query_data_request(
Ok(data) 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 /// Build the parameterised `SELECT … FROM …` statement for a
/// `show data` query (ADR-0026 §5–§6). Separated from /// `show data` query (ADR-0026 §5–§6). Separated from
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY /// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
+15
View File
@@ -283,6 +283,15 @@ pub enum Command {
Explain { Explain {
query: Box<Self>, 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 /// App-lifecycle command (per ADR-0003). These work in both
/// simple and advanced modes; the dispatcher branches on the /// simple and advanced modes; the dispatcher branches on the
/// `Command::App(...)` variant before mode-specific routing. /// `Command::App(...)` variant before mode-specific routing.
@@ -577,6 +586,7 @@ impl Command {
Self::ShowData { .. } => "show data", Self::ShowData { .. } => "show data",
Self::Replay { .. } => "replay", Self::Replay { .. } => "replay",
Self::Explain { .. } => "explain", Self::Explain { .. } => "explain",
Self::Select { .. } => "select",
Self::App(app) => match app { Self::App(app) => match app {
AppCommand::Quit => "quit", AppCommand::Quit => "quit",
AppCommand::Help => "help", AppCommand::Help => "help",
@@ -639,6 +649,11 @@ impl Command {
// Explain forwards to the wrapped query — the table // Explain forwards to the wrapped query — the table
// the plan is about is the inner command's table. // the plan is about is the inner command's table.
Self::Explain { query } => query.target_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 // App commands aren't tied to schema entities — the
// verb is the most identifying thing. The // verb is the most identifying thing. The
// display_subject override below provides a richer // display_subject override below provides a richer
+10 -10
View File
@@ -110,19 +110,19 @@ const SAVE_AS_OPT: Node = Node::Optional(&SAVE_AS_WORD);
// --- AST builders -------------------------------------------------- // --- 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)) 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)) 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)) 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") { if path.contains_word("as") {
Ok(Command::App(AppCommand::SaveAs)) Ok(Command::App(AppCommand::SaveAs))
} else { } 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)) 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)) 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 let bare = path
.find(|i| matches!(i.kind, MatchedKind::BarePath)) .find(|i| matches!(i.kind, MatchedKind::BarePath))
.map(|i| i.text.clone()); .map(|i| i.text.clone());
Ok(Command::App(AppCommand::Export { path: bare })) 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 let bare_path = path
.find(|i| matches!(i.kind, MatchedKind::BarePath)) .find(|i| matches!(i.kind, MatchedKind::BarePath))
.map(|i| i.text.clone()) .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` // The Choice surfaces the matched value as either a `Word`
// (known) or an `Ident` (unknown). The unknown branch's // (known) or an `Ident` (unknown). The unknown branch's
// validator always errors, so reaching the AST builder // 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 })) 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") { let value = if path.contains_word("short") {
Some(MessagesValue::Short) Some(MessagesValue::Short)
} else if path.contains_word("verbose") { } else if path.contains_word("verbose") {
+182 -11
View File
@@ -20,6 +20,7 @@ use crate::dsl::command::{Command, Expr, RowFilter};
use crate::dsl::grammar::{ use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr, CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{column_value_list, current_column_value}, shared::{column_value_list, current_column_value},
sql_expr,
}; };
use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::context::WalkContext;
use crate::dsl::value::Value; use crate::dsl::value::Value;
@@ -352,6 +353,154 @@ const EXPLAIN_CHOICES: &[Node] = &[
]; ];
const EXPLAIN_SHAPE: Node = Node::Choice(EXPLAIN_CHOICES); 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 // 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 let sub = path
.items .items
.iter() .iter()
@@ -400,7 +549,7 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
.nth(1); .nth(1);
let name = require_ident(path, "table_name")?; let name = require_ident(path, "table_name")?;
match sub { match sub {
Some("data") => build_show_data(path), Some("data") => build_show_data(path, _source),
Some("table") => Ok(Command::ShowTable { name }), Some("table") => Ok(Command::ShowTable { name }),
_ => Err(ValidationError { _ => Err(ValidationError {
message_key: "parse.error_wrapper", 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 /// (no positional `nth` lookups), so it serves both the
/// standalone `show data` entry word and the `explain show /// standalone `show data` entry word and the `explain show
/// data …` wrapper, where the entry-word offset shifts. /// 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 { Ok(Command::ShowData {
name: require_ident(path, "table_name")?, name: require_ident(path, "table_name")?,
filter: build_show_filter(path)?, 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")?; let table = require_ident(path, "table_name")?;
// Locate the second `values` keyword (the first is the // Locate the second `values` keyword (the first is the
@@ -654,7 +803,7 @@ fn collect_values_in_parens(
Ok(out) 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 table = require_ident(path, "table_name")?;
let assignments = collect_assignments(path)?; let assignments = collect_assignments(path)?;
let filter = collect_filter(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 table = require_ident(path, "table_name")?;
let filter = collect_filter(path)?; let filter = collect_filter(path)?;
Ok(Command::Delete { table, filter }) 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 /// `build_delete`), all of which are role-based and so are
/// indifferent to the entry-word offset the `explain` prefix /// indifferent to the entry-word offset the `explain` prefix
/// introduces. /// introduces.
fn build_explain(path: &MatchedPath) -> Result<Command, ValidationError> { fn build_explain(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
let inner_word = path let inner_word = path
.items .items
.iter() .iter()
@@ -747,9 +896,9 @@ fn build_explain(path: &MatchedPath) -> Result<Command, ValidationError> {
}) })
.nth(1); .nth(1);
let inner = match inner_word { let inner = match inner_word {
Some("show") => build_show_data(path)?, Some("show") => build_show_data(path, _source)?,
Some("update") => build_update(path)?, Some("update") => build_update(path, _source)?,
Some("delete") => build_delete(path)?, Some("delete") => build_delete(path, _source)?,
_ => { _ => {
return Err(ValidationError { return Err(ValidationError {
message_key: "parse.error_wrapper", 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_CHOICES: &[Node] = &[Node::StringLit, Node::BarePath];
const REPLAY_PATH: Node = Node::Choice(REPLAY_PATH_CHOICES); 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 let payload = path
.items .items
.iter() .iter()
@@ -792,6 +941,18 @@ fn build_replay(path: &MatchedPath) -> Result<Command, ValidationError> {
Ok(Command::Replay { path: payload }) 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 // CommandNodes
// ================================================================= // =================================================================
@@ -838,6 +999,16 @@ pub static EXPLAIN: CommandNode = CommandNode {
help_id: Some("data.explain"), help_id: Some("data.explain"),
usage_ids: &["parse.usage.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) // Tests — `explain` grammar (ADR-0028 §1)
// ================================================================= // =================================================================
+11 -11
View File
@@ -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 // Discriminate by the second word matched (the entry was
// `drop`, the next Word is `table` / `column` / `relationship`). // `drop`, the next Word is `table` / `column` / `relationship`).
let sub = path 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") => { Some("relationship") => {
// Endpoints form has `from` as the third Word. // Endpoints form has `from` as the third Word.
let has_from = path 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 // Second matched Word distinguishes column vs the `1:n
// relationship` form. The `1` literal counts as a Word // relationship` form. The `1` literal counts as a Word
// (the walker records Literal matches as MatchedKind::Word // (the walker records Literal matches as MatchedKind::Word
@@ -631,13 +631,13 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
check, check,
}) })
} }
Some("1") => build_add_relationship(path), Some("1") => build_add_relationship(path, _source),
Some("index") => Ok(Command::AddIndex { Some("index") => Ok(Command::AddIndex {
name: ident(path, "index_name").map(str::to_string), name: ident(path, "index_name").map(str::to_string),
table: require_ident(path, "table_name")?, table: require_ident(path, "table_name")?,
columns: collect_idents(path, "column_name"), columns: collect_idents(path, "column_name"),
}), }),
Some("constraint") => build_add_constraint(path), Some("constraint") => build_add_constraint(path, _source),
_ => Err(ValidationError { _ => Err(ValidationError {
message_key: "parse.error_wrapper", message_key: "parse.error_wrapper",
args: vec![("detail", "unknown add subcommand".to_string())], 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 // Collect all referential-clause actions in matched order
// and validate at-most-2 + not-repeated. The `on <delete| // and validate at-most-2 + not-repeated. The `on <delete|
// update> <action>` sequence shows up as a run of Word // 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 { Ok(Command::RenameColumn {
table: require_ident(path, "table_name")?, table: require_ident(path, "table_name")?,
old: require_ident(path, "column_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_text = require_ident(path, "type")?;
let ty = ty_text let ty = ty_text
.parse::<crate::dsl::types::Type>() .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 /// recovers it. The §9 redundancy and §5 dry-run checks are
/// execution-time (the parser has no schema) and live in the /// execution-time (the parser has no schema) and live in the
/// database worker. /// 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 (not_null, unique, default, check) = collect_column_constraints(path)?;
let constraint = if not_null { let constraint = if not_null {
Constraint::NotNull 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 /// (ADR-0029 §2.2). `drop` names only the kind — the
/// `DROP_CONSTRAINT_KIND` Choice is payload-free, so the kind /// `DROP_CONSTRAINT_KIND` Choice is payload-free, so the kind
/// is recovered from which keyword(s) the path matched. /// 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 let words: Vec<&'static str> = path
.items .items
.iter() .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")?; let name = require_ident(path, "table_name")?;
// Walk the matched items, segmenting per column: a // Walk the matched items, segmenting per column: a
+29 -1
View File
@@ -380,7 +380,13 @@ pub struct CommandNode {
/// rejections that are easier to express imperatively than /// rejections that are easier to express imperatively than
/// as a per-node validator (Phase A: none — every app /// as a per-node validator (Phase A: none — every app
/// command's ast_builder is infallible). /// 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 /// Catalog key (`help.<id>`) for this command's in-app
/// `help` entry. Consumed by `App::note_help`, which /// `help` entry. Consumed by `App::note_help`, which
/// iterates the REGISTRY and translates each `help_id` — /// iterates the REGISTRY and translates each `help_id` —
@@ -486,8 +492,30 @@ pub static REGISTRY: &[&CommandNode] = &[
&data::DELETE, &data::DELETE,
&data::REPLAY, &data::REPLAY,
&data::EXPLAIN, &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. /// Look up a `CommandNode` by entry word, case-insensitively.
/// ///
/// Used by the router to decide whether the walker owns this /// Used by the router to decide whether the walker owns this
+40 -3
View File
@@ -13,6 +13,7 @@
//! identifier-shape token isn't a registered entry word. //! identifier-shape token isn't a registered entry word.
use crate::dsl::command::Command; use crate::dsl::command::Command;
use crate::mode::Mode;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError { pub enum ParseError {
@@ -94,8 +95,13 @@ impl ParseError {
/// `DynamicSubgrammar`) fall back to schema-unaware behaviour. /// `DynamicSubgrammar`) fall back to schema-unaware behaviour.
/// Use `parse_command_with_schema` to enable typed value slots /// Use `parse_command_with_schema` to enable typed value slots
/// (ADR-0024 §Phase D). /// (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> { 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). /// 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` /// the walker can populate `current_table` / `current_column`
/// from existing entities and `DynamicSubgrammar` factories /// from existing entities and `DynamicSubgrammar` factories
/// can unfold per-column typed value slots. /// 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( pub fn parse_command_with_schema(
input: &str, input: &str,
schema: &crate::completion::SchemaCache, schema: &crate::completion::SchemaCache,
) -> Result<Command, ParseError> { ) -> 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( fn parse_command_inner(
input: &str, input: &str,
schema: Option<&crate::completion::SchemaCache>, schema: Option<&crate::completion::SchemaCache>,
mode: Mode,
) -> Result<Command, ParseError> { ) -> Result<Command, ParseError> {
if input.trim().is_empty() { if input.trim().is_empty() {
return Err(ParseError::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; return result;
} }
Err(unknown_command_error(input)) Err(unknown_command_error(input))
@@ -157,11 +192,13 @@ fn unknown_command_error(source: &str) -> ParseError {
fn try_walker_route( fn try_walker_route(
source: &str, source: &str,
schema: Option<&crate::completion::SchemaCache>, schema: Option<&crate::completion::SchemaCache>,
mode: Mode,
) -> Option<Result<Command, ParseError>> { ) -> Option<Result<Command, ParseError>> {
use crate::dsl::walker::{self, outcome::WalkBound}; use crate::dsl::walker::{self, outcome::WalkBound};
let mut ctx = schema.map_or_else(walker::context::WalkContext::new, |s| { let mut ctx = schema.map_or_else(walker::context::WalkContext::new, |s| {
walker::context::WalkContext::with_schema(s) walker::context::WalkContext::with_schema(s)
}); });
ctx.mode = mode;
let (result, command) = walker::walk(source, WalkBound::EndOfInput, &mut ctx); let (result, command) = walker::walk(source, WalkBound::EndOfInput, &mut ctx);
let result = result?; let result = result?;
Some(walker_outcome_to_parse_result(source, result, command)) Some(walker_outcome_to_parse_result(source, result, command))
+9
View File
@@ -11,6 +11,7 @@
//! generic value-literal slot. //! generic value-literal slot.
use crate::completion::{SchemaCache, TableColumn}; use crate::completion::{SchemaCache, TableColumn};
use crate::mode::Mode;
/// Per-walk state. /// Per-walk state.
/// ///
@@ -28,6 +29,13 @@ use crate::completion::{SchemaCache, TableColumn};
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct WalkContext<'a> { pub struct WalkContext<'a> {
pub schema: Option<&'a SchemaCache>, 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: Option<String>,
pub current_table_columns: Option<Vec<TableColumn>>, pub current_table_columns: Option<Vec<TableColumn>>,
pub current_column: Option<TableColumn>, pub current_column: Option<TableColumn>,
@@ -100,6 +108,7 @@ impl<'a> WalkContext<'a> {
pub const fn with_schema(schema: &'a SchemaCache) -> Self { pub const fn with_schema(schema: &'a SchemaCache) -> Self {
Self { Self {
schema: Some(schema), schema: Some(schema),
mode: Mode::Simple,
current_table: None, current_table: None,
current_table_columns: None, current_table_columns: None,
current_column: None, current_column: None,
+32 -1
View File
@@ -865,6 +865,37 @@ pub fn walk<'a>(
class: grammar::HighlightClass::Keyword, 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 mut tail_expected: Vec<Expectation> = Vec::new();
let outcome = match walk_node( let outcome = match walk_node(
effective_source, effective_source,
@@ -937,7 +968,7 @@ pub fn walk<'a>(
// the catalog wording correctly) rather than as a generic // the catalog wording correctly) rather than as a generic
// "AST builder failed" fallback. // "AST builder failed" fallback.
let (final_outcome, cmd) = match outcome { 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)), Ok(c) => (outcome, Some(c)),
Err(error) => ( Err(error) => (
WalkOutcome::ValidationFailed { WalkOutcome::ValidationFailed {
+4
View File
@@ -240,6 +240,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.rebuild", &[]), ("parse.usage.rebuild", &[]),
("parse.usage.replay", &[]), ("parse.usage.replay", &[]),
("parse.usage.save", &[]), ("parse.usage.save", &[]),
("parse.usage.select", &[]),
("parse.usage.show_data", &[]), ("parse.usage.show_data", &[]),
("parse.usage.show_table", &[]), ("parse.usage.show_table", &[]),
("parse.usage.update", &[]), ("parse.usage.update", &[]),
@@ -293,6 +294,9 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("project.switched_ok", &["display_name"]), ("project.switched_ok", &["display_name"]),
// ---- Advanced-mode placeholder ---- // ---- Advanced-mode placeholder ----
("advanced_mode.not_implemented", &["input"]), ("advanced_mode.not_implemented", &["input"]),
// ---- Advanced-mode SQL surface (ADR-0030) ----
("advanced_mode.sql_in_simple", &["command"]),
("select.internal_table", &["table"]),
( (
"cli.invalid_value", "cli.invalid_value",
&["flag", "value", "expected"], &["flag", "value", "expected"],
+8 -1
View File
@@ -462,6 +462,8 @@ parse:
explain update <Table> set <col>=<value>[, ...] (where <expr> | --all-rows) explain update <Table> set <col>=<value>[, ...] (where <expr> | --all-rows)
explain delete from <Table> (where <expr> | --all-rows) explain delete from <Table> (where <expr> | --all-rows)
replay: "replay <path> | replay '<path with spaces>'" 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 # App-lifecycle commands (per ADR-0003, surfaced through
# the parser so they participate in usage templates + # the parser so they participate in usage templates +
# completion). Templates here describe the surface # completion). Templates here describe the surface
@@ -602,9 +604,14 @@ persistence:
bad_output: "migrator produced an unparseable result: {detail}" bad_output: "migrator produced an unparseable result: {detail}"
io: "io error during migration on `{path}`: {source}" io: "io error during migration on `{path}`: {source}"
# ---- Advanced-mode placeholder until SQL parser lands (Q1) ---------- # ---- Advanced-mode SQL surface (ADR-0030) ---------------------------
advanced_mode: advanced_mode:
not_implemented: "advanced mode SQL not implemented yet — echo: {input}" 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) ------------------------- # ---- Persistence-fatal banner (ADR-0015 §8) -------------------------
fatal: fatal:
+5 -1
View File
@@ -7,8 +7,12 @@
use std::fmt; use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Mode { 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, Simple,
Advanced, Advanced,
} }
+7
View File
@@ -1874,6 +1874,13 @@ async fn execute_command_typed(
.query_data(name, filter, limit, src) .query_data(name, filter, limit, src)
.await .await
.map(CommandOutcome::Query), .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 // `EXPLAIN QUERY PLAN` never executes the wrapped
// statement (ADR-0028 §2), so explaining a destructive // statement (ADR-0028 §2), so explaining a destructive
// command is safe. `src` is unused here — explain is a // command is safe. `src` is unused here — explain is a
+1
View File
@@ -218,6 +218,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
ShowData { .. } => "ShowData".into(), ShowData { .. } => "ShowData".into(),
Replay { .. } => "Replay".into(), Replay { .. } => "Replay".into(),
Explain { .. } => "Explain".into(), Explain { .. } => "Explain".into(),
Select { .. } => "Select".into(),
App(app) => match app { App(app) => match app {
AppCommand::Quit => "App(Quit)".into(), AppCommand::Quit => "App(Quit)".into(),
AppCommand::Help => "App(Help)".into(), AppCommand::Help => "App(Help)".into(),
+9 -3
View File
@@ -145,15 +145,21 @@ fn colon_escape_in_simple_mode_is_one_shot() {
type_str(&mut app, ":select 1"); type_str(&mut app, ":select 1");
submit(&mut app); submit(&mut app);
assert_eq!(app.mode, Mode::Simple); assert_eq!(app.mode, Mode::Simple);
// Advanced mode currently echoes (SQL handling lands later); // The line ran under the one-shot effective Advanced mode
// the echoed line should carry the advanced submission mode. // (ADR-0030 §2): the `:` is stripped, the SQL grammar
// dispatches `select 1`, and the echoed line carries the
// submission's effective mode.
let echoed = app let echoed = app
.output .output
.iter() .iter()
.rfind(|l| l.kind == OutputKind::Echo) .rfind(|l| l.kind == OutputKind::Echo)
.expect("echo output present"); .expect("echo output present");
assert_eq!(echoed.mode_at_submission, Mode::Advanced); assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert_eq!(echoed.text, "select 1"); assert!(
echoed.text.contains("select 1") && !echoed.text.contains(":select"),
"echo carries the stripped input: {:?}",
echoed.text,
);
// Subsequent submission (unrecognised in simple mode) parse-errors, // Subsequent submission (unrecognised in simple mode) parse-errors,
// not echoes — confirming the mode reverted. // not echoes — confirming the mode reverted.