From 6369066fe45df22377f721145a2a86932f89b957 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 19 May 2026 21:46:56 +0000 Subject: [PATCH] grammar: SQL SELECT end-to-end (ADR-0030 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `, 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` (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. --- src/app.rs | 96 +++++++++++----- src/db.rs | 98 ++++++++++++++++ src/dsl/command.rs | 15 +++ src/dsl/grammar/app.rs | 20 ++-- src/dsl/grammar/data.rs | 193 ++++++++++++++++++++++++++++++-- src/dsl/grammar/ddl.rs | 22 ++-- src/dsl/grammar/mod.rs | 30 ++++- src/dsl/parser.rs | 43 ++++++- src/dsl/walker/context.rs | 9 ++ src/dsl/walker/mod.rs | 33 +++++- src/friendly/keys.rs | 4 + src/friendly/strings/en-US.yaml | 9 +- src/mode.rs | 6 +- src/runtime.rs | 7 ++ tests/typing_surface/mod.rs | 1 + tests/walking_skeleton.rs | 12 +- 16 files changed, 527 insertions(+), 71 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5007823..1a577f8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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] diff --git a/src/db.rs b/src/db.rs index 0d558ab..3161467 100644 --- a/src/db.rs +++ b/src/db.rs @@ -567,6 +567,19 @@ enum Request { source: Option, reply: oneshot::Sender>, }, + /// 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, + reply: oneshot::Sender>, + }, /// 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, + ) -> Result { + 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 { + 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 { + debug!(sql = %sql, "run_select"); + let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?; + let column_names: Vec = stmt + .column_names() + .into_iter() + .map(String::from) + .collect(); + let col_count = column_names.len(); + let column_types: Vec> = vec![None; col_count]; + let rows_iter = stmt + .query_map([], |row| { + let mut cells: Vec = 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::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 diff --git a/src/dsl/command.rs b/src/dsl/command.rs index ca41dc6..cfe4bfb 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -283,6 +283,15 @@ pub enum Command { Explain { query: Box, }, + /// 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 diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index 74a1d70..ef96045 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -110,19 +110,19 @@ const SAVE_AS_OPT: Node = Node::Optional(&SAVE_AS_WORD); // --- AST builders -------------------------------------------------- -const fn build_quit(_path: &MatchedPath) -> Result { +const fn build_quit(_path: &MatchedPath, _source: &str) -> Result { Ok(Command::App(AppCommand::Quit)) } -const fn build_help(_path: &MatchedPath) -> Result { +const fn build_help(_path: &MatchedPath, _source: &str) -> Result { Ok(Command::App(AppCommand::Help)) } -const fn build_rebuild(_path: &MatchedPath) -> Result { +const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result { Ok(Command::App(AppCommand::Rebuild)) } -fn build_save(path: &MatchedPath) -> Result { +fn build_save(path: &MatchedPath, _source: &str) -> Result { if path.contains_word("as") { Ok(Command::App(AppCommand::SaveAs)) } else { @@ -130,22 +130,22 @@ fn build_save(path: &MatchedPath) -> Result { } } -const fn build_new(_path: &MatchedPath) -> Result { +const fn build_new(_path: &MatchedPath, _source: &str) -> Result { Ok(Command::App(AppCommand::New)) } -const fn build_load(_path: &MatchedPath) -> Result { +const fn build_load(_path: &MatchedPath, _source: &str) -> Result { Ok(Command::App(AppCommand::Load)) } -fn build_export(path: &MatchedPath) -> Result { +fn build_export(path: &MatchedPath, _source: &str) -> Result { 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 { +fn build_import(path: &MatchedPath, _source: &str) -> Result { 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 { })) } -fn build_mode(path: &MatchedPath) -> Result { +fn build_mode(path: &MatchedPath, _source: &str) -> Result { // 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 { Ok(Command::App(AppCommand::Mode { value })) } -fn build_messages(path: &MatchedPath) -> Result { +fn build_messages(path: &MatchedPath, _source: &str) -> Result { let value = if path.contains_word("short") { Some(MessagesValue::Short) } else if path.contains_word("verbose") { diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 2d8f22f..4d8109f 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -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 ` — 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 `. +static SELECT_WHERE_NODES: &[Node] = &[Node::Word(Word::keyword("where")), SQL_EXPR]; +static SELECT_WHERE: Node = Node::Seq(SELECT_WHERE_NODES); + +/// `order by ( , )*`, 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 ` — 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
` — 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 { } } -fn build_show(path: &MatchedPath) -> Result { +fn build_show(path: &MatchedPath, _source: &str) -> Result { let sub = path .items .iter() @@ -400,7 +549,7 @@ fn build_show(path: &MatchedPath) -> Result { .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 { /// (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 { +fn build_show_data(path: &MatchedPath, _source: &str) -> Result { 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, ValidationError> }) } -fn build_insert(path: &MatchedPath) -> Result { +fn build_insert(path: &MatchedPath, _source: &str) -> Result { 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 { +fn build_update(path: &MatchedPath, _source: &str) -> Result { 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 { )?)) } -fn build_delete(path: &MatchedPath) -> Result { +fn build_delete(path: &MatchedPath, _source: &str) -> Result { 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 { /// `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 { +fn build_explain(path: &MatchedPath, _source: &str) -> Result { let inner_word = path .items .iter() @@ -747,9 +896,9 @@ fn build_explain(path: &MatchedPath) -> Result { }) .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 { const REPLAY_PATH_CHOICES: &[Node] = &[Node::StringLit, Node::BarePath]; const REPLAY_PATH: Node = Node::Choice(REPLAY_PATH_CHOICES); -fn build_replay(path: &MatchedPath) -> Result { +fn build_replay(path: &MatchedPath, _source: &str) -> Result { let payload = path .items .iter() @@ -792,6 +941,18 @@ fn build_replay(path: &MatchedPath) -> Result { 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 { + 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) // ================================================================= diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index ceaf7c3..fbd620b 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -522,7 +522,7 @@ fn parse_action(words: &[&'static str]) -> ReferentialAction { } } -fn build_drop(path: &MatchedPath) -> Result { +fn build_drop(path: &MatchedPath, _source: &str) -> Result { // 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 { }) } } - 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 { } } -fn build_add(path: &MatchedPath) -> Result { +fn build_add(path: &MatchedPath, _source: &str) -> Result { // 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 { 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 { } } -fn build_add_relationship(path: &MatchedPath) -> Result { +fn build_add_relationship(path: &MatchedPath, _source: &str) -> Result { // Collect all referential-clause actions in matched order // and validate at-most-2 + not-repeated. The `on ` sequence shows up as a run of Word @@ -715,7 +715,7 @@ fn build_add_relationship(path: &MatchedPath) -> Result Result { +fn build_rename_column(path: &MatchedPath, _source: &str) -> Result { 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 { }) } -fn build_change_column(path: &MatchedPath) -> Result { +fn build_change_column(path: &MatchedPath, _source: &str) -> Result { let ty_text = require_ident(path, "type")?; let ty = ty_text .parse::() @@ -775,7 +775,7 @@ fn build_change_column(path: &MatchedPath) -> Result { /// 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 { +fn build_add_constraint(path: &MatchedPath, _source: &str) -> Result { 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 /// (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 { +fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result { 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 { +fn build_create_table(path: &MatchedPath, _source: &str) -> Result { let name = require_ident(path, "table_name")?; // Walk the matched items, segmenting per column: a diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index d65fc76..050e96d 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -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, + /// + /// `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, /// Catalog key (`help.`) 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 diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 6a63e4d..2a89a0d 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -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 { - 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 { /// 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 { - 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 { + 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 { + parse_command_inner(input, Some(schema), mode) } fn parse_command_inner( input: &str, schema: Option<&crate::completion::SchemaCache>, + mode: Mode, ) -> Result { 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> { 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)) diff --git a/src/dsl/walker/context.rs b/src/dsl/walker/context.rs index 0634c9f..587b70d 100644 --- a/src/dsl/walker/context.rs +++ b/src/dsl/walker/context.rs @@ -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, pub current_table_columns: Option>, pub current_column: Option, @@ -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, diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index a4a304a..490975a 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -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 = 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 { diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 16baf3a..9b2a5b8 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -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"], diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index bf63298..3a9cba8 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -462,6 +462,8 @@ parse: explain update
set =[, ...] (where | --all-rows) explain delete from
(where | --all-rows) replay: "replay | replay ''" + # SQL `SELECT` (advanced mode; ADR-0030 / ADR-0031). + select: "select (* | [ as ][, ...]) from
[where ] [order by [ asc|desc][, ...]] [limit ]" # 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: diff --git a/src/mode.rs b/src/mode.rs index 0d519d5..366b2ca 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -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, } diff --git a/src/runtime.rs b/src/runtime.rs index 099cdc3..95fab1f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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 diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 1364fc9..412ecb0 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -218,6 +218,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { ShowData { .. } => "ShowData".into(), Replay { .. } => "Replay".into(), Explain { .. } => "Explain".into(), + Select { .. } => "Select".into(), App(app) => match app { AppCommand::Quit => "App(Quit)".into(), AppCommand::Help => "App(Help)".into(), diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index 954dcfb..2111708 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -145,15 +145,21 @@ fn colon_escape_in_simple_mode_is_one_shot() { type_str(&mut app, ":select 1"); submit(&mut app); assert_eq!(app.mode, Mode::Simple); - // Advanced mode currently echoes (SQL handling lands later); - // the echoed line should carry the advanced submission mode. + // The line ran under the one-shot effective Advanced 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 .output .iter() .rfind(|l| l.kind == OutputKind::Echo) .expect("echo output present"); 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, // not echoes — confirming the mode reverted.