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