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
+285 -18
View File
@@ -14,6 +14,7 @@
//! advanced effective mode (ADR-0037).
use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction;
use crate::dsl::Command;
use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
@@ -33,9 +34,9 @@ use crate::dsl::value::Value;
/// limited form orders by the table's primary key (not on the `Command`);
/// that is built post-execution by [`echo_for_query`] (ADR-0038 §4).
#[must_use]
pub fn echo_for(command: &Command, mode: EffectiveMode) -> Option<String> {
pub fn echo_for(command: &Command, mode: EffectiveMode) -> Option<Vec<String>> {
if mode.is_advanced() {
command_to_sql(command)
command_to_sql(command).map(|sql| vec![sql])
} else {
None
}
@@ -57,7 +58,7 @@ pub fn echo_for_query(
command: &Command,
mode: EffectiveMode,
primary_key: &[String],
) -> Option<String> {
) -> Option<Vec<String>> {
if !mode.is_advanced() {
return None;
}
@@ -66,7 +67,7 @@ pub fn echo_for_query(
name,
filter,
limit,
} => Some(render_show_data(name, filter.as_ref(), *limit, primary_key)),
} => Some(vec![render_show_data(name, filter.as_ref(), *limit, primary_key)]),
_ => None,
}
}
@@ -226,6 +227,117 @@ fn render_show_data(
s
}
/// `CREATE INDEX <name> ON <table> (col, …)` — the `add index` echo
/// (ADR-0038 §7 Bucket B). `name` is the resolved index name (the
/// user-given `as N` or the worker's auto-name `<table>_<cols>_idx`);
/// the runtime sources it from the post-execution table description.
pub(crate) fn render_create_index(name: &str, table: &str, columns: &[String]) -> String {
format!("CREATE INDEX {name} ON {table} ({})", columns.join(", "))
}
/// `DROP INDEX <name>` — the positional-form `drop index` echo
/// (ADR-0038 §7 Bucket B). The runtime resolves the name **pre-execution**
/// (the index is gone post-exec) by describing the table and matching by
/// column set.
pub(crate) fn render_drop_index(name: &str) -> String {
format!("DROP INDEX {name}")
}
/// `ALTER TABLE <C> ADD CONSTRAINT <name> FOREIGN KEY (<cc>) REFERENCES
/// <P> (<pc>) [ON DELETE …] [ON UPDATE …]` — the `add relationship` echo
/// (ADR-0038 §7 Bucket B), without `--create-fk`. Multi-line `--create-fk`
/// is a separate renderer (Slice 2b). The `ON DELETE` / `ON UPDATE`
/// clauses are emitted only when the action is non-default — the standard
/// (`NO ACTION`) is the implicit default in the SQL grammar, and emitting
/// it would clutter the echo without changing meaning.
pub(crate) fn render_add_relationship(
name: &str,
parent_table: &str,
parent_column: &str,
child_table: &str,
child_column: &str,
on_delete: ReferentialAction,
on_update: ReferentialAction,
) -> String {
let mut s = format!(
"ALTER TABLE {child_table} ADD CONSTRAINT {name} FOREIGN KEY ({child_column}) REFERENCES {parent_table} ({parent_column})"
);
if on_delete != ReferentialAction::default_action() {
s.push_str(&format!(" ON DELETE {}", on_delete.sql_clause()));
}
if on_update != ReferentialAction::default_action() {
s.push_str(&format!(" ON UPDATE {}", on_update.sql_clause()));
}
s
}
/// `ALTER TABLE <C> DROP CONSTRAINT <name>` — the `drop relationship`
/// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an
/// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre-
/// execution** via a describe — for a `Named` drop the worker resolves
/// the child table from metadata, which is gone after the drop.
pub(crate) fn render_drop_relationship(name: &str, child_table: &str) -> String {
format!("ALTER TABLE {child_table} DROP CONSTRAINT {name}")
}
/// Multi-line echo for `drop column T.c --cascade` (ADR-0038 §7 Bucket B,
/// category 2). Emits one `DROP INDEX <name>` line per covering index
/// (ADR-0025) followed by the final `ALTER TABLE T DROP COLUMN c`. The
/// SQL `DROP COLUMN` refuses an indexed column, so the indexes must come
/// first — the lines *are* the explanation, no prose (§6 category 2).
/// With zero dropped indexes (`--cascade` set on an unindexed column) the
/// result is a single line, still correct.
pub(crate) fn render_drop_column_cascade(
table: &str,
column: &str,
dropped_indexes: &[String],
) -> Vec<String> {
let mut lines: Vec<String> = dropped_indexes
.iter()
.map(|name| format!("DROP INDEX {name}"))
.collect();
lines.push(format!("ALTER TABLE {table} DROP COLUMN {column}"));
lines
}
/// Multi-line echo for `add 1:n relationship … --create-fk` when the
/// child column was *newly created* (ADR-0038 §7 Bucket B, category 2).
/// Emits the `ALTER TABLE … ADD COLUMN …` line first (with the FK
/// child-side type — `Type::fk_target_type` of the parent's PK type),
/// then the `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY …` line. When
/// the column already existed, the runtime instead uses
/// [`render_add_relationship`] for a single-line echo (the `ADD COLUMN`
/// line would be a no-op-with-error in advanced SQL — "column already
/// exists" — and the catalogue specifies "one line if the column already
/// existed").
#[allow(clippy::too_many_arguments)] // the SQL FK has many slots — all inherent.
pub(crate) fn render_add_relationship_create_fk(
name: &str,
parent_table: &str,
parent_column: &str,
child_table: &str,
child_column: &str,
on_delete: ReferentialAction,
on_update: ReferentialAction,
new_child_column_type: crate::dsl::types::Type,
) -> Vec<String> {
vec![
format!(
"ALTER TABLE {child_table} ADD COLUMN {child_column} {}",
new_child_column_type.keyword()
),
render_add_relationship(
name,
parent_table,
parent_column,
child_table,
child_column,
on_delete,
on_update,
),
]
}
/// Append the `NOT NULL` / `UNIQUE` / `DEFAULT` / `CHECK` column-constraint
/// suffix (ADR-0029). The advanced-mode column-constraint grammar is
/// order-independent (`Repeated(Choice…)`, ADR-0035 §4a), so this fixed
@@ -718,9 +830,9 @@ mod tests {
filter: None,
limit: None,
};
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
assert_eq!(sql, "SELECT * FROM T");
assert!(matches!(reparse(&sql), Ok(Command::Select { .. })));
let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
assert_eq!(lines.as_slice(), &["SELECT * FROM T"]);
assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
}
#[test]
@@ -730,9 +842,9 @@ mod tests {
filter: Some(eq("name", Value::Text("Bob".to_string()))),
limit: None,
};
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
assert_eq!(sql, "SELECT * FROM T WHERE name = 'Bob'");
assert!(matches!(reparse(&sql), Ok(Command::Select { .. })));
let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
assert_eq!(lines.as_slice(), &["SELECT * FROM T WHERE name = 'Bob'"]);
assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
}
#[test]
@@ -743,9 +855,9 @@ mod tests {
limit: Some(5),
};
let pk = vec!["id".to_string()];
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &pk).expect("echo");
assert_eq!(sql, "SELECT * FROM T ORDER BY id LIMIT 5");
assert!(matches!(reparse(&sql), Ok(Command::Select { .. })));
let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &pk).expect("echo");
assert_eq!(lines.as_slice(), &["SELECT * FROM T ORDER BY id LIMIT 5"]);
assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
}
#[test]
@@ -756,9 +868,12 @@ mod tests {
limit: Some(3),
};
let pk = vec!["a".to_string(), "b".to_string()];
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &pk).expect("echo");
assert_eq!(sql, "SELECT * FROM T WHERE active = true ORDER BY a, b LIMIT 3");
assert!(matches!(reparse(&sql), Ok(Command::Select { .. })));
let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &pk).expect("echo");
assert_eq!(
lines.as_slice(),
&["SELECT * FROM T WHERE active = true ORDER BY a, b LIMIT 3"]
);
assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
}
#[test]
@@ -769,8 +884,8 @@ mod tests {
filter: None,
limit: Some(2),
};
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
assert_eq!(sql, "SELECT * FROM T LIMIT 2");
let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
assert_eq!(lines.as_slice(), &["SELECT * FROM T LIMIT 2"]);
}
#[test]
@@ -792,6 +907,158 @@ mod tests {
assert!(echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).is_none());
}
// --- Bucket B single-statement renderers (Phase 2, Slice 2a) -----
#[test]
fn add_index_renders_and_round_trips() {
// Named form — the name is what was passed in.
let sql = render_create_index("MyIdx", "T", &["a".to_string(), "b".to_string()]);
assert_eq!(sql, "CREATE INDEX MyIdx ON T (a, b)");
assert!(matches!(reparse(&sql), Ok(Command::SqlCreateIndex { .. })));
}
#[test]
fn add_index_auto_name_format_matches_worker() {
// Mirrors the worker's `resolve_index_name` (`{table}_{cols}_idx`) —
// not directly used by the renderer (the runtime sources the resolved
// name from the description), but pins the expected auto-name shape.
let sql = render_create_index("Customers_Email_idx", "Customers", &["Email".to_string()]);
assert_eq!(sql, "CREATE INDEX Customers_Email_idx ON Customers (Email)");
assert!(matches!(reparse(&sql), Ok(Command::SqlCreateIndex { .. })));
}
#[test]
fn drop_index_round_trips() {
let sql = render_drop_index("Customers_Email_idx");
assert_eq!(sql, "DROP INDEX Customers_Email_idx");
assert!(matches!(reparse(&sql), Ok(Command::SqlDropIndex { .. })));
}
#[test]
fn add_relationship_no_referential_actions_round_trips() {
// Default `NoAction` / `NoAction` → no `ON DELETE` / `ON UPDATE`
// clauses (the implicit standard default — emitting them would
// clutter the echo without changing meaning).
let sql = render_add_relationship(
"Orders_CustId_to_Customers_id",
"Customers",
"id",
"Orders",
"CustId",
ReferentialAction::NoAction,
ReferentialAction::NoAction,
);
assert_eq!(
sql,
"ALTER TABLE Orders ADD CONSTRAINT Orders_CustId_to_Customers_id FOREIGN KEY (CustId) REFERENCES Customers (id)"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
#[test]
fn add_relationship_with_cascade_and_set_null_round_trips() {
let sql = render_add_relationship(
"places",
"Customers",
"id",
"Orders",
"CustId",
ReferentialAction::Cascade,
ReferentialAction::SetNull,
);
assert_eq!(
sql,
"ALTER TABLE Orders ADD CONSTRAINT places FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE ON UPDATE SET NULL"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
#[test]
fn drop_relationship_round_trips() {
let sql = render_drop_relationship("places", "Orders");
assert_eq!(sql, "ALTER TABLE Orders DROP CONSTRAINT places");
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
// --- Bucket B multi-statement renderers (Phase 2, Slice 2b) ------
#[test]
fn drop_column_cascade_emits_drop_indexes_then_drop_column_and_each_round_trips() {
let lines = render_drop_column_cascade(
"Orders",
"CustId",
&["Orders_CustId_idx".to_string(), "Orders_CustId_Day_idx".to_string()],
);
assert_eq!(
lines.as_slice(),
&[
"DROP INDEX Orders_CustId_idx",
"DROP INDEX Orders_CustId_Day_idx",
"ALTER TABLE Orders DROP COLUMN CustId",
]
);
// Each line is itself runnable advanced-mode SQL (the §1 contract
// holds per line for category 2).
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. })));
}
#[test]
fn drop_column_cascade_with_no_covering_indexes_is_single_line() {
// `--cascade` flagged on an unindexed column collapses to the
// plain `DROP COLUMN` — still semantically equivalent.
let lines = render_drop_column_cascade("T", "c", &[]);
assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN c"]);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
}
#[test]
fn add_relationship_create_fk_emits_add_column_then_fk_and_each_round_trips() {
let lines = render_add_relationship_create_fk(
"Customers_id_to_Orders_CustId",
"Customers",
"id",
"Orders",
"CustId",
ReferentialAction::Cascade,
ReferentialAction::NoAction,
// Parent PK is `serial` → child FK column is `int`
// (`Type::fk_target_type` strips auto-gen semantics; ADR-0011).
crate::dsl::types::Type::Int,
);
assert_eq!(
lines.as_slice(),
&[
"ALTER TABLE Orders ADD COLUMN CustId int",
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE",
]
);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. })));
}
#[test]
fn add_relationship_create_fk_with_shortid_parent_targets_text_column() {
// `Type::fk_target_type(ShortId)` → Text (ADR-0011).
let lines = render_add_relationship_create_fk(
"Items_code_to_Lines_code",
"Items",
"code",
"Lines",
"code",
ReferentialAction::NoAction,
ReferentialAction::NoAction,
crate::dsl::types::Type::Text,
);
assert_eq!(lines[0], "ALTER TABLE Lines ADD COLUMN code text");
// No referential clauses when both default.
assert_eq!(
lines[1],
"ALTER TABLE Lines ADD CONSTRAINT Items_code_to_Lines_code FOREIGN KEY (code) REFERENCES Items (code)"
);
}
// --- expr / literal rendering ------------------------------------
#[test]