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:
+73
-11
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user