feat: DSL→SQL teaching echo — Bucket B renderer (ADR-0038 Phase 2)

Expands the renderer to Bucket B — resolved-name single-statement
echoes plus the two category-2 multi-statement forms. Every catalogue
row round-trips per line through the advanced-mode walker (the §1
copy-paste contract; §6 category 2 holds the contract per line):

  add index [as N] on T (cols)           → CREATE INDEX <name> ON T (cols)
  drop index on T (cols) (positional)    → DROP INDEX <name>
  add 1:n relationship [as N] …          → ALTER TABLE C ADD CONSTRAINT
                                            <name> FOREIGN KEY (cc)
                                            REFERENCES P (pc) [ON …]
  drop relationship (endpoints or named) → ALTER TABLE C DROP CONSTRAINT
                                            <name>
  drop column T.c --cascade              → DROP INDEX <ix1> ⏎ … ⏎
                                            ALTER TABLE T DROP COLUMN c
  add relationship … --create-fk         → ALTER TABLE C ADD COLUMN cc <ty>
   (child column newly created)            ⏎ ALTER TABLE … ADD CONSTRAINT
   (already existed) collapses to a single-line FK echo

Refactors the echo payload from Option<String> to Option<Vec<String>>
across the 7 success events + arms + render path — one entry per
statement; the Bucket A single-line echoes wrap as Some(vec![s]). Plain
rendering repeats `Executing SQL:` per line; the de-emphasised
styled-runs polish (ADR-0038 §4) will refine it later.

Adds the two echo build paths the handoff §5 ⚠️ gotcha foreshadowed:

* collect_echo_lookups (pre-execution, runtime): resolves names the
  dropped thing or not-yet-created column would erase post-execution —
  drop index (positional), drop relationship (both endpoints and named
  selectors, the latter via a list_tables scan acceptable for teaching-
  playground schemas), and the --create-fk pre-state (whether the child
  column existed + the parent PK type to derive the new column type via
  Type::fk_target_type).
* build_schema_echo (post-execution, runtime): subsumes the Bucket A
  pure-Command schema cases and renders Bucket B from the description +
  the lookups.

The DropColumn arm gains build_drop_column_cascade_echo, which reads
DropColumnResult.dropped_indexes to emit the multi-line cascade echo;
non-cascade falls through to the pre-execution Bucket A echo unchanged.

Tests: 2013 passed / 0 failed / 1 ignored (pre-existing); clippy clean
(`--all-targets -D warnings`, nursery). Two end-to-end runtime tests
exercise the resolved-name and multi-statement flows against a real
worker (auto-named index, both drop-relationship selector forms, both
--create-fk branches). One app-level test pins the multi-line rendering
(one Executing SQL: per statement, in order, beneath [ok]).

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