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
+182 -11
View File
@@ -20,6 +20,7 @@ use crate::dsl::command::{Command, Expr, RowFilter};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{column_value_list, current_column_value},
sql_expr,
};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::value::Value;
@@ -352,6 +353,154 @@ const EXPLAIN_CHOICES: &[Node] = &[
];
const EXPLAIN_SHAPE: Node = Node::Choice(EXPLAIN_CHOICES);
// =================================================================
// select — SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031)
// =================================================================
//
// Phase 1's single-table `SELECT`: a projection, a `FROM` table,
// and optional `WHERE` / `ORDER BY` / `LIMIT`. The projection,
// `WHERE` and `ORDER BY` expression slots reference the SQL
// expression grammar (ADR-0031) through `Subgrammar`, so SQL gets
// the same completion / highlighting / hints as the DSL for free.
//
// Advanced mode only — the walker's mode gate (ADR-0030 §2,
// `grammar::is_advanced_only`) refuses `select` in simple mode
// with the "this is SQL" hint, so this grammar is never reached
// there.
//
// `JOIN`s, `GROUP BY` / `HAVING`, subqueries, `UNION`, CTEs, and
// `OFFSET` are ADR-0030 Phase 2 ("`SELECT` — full"); implicit
// column aliasing (`select a x`) and qualified `t.*` are out of
// Phase 1 (see the inline notes).
/// A SQL expression slot — the ADR-0031 fragment as one node.
const SQL_EXPR: Node = Node::Subgrammar(&sql_expr::SQL_OR_EXPR);
/// `as <alias>` — the explicit projection alias. Implicit
/// aliasing (`select a x`) is not supported: a bare alias is
/// ambiguous with the `from` keyword, whereas `as` is not.
const SELECT_ALIAS_IDENT: Node = Node::Ident {
source: IdentSource::NewName,
role: "select_alias",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
};
static SELECT_AS_ALIAS_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
SELECT_ALIAS_IDENT,
];
static SELECT_AS_ALIAS: Node = Node::Seq(SELECT_AS_ALIAS_NODES);
/// A projection item: a SQL expression with an optional alias.
static SELECT_PROJ_ITEM_NODES: &[Node] = &[
SQL_EXPR,
Node::Optional(&SELECT_AS_ALIAS),
];
static SELECT_PROJ_ITEM: Node = Node::Seq(SELECT_PROJ_ITEM_NODES);
/// `proj_item ( , proj_item )*`.
const SELECT_PROJ_LIST: Node = Node::Repeated {
inner: &SELECT_PROJ_ITEM,
separator: Some(&Node::Punct(',')),
min: 1,
};
/// `projection := '*' | proj_item ( , proj_item )*`. (`t.*`
/// qualified star is Phase 2 — it needs join scope.)
const SELECT_PROJECTION_CHOICES: &[Node] = &[Node::Punct('*'), SELECT_PROJ_LIST];
const SELECT_PROJECTION: Node = Node::Choice(SELECT_PROJECTION_CHOICES);
/// The `FROM` table. `writes_table` so the `WHERE` / `ORDER BY`
/// expression column slots complete against this table; the
/// validator rejects the internal `__rdbms_*` metadata tables
/// (ADR-0030 §6).
const SELECT_FROM_TABLE: Node = Node::Ident {
source: IdentSource::Tables,
role: "table_name",
validator: Some(reject_internal_table),
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
};
/// `where <sql_expr>`.
static SELECT_WHERE_NODES: &[Node] = &[Node::Word(Word::keyword("where")), SQL_EXPR];
static SELECT_WHERE: Node = Node::Seq(SELECT_WHERE_NODES);
/// `order by <item> ( , <item> )*`, each item a SQL expression
/// with an optional `asc` / `desc`.
const SELECT_SORT_DIR_CHOICES: &[Node] = &[
Node::Word(Word::keyword("asc")),
Node::Word(Word::keyword("desc")),
];
static SELECT_ORDER_ITEM_NODES: &[Node] = &[
SQL_EXPR,
Node::Optional(&Node::Choice(SELECT_SORT_DIR_CHOICES)),
];
static SELECT_ORDER_ITEM: Node = Node::Seq(SELECT_ORDER_ITEM_NODES);
static SELECT_ORDER_BY_NODES: &[Node] = &[
Node::Word(Word::keyword("order")),
Node::Word(Word::keyword("by")),
Node::Repeated {
inner: &SELECT_ORDER_ITEM,
separator: Some(&Node::Punct(',')),
min: 1,
},
];
static SELECT_ORDER_BY: Node = Node::Seq(SELECT_ORDER_BY_NODES);
/// `limit <n>` — reuses the `show data` non-negative-integer
/// validator (`OFFSET` is Phase 2).
static SELECT_LIMIT_NODES: &[Node] = &[
Node::Word(Word::keyword("limit")),
Node::NumberLit {
validator: Some(LIMIT_VALIDATOR),
},
];
static SELECT_LIMIT: Node = Node::Seq(SELECT_LIMIT_NODES);
/// `from <Table>` — the FROM clause is optional so that the
/// zero-table form `select 1`, `select upper('x')` (a constant
/// or function-call expression) is admitted alongside the
/// single-table form. The expression slots resolve column
/// completion against the FROM table when present; without it,
/// the engine will reject any column references.
static SELECT_FROM_CLAUSE_NODES: &[Node] = &[
Node::Word(Word::keyword("from")),
SELECT_FROM_TABLE,
];
static SELECT_FROM_CLAUSE: Node = Node::Seq(SELECT_FROM_CLAUSE_NODES);
const SELECT_NODES: &[Node] = &[
SELECT_PROJECTION,
Node::Optional(&SELECT_FROM_CLAUSE),
Node::Optional(&SELECT_WHERE),
Node::Optional(&SELECT_ORDER_BY),
Node::Optional(&SELECT_LIMIT),
// ADR-0030 §3: one statement per submission; a trailing `;`
// is tolerated.
Node::Optional(&Node::Punct(';')),
];
const SELECT_SHAPE: Node = Node::Seq(SELECT_NODES);
/// Reject a reference to an internal `__rdbms_*` metadata table
/// in a `SELECT`'s `FROM` (ADR-0030 §6 — those tables are not in
/// the query surface).
fn reject_internal_table(name: &str) -> Result<(), ValidationError> {
if name.to_ascii_lowercase().starts_with("__rdbms_") {
Err(ValidationError {
message_key: "select.internal_table",
args: vec![("table", name.to_string())],
})
} else {
Ok(())
}
}
// =================================================================
// AST builders
// =================================================================
@@ -389,7 +538,7 @@ pub(crate) fn item_to_value(item: &MatchedItem) -> Option<Value> {
}
}
fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
fn build_show(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
let sub = path
.items
.iter()
@@ -400,7 +549,7 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
.nth(1);
let name = require_ident(path, "table_name")?;
match sub {
Some("data") => build_show_data(path),
Some("data") => build_show_data(path, _source),
Some("table") => Ok(Command::ShowTable { name }),
_ => Err(ValidationError {
message_key: "parse.error_wrapper",
@@ -413,7 +562,7 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
/// (no positional `nth` lookups), so it serves both the
/// standalone `show data` entry word and the `explain show
/// data …` wrapper, where the entry-word offset shifts.
fn build_show_data(path: &MatchedPath) -> Result<Command, ValidationError> {
fn build_show_data(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::ShowData {
name: require_ident(path, "table_name")?,
filter: build_show_filter(path)?,
@@ -474,7 +623,7 @@ fn build_show_limit(path: &MatchedPath) -> Result<Option<u64>, ValidationError>
})
}
fn build_insert(path: &MatchedPath) -> Result<Command, ValidationError> {
fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
let table = require_ident(path, "table_name")?;
// Locate the second `values` keyword (the first is the
@@ -654,7 +803,7 @@ fn collect_values_in_parens(
Ok(out)
}
fn build_update(path: &MatchedPath) -> Result<Command, ValidationError> {
fn build_update(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
let table = require_ident(path, "table_name")?;
let assignments = collect_assignments(path)?;
let filter = collect_filter(path)?;
@@ -723,7 +872,7 @@ fn collect_filter(path: &MatchedPath) -> Result<RowFilter, ValidationError> {
)?))
}
fn build_delete(path: &MatchedPath) -> Result<Command, ValidationError> {
fn build_delete(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
let table = require_ident(path, "table_name")?;
let filter = collect_filter(path)?;
Ok(Command::Delete { table, filter })
@@ -737,7 +886,7 @@ fn build_delete(path: &MatchedPath) -> Result<Command, ValidationError> {
/// `build_delete`), all of which are role-based and so are
/// indifferent to the entry-word offset the `explain` prefix
/// introduces.
fn build_explain(path: &MatchedPath) -> Result<Command, ValidationError> {
fn build_explain(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
let inner_word = path
.items
.iter()
@@ -747,9 +896,9 @@ fn build_explain(path: &MatchedPath) -> Result<Command, ValidationError> {
})
.nth(1);
let inner = match inner_word {
Some("show") => build_show_data(path)?,
Some("update") => build_update(path)?,
Some("delete") => build_delete(path)?,
Some("show") => build_show_data(path, _source)?,
Some("update") => build_update(path, _source)?,
Some("delete") => build_delete(path, _source)?,
_ => {
return Err(ValidationError {
message_key: "parse.error_wrapper",
@@ -777,7 +926,7 @@ fn build_explain(path: &MatchedPath) -> Result<Command, ValidationError> {
const REPLAY_PATH_CHOICES: &[Node] = &[Node::StringLit, Node::BarePath];
const REPLAY_PATH: Node = Node::Choice(REPLAY_PATH_CHOICES);
fn build_replay(path: &MatchedPath) -> Result<Command, ValidationError> {
fn build_replay(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
let payload = path
.items
.iter()
@@ -792,6 +941,18 @@ fn build_replay(path: &MatchedPath) -> Result<Command, ValidationError> {
Ok(Command::Replay { path: payload })
}
/// `Command::Select` carries the validated SQL text verbatim
/// (ADR-0030 §4/§6, ADR-0031 §2): a `SELECT` builds no AST — the
/// walk has confirmed it is in the supported subset, and the
/// worker runs the statement as text. `source` is the full
/// submitted line; on a `Match` outcome the `SELECT` shape
/// consumed all of it.
fn build_select(_path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
Ok(Command::Select {
sql: source.trim().to_string(),
})
}
// =================================================================
// CommandNodes
// =================================================================
@@ -838,6 +999,16 @@ pub static EXPLAIN: CommandNode = CommandNode {
help_id: Some("data.explain"),
usage_ids: &["parse.usage.explain"],};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031). Advanced mode only —
/// gated by `grammar::is_advanced_only`. `help_id` is `None`
/// until the `help sql` page lands (ADR-0030 Phase 6).
pub static SELECT: CommandNode = CommandNode {
entry: Word::keyword("select"),
shape: SELECT_SHAPE,
ast_builder: build_select,
help_id: None,
usage_ids: &["parse.usage.select"],};
// =================================================================
// Tests — `explain` grammar (ADR-0028 §1)
// =================================================================