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

Expands the renderer skeleton from ADR-0038's first slice to the full
single-statement catalogue. Every Bucket A row round-trips through the
advanced-mode walker (the §1 copy-paste contract):

  add column / drop column (non-cascade) / rename column / change column
  (SET DATA TYPE) / add constraint (not null, default, unique, check) /
  drop constraint (not null, default) / show data [where] [limit] /
  delete --all-rows / update --all-rows

Adds the Expr→SQL and Value→SQL-literal renderers (ADR-0038 §5) — bare
identifiers, inlined literals, NULL uppercase, standard <> for inequality
— and threads `echo: Option<String>` onto the six remaining success
events (DslAddColumn/DropColumn/ChangeColumn/Data/Update/Delete
Succeeded) with matching runtime construction and App stash arms.

`show data` is the one Bucket A row whose echo needs schema info beyond
the Command (the `ORDER BY <pk>` for a limited query): the pure
renderer takes the primary key as a parameter, and the runtime sources
it post-execution via describe_table — gated on advanced mode + limit
present, mirroring the enrich_dsl_failure describe pattern. An
end-to-end test pins the describe→PK→ORDER BY glue against a real
worker; the simple-mode gate and unlimited-no-lookup paths are covered
too.

Also fixes a contract gap surfaced while completing the catalogue: the
existing create-table echo silently dropped per-column DEFAULT / CHECK,
which simple-mode `create table … with pk c(ty) check (…)` does parse
(ADR-0029) — so the echo was non-equivalent. The render now emits the
full ADR-0029 column-constraint suffix, sharing one append_constraints
helper with `add column`.

Phase 2 (Bucket B — resolved-name + multi-line echoes, including
`add index`), Phase 3 (category-3 prose), and the de-emphasised
styled-runs polish remain deferred per ADR-0038 §8 phasing.

Tests: 2000 passed / 0 failed / 1 ignored (pre-existing); clippy clean
(`--all-targets -D warnings`, nursery).
This commit is contained in:
claude@clouddev1
2026-05-28 06:53:43 +00:00
parent 9d66073ff7
commit 90479cb879
5 changed files with 1049 additions and 25 deletions
+179 -6
View File
@@ -498,7 +498,14 @@ impl App {
self.note_system(crate::t!("ddl.create_index_skipped_exists", name = name));
Vec::new()
}
AppEvent::DslDataSucceeded { command, data } => {
AppEvent::DslDataSucceeded {
command,
data,
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_query_success(&command, &data);
Vec::new()
}
@@ -510,23 +517,48 @@ impl App {
self.handle_dsl_insert_success(&command, &result);
Vec::new()
}
AppEvent::DslUpdateSucceeded { command, result } => {
AppEvent::DslUpdateSucceeded {
command,
result,
echo,
} => {
self.pending_echo = echo;
self.handle_dsl_update_success(&command, &result);
Vec::new()
}
AppEvent::DslDeleteSucceeded { command, result } => {
AppEvent::DslDeleteSucceeded {
command,
result,
echo,
} => {
self.pending_echo = echo;
self.handle_dsl_delete_success(&command, &result);
Vec::new()
}
AppEvent::DslChangeColumnSucceeded { command, result } => {
AppEvent::DslChangeColumnSucceeded {
command,
result,
echo,
} => {
self.pending_echo = echo;
self.handle_dsl_change_column_success(&command, result);
Vec::new()
}
AppEvent::DslAddColumnSucceeded { command, result } => {
AppEvent::DslAddColumnSucceeded {
command,
result,
echo,
} => {
self.pending_echo = echo;
self.handle_dsl_add_column_success(&command, result);
Vec::new()
}
AppEvent::DslDropColumnSucceeded { command, result } => {
AppEvent::DslDropColumnSucceeded {
command,
result,
echo,
} => {
self.pending_echo = echo;
self.handle_dsl_drop_column_success(&command, result);
Vec::new()
}
@@ -2879,6 +2911,143 @@ mod tests {
);
}
#[test]
fn bucket_a_success_events_render_the_teaching_echo_beneath_ok() {
// ADR-0038 Phase 1: every Bucket A success event that gained an
// `echo` field stashes it for `note_ok_summary` to render
// immediately beneath `[ok]`. One case per event guards its
// update() arm's stash + ordering — including the handlers that
// render extra content (structure / notes), where the echo must
// still sit directly beneath `[ok]`, above that content.
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DeleteResult, DropColumnResult,
UpdateResult,
};
use crate::dsl::command::{ChangeColumnMode, RowFilter};
use crate::dsl::value::Value;
fn assert_echo_beneath_ok(app: &App, expected: &str) {
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(expected), "echo carries the SQL: {texts:?}");
}
fn empty_data() -> DataResult {
DataResult {
table_name: "T".to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
}
}
// show data → DslDataSucceeded (the post-execution query path).
let mut app = App::new();
app.update(AppEvent::DslDataSucceeded {
command: Command::ShowData {
name: "T".to_string(),
filter: None,
limit: None,
},
data: empty_data(),
echo: Some("SELECT * FROM T".to_string()),
});
assert_echo_beneath_ok(&app, "SELECT * FROM T");
// update … --all-rows → DslUpdateSucceeded.
let mut app = App::new();
app.update(AppEvent::DslUpdateSucceeded {
command: Command::Update {
table: "T".to_string(),
assignments: vec![("v".to_string(), Value::Number("1".to_string()))],
filter: RowFilter::AllRows,
},
result: UpdateResult {
rows_affected: 1,
data: empty_data(),
},
echo: Some("UPDATE T SET v = 1".to_string()),
});
assert_echo_beneath_ok(&app, "UPDATE T SET v = 1");
// delete … --all-rows → DslDeleteSucceeded.
let mut app = App::new();
app.update(AppEvent::DslDeleteSucceeded {
command: Command::Delete {
table: "T".to_string(),
filter: RowFilter::AllRows,
},
result: DeleteResult {
rows_affected: 1,
cascade: Vec::new(),
data: empty_data(),
},
echo: Some("DELETE FROM T".to_string()),
});
assert_echo_beneath_ok(&app, "DELETE FROM T");
// add column → DslAddColumnSucceeded (handler also renders structure).
let mut app = App::new();
app.update(AppEvent::DslAddColumnSucceeded {
command: Command::AddColumn {
table: "T".to_string(),
column: "c".to_string(),
ty: Type::Int,
not_null: false,
unique: false,
default: None,
check: None,
},
result: AddColumnResult {
description: sample_description("T"),
client_side_notes: Vec::new(),
},
echo: Some("ALTER TABLE T ADD COLUMN c int".to_string()),
});
assert_echo_beneath_ok(&app, "ALTER TABLE T ADD COLUMN c int");
// drop column → DslDropColumnSucceeded.
let mut app = App::new();
app.update(AppEvent::DslDropColumnSucceeded {
command: Command::DropColumn {
table: "T".to_string(),
column: "c".to_string(),
cascade: false,
},
result: DropColumnResult {
description: sample_description("T"),
dropped_indexes: Vec::new(),
},
echo: Some("ALTER TABLE T DROP COLUMN c".to_string()),
});
assert_echo_beneath_ok(&app, "ALTER TABLE T DROP COLUMN c");
// change column → DslChangeColumnSucceeded.
let mut app = App::new();
app.update(AppEvent::DslChangeColumnSucceeded {
command: Command::ChangeColumnType {
table: "T".to_string(),
column: "c".to_string(),
ty: Type::Text,
mode: ChangeColumnMode::Default,
},
result: ChangeColumnTypeResult {
description: sample_description("T"),
client_side: None,
},
echo: Some("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 mode_command_switches_persistently() {
let mut app = App::new();
@@ -3982,6 +4151,7 @@ mod tests {
rows: Vec::new(),
},
},
echo: None,
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
@@ -4015,6 +4185,7 @@ mod tests {
rows: vec![vec![Some("1".to_string()), Some("9".to_string())]],
},
},
echo: None,
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
@@ -4055,6 +4226,7 @@ mod tests {
rows: Vec::new(),
},
},
echo: None,
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
@@ -4102,6 +4274,7 @@ mod tests {
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
},
},
echo: None,
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(