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
|
/// runs, consumed by `note_ok_summary` (which pushes it beneath
|
||||||
/// `[ok]`), within the same synchronous `update()` call. `None` when
|
/// `[ok]`), within the same synchronous `update()` call. `None` when
|
||||||
/// the command has no echo.
|
/// the command has no echo.
|
||||||
pending_echo: Option<String>,
|
pending_echo: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dialogs that take over keyboard input when active.
|
/// 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
|
// ADR-0038: the DSL → SQL teaching echo, beneath `[ok]`. Set on
|
||||||
// the success event when a DSL-form command ran in an advanced
|
// the success event when a DSL-form command ran in an advanced
|
||||||
// effective mode (ADR-0037); `None` otherwise. De-emphasised.
|
// effective mode (ADR-0037); `None` otherwise. De-emphasised
|
||||||
if let Some(sql) = self.pending_echo.take() {
|
// (styled-runs polish per ADR-0038 §4 still pending). One line
|
||||||
self.note_system(crate::t!("echo.executing_sql", sql = sql));
|
// 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 {
|
app.update(AppEvent::DslSucceeded {
|
||||||
command: cmd.clone(),
|
command: cmd.clone(),
|
||||||
description: None,
|
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 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");
|
let ok_idx = texts.iter().position(|t| t.starts_with("[ok]")).expect("an [ok] line");
|
||||||
@@ -2958,7 +2964,7 @@ mod tests {
|
|||||||
limit: None,
|
limit: None,
|
||||||
},
|
},
|
||||||
data: empty_data(),
|
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");
|
assert_echo_beneath_ok(&app, "SELECT * FROM T");
|
||||||
|
|
||||||
@@ -2974,7 +2980,7 @@ mod tests {
|
|||||||
rows_affected: 1,
|
rows_affected: 1,
|
||||||
data: empty_data(),
|
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");
|
assert_echo_beneath_ok(&app, "UPDATE T SET v = 1");
|
||||||
|
|
||||||
@@ -2990,7 +2996,7 @@ mod tests {
|
|||||||
cascade: Vec::new(),
|
cascade: Vec::new(),
|
||||||
data: empty_data(),
|
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");
|
assert_echo_beneath_ok(&app, "DELETE FROM T");
|
||||||
|
|
||||||
@@ -3010,7 +3016,7 @@ mod tests {
|
|||||||
description: sample_description("T"),
|
description: sample_description("T"),
|
||||||
client_side_notes: Vec::new(),
|
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");
|
assert_echo_beneath_ok(&app, "ALTER TABLE T ADD COLUMN c int");
|
||||||
|
|
||||||
@@ -3026,7 +3032,7 @@ mod tests {
|
|||||||
description: sample_description("T"),
|
description: sample_description("T"),
|
||||||
dropped_indexes: Vec::new(),
|
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");
|
assert_echo_beneath_ok(&app, "ALTER TABLE T DROP COLUMN c");
|
||||||
|
|
||||||
@@ -3043,11 +3049,67 @@ mod tests {
|
|||||||
description: sample_description("T"),
|
description: sample_description("T"),
|
||||||
client_side: None,
|
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");
|
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]
|
#[test]
|
||||||
fn mode_command_switches_persistently() {
|
fn mode_command_switches_persistently() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
+285
-18
@@ -14,6 +14,7 @@
|
|||||||
//! advanced effective mode (ADR-0037).
|
//! advanced effective mode (ADR-0037).
|
||||||
|
|
||||||
use crate::app::EffectiveMode;
|
use crate::app::EffectiveMode;
|
||||||
|
use crate::dsl::ReferentialAction;
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
use crate::dsl::command::{
|
use crate::dsl::command::{
|
||||||
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
|
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`);
|
/// 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).
|
/// that is built post-execution by [`echo_for_query`] (ADR-0038 §4).
|
||||||
#[must_use]
|
#[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() {
|
if mode.is_advanced() {
|
||||||
command_to_sql(command)
|
command_to_sql(command).map(|sql| vec![sql])
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,7 @@ pub fn echo_for_query(
|
|||||||
command: &Command,
|
command: &Command,
|
||||||
mode: EffectiveMode,
|
mode: EffectiveMode,
|
||||||
primary_key: &[String],
|
primary_key: &[String],
|
||||||
) -> Option<String> {
|
) -> Option<Vec<String>> {
|
||||||
if !mode.is_advanced() {
|
if !mode.is_advanced() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -66,7 +67,7 @@ pub fn echo_for_query(
|
|||||||
name,
|
name,
|
||||||
filter,
|
filter,
|
||||||
limit,
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +227,117 @@ fn render_show_data(
|
|||||||
s
|
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
|
/// Append the `NOT NULL` / `UNIQUE` / `DEFAULT` / `CHECK` column-constraint
|
||||||
/// suffix (ADR-0029). The advanced-mode column-constraint grammar is
|
/// suffix (ADR-0029). The advanced-mode column-constraint grammar is
|
||||||
/// order-independent (`Repeated(Choice…)`, ADR-0035 §4a), so this fixed
|
/// order-independent (`Repeated(Choice…)`, ADR-0035 §4a), so this fixed
|
||||||
@@ -718,9 +830,9 @@ mod tests {
|
|||||||
filter: None,
|
filter: None,
|
||||||
limit: None,
|
limit: None,
|
||||||
};
|
};
|
||||||
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
|
let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
|
||||||
assert_eq!(sql, "SELECT * FROM T");
|
assert_eq!(lines.as_slice(), &["SELECT * FROM T"]);
|
||||||
assert!(matches!(reparse(&sql), Ok(Command::Select { .. })));
|
assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -730,9 +842,9 @@ mod tests {
|
|||||||
filter: Some(eq("name", Value::Text("Bob".to_string()))),
|
filter: Some(eq("name", Value::Text("Bob".to_string()))),
|
||||||
limit: None,
|
limit: None,
|
||||||
};
|
};
|
||||||
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
|
let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
|
||||||
assert_eq!(sql, "SELECT * FROM T WHERE name = 'Bob'");
|
assert_eq!(lines.as_slice(), &["SELECT * FROM T WHERE name = 'Bob'"]);
|
||||||
assert!(matches!(reparse(&sql), Ok(Command::Select { .. })));
|
assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -743,9 +855,9 @@ mod tests {
|
|||||||
limit: Some(5),
|
limit: Some(5),
|
||||||
};
|
};
|
||||||
let pk = vec!["id".to_string()];
|
let pk = vec!["id".to_string()];
|
||||||
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &pk).expect("echo");
|
let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &pk).expect("echo");
|
||||||
assert_eq!(sql, "SELECT * FROM T ORDER BY id LIMIT 5");
|
assert_eq!(lines.as_slice(), &["SELECT * FROM T ORDER BY id LIMIT 5"]);
|
||||||
assert!(matches!(reparse(&sql), Ok(Command::Select { .. })));
|
assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -756,9 +868,12 @@ mod tests {
|
|||||||
limit: Some(3),
|
limit: Some(3),
|
||||||
};
|
};
|
||||||
let pk = vec!["a".to_string(), "b".to_string()];
|
let pk = vec!["a".to_string(), "b".to_string()];
|
||||||
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &pk).expect("echo");
|
let lines = 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_eq!(
|
||||||
assert!(matches!(reparse(&sql), Ok(Command::Select { .. })));
|
lines.as_slice(),
|
||||||
|
&["SELECT * FROM T WHERE active = true ORDER BY a, b LIMIT 3"]
|
||||||
|
);
|
||||||
|
assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -769,8 +884,8 @@ mod tests {
|
|||||||
filter: None,
|
filter: None,
|
||||||
limit: Some(2),
|
limit: Some(2),
|
||||||
};
|
};
|
||||||
let sql = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
|
let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
|
||||||
assert_eq!(sql, "SELECT * FROM T LIMIT 2");
|
assert_eq!(lines.as_slice(), &["SELECT * FROM T LIMIT 2"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -792,6 +907,158 @@ mod tests {
|
|||||||
assert!(echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).is_none());
|
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 ------------------------------------
|
// --- expr / literal rendering ------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+7
-7
@@ -32,7 +32,7 @@ pub enum AppEvent {
|
|||||||
/// advanced effective mode (ADR-0037). `None` when no echo applies
|
/// advanced effective mode (ADR-0037). `None` when no echo applies
|
||||||
/// (simple mode, SQL-entered, or a form with no echo). The App
|
/// (simple mode, SQL-entered, or a form with no echo). The App
|
||||||
/// renders it beneath `[ok]`.
|
/// renders it beneath `[ok]`.
|
||||||
echo: Option<String>,
|
echo: Option<Vec<String>>,
|
||||||
},
|
},
|
||||||
/// A SQL `CREATE TABLE IF NOT EXISTS` matched an existing table —
|
/// A SQL `CREATE TABLE IF NOT EXISTS` matched an existing table —
|
||||||
/// a no-op (ADR-0035 §4). Renders the existing structure plus an
|
/// a no-op (ADR-0035 §4). Renders the existing structure plus an
|
||||||
@@ -68,7 +68,7 @@ pub enum AppEvent {
|
|||||||
DslDataSucceeded {
|
DslDataSucceeded {
|
||||||
command: Command,
|
command: Command,
|
||||||
data: DataResult,
|
data: DataResult,
|
||||||
echo: Option<String>,
|
echo: Option<Vec<String>>,
|
||||||
},
|
},
|
||||||
/// An `explain …` command succeeded (ADR-0028). `plan`
|
/// An `explain …` command succeeded (ADR-0028). `plan`
|
||||||
/// carries the captured query plan; nothing was executed.
|
/// 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
|
/// The DSL → SQL teaching echo (ADR-0038): `UPDATE T SET …` for an
|
||||||
/// `update … --all-rows` fall-through. `None` for a SQL-entered
|
/// `update … --all-rows` fall-through. `None` for a SQL-entered
|
||||||
/// `UPDATE` or any simple-mode submission.
|
/// `UPDATE` or any simple-mode submission.
|
||||||
echo: Option<String>,
|
echo: Option<Vec<String>>,
|
||||||
},
|
},
|
||||||
DslDeleteSucceeded {
|
DslDeleteSucceeded {
|
||||||
command: Command,
|
command: Command,
|
||||||
@@ -91,7 +91,7 @@ pub enum AppEvent {
|
|||||||
/// The DSL → SQL teaching echo (ADR-0038): `DELETE FROM T` for a
|
/// The DSL → SQL teaching echo (ADR-0038): `DELETE FROM T` for a
|
||||||
/// `delete … --all-rows` fall-through. `None` for a SQL-entered
|
/// `delete … --all-rows` fall-through. `None` for a SQL-entered
|
||||||
/// `DELETE` or any simple-mode submission.
|
/// `DELETE` or any simple-mode submission.
|
||||||
echo: Option<String>,
|
echo: Option<Vec<String>>,
|
||||||
},
|
},
|
||||||
/// A `change column …` succeeded. `result` carries both the
|
/// A `change column …` succeeded. `result` carries both the
|
||||||
/// post-rebuild description (for the auto-show) and 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
|
/// The DSL → SQL teaching echo (ADR-0038): `ALTER TABLE T ALTER
|
||||||
/// COLUMN c SET DATA TYPE …`. `None` in simple mode. (The
|
/// COLUMN c SET DATA TYPE …`. `None` in simple mode. (The
|
||||||
/// `--dont-convert` caveat line is category-3, a later slice.)
|
/// `--dont-convert` caveat line is category-3, a later slice.)
|
||||||
echo: Option<String>,
|
echo: Option<Vec<String>>,
|
||||||
},
|
},
|
||||||
/// An `add column …` succeeded. `result` carries the
|
/// An `add column …` succeeded. `result` carries the
|
||||||
/// post-add description plus any `[client-side]` notes
|
/// post-add description plus any `[client-side]` notes
|
||||||
@@ -112,7 +112,7 @@ pub enum AppEvent {
|
|||||||
result: AddColumnResult,
|
result: AddColumnResult,
|
||||||
/// The DSL → SQL teaching echo (ADR-0038): `ALTER TABLE T ADD
|
/// The DSL → SQL teaching echo (ADR-0038): `ALTER TABLE T ADD
|
||||||
/// COLUMN c <ty> …`. `None` in simple mode.
|
/// COLUMN c <ty> …`. `None` in simple mode.
|
||||||
echo: Option<String>,
|
echo: Option<Vec<String>>,
|
||||||
},
|
},
|
||||||
/// A `drop column …` succeeded. `result` carries the
|
/// A `drop column …` succeeded. `result` carries the
|
||||||
/// post-drop description plus the names of any indexes
|
/// 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
|
/// The DSL → SQL teaching echo (ADR-0038): `ALTER TABLE T DROP
|
||||||
/// COLUMN c` for a plain (non-`--cascade`) drop. `None` in simple
|
/// COLUMN c` for a plain (non-`--cascade`) drop. `None` in simple
|
||||||
/// mode, and for `--cascade` (a multi-statement echo, Phase 2).
|
/// 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
|
/// A DSL command failed. `error` is the structured
|
||||||
/// payload, `facts` is the runtime-built schema-resolved
|
/// payload, `facts` is the runtime-built schema-resolved
|
||||||
|
|||||||
+743
-13
@@ -33,7 +33,9 @@ use crate::db::{
|
|||||||
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
|
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
|
||||||
QueryPlan, TableDescription, UpdateResult,
|
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::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec};
|
||||||
use crate::dsl::walker::Severity;
|
use crate::dsl::walker::Severity;
|
||||||
use crate::event::AppEvent;
|
use crate::event::AppEvent;
|
||||||
@@ -1265,17 +1267,36 @@ fn spawn_dsl_dispatch(
|
|||||||
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
|
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
|
||||||
// command submitted in an advanced effective mode (ADR-0037).
|
// command submitted in an advanced effective mode (ADR-0037).
|
||||||
// `replay` bypasses this spawn (it calls `execute_command_typed`
|
// `replay` bypasses this spawn (it calls `execute_command_typed`
|
||||||
// directly), so replayed lines never echo. Built before execution
|
// directly), so replayed lines never echo.
|
||||||
// from the Command; resolved-name / category-3 forms (Bucket B/§6,
|
//
|
||||||
// later slices) will additionally read the execution result.
|
// 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 echo = crate::echo::echo_for(&command, submission_mode);
|
||||||
let outcome = execute_command_typed(&database, command.clone(), source).await;
|
let outcome = execute_command_typed(&database, command.clone(), source).await;
|
||||||
let event = match outcome {
|
let event = match outcome {
|
||||||
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
|
Ok(CommandOutcome::Schema(description)) => {
|
||||||
|
let schema_echo = build_schema_echo(
|
||||||
|
&command,
|
||||||
|
submission_mode,
|
||||||
|
description.as_ref(),
|
||||||
|
&lookups,
|
||||||
|
);
|
||||||
|
AppEvent::DslSucceeded {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
description,
|
description,
|
||||||
echo,
|
echo: schema_echo,
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Ok(CommandOutcome::SchemaSkipped(description)) => AppEvent::DslCreateSkipped {
|
Ok(CommandOutcome::SchemaSkipped(description)) => AppEvent::DslCreateSkipped {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
description,
|
description,
|
||||||
@@ -1333,11 +1354,23 @@ fn spawn_dsl_dispatch(
|
|||||||
result,
|
result,
|
||||||
echo,
|
echo,
|
||||||
},
|
},
|
||||||
Ok(CommandOutcome::DropColumn(result)) => AppEvent::DslDropColumnSucceeded {
|
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(),
|
command: command.clone(),
|
||||||
result,
|
result,
|
||||||
echo,
|
echo: cascade_echo.or(echo),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Err(DbError::PersistenceFatal {
|
Err(DbError::PersistenceFatal {
|
||||||
operation,
|
operation,
|
||||||
path,
|
path,
|
||||||
@@ -1396,7 +1429,7 @@ async fn build_show_data_echo(
|
|||||||
database: &Database,
|
database: &Database,
|
||||||
command: &Command,
|
command: &Command,
|
||||||
submission_mode: crate::app::EffectiveMode,
|
submission_mode: crate::app::EffectiveMode,
|
||||||
) -> Option<String> {
|
) -> Option<Vec<String>> {
|
||||||
if !submission_mode.is_advanced() {
|
if !submission_mode.is_advanced() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1423,6 +1456,316 @@ async fn build_show_data_echo(
|
|||||||
crate::echo::echo_for_query(command, submission_mode, &primary_key)
|
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).
|
/// Build schema-resolved enrichment for a DSL failure (ADR-0019 §6).
|
||||||
///
|
///
|
||||||
/// Best-effort: every lookup is independently fallible and a
|
/// Best-effort: every lookup is independently fallible and a
|
||||||
@@ -2612,7 +2955,7 @@ mod tests {
|
|||||||
// Limited → ORDER BY the resolved primary key.
|
// Limited → ORDER BY the resolved primary key.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
super::build_show_data_echo(&db, &limited, EffectiveMode::AdvancedPersistent).await,
|
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.
|
// Simple mode → silent, gated before any lookup.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2627,7 +2970,394 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
super::build_show_data_echo(&db, &unlimited, EffectiveMode::AdvancedPersistent).await,
|
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