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();
+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]
+7 -7
View File
@@ -32,7 +32,7 @@ pub enum AppEvent {
/// advanced effective mode (ADR-0037). `None` when no echo applies
/// (simple mode, SQL-entered, or a form with no echo). The App
/// renders it beneath `[ok]`.
echo: Option<String>,
echo: Option<Vec<String>>,
},
/// A SQL `CREATE TABLE IF NOT EXISTS` matched an existing table —
/// a no-op (ADR-0035 §4). Renders the existing structure plus an
@@ -68,7 +68,7 @@ pub enum AppEvent {
DslDataSucceeded {
command: Command,
data: DataResult,
echo: Option<String>,
echo: Option<Vec<String>>,
},
/// An `explain …` command succeeded (ADR-0028). `plan`
/// carries the captured query plan; nothing was executed.
@@ -83,7 +83,7 @@ pub enum AppEvent {
/// The DSL → SQL teaching echo (ADR-0038): `UPDATE T SET …` for an
/// `update … --all-rows` fall-through. `None` for a SQL-entered
/// `UPDATE` or any simple-mode submission.
echo: Option<String>,
echo: Option<Vec<String>>,
},
DslDeleteSucceeded {
command: Command,
@@ -91,7 +91,7 @@ pub enum AppEvent {
/// The DSL → SQL teaching echo (ADR-0038): `DELETE FROM T` for a
/// `delete … --all-rows` fall-through. `None` for a SQL-entered
/// `DELETE` or any simple-mode submission.
echo: Option<String>,
echo: Option<Vec<String>>,
},
/// A `change column …` succeeded. `result` carries both the
/// post-rebuild description (for the auto-show) and the
@@ -102,7 +102,7 @@ pub enum AppEvent {
/// The DSL → SQL teaching echo (ADR-0038): `ALTER TABLE T ALTER
/// COLUMN c SET DATA TYPE …`. `None` in simple mode. (The
/// `--dont-convert` caveat line is category-3, a later slice.)
echo: Option<String>,
echo: Option<Vec<String>>,
},
/// An `add column …` succeeded. `result` carries the
/// post-add description plus any `[client-side]` notes
@@ -112,7 +112,7 @@ pub enum AppEvent {
result: AddColumnResult,
/// The DSL → SQL teaching echo (ADR-0038): `ALTER TABLE T ADD
/// COLUMN c <ty> …`. `None` in simple mode.
echo: Option<String>,
echo: Option<Vec<String>>,
},
/// A `drop column …` succeeded. `result` carries the
/// post-drop description plus the names of any indexes
@@ -123,7 +123,7 @@ pub enum AppEvent {
/// The DSL → SQL teaching echo (ADR-0038): `ALTER TABLE T DROP
/// COLUMN c` for a plain (non-`--cascade`) drop. `None` in simple
/// mode, and for `--cascade` (a multi-statement echo, Phase 2).
echo: Option<String>,
echo: Option<Vec<String>>,
},
/// A DSL command failed. `error` is the structured
/// payload, `facts` is the runtime-built schema-resolved
+747 -17
View File
@@ -33,7 +33,9 @@ use crate::db::{
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
QueryPlan, TableDescription, UpdateResult,
};
use crate::dsl::command::{Constraint, ConstraintKind, TableConstraint};
use crate::dsl::command::{
Constraint, ConstraintKind, IndexSelector, RelationshipSelector, TableConstraint,
};
use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec};
use crate::dsl::walker::Severity;
use crate::event::AppEvent;
@@ -1265,17 +1267,36 @@ fn spawn_dsl_dispatch(
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
// command submitted in an advanced effective mode (ADR-0037).
// `replay` bypasses this spawn (it calls `execute_command_typed`
// directly), so replayed lines never echo. Built before execution
// from the Command; resolved-name / category-3 forms (Bucket B/§6,
// later slices) will additionally read the execution result.
// directly), so replayed lines never echo.
//
// Two echo sources converge here. The Bucket A pre-execution
// path (`echo_for` → `command_to_sql`) handles every echo that is
// a pure function of the `Command` — wired into the non-Schema
// arms below (Update / Delete / AddColumn / DropColumn /
// ChangeColumn). The Schema arm uses `build_schema_echo`, which
// subsumes the Bucket A pure-Command schema cases *and* adds the
// Bucket B resolved-name cases that need the post-exec
// description (`add index` / `add relationship`) or a pre-exec
// lookup (`drop index` / `drop relationship` — the dropped thing
// is gone after execution, hence `collect_drop_lookups` runs
// first).
let lookups = collect_echo_lookups(&database, &command, submission_mode).await;
let echo = crate::echo::echo_for(&command, submission_mode);
let outcome = execute_command_typed(&database, command.clone(), source).await;
let event = match outcome {
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
command: command.clone(),
description,
echo,
},
Ok(CommandOutcome::Schema(description)) => {
let schema_echo = build_schema_echo(
&command,
submission_mode,
description.as_ref(),
&lookups,
);
AppEvent::DslSucceeded {
command: command.clone(),
description,
echo: schema_echo,
}
}
Ok(CommandOutcome::SchemaSkipped(description)) => AppEvent::DslCreateSkipped {
command: command.clone(),
description,
@@ -1333,11 +1354,23 @@ fn spawn_dsl_dispatch(
result,
echo,
},
Ok(CommandOutcome::DropColumn(result)) => AppEvent::DslDropColumnSucceeded {
command: command.clone(),
result,
echo,
},
Ok(CommandOutcome::DropColumn(result)) => {
// `drop column --cascade` is the only DropColumn shape
// whose echo needs the execution result (the names of
// the covering indexes the rebuild removed — Bucket B
// category 2, ADR-0038 §7 Slice 2b). Non-cascade falls
// through to the pre-execution `echo` from `echo_for`.
let cascade_echo = build_drop_column_cascade_echo(
&command,
submission_mode,
&result,
);
AppEvent::DslDropColumnSucceeded {
command: command.clone(),
result,
echo: cascade_echo.or(echo),
}
}
Err(DbError::PersistenceFatal {
operation,
path,
@@ -1396,7 +1429,7 @@ async fn build_show_data_echo(
database: &Database,
command: &Command,
submission_mode: crate::app::EffectiveMode,
) -> Option<String> {
) -> Option<Vec<String>> {
if !submission_mode.is_advanced() {
return None;
}
@@ -1423,6 +1456,316 @@ async fn build_show_data_echo(
crate::echo::echo_for_query(command, submission_mode, &primary_key)
}
/// Pre-execution lookups captured for the teaching echo (ADR-0038 §7
/// Bucket B).
///
/// Two classes of echo need information that the `Command` alone doesn't
/// carry and that may not be recoverable from the post-execution
/// `description`:
///
/// - **Drops** of resolved-name things (`drop index` positional,
/// `drop relationship`): the thing is *gone* post-execution, so the
/// runtime resolves the name (and for `drop relationship Named`, the
/// owning child table) **before** calling the worker.
/// - **`add relationship --create-fk`**: the multi-line echo (category
/// 2, Slice 2b) emits an `ADD COLUMN` line *only when the child column
/// was newly created*; the runtime resolves both the pre-state
/// (existed?) and the new column type (from the parent's PK via
/// `Type::fk_target_type`) up front, so the post-exec builder is a
/// pure formatter.
///
/// Empty (`None` on each field) in simple mode, or when the command does
/// not need a lookup, or when the lookup didn't find anything (defensive
/// — the executor will then refuse with its own error and the echo
/// simply doesn't fire).
#[derive(Default)]
struct EchoLookups {
/// For `Command::DropIndex { IndexSelector::Columns }` — the resolved
/// index name. The positional form `drop index on T(cols)` reaches
/// this; the SQL `DROP INDEX <name>` is `Command::SqlDropIndex` and
/// is already SQL (no echo).
drop_index_name: Option<String>,
/// For `Command::DropRelationship` — `(resolved name, child_table)`.
/// For `Endpoints`, name is resolved + child_table is from the
/// command (captured here uniformly so the post-exec builder uses one
/// shape). For `Named`, name is from the command + child_table is
/// resolved via a scan of user tables (small schemas — fine for a
/// teaching playground).
drop_relationship: Option<(String, String)>,
/// For `Command::AddRelationship { create_fk: true, .. }` — the
/// type of the child column the `--create-fk` flag will create, *if*
/// the column did not already exist (`Some(ty)` → newly created →
/// multi-line echo; `None` → already existed → single-line echo).
/// The type is derived from the parent's PK column type via
/// `Type::fk_target_type` (ADR-0011: `serial → int`, `shortid →
/// text`, others identity). The outer `Option` is `None` for
/// not-applicable commands (not a `--create-fk` add, or simple mode,
/// or a pre-execution lookup failed); the inner option encodes the
/// existed-vs-created distinction.
add_rel_create_fk_new_column_type: Option<Option<crate::dsl::types::Type>>,
}
/// Resolve drop-target names and `--create-fk` pre-state **before**
/// execution, for the Bucket B echoes that need them (ADR-0038 §7).
/// Best-effort: an unresolved lookup yields `None` and the echo for that
/// command silently doesn't fire — the executor's own error path
/// surfaces any real problem.
async fn collect_echo_lookups(
database: &Database,
command: &Command,
submission_mode: crate::app::EffectiveMode,
) -> EchoLookups {
let mut out = EchoLookups::default();
if !submission_mode.is_advanced() {
return out;
}
match command {
Command::DropIndex {
selector: IndexSelector::Columns { table, columns },
} => {
if let Ok(desc) = database.describe_table(table.clone(), None).await
&& let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns)
{
out.drop_index_name = Some(idx.name.clone());
}
}
Command::DropRelationship {
selector:
RelationshipSelector::Endpoints {
parent_table,
parent_column,
child_table,
child_column,
},
} => {
if let Ok(desc) = database.describe_table(child_table.clone(), None).await
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
r.other_table == *parent_table
&& r.other_column == *parent_column
&& r.local_column == *child_column
})
{
out.drop_relationship = Some((rel.name.clone(), child_table.clone()));
}
}
Command::DropRelationship {
selector: RelationshipSelector::Named { name },
} => {
// The named selector doesn't carry the child table — the
// worker resolves it from the relationships metadata. Mirror
// that with a small scan of user tables. For a teaching
// playground (small schemas) this is cheap; a dedicated
// resolver API would be the next step if schemas grow.
if let Ok(tables) = database.list_tables().await {
for table in tables {
if let Ok(desc) = database.describe_table(table.clone(), None).await
&& desc.outbound_relationships.iter().any(|r| r.name == *name)
{
out.drop_relationship = Some((name.clone(), table.clone()));
break;
}
}
}
}
Command::AddRelationship {
create_fk: true,
parent_table,
parent_column,
child_table,
child_column,
..
} => {
// Two pre-state facts feed the multi-line `--create-fk` echo
// (ADR-0038 §7 Bucket B, category 2): whether the child
// column already exists (determines single- vs multi-line)
// and the parent PK column's user type (determines the
// newly-created child column's type via
// `Type::fk_target_type`). Both are looked up post-exec from
// the description for `add relationship` (no `--create-fk`),
// but the `--create-fk` multi-line case needs them *before*
// execution to know whether to emit an `ADD COLUMN` line.
let parent_pk_type = database
.describe_table(parent_table.clone(), None)
.await
.ok()
.and_then(|d| {
d.columns
.iter()
.find(|c| c.name == *parent_column)
.and_then(|c| c.user_type)
});
let child_column_existed = database
.describe_table(child_table.clone(), None)
.await
.ok()
.map(|d| d.columns.iter().any(|c| c.name == *child_column));
if let (Some(parent_ty), Some(existed)) = (parent_pk_type, child_column_existed) {
out.add_rel_create_fk_new_column_type = Some(if existed {
None
} else {
Some(parent_ty.fk_target_type())
});
}
}
_ => {}
}
out
}
/// Build the teaching echo for a `Schema`-outcome command (ADR-0038).
///
/// Subsumes both the Bucket A pure-`Command` echoes (`create table`,
/// `rename column`, `add`/`drop constraint` — for which it delegates to
/// `echo::command_to_sql`) **and** the Bucket B resolved-name echoes
/// (`add`/`drop index`, `add`/`drop relationship`), which read the
/// post-execution `description` (for adds) or `drop_lookups` (for drops).
/// Returns `None` for non-advanced mode, for Bucket C / `Sql*` variants
/// that don't echo, and for the `--create-fk` form (Slice 2b — Phase 2
/// next slice).
fn build_schema_echo(
command: &Command,
submission_mode: crate::app::EffectiveMode,
description: Option<&TableDescription>,
lookups: &EchoLookups,
) -> Option<Vec<String>> {
if !submission_mode.is_advanced() {
return None;
}
match command {
Command::AddIndex {
name,
table,
columns,
} => {
// The post-exec description carries the new index with its
// stored name (user-given `as N` or worker auto-generated).
// Always sourcing from the description (rather than command
// when `name = Some`) keeps the runtime in one path and
// matches whatever the worker actually wrote.
let resolved = description
.and_then(|d| d.indexes.iter().find(|i| i.columns == *columns))
.map(|i| i.name.clone())
.or_else(|| name.clone());
resolved.map(|n| vec![crate::echo::render_create_index(&n, table, columns)])
}
Command::DropIndex {
selector: IndexSelector::Columns { .. },
} => lookups
.drop_index_name
.as_ref()
.map(|n| vec![crate::echo::render_drop_index(n)]),
Command::AddRelationship {
name,
parent_table,
parent_column,
child_table,
child_column,
on_delete,
on_update,
create_fk,
} => {
// Resolve the relationship name from the parent's inbound
// relationships (target_table for AddRelationship is the
// parent — `database.add_relationship` returns the parent's
// description per ADR-0013), falling back to the command's
// explicit `name` when the description is unavailable.
let resolved = description
.and_then(|d| {
d.inbound_relationships.iter().find(|r| {
r.other_table == *child_table
&& r.other_column == *child_column
&& r.local_column == *parent_column
})
})
.map(|r| r.name.clone())
.or_else(|| name.clone())?;
if *create_fk {
// Multi-line iff the child column was newly created
// (`--create-fk`'s pre-state, captured pre-execution
// into `add_rel_create_fk_new_column_type`). When the
// column already existed the echo collapses to the
// single-line FK form — the SQL `ADD COLUMN` would be
// a no-op-with-error otherwise, and the catalogue is
// explicit: "one line if the column already existed".
Some(lookups.add_rel_create_fk_new_column_type?.map_or_else(
|| {
vec![crate::echo::render_add_relationship(
&resolved,
parent_table,
parent_column,
child_table,
child_column,
*on_delete,
*on_update,
)]
},
|new_ty| {
crate::echo::render_add_relationship_create_fk(
&resolved,
parent_table,
parent_column,
child_table,
child_column,
*on_delete,
*on_update,
new_ty,
)
},
))
} else {
Some(vec![crate::echo::render_add_relationship(
&resolved,
parent_table,
parent_column,
child_table,
child_column,
*on_delete,
*on_update,
)])
}
}
Command::DropRelationship { .. } => lookups
.drop_relationship
.as_ref()
.map(|(name, child_table)| {
vec![crate::echo::render_drop_relationship(name, child_table)]
}),
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C
// variants like `Sql*` / `ShowTable`) routes through the existing
// `echo::command_to_sql` — wrapping its `Option<String>` to fit the
// multi-line `Option<Vec<String>>` payload uniformly.
_ => crate::echo::command_to_sql(command).map(|s| vec![s]),
}
}
/// Build the `drop column --cascade` multi-line teaching echo (ADR-0038
/// §7 Bucket B, category 2). Returns `None` for non-`--cascade` drops
/// (the pre-execution `echo_for` already produced the single-line plain
/// `DROP COLUMN` echo for Bucket A) and for simple mode. Reads
/// `DropColumnResult::dropped_indexes` for the index names the rebuild
/// removed.
fn build_drop_column_cascade_echo(
command: &Command,
submission_mode: crate::app::EffectiveMode,
result: &DropColumnResult,
) -> Option<Vec<String>> {
if !submission_mode.is_advanced() {
return None;
}
match command {
Command::DropColumn {
table,
column,
cascade: true,
} => Some(crate::echo::render_drop_column_cascade(
table,
column,
&result.dropped_indexes,
)),
_ => None,
}
}
/// Build schema-resolved enrichment for a DSL failure (ADR-0019 §6).
///
/// Best-effort: every lookup is independently fallible and a
@@ -2612,7 +2955,7 @@ mod tests {
// Limited → ORDER BY the resolved primary key.
assert_eq!(
super::build_show_data_echo(&db, &limited, EffectiveMode::AdvancedPersistent).await,
Some("SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()),
Some(vec!["SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()]),
);
// Simple mode → silent, gated before any lookup.
assert_eq!(
@@ -2627,7 +2970,394 @@ mod tests {
};
assert_eq!(
super::build_show_data_echo(&db, &unlimited, EffectiveMode::AdvancedPersistent).await,
Some("SELECT * FROM Customers".to_string()),
Some(vec!["SELECT * FROM Customers".to_string()]),
);
}
/// End-to-end cover for the Bucket B resolved-name echoes (ADR-0038
/// §7) against a real worker: `add`/`drop index` (auto-named) and
/// `add`/`drop relationship`. The pure renderers are unit-tested in
/// `echo`; this pins the runtime glue — `collect_drop_lookups`
/// (pre-execution, for drops) and `build_schema_echo` (post-execution
/// for adds, post-pre-exec for drops) — both for adds (description
/// lookup) and drops (pre-execution lookup including the named-
/// selector child-table scan).
#[tokio::test]
async fn bucket_b_resolved_name_echoes_against_real_worker() {
use crate::app::EffectiveMode;
use crate::db::Database;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{ColumnSpec, IndexSelector, RelationshipSelector};
use crate::dsl::types::Type;
use crate::dsl::Command;
let db = Database::open(":memory:").expect("open in-memory");
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("Email", Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.expect("create Customers");
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("CustId", Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.expect("create Orders");
// --- add index (auto-named) ----------------------------------
let desc_after_add_index = db
.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.await
.expect("add index");
let add_idx_cmd = Command::AddIndex {
name: None,
table: "Customers".to_string(),
columns: vec!["Email".to_string()],
};
assert_eq!(
super::build_schema_echo(
&add_idx_cmd,
EffectiveMode::AdvancedPersistent,
Some(&desc_after_add_index),
&super::EchoLookups::default(),
),
Some(vec![
"CREATE INDEX Customers_Email_idx ON Customers (Email)".to_string()
]),
"auto-named index resolved from post-exec description",
);
// --- drop index (positional) — pre-exec lookup ---------------
let drop_idx_cmd = Command::DropIndex {
selector: IndexSelector::Columns {
table: "Customers".to_string(),
columns: vec!["Email".to_string()],
},
};
let drop_idx_lookups =
super::collect_echo_lookups(&db, &drop_idx_cmd, EffectiveMode::AdvancedPersistent)
.await;
assert_eq!(
drop_idx_lookups.drop_index_name.as_deref(),
Some("Customers_Email_idx"),
"drop-index pre-exec lookup finds the index by column set",
);
let desc_after_drop_idx = db
.drop_index(
IndexSelector::Columns {
table: "Customers".to_string(),
columns: vec!["Email".to_string()],
},
None,
)
.await
.expect("drop index");
assert_eq!(
super::build_schema_echo(
&drop_idx_cmd,
EffectiveMode::AdvancedPersistent,
Some(&desc_after_drop_idx),
&drop_idx_lookups,
),
Some(vec!["DROP INDEX Customers_Email_idx".to_string()]),
);
// Simple mode → no lookup, no echo.
assert!(
super::collect_echo_lookups(&db, &drop_idx_cmd, EffectiveMode::Simple)
.await
.drop_index_name
.is_none(),
"simple-mode gate skips the pre-exec describe",
);
// --- add relationship (auto-named) ---------------------------
let desc_after_add_rel = db
.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None,
)
.await
.expect("add relationship");
let add_rel_cmd = Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_column: "id".to_string(),
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
create_fk: false,
};
assert_eq!(
super::build_schema_echo(
&add_rel_cmd,
EffectiveMode::AdvancedPersistent,
Some(&desc_after_add_rel),
&super::EchoLookups::default(),
),
Some(vec![
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE".to_string()
]),
"auto-named relationship resolved from parent's inbound_relationships",
);
// --- drop relationship by endpoints — pre-exec lookup --------
let drop_rel_endpoints = Command::DropRelationship {
selector: RelationshipSelector::Endpoints {
parent_table: "Customers".to_string(),
parent_column: "id".to_string(),
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
},
};
let endpoints_lookups = super::collect_echo_lookups(
&db,
&drop_rel_endpoints,
EffectiveMode::AdvancedPersistent,
)
.await;
assert_eq!(
endpoints_lookups.drop_relationship,
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
"endpoints selector resolves name via child describe",
);
// --- drop relationship by name — child-table scan ------------
let drop_rel_named = Command::DropRelationship {
selector: RelationshipSelector::Named {
name: "Customers_id_to_Orders_CustId".to_string(),
},
};
let named_lookups =
super::collect_echo_lookups(&db, &drop_rel_named, EffectiveMode::AdvancedPersistent)
.await;
assert_eq!(
named_lookups.drop_relationship,
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
"named selector scans user tables to find the child",
);
// Either selector → same echo.
for (cmd, lookups) in [
(&drop_rel_endpoints, &endpoints_lookups),
(&drop_rel_named, &named_lookups),
] {
assert_eq!(
super::build_schema_echo(
cmd,
EffectiveMode::AdvancedPersistent,
None, // description not needed for drops
lookups,
),
Some(vec![
"ALTER TABLE Orders DROP CONSTRAINT Customers_id_to_Orders_CustId".to_string()
]),
);
}
}
/// End-to-end cover for the Bucket B multi-statement echoes (ADR-0038
/// §7 / §6 category 2) against a real worker: `drop column --cascade`
/// (post-exec `DropColumnResult.dropped_indexes`) and `add
/// relationship --create-fk` (pre-exec lookup of the parent PK type +
/// whether the child column existed; the multi-line shape fires only
/// when the column was newly created).
#[tokio::test]
async fn bucket_b_multi_statement_echoes_against_real_worker() {
use crate::app::EffectiveMode;
use crate::db::Database;
use crate::dsl::ReferentialAction;
use crate::dsl::command::ColumnSpec;
use crate::dsl::types::Type;
use crate::dsl::Command;
// --- drop column --cascade -----------------------------------
let db = Database::open(":memory:").expect("open in-memory");
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("Email", Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.expect("create Customers");
db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.await
.expect("index Email");
let drop_cmd = Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
cascade: true,
};
let drop_result = db
.drop_column("Customers".to_string(), "Email".to_string(), true, None)
.await
.expect("drop column --cascade");
assert_eq!(
super::build_drop_column_cascade_echo(
&drop_cmd,
EffectiveMode::AdvancedPersistent,
&drop_result,
),
Some(vec![
"DROP INDEX Customers_Email_idx".to_string(),
"ALTER TABLE Customers DROP COLUMN Email".to_string(),
]),
);
// Simple mode → silent.
assert!(
super::build_drop_column_cascade_echo(
&drop_cmd,
EffectiveMode::Simple,
&drop_result,
)
.is_none(),
);
// --- add relationship --create-fk (column newly created) ----
let db = Database::open(":memory:").expect("open in-memory");
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id", Type::Serial)],
vec!["id".to_string()],
None,
)
.await
.expect("create Customers");
// Orders WITHOUT CustId — `--create-fk` will add it.
db.create_table(
"Orders".to_string(),
vec![ColumnSpec::new("id", Type::Serial)],
vec!["id".to_string()],
None,
)
.await
.expect("create Orders");
let add_fk_cmd = Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_column: "id".to_string(),
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
create_fk: true,
};
// Pre-exec lookup: parent PK is `serial` → child type = `int`;
// child column did not exist → newly created.
let pre_lookups =
super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
assert_eq!(
pre_lookups.add_rel_create_fk_new_column_type,
Some(Some(Type::Int)),
"pre-exec captures `serial → int` for the newly-created child column",
);
let parent_desc = db
.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
true,
None,
)
.await
.expect("add --create-fk");
assert_eq!(
super::build_schema_echo(
&add_fk_cmd,
EffectiveMode::AdvancedPersistent,
Some(&parent_desc),
&pre_lookups,
),
Some(vec![
"ALTER TABLE Orders ADD COLUMN CustId int".to_string(),
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE".to_string(),
]),
"multi-line echo fires when the child column was newly created",
);
// --- add relationship --create-fk (column already existed) --
let db = Database::open(":memory:").expect("open in-memory");
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id", Type::Serial)],
vec!["id".to_string()],
None,
)
.await
.expect("create Customers");
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("CustId", Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.expect("create Orders");
let pre_lookups =
super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
assert_eq!(
pre_lookups.add_rel_create_fk_new_column_type,
Some(None),
"pre-exec records the child column already existed → single-line echo",
);
let parent_desc = db
.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
true,
None,
)
.await
.expect("add --create-fk (existing column)");
assert_eq!(
super::build_schema_echo(
&add_fk_cmd,
EffectiveMode::AdvancedPersistent,
Some(&parent_desc),
&pre_lookups,
),
Some(vec![
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE".to_string()
]),
"single-line FK echo when the child column already existed",
);
}
}