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:
+285
-18
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user