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:
+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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user