feat: DSL→SQL teaching echo — channel + create-table slice (ADR-0037 + ADR-0038)
Walking skeleton validating the whole echo architecture end to end; the Command→SQL renderer currently covers `create table`, with the rest of Bucket A / B / category-3 to follow (ADR-0038 §8). - Channel (ADR-0037): the three-way EffectiveMode (reusing the existing enum, not a new SubmissionMode — recorded in the ADR) rides on Action::ExecuteDsl to the runtime. `replay` bypasses the interactive spawn, so it never echoes (silent, for free). - Echo (ADR-0038): built at the runtime's ExecuteDsl dispatch — the worker gets decomposed calls, not the Command, so ADR §4's "worker builds it" was corrected to the dispatch layer. Gated by echo_for (advanced effective mode + DSL-form). Carried on DslSucceeded; rendered by note_ok_summary as `Executing SQL: …` immediately beneath `[ok]`. New src/echo.rs renderer; echo.executing_sql i18n key. - command_to_sql: `create table` → `CREATE TABLE T (id serial PRIMARY KEY)` (single inline / compound table-level PK), playground type vocabulary, round-trip-verified against the advanced walker (the §1 contract). Tests: echo.rs (render, round-trip contract, mode gate, Sql*-not-echoed); app.rs (submit carries the 3-way mode; echo renders beneath [ok]). Suite 1970/0/1; clippy clean.
This commit is contained in:
@@ -109,20 +109,24 @@ effective mode it ran under. The value is **output-only**: no executor
|
|||||||
branches its *effect* on it (that would be a behavioural mode dependency,
|
branches its *effect* on it (that would be a behavioural mode dependency,
|
||||||
which ADR-0033 Amendment 3 forbids — identity and effect are intrinsic).
|
which ADR-0033 Amendment 3 forbids — identity and effect are intrinsic).
|
||||||
|
|
||||||
### 3. The worker produces mode-dependent output; the App renders it
|
### 3. The runtime's execution dispatcher produces the echo; the App renders it
|
||||||
|
|
||||||
For the first consumer (ADR-0038): when the command is a **DSL-form**
|
For the first consumer (ADR-0038): when the command is a **DSL-form**
|
||||||
command (`Command::CreateTable`/`Insert`/… — *not* the `Sql*` variants)
|
command (`Command::CreateTable`/`Insert`/… — *not* the `Sql*` variants)
|
||||||
and `submission_mode` is `Advanced` or `AdvancedOneShot`, the worker
|
and `submission_mode` is `Advanced` or `AdvancedOneShot`, the teaching
|
||||||
builds the teaching echo (equivalent SQL + any category-3 expansion
|
echo (equivalent SQL + any category-3 expansion data — ADR-0038) is built
|
||||||
data — ADR-0038) and returns it on the result event. In `Simple` mode,
|
from the `Command` **plus the worker's execution result**, and the App
|
||||||
or for a command typed as SQL, no echo is produced. The App renders the
|
renders it as de-emphasised `OutputLine`(s) beneath `[ok]`. In `Simple`
|
||||||
returned echo as de-emphasised `OutputLine`(s) beneath `[ok]`.
|
mode, or for a command typed as SQL, no echo is produced.
|
||||||
|
|
||||||
Co-locating echo construction with execution is deliberate: the echo's
|
**Where it is built (build correction — see Implementation notes).** Not
|
||||||
harder forms (resolved auto-names, generated `shortid`s, conversion
|
in the db.rs worker: the worker receives *decomposed* calls, not the
|
||||||
counts) are facts the worker already computes. Gating on the threaded
|
`Command`, so it cannot render `Command → SQL`. The echo is built at the
|
||||||
mode means the work happens **only when an echo will be shown**.
|
**runtime's `ExecuteDsl` handler**, the one place where the `Command`,
|
||||||
|
the threaded `EffectiveMode`, and the worker's result (resolved
|
||||||
|
auto-names, generated `shortid`s, conversion counts) all converge. This
|
||||||
|
is still **execution-time aware** — it consumes the execution *results* —
|
||||||
|
it just lives at the dispatch layer, not inside the storage worker.
|
||||||
|
|
||||||
**Non-interactive re-execution does not echo.** `replay` (ADR-0034)
|
**Non-interactive re-execution does not echo.** `replay` (ADR-0034)
|
||||||
re-runs recorded commands through the dispatch pipeline in advanced mode
|
re-runs recorded commands through the dispatch pipeline in advanced mode
|
||||||
@@ -172,6 +176,26 @@ the gating contract in §3.
|
|||||||
`Action` → worker round-trip; a Simple-mode DSL command yields no echo
|
`Action` → worker round-trip; a Simple-mode DSL command yields no echo
|
||||||
request while an Advanced / one-shot one does (the gating contract).
|
request while an Advanced / one-shot one does (the gating contract).
|
||||||
|
|
||||||
|
## Implementation notes (2026-05-27, during build)
|
||||||
|
|
||||||
|
Two refinements found when building, recorded so the ADR matches reality:
|
||||||
|
|
||||||
|
- **Reuse the existing `EffectiveMode`, do not add `SubmissionMode`.** The
|
||||||
|
codebase already has `EffectiveMode { Simple, AdvancedPersistent,
|
||||||
|
AdvancedOneShot }` (`app.rs`), computed by `effective_mode()` and used
|
||||||
|
today for the `:` one-shot UI feedback. It is exactly the three-way,
|
||||||
|
per-submission, *separate-from-`Mode`* enum §1 argued for — so §1's
|
||||||
|
"new enum" is already satisfied; the build reuses `EffectiveMode`
|
||||||
|
(`AdvancedPersistent` is the ADR's `Advanced`). No new type.
|
||||||
|
- **The channel ships with its consumer (merged with ADR-0038).** A
|
||||||
|
threaded-but-unread `EffectiveMode` on the worker request is dead code,
|
||||||
|
which this project's `-D warnings` (nursery) rejects. The side-channel
|
||||||
|
has no consumer other than the echo, so the `Action`→worker threading
|
||||||
|
is built **together with ADR-0038** rather than as a standalone commit
|
||||||
|
— the submit-side resolution (which `Action` carries which
|
||||||
|
`EffectiveMode`) is Tier-1 testable, and the worker-side threading
|
||||||
|
becomes live + end-to-end testable the moment the echo reads it.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- ADR-0033 Amendment 3 — deferred this side-channel; defines the
|
- ADR-0033 Amendment 3 — deferred this side-channel; defines the
|
||||||
|
|||||||
@@ -97,13 +97,17 @@ echoes would bury the replay summary — ADR-0037 §3).
|
|||||||
|
|
||||||
### 4. Where it is built and rendered
|
### 4. Where it is built and rendered
|
||||||
|
|
||||||
The **worker builds** the echo (ADR-0037 §3) — it alone holds the facts
|
The echo is built at the **runtime's `ExecuteDsl` handler** (build
|
||||||
several echoes need: auto-resolved index / relationship names, generated
|
correction, ADR-0037 §3 + Implementation notes): the db.rs worker
|
||||||
`shortid` values, and lossy-conversion counts. It returns the echo
|
receives *decomposed* calls, not the `Command`, so it cannot render
|
||||||
payload on the result event. The **App renders** it as one or more
|
`Command → SQL`. The runtime is the one place where the `Command`, the
|
||||||
**de-emphasised** `OutputLine`s beneath the `[ok]` summary, using the
|
threaded `EffectiveMode`, and the worker's **result** (resolved auto-
|
||||||
ADR-0028 styled-runs mechanism (a dimmed `Executing SQL:` prefix; the SQL
|
names, generated `shortid`s, conversion counts) all converge — so it
|
||||||
itself in a code-ish run). One statement per line (§6 category 2).
|
builds the echo there, still **execution-time aware** (it consumes the
|
||||||
|
results). The **App renders** it as one or more **de-emphasised**
|
||||||
|
`OutputLine`s beneath the `[ok]` summary, using the ADR-0028 styled-runs
|
||||||
|
mechanism (a dimmed `Executing SQL:` prefix; the SQL in a code-ish run).
|
||||||
|
One statement per line (§6 category 2).
|
||||||
|
|
||||||
### 5. `Value → SQL-literal` rendering
|
### 5. `Value → SQL-literal` rendering
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! itself, which keeps update directly testable without a Tokio
|
//! itself, which keeps update directly testable without a Tokio
|
||||||
//! runtime, a real terminal, or a database.
|
//! runtime, a real terminal, or a database.
|
||||||
|
|
||||||
|
use crate::app::EffectiveMode;
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -23,6 +24,12 @@ pub enum Action {
|
|||||||
ExecuteDsl {
|
ExecuteDsl {
|
||||||
command: Command,
|
command: Command,
|
||||||
source: String,
|
source: String,
|
||||||
|
/// The effective mode the line was submitted under (ADR-0037):
|
||||||
|
/// `Simple` / `AdvancedPersistent` / `AdvancedOneShot`. Output-only
|
||||||
|
/// — execution semantics do not depend on it; the runtime uses it
|
||||||
|
/// to gate the DSL → SQL teaching echo (ADR-0038), which fires for
|
||||||
|
/// DSL-form commands submitted in an advanced effective mode.
|
||||||
|
submission_mode: EffectiveMode,
|
||||||
},
|
},
|
||||||
/// Record a *failed* submission to `history.log` as an `err`
|
/// Record a *failed* submission to `history.log` as an `err`
|
||||||
/// record (ADR-0034 §1/§2). Emitted by the pure-sync `App`
|
/// record (ADR-0034 §1/§2). Emitted by the pure-sync `App`
|
||||||
|
|||||||
+102
-11
@@ -216,6 +216,12 @@ pub struct App {
|
|||||||
/// flag; the `undo` / `redo` commands then report undo is off
|
/// flag; the `undo` / `redo` commands then report undo is off
|
||||||
/// rather than emitting a prepare action.
|
/// rather than emitting a prepare action.
|
||||||
pub undo_enabled: bool,
|
pub undo_enabled: bool,
|
||||||
|
/// The DSL → SQL teaching echo (ADR-0038) for the command currently
|
||||||
|
/// being rendered: set from the success event just before its handler
|
||||||
|
/// runs, consumed by `note_ok_summary` (which pushes it beneath
|
||||||
|
/// `[ok]`), within the same synchronous `update()` call. `None` when
|
||||||
|
/// the command has no echo.
|
||||||
|
pending_echo: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dialogs that take over keyboard input when active.
|
/// Dialogs that take over keyboard input when active.
|
||||||
@@ -346,6 +352,7 @@ impl App {
|
|||||||
// Undo is on by default; the runtime flips this off for
|
// Undo is on by default; the runtime flips this off for
|
||||||
// a `--no-undo` session (ADR-0006 Amendment 1).
|
// a `--no-undo` session (ADR-0006 Amendment 1).
|
||||||
undo_enabled: true,
|
undo_enabled: true,
|
||||||
|
pending_echo: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +445,11 @@ impl App {
|
|||||||
AppEvent::DslSucceeded {
|
AppEvent::DslSucceeded {
|
||||||
command,
|
command,
|
||||||
description,
|
description,
|
||||||
|
echo,
|
||||||
} => {
|
} => {
|
||||||
|
// Stash the teaching echo (ADR-0038) for `note_ok_summary`
|
||||||
|
// to render beneath `[ok]` — consumed synchronously below.
|
||||||
|
self.pending_echo = echo;
|
||||||
self.handle_dsl_success(&command, description);
|
self.handle_dsl_success(&command, description);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -1113,12 +1124,16 @@ impl App {
|
|||||||
|
|
||||||
// `:` one-shot escape: in simple mode, a leading `:` means
|
// `:` one-shot escape: in simple mode, a leading `:` means
|
||||||
// treat *this single submission* as advanced. The persistent
|
// treat *this single submission* as advanced. The persistent
|
||||||
// mode is unchanged.
|
// mode is unchanged. The three-way `EffectiveMode` (ADR-0037) is
|
||||||
let (effective_mode, effective_input) =
|
// carried through dispatch so the runtime can gate the DSL → SQL
|
||||||
|
// teaching echo (ADR-0038) on an advanced effective mode.
|
||||||
|
let (submission_mode, effective_input) =
|
||||||
if self.mode == Mode::Simple && trimmed.starts_with(':') {
|
if self.mode == Mode::Simple && trimmed.starts_with(':') {
|
||||||
(Mode::Advanced, trimmed[1..].trim().to_string())
|
(EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string())
|
||||||
|
} else if self.mode == Mode::Advanced {
|
||||||
|
(EffectiveMode::AdvancedPersistent, trimmed.to_string())
|
||||||
} else {
|
} else {
|
||||||
(self.mode, trimmed.to_string())
|
(EffectiveMode::Simple, trimmed.to_string())
|
||||||
};
|
};
|
||||||
|
|
||||||
if effective_input.is_empty() {
|
if effective_input.is_empty() {
|
||||||
@@ -1143,7 +1158,7 @@ impl App {
|
|||||||
// form in advanced mode runs and a SQL form in simple
|
// form in advanced mode runs and a SQL form in simple
|
||||||
// mode yields the precise "this is SQL" hint through the
|
// mode yields the precise "this is SQL" hint through the
|
||||||
// walker's mode gate — no separate placeholder branch.
|
// walker's mode gate — no separate placeholder branch.
|
||||||
self.dispatch_dsl(&effective_input, effective_mode)
|
self.dispatch_dsl(&effective_input, submission_mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a parsed app-lifecycle command. Works in both
|
/// Dispatch a parsed app-lifecycle command. Works in both
|
||||||
@@ -1237,7 +1252,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
|
fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec<Action> {
|
||||||
|
// The two-way mode the walker + the `[mode]` render tag read; the
|
||||||
|
// three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for
|
||||||
|
// the runtime's echo gate (ADR-0038).
|
||||||
|
let mode = submission_mode.as_mode();
|
||||||
// ADR-0024 §Phase D: parse with the live schema so typed
|
// ADR-0024 §Phase D: parse with the live schema so typed
|
||||||
// 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
|
||||||
@@ -1250,7 +1269,7 @@ impl App {
|
|||||||
match crate::dsl::parser::parse_command_with_schema_in_mode(
|
match crate::dsl::parser::parse_command_with_schema_in_mode(
|
||||||
input,
|
input,
|
||||||
&self.schema_cache,
|
&self.schema_cache,
|
||||||
submission_mode,
|
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
|
||||||
@@ -1270,7 +1289,7 @@ impl App {
|
|||||||
self.push_output(OutputLine {
|
self.push_output(OutputLine {
|
||||||
text: crate::t!("dsl.running", input = input),
|
text: crate::t!("dsl.running", input = input),
|
||||||
kind: OutputKind::Echo,
|
kind: OutputKind::Echo,
|
||||||
mode_at_submission: submission_mode,
|
mode_at_submission: mode,
|
||||||
styled_runs: None,
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
vec![Action::Replay { path }]
|
vec![Action::Replay { path }]
|
||||||
@@ -1279,12 +1298,13 @@ impl App {
|
|||||||
self.push_output(OutputLine {
|
self.push_output(OutputLine {
|
||||||
text: crate::t!("dsl.running", input = input),
|
text: crate::t!("dsl.running", input = input),
|
||||||
kind: OutputKind::Echo,
|
kind: OutputKind::Echo,
|
||||||
mode_at_submission: submission_mode,
|
mode_at_submission: mode,
|
||||||
styled_runs: None,
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
vec![Action::ExecuteDsl {
|
vec![Action::ExecuteDsl {
|
||||||
command: cmd,
|
command: cmd,
|
||||||
source: input.to_string(),
|
source: input.to_string(),
|
||||||
|
submission_mode,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
Err(ParseError::Empty) => Vec::new(),
|
Err(ParseError::Empty) => Vec::new(),
|
||||||
@@ -1294,7 +1314,7 @@ impl App {
|
|||||||
self.push_output(OutputLine {
|
self.push_output(OutputLine {
|
||||||
text: crate::t!("dsl.running", input = input),
|
text: crate::t!("dsl.running", input = input),
|
||||||
kind: OutputKind::Echo,
|
kind: OutputKind::Echo,
|
||||||
mode_at_submission: submission_mode,
|
mode_at_submission: mode,
|
||||||
styled_runs: None,
|
styled_runs: None,
|
||||||
});
|
});
|
||||||
// Caret pointer at the failure position, when we
|
// Caret pointer at the failure position, when we
|
||||||
@@ -1334,7 +1354,7 @@ impl App {
|
|||||||
// covers SQL constructs that surface only on submit
|
// covers SQL constructs that surface only on submit
|
||||||
// (e.g. `delete … returning`, where the live hint
|
// (e.g. `delete … returning`, where the live hint
|
||||||
// shows WHERE-completion rather than an error).
|
// shows WHERE-completion rather than an error).
|
||||||
if submission_mode == Mode::Simple
|
if mode == Mode::Simple
|
||||||
&& let Some(note) =
|
&& let Some(note) =
|
||||||
crate::input_render::advanced_alternative_note(input, &self.schema_cache)
|
crate::input_render::advanced_alternative_note(input, &self.schema_cache)
|
||||||
{
|
{
|
||||||
@@ -1367,6 +1387,12 @@ impl App {
|
|||||||
verb = command.verb(),
|
verb = command.verb(),
|
||||||
subject = command.display_subject()
|
subject = command.display_subject()
|
||||||
));
|
));
|
||||||
|
// ADR-0038: the DSL → SQL teaching echo, beneath `[ok]`. Set on
|
||||||
|
// the success event when a DSL-form command ran in an advanced
|
||||||
|
// effective mode (ADR-0037); `None` otherwise. De-emphasised.
|
||||||
|
if let Some(sql) = self.pending_echo.take() {
|
||||||
|
self.note_system(crate::t!("echo.executing_sql", sql = sql));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
||||||
@@ -2789,6 +2815,70 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn submit_carries_the_three_way_effective_submission_mode() {
|
||||||
|
// ADR-0037: ExecuteDsl carries the effective mode so the runtime
|
||||||
|
// can gate the teaching echo (ADR-0038).
|
||||||
|
let case = |mode: Mode, input: &str| -> EffectiveMode {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.mode = mode;
|
||||||
|
type_str(&mut app, input);
|
||||||
|
match submit(&mut app).as_slice() {
|
||||||
|
[Action::ExecuteDsl { submission_mode, .. }] => *submission_mode,
|
||||||
|
other => panic!("expected one ExecuteDsl; got {other:?}"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
case(Mode::Advanced, "create table T with pk"),
|
||||||
|
EffectiveMode::AdvancedPersistent
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
case(Mode::Simple, ":create table T with pk"),
|
||||||
|
EffectiveMode::AdvancedOneShot
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
case(Mode::Simple, "create table T with pk"),
|
||||||
|
EffectiveMode::Simple
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dsl_success_renders_the_teaching_echo_beneath_ok() {
|
||||||
|
// ADR-0038: the echo carried on the success event renders as a
|
||||||
|
// line immediately beneath the `[ok]` summary.
|
||||||
|
let cmd = Command::CreateTable {
|
||||||
|
name: "Other".to_string(),
|
||||||
|
columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)],
|
||||||
|
primary_key: vec!["id".to_string()],
|
||||||
|
};
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::DslSucceeded {
|
||||||
|
command: cmd.clone(),
|
||||||
|
description: None,
|
||||||
|
echo: Some("CREATE TABLE Other (id serial PRIMARY KEY)".to_string()),
|
||||||
|
});
|
||||||
|
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
|
||||||
|
let ok_idx = texts.iter().position(|t| t.starts_with("[ok]")).expect("an [ok] line");
|
||||||
|
let echo_idx = texts
|
||||||
|
.iter()
|
||||||
|
.position(|t| t.contains("Executing SQL:"))
|
||||||
|
.expect("an echo line");
|
||||||
|
assert_eq!(echo_idx, ok_idx + 1, "echo sits immediately beneath [ok]: {texts:?}");
|
||||||
|
assert!(texts[echo_idx].contains("CREATE TABLE Other (id serial PRIMARY KEY)"));
|
||||||
|
|
||||||
|
// No echo line when the event carries none (simple mode etc.).
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::DslSucceeded {
|
||||||
|
command: cmd,
|
||||||
|
description: None,
|
||||||
|
echo: None,
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
!app.output.iter().any(|l| l.text.contains("Executing SQL:")),
|
||||||
|
"no echo line when echo is None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_command_switches_persistently() {
|
fn mode_command_switches_persistently() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -2960,6 +3050,7 @@ mod tests {
|
|||||||
app.update(AppEvent::DslSucceeded {
|
app.update(AppEvent::DslSucceeded {
|
||||||
command: cmd,
|
command: cmd,
|
||||||
description: Some(desc.clone()),
|
description: Some(desc.clone()),
|
||||||
|
echo: None,
|
||||||
});
|
});
|
||||||
assert_eq!(app.current_table, Some(desc));
|
assert_eq!(app.current_table, Some(desc));
|
||||||
// Some line in the output buffer is the structure
|
// Some line in the output buffer is the structure
|
||||||
|
|||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
//! The DSL → SQL teaching echo renderer (ADR-0038).
|
||||||
|
//!
|
||||||
|
//! Maps a **DSL-form** `Command` to the equivalent advanced-mode SQL, so
|
||||||
|
//! a learner who typed the simple-mode form reads off how to spell it in
|
||||||
|
//! SQL (ADR-0030 §10). The output obeys the **copy-paste contract**
|
||||||
|
//! (ADR-0038 §1): it is runnable advanced-mode SQL in the playground's
|
||||||
|
//! own type vocabulary (`Type::keyword()`), so it round-trips through the
|
||||||
|
//! advanced walker. The standard-first dialect (ADR-0035 Amendment 2)
|
||||||
|
//! governs statement shape; the playground keywords fill the type slots.
|
||||||
|
//!
|
||||||
|
//! `None` means "no echo" — a command in Bucket C (ADR-0038 §7) or a form
|
||||||
|
//! not yet covered by the renderer. The caller (the runtime's `ExecuteDsl`
|
||||||
|
//! dispatch) only invokes this for DSL-form commands submitted in an
|
||||||
|
//! advanced effective mode (ADR-0037).
|
||||||
|
|
||||||
|
use crate::app::EffectiveMode;
|
||||||
|
use crate::dsl::Command;
|
||||||
|
use crate::dsl::command::ColumnSpec;
|
||||||
|
|
||||||
|
/// The teaching echo for a command submitted under `mode`, or `None`.
|
||||||
|
///
|
||||||
|
/// Fires only in an advanced effective mode (`AdvancedPersistent` /
|
||||||
|
/// `AdvancedOneShot`) — simple mode stays uncluttered (ADR-0030 §10) — and
|
||||||
|
/// only for a DSL-form command that has an echo (`command_to_sql`; a
|
||||||
|
/// `Sql*` / app command returns `None`). This is the runtime's gate;
|
||||||
|
/// replay never reaches it (it bypasses the spawn). (ADR-0037 + ADR-0038)
|
||||||
|
#[must_use]
|
||||||
|
pub fn echo_for(command: &Command, mode: EffectiveMode) -> Option<String> {
|
||||||
|
if mode.is_advanced() {
|
||||||
|
command_to_sql(command)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the equivalent advanced-mode SQL for a DSL-form command, or
|
||||||
|
/// `None` when it has no echo.
|
||||||
|
#[must_use]
|
||||||
|
pub fn command_to_sql(command: &Command) -> Option<String> {
|
||||||
|
match command {
|
||||||
|
Command::CreateTable {
|
||||||
|
name,
|
||||||
|
columns,
|
||||||
|
primary_key,
|
||||||
|
} => Some(render_create_table(name, columns, primary_key)),
|
||||||
|
// Remaining Bucket A/B forms land in follow-up slices (ADR-0038 §8).
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CREATE TABLE <name> (<col defs>[, PRIMARY KEY (…)])` in the
|
||||||
|
/// playground's type vocabulary. A single first-column primary key is
|
||||||
|
/// rendered inline (`id serial PRIMARY KEY`), matching the rebuild
|
||||||
|
/// generator's rule (ADR-0035 §4a) and the round-trip surface; a compound
|
||||||
|
/// key becomes a table-level `PRIMARY KEY (a, b)`.
|
||||||
|
fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String]) -> String {
|
||||||
|
let inline_pk =
|
||||||
|
primary_key.len() == 1 && columns.first().is_some_and(|c| c.name == primary_key[0]);
|
||||||
|
let col_defs: Vec<String> = columns
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
let mut s = format!("{} {}", c.name, c.ty.keyword());
|
||||||
|
if inline_pk && c.name == primary_key[0] {
|
||||||
|
s.push_str(" PRIMARY KEY");
|
||||||
|
}
|
||||||
|
if c.not_null {
|
||||||
|
s.push_str(" NOT NULL");
|
||||||
|
}
|
||||||
|
if c.unique {
|
||||||
|
s.push_str(" UNIQUE");
|
||||||
|
}
|
||||||
|
s
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if primary_key.len() > 1 {
|
||||||
|
format!(
|
||||||
|
"CREATE TABLE {name} ({}, PRIMARY KEY ({}))",
|
||||||
|
col_defs.join(", "),
|
||||||
|
primary_key.join(", "),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("CREATE TABLE {name} ({})", col_defs.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::dsl::types::Type;
|
||||||
|
|
||||||
|
fn create_table(name: &str, cols: Vec<ColumnSpec>, pk: &[&str]) -> Command {
|
||||||
|
Command::CreateTable {
|
||||||
|
name: name.to_string(),
|
||||||
|
columns: cols,
|
||||||
|
primary_key: pk.iter().map(|s| (*s).to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_table_single_serial_pk_renders_inline() {
|
||||||
|
let cmd = create_table("Other", vec![ColumnSpec::new("id", Type::Serial)], &["id"]);
|
||||||
|
assert_eq!(
|
||||||
|
command_to_sql(&cmd).as_deref(),
|
||||||
|
Some("CREATE TABLE Other (id serial PRIMARY KEY)")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_table_compound_pk_renders_table_level() {
|
||||||
|
let cmd = create_table(
|
||||||
|
"T",
|
||||||
|
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
|
||||||
|
&["a", "b"],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
command_to_sql(&cmd).as_deref(),
|
||||||
|
Some("CREATE TABLE T (a int, b int, PRIMARY KEY (a, b))")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_table_echo_round_trips_in_advanced_mode() {
|
||||||
|
// ADR-0038 §1 copy-paste contract: the echo is runnable advanced SQL.
|
||||||
|
let cmd = create_table("Other", vec![ColumnSpec::new("id", Type::Serial)], &["id"]);
|
||||||
|
let sql = command_to_sql(&cmd).expect("echo");
|
||||||
|
let reparsed = crate::dsl::parser::parse_command_in_mode(&sql, crate::mode::Mode::Advanced);
|
||||||
|
assert!(
|
||||||
|
matches!(reparsed, Ok(Command::SqlCreateTable { .. })),
|
||||||
|
"echo must round-trip as runnable advanced SQL; got {reparsed:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn echo_for_gates_on_advanced_mode() {
|
||||||
|
let cmd = create_table("Other", vec![ColumnSpec::new("id", Type::Serial)], &["id"]);
|
||||||
|
assert!(echo_for(&cmd, EffectiveMode::AdvancedPersistent).is_some());
|
||||||
|
assert!(echo_for(&cmd, EffectiveMode::AdvancedOneShot).is_some());
|
||||||
|
assert!(
|
||||||
|
echo_for(&cmd, EffectiveMode::Simple).is_none(),
|
||||||
|
"simple mode stays uncluttered (ADR-0030 §10)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sql_entered_command_is_not_echoed() {
|
||||||
|
// A command the user typed as SQL (SqlCreateTable) is not echoed
|
||||||
|
// back (ADR-0030 §10) — command_to_sql covers only DSL-form variants.
|
||||||
|
let cmd = Command::SqlCreateTable {
|
||||||
|
name: "T".to_string(),
|
||||||
|
columns: vec![ColumnSpec::new("id", Type::Serial)],
|
||||||
|
primary_key: vec!["id".to_string()],
|
||||||
|
unique_constraints: Vec::new(),
|
||||||
|
check_constraints: Vec::new(),
|
||||||
|
foreign_keys: Vec::new(),
|
||||||
|
if_not_exists: false,
|
||||||
|
};
|
||||||
|
assert!(command_to_sql(&cmd).is_none());
|
||||||
|
assert!(echo_for(&cmd, EffectiveMode::AdvancedPersistent).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,12 @@ pub enum AppEvent {
|
|||||||
DslSucceeded {
|
DslSucceeded {
|
||||||
command: Command,
|
command: Command,
|
||||||
description: Option<TableDescription>,
|
description: Option<TableDescription>,
|
||||||
|
/// The DSL → SQL teaching echo (ADR-0038): equivalent advanced-mode
|
||||||
|
/// SQL, built by the runtime when a DSL-form command ran in an
|
||||||
|
/// advanced effective mode (ADR-0037). `None` when no echo applies
|
||||||
|
/// (simple mode, SQL-entered, or a form with no echo). The App
|
||||||
|
/// renders it beneath `[ok]`.
|
||||||
|
echo: Option<String>,
|
||||||
},
|
},
|
||||||
/// A SQL `CREATE TABLE IF NOT EXISTS` matched an existing table —
|
/// A SQL `CREATE TABLE IF NOT EXISTS` matched an existing table —
|
||||||
/// a no-op (ADR-0035 §4). Renders the existing structure plus an
|
/// a no-op (ADR-0035 §4). Renders the existing structure plus an
|
||||||
|
|||||||
@@ -497,6 +497,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("ok.rows_inserted", &["count"]),
|
("ok.rows_inserted", &["count"]),
|
||||||
("ok.rows_updated", &["count"]),
|
("ok.rows_updated", &["count"]),
|
||||||
("ok.summary", &["verb", "subject"]),
|
("ok.summary", &["verb", "subject"]),
|
||||||
|
// ---- DSL → SQL teaching echo (ADR-0038) ----
|
||||||
|
("echo.executing_sql", &["sql"]),
|
||||||
// ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ----
|
// ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ----
|
||||||
("client_side.auto_fill_add_serial", &["count"]),
|
("client_side.auto_fill_add_serial", &["count"]),
|
||||||
("client_side.auto_fill_add_shortid", &["count"]),
|
("client_side.auto_fill_add_shortid", &["count"]),
|
||||||
|
|||||||
@@ -874,6 +874,12 @@ db:
|
|||||||
Cannot add this CHECK to `{table}.{column}`: {total} row(s) do not satisfy `{rule}`.
|
Cannot add this CHECK to `{table}.{column}`: {total} row(s) do not satisfy `{rule}`.
|
||||||
|
|
||||||
# ---- DSL command success summaries (ADR-0019 §9 sweep) --------------
|
# ---- DSL command success summaries (ADR-0019 §9 sweep) --------------
|
||||||
|
# DSL → SQL teaching echo (ADR-0038): the equivalent advanced-mode SQL,
|
||||||
|
# rendered beneath `[ok]` for a DSL-form command run in an advanced
|
||||||
|
# effective mode (ADR-0037).
|
||||||
|
echo:
|
||||||
|
executing_sql: "Executing SQL: {sql}"
|
||||||
|
|
||||||
ok:
|
ok:
|
||||||
# Generic `[ok] <verb> <subject>` header used for every
|
# Generic `[ok] <verb> <subject>` header used for every
|
||||||
# successful DSL command. Verbs come from `Command::verb()`
|
# successful DSL command. Verbs come from `Command::verb()`
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod cli;
|
|||||||
pub mod completion;
|
pub mod completion;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod dsl;
|
pub mod dsl;
|
||||||
|
pub mod echo;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod friendly;
|
pub mod friendly;
|
||||||
pub mod input_render;
|
pub mod input_render;
|
||||||
|
|||||||
+15
-1
@@ -384,12 +384,17 @@ async fn run_loop(
|
|||||||
debug!("quit action received");
|
debug!("quit action received");
|
||||||
should_quit = true;
|
should_quit = true;
|
||||||
}
|
}
|
||||||
Action::ExecuteDsl { command, source } => {
|
Action::ExecuteDsl {
|
||||||
|
command,
|
||||||
|
source,
|
||||||
|
submission_mode,
|
||||||
|
} => {
|
||||||
spawn_dsl_dispatch(
|
spawn_dsl_dispatch(
|
||||||
session.database().clone(),
|
session.database().clone(),
|
||||||
event_tx.clone(),
|
event_tx.clone(),
|
||||||
command,
|
command,
|
||||||
source,
|
source,
|
||||||
|
submission_mode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Action::JournalFailure { source } => {
|
Action::JournalFailure { source } => {
|
||||||
@@ -1251,16 +1256,25 @@ fn spawn_dsl_dispatch(
|
|||||||
event_tx: mpsc::Sender<AppEvent>,
|
event_tx: mpsc::Sender<AppEvent>,
|
||||||
command: Command,
|
command: Command,
|
||||||
source: String,
|
source: String,
|
||||||
|
submission_mode: crate::app::EffectiveMode,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Retain the source for `DslFailed` so the App can journal a
|
// Retain the source for `DslFailed` so the App can journal a
|
||||||
// rejected command as `err` (ADR-0034 §1/§2).
|
// rejected command as `err` (ADR-0034 §1/§2).
|
||||||
let source_for_journal = source.clone();
|
let source_for_journal = source.clone();
|
||||||
|
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
|
||||||
|
// command submitted in an advanced effective mode (ADR-0037).
|
||||||
|
// `replay` bypasses this spawn (it calls `execute_command_typed`
|
||||||
|
// directly), so replayed lines never echo. Built before execution
|
||||||
|
// from the Command; resolved-name / category-3 forms (Bucket B/§6,
|
||||||
|
// later slices) will additionally read the execution result.
|
||||||
|
let echo = crate::echo::echo_for(&command, submission_mode);
|
||||||
let outcome = execute_command_typed(&database, command.clone(), source).await;
|
let outcome = execute_command_typed(&database, command.clone(), source).await;
|
||||||
let event = match outcome {
|
let event = match outcome {
|
||||||
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
|
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
description,
|
description,
|
||||||
|
echo,
|
||||||
},
|
},
|
||||||
Ok(CommandOutcome::SchemaSkipped(description)) => AppEvent::DslCreateSkipped {
|
Ok(CommandOutcome::SchemaSkipped(description)) => AppEvent::DslCreateSkipped {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ fn advanced_mode_select_dispatches_as_command_select() {
|
|||||||
[Action::ExecuteDsl {
|
[Action::ExecuteDsl {
|
||||||
command: Command::Select { sql },
|
command: Command::Select { sql },
|
||||||
source,
|
source,
|
||||||
|
..
|
||||||
}] => {
|
}] => {
|
||||||
assert!(
|
assert!(
|
||||||
sql.contains("select 1"),
|
sql.contains("select 1"),
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
|
|||||||
app.update(AppEvent::DslSucceeded {
|
app.update(AppEvent::DslSucceeded {
|
||||||
command: expected_cmd,
|
command: expected_cmd,
|
||||||
description: Some(desc.clone()),
|
description: Some(desc.clone()),
|
||||||
|
echo: None,
|
||||||
});
|
});
|
||||||
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
|
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
|
||||||
|
|
||||||
@@ -358,6 +359,7 @@ fn add_column_flow_updates_structure_view() {
|
|||||||
check: None,
|
check: None,
|
||||||
},
|
},
|
||||||
description: Some(updated.clone()),
|
description: Some(updated.clone()),
|
||||||
|
echo: None,
|
||||||
});
|
});
|
||||||
assert_eq!(app.current_table, Some(updated));
|
assert_eq!(app.current_table, Some(updated));
|
||||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
@@ -387,6 +389,7 @@ fn drop_table_flow_clears_items_list() {
|
|||||||
name: "Customers".to_string(),
|
name: "Customers".to_string(),
|
||||||
},
|
},
|
||||||
description: None,
|
description: None,
|
||||||
|
echo: None,
|
||||||
});
|
});
|
||||||
app.update(AppEvent::TablesRefreshed(Vec::new()));
|
app.update(AppEvent::TablesRefreshed(Vec::new()));
|
||||||
|
|
||||||
@@ -460,6 +463,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
|||||||
create_fk: false,
|
create_fk: false,
|
||||||
},
|
},
|
||||||
description: Some(customers),
|
description: Some(customers),
|
||||||
|
echo: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
@@ -513,6 +517,7 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
|
|||||||
check: None,
|
check: None,
|
||||||
},
|
},
|
||||||
description: Some(customers),
|
description: Some(customers),
|
||||||
|
echo: None,
|
||||||
});
|
});
|
||||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
||||||
|
|||||||
Reference in New Issue
Block a user