feat: DSL→SQL teaching echo — Bucket B renderer (ADR-0038 Phase 2)

Expands the renderer to Bucket B — resolved-name single-statement
echoes plus the two category-2 multi-statement forms. Every catalogue
row round-trips per line through the advanced-mode walker (the §1
copy-paste contract; §6 category 2 holds the contract per line):

  add index [as N] on T (cols)           → CREATE INDEX <name> ON T (cols)
  drop index on T (cols) (positional)    → DROP INDEX <name>
  add 1:n relationship [as N] …          → ALTER TABLE C ADD CONSTRAINT
                                            <name> FOREIGN KEY (cc)
                                            REFERENCES P (pc) [ON …]
  drop relationship (endpoints or named) → ALTER TABLE C DROP CONSTRAINT
                                            <name>
  drop column T.c --cascade              → DROP INDEX <ix1> ⏎ … ⏎
                                            ALTER TABLE T DROP COLUMN c
  add relationship … --create-fk         → ALTER TABLE C ADD COLUMN cc <ty>
   (child column newly created)            ⏎ ALTER TABLE … ADD CONSTRAINT
   (already existed) collapses to a single-line FK echo

Refactors the echo payload from Option<String> to Option<Vec<String>>
across the 7 success events + arms + render path — one entry per
statement; the Bucket A single-line echoes wrap as Some(vec![s]). Plain
rendering repeats `Executing SQL:` per line; the de-emphasised
styled-runs polish (ADR-0038 §4) will refine it later.

Adds the two echo build paths the handoff §5 ⚠️ gotcha foreshadowed:

* collect_echo_lookups (pre-execution, runtime): resolves names the
  dropped thing or not-yet-created column would erase post-execution —
  drop index (positional), drop relationship (both endpoints and named
  selectors, the latter via a list_tables scan acceptable for teaching-
  playground schemas), and the --create-fk pre-state (whether the child
  column existed + the parent PK type to derive the new column type via
  Type::fk_target_type).
* build_schema_echo (post-execution, runtime): subsumes the Bucket A
  pure-Command schema cases and renders Bucket B from the description +
  the lookups.

The DropColumn arm gains build_drop_column_cascade_echo, which reads
DropColumnResult.dropped_indexes to emit the multi-line cascade echo;
non-cascade falls through to the pre-execution Bucket A echo unchanged.

Tests: 2013 passed / 0 failed / 1 ignored (pre-existing); clippy clean
(`--all-targets -D warnings`, nursery). Two end-to-end runtime tests
exercise the resolved-name and multi-statement flows against a real
worker (auto-named index, both drop-relationship selector forms, both
--create-fk branches). One app-level test pins the multi-line rendering
(one Executing SQL: per statement, in order, beneath [ok]).

Phase 3 (category-3 prose — shortid generation, type-conversion
transforms, `change column --dont-convert` caveat) and the §4
de-emphasised styled-runs rendering polish remain per ADR-0038 §8
phasing.
This commit is contained in:
claude@clouddev1
2026-05-28 07:54:05 +00:00
parent 558cfae151
commit 275c726ad4
4 changed files with 1112 additions and 53 deletions
+73 -11
View File
@@ -221,7 +221,7 @@ pub struct App {
/// 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>,
pending_echo: Option<Vec<String>>,
}
/// Dialogs that take over keyboard input when active.
@@ -1421,9 +1421,15 @@ impl App {
));
// 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));
// effective mode (ADR-0037); `None` otherwise. De-emphasised
// (styled-runs polish per ADR-0038 §4 still pending). One line
// per statement — single-statement echoes render one line;
// multi-statement (`drop column --cascade`, `add relationship
// --create-fk`) render one per entry (ADR-0038 §6 category 2).
if let Some(lines) = self.pending_echo.take() {
for line in lines {
self.note_system(crate::t!("echo.executing_sql", sql = line));
}
}
}
@@ -2887,7 +2893,7 @@ mod tests {
app.update(AppEvent::DslSucceeded {
command: cmd.clone(),
description: None,
echo: Some("CREATE TABLE Other (id serial PRIMARY KEY)".to_string()),
echo: Some(vec!["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");
@@ -2958,7 +2964,7 @@ mod tests {
limit: None,
},
data: empty_data(),
echo: Some("SELECT * FROM T".to_string()),
echo: Some(vec!["SELECT * FROM T".to_string()]),
});
assert_echo_beneath_ok(&app, "SELECT * FROM T");
@@ -2974,7 +2980,7 @@ mod tests {
rows_affected: 1,
data: empty_data(),
},
echo: Some("UPDATE T SET v = 1".to_string()),
echo: Some(vec!["UPDATE T SET v = 1".to_string()]),
});
assert_echo_beneath_ok(&app, "UPDATE T SET v = 1");
@@ -2990,7 +2996,7 @@ mod tests {
cascade: Vec::new(),
data: empty_data(),
},
echo: Some("DELETE FROM T".to_string()),
echo: Some(vec!["DELETE FROM T".to_string()]),
});
assert_echo_beneath_ok(&app, "DELETE FROM T");
@@ -3010,7 +3016,7 @@ mod tests {
description: sample_description("T"),
client_side_notes: Vec::new(),
},
echo: Some("ALTER TABLE T ADD COLUMN c int".to_string()),
echo: Some(vec!["ALTER TABLE T ADD COLUMN c int".to_string()]),
});
assert_echo_beneath_ok(&app, "ALTER TABLE T ADD COLUMN c int");
@@ -3026,7 +3032,7 @@ mod tests {
description: sample_description("T"),
dropped_indexes: Vec::new(),
},
echo: Some("ALTER TABLE T DROP COLUMN c".to_string()),
echo: Some(vec!["ALTER TABLE T DROP COLUMN c".to_string()]),
});
assert_echo_beneath_ok(&app, "ALTER TABLE T DROP COLUMN c");
@@ -3043,11 +3049,67 @@ mod tests {
description: sample_description("T"),
client_side: None,
},
echo: Some("ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string()),
echo: Some(vec!["ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string()]),
});
assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
}
#[test]
fn bucket_b_multi_line_echo_renders_one_line_per_statement_beneath_ok() {
// ADR-0038 §6 category 2 / §4 / Phase 2 Slice 2b: a `drop column
// --cascade` echo carries one `DROP INDEX <name>` line per
// covering index plus the final `ALTER TABLE … DROP COLUMN …`.
// The App renders each as its own `Executing SQL:` line beneath
// `[ok]`, in order — the styled-runs polish refines the
// presentation later, but ordering and one-per-statement are the
// semantic invariants pinned here.
use crate::db::DropColumnResult;
let mut app = App::new();
app.update(AppEvent::DslDropColumnSucceeded {
command: Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
cascade: true,
},
result: DropColumnResult {
description: sample_description("Customers"),
dropped_indexes: vec![
"Customers_Email_idx".to_string(),
"Customers_Email_Day_idx".to_string(),
],
},
echo: Some(vec![
"DROP INDEX Customers_Email_idx".to_string(),
"DROP INDEX Customers_Email_Day_idx".to_string(),
"ALTER TABLE Customers DROP COLUMN Email".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");
// The three echo lines sit immediately beneath [ok], in order.
assert!(
texts[ok_idx + 1].contains("Executing SQL: DROP INDEX Customers_Email_idx"),
"first echo line: {texts:?}",
);
assert!(
texts[ok_idx + 2].contains("Executing SQL: DROP INDEX Customers_Email_Day_idx"),
"second echo line: {texts:?}",
);
assert!(
texts[ok_idx + 3]
.contains("Executing SQL: ALTER TABLE Customers DROP COLUMN Email"),
"third echo line: {texts:?}",
);
// Pin the `Executing SQL:` prefix repeats once per statement
// (the plain-rendering shape until the styled-runs polish lands).
let exec_count = texts.iter().filter(|t| t.contains("Executing SQL:")).count();
assert_eq!(exec_count, 3, "one Executing SQL: per statement: {texts:?}");
}
#[test]
fn mode_command_switches_persistently() {
let mut app = App::new();