From 275c726ad43e44c25c20fc6bf3d473037ea0f952 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 28 May 2026 07:54:05 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20DSL=E2=86=92SQL=20teaching=20echo=20?= =?UTF-8?q?=E2=80=94=20Bucket=20B=20renderer=20(ADR-0038=20Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ON T (cols) drop index on T (cols) (positional) → DROP INDEX add 1:n relationship [as N] … → ALTER TABLE C ADD CONSTRAINT FOREIGN KEY (cc) REFERENCES P (pc) [ON …] drop relationship (endpoints or named) → ALTER TABLE C DROP CONSTRAINT drop column T.c --cascade → DROP INDEX ⏎ … ⏎ ALTER TABLE T DROP COLUMN c add relationship … --create-fk → ALTER TABLE C ADD COLUMN cc (child column newly created) ⏎ ALTER TABLE … ADD CONSTRAINT (already existed) collapses to a single-line FK echo Refactors the echo payload from Option to Option> 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. --- src/app.rs | 84 +++++- src/echo.rs | 303 ++++++++++++++++++-- src/event.rs | 14 +- src/runtime.rs | 764 +++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 1112 insertions(+), 53 deletions(-) diff --git a/src/app.rs b/src/app.rs index d231b55..6590e84 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, + pending_echo: Option>, } /// 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 ` 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(); diff --git a/src/echo.rs b/src/echo.rs index 4a4e40c..6dc6ec1 100644 --- a/src/echo.rs +++ b/src/echo.rs @@ -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 { +pub fn echo_for(command: &Command, mode: EffectiveMode) -> Option> { 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 { +) -> Option> { 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 ON (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 `
__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 ` — 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 ADD CONSTRAINT FOREIGN KEY () REFERENCES +///

() [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 DROP CONSTRAINT ` — 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 ` 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 { + let mut lines: Vec = 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 { + 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] diff --git a/src/event.rs b/src/event.rs index 963c8de..499c77a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -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, + echo: Option>, }, /// 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, + echo: Option>, }, /// 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, + echo: Option>, }, 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, + echo: Option>, }, /// 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, + echo: Option>, }, /// 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 …`. `None` in simple mode. - echo: Option, + echo: Option>, }, /// 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, + echo: Option>, }, /// A DSL command failed. `error` is the structured /// payload, `facts` is the runtime-built schema-resolved diff --git a/src/runtime.rs b/src/runtime.rs index 15e5065..cf6541a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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 { +) -> Option> { 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 ` is `Command::SqlDropIndex` and + /// is already SQL (no echo). + drop_index_name: Option, + /// 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>, +} + +/// 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> { + 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` to fit the + // multi-line `Option>` 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> { + 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", ); } }