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