style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock `cargo fmt` defaults so a `cargo fmt --check` CI gate can follow. Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline), clippy clean. A .git-blame-ignore-revs entry follows so `git blame` skips this commit.
This commit is contained in:
@@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
@@ -78,7 +77,10 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() {
|
||||
let r = rt();
|
||||
r.block_on(db.create_table(
|
||||
"Items".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("qty", Type::Int)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Int),
|
||||
ColumnSpec::new("qty", Type::Int),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
Some("create".to_string()),
|
||||
))
|
||||
@@ -129,7 +131,11 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() {
|
||||
.block_on(db.query_data("Items".to_string(), None, None))
|
||||
.expect("query")
|
||||
.rows;
|
||||
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)");
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
1,
|
||||
"the wrong-case insert survived the rebuild (no data loss)"
|
||||
);
|
||||
assert_eq!(rows[0][1].as_deref(), Some("kept"));
|
||||
}
|
||||
|
||||
@@ -146,9 +152,19 @@ fn add_column_with_case_variant_table_survives_rebuild() {
|
||||
);
|
||||
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe");
|
||||
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added");
|
||||
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild");
|
||||
let desc = r
|
||||
.block_on(db.describe_table("Items".to_string()))
|
||||
.expect("describe");
|
||||
let qty = desc
|
||||
.columns
|
||||
.iter()
|
||||
.find(|c| c.name == "qty")
|
||||
.expect("qty added");
|
||||
assert_eq!(
|
||||
qty.user_type,
|
||||
Some(Type::Int),
|
||||
"qty's user-type survived the rebuild"
|
||||
);
|
||||
// The CHECK is intact too (a negative qty is refused under the real table).
|
||||
assert!(
|
||||
r.block_on(db.insert(
|
||||
@@ -175,9 +191,15 @@ fn drop_table_with_case_variant_name_clears_table_and_csv() {
|
||||
insert into Items (id, note) values (1, 'x')\n\
|
||||
drop table items\n",
|
||||
);
|
||||
assert!(!tables(&db, &r).contains(&"Items".to_string()), "the table was dropped");
|
||||
assert!(
|
||||
!tables(&db, &r).contains(&"Items".to_string()),
|
||||
"the table was dropped"
|
||||
);
|
||||
let csv = project.path().join(project::DATA_DIR).join("Items.csv");
|
||||
assert!(!csv.exists(), "the CSV was removed despite the case-variant drop");
|
||||
assert!(
|
||||
!csv.exists(),
|
||||
"the CSV was removed despite the case-variant drop"
|
||||
);
|
||||
|
||||
// A fresh rebuild yields no Items (the metadata/yaml has no orphan).
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
@@ -224,12 +246,24 @@ fn add_relationship_with_case_variant_tables_survives_rebuild() {
|
||||
add 1:n relationship from parent.id to child.parent_id\n",
|
||||
);
|
||||
// The parent's inbound relationship is visible under the stored case.
|
||||
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
|
||||
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case");
|
||||
let p = r
|
||||
.block_on(db.describe_table("Parent".to_string()))
|
||||
.expect("describe Parent");
|
||||
assert_eq!(
|
||||
p.inbound_relationships.len(),
|
||||
1,
|
||||
"relationship recorded under the stored case"
|
||||
);
|
||||
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
||||
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
|
||||
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild");
|
||||
let p = r
|
||||
.block_on(db.describe_table("Parent".to_string()))
|
||||
.expect("describe Parent");
|
||||
assert_eq!(
|
||||
p.inbound_relationships.len(),
|
||||
1,
|
||||
"relationship survived the rebuild"
|
||||
);
|
||||
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, true)
|
||||
.expect("open db with persistence");
|
||||
@@ -48,7 +47,9 @@ fn make_t_with_check(db: &Database, r: &tokio::runtime::Runtime) {
|
||||
vec!["a < b".to_string()],
|
||||
vec![],
|
||||
false,
|
||||
Some("create table T (id int primary key, a int, b int, c text, check (a < b))".to_string()),
|
||||
Some(
|
||||
"create table T (id int primary key, a int, b int, c text, check (a < b))".to_string(),
|
||||
),
|
||||
))
|
||||
.expect("create T with table CHECK");
|
||||
}
|
||||
@@ -285,7 +286,10 @@ fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name()
|
||||
.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
|
||||
.expect_err("dropping a composite-UNIQUE column is refused");
|
||||
let msg = err.friendly_message();
|
||||
assert!(msg.contains("unique_a_b"), "names the derived constraint; got: {msg}");
|
||||
assert!(
|
||||
msg.contains("unique_a_b"),
|
||||
"names the derived constraint; got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("drop constraint unique_a_b"),
|
||||
"points at the actionable drop command; got: {msg}"
|
||||
@@ -351,14 +355,24 @@ fn rename_column_with_a_column_level_check_is_refused() {
|
||||
make_t_with_column_checks(&db, &r);
|
||||
// `qty`'s own check `qty >= 0` references qty → refused.
|
||||
assert!(
|
||||
r.block_on(db.rename_column("T".to_string(), "qty".to_string(), "amount".to_string(), None))
|
||||
.is_err(),
|
||||
r.block_on(db.rename_column(
|
||||
"T".to_string(),
|
||||
"qty".to_string(),
|
||||
"amount".to_string(),
|
||||
None
|
||||
))
|
||||
.is_err(),
|
||||
"renaming a column with its own column-level CHECK is refused"
|
||||
);
|
||||
// `price` is referenced by `discount`'s check `discount < price`.
|
||||
assert!(
|
||||
r.block_on(db.rename_column("T".to_string(), "price".to_string(), "cost".to_string(), None))
|
||||
.is_err(),
|
||||
r.block_on(db.rename_column(
|
||||
"T".to_string(),
|
||||
"price".to_string(),
|
||||
"cost".to_string(),
|
||||
None
|
||||
))
|
||||
.is_err(),
|
||||
"renaming a column referenced by another column's CHECK is refused"
|
||||
);
|
||||
// `id` is referenced by no CHECK → rename succeeds.
|
||||
|
||||
+51
-28
@@ -9,7 +9,7 @@
|
||||
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{
|
||||
parse_command, ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value,
|
||||
ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value, parse_command,
|
||||
};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project;
|
||||
@@ -18,10 +18,8 @@ use rdbms_playground::project;
|
||||
|
||||
#[test]
|
||||
fn parenthesized_compound_endpoint_parses_to_column_lists() {
|
||||
let cmd = parse_command(
|
||||
"add 1:n relationship from Parent.(a, b) to Child.(x, y)",
|
||||
)
|
||||
.expect("parses");
|
||||
let cmd =
|
||||
parse_command("add 1:n relationship from Parent.(a, b) to Child.(x, y)").expect("parses");
|
||||
match cmd {
|
||||
Command::AddRelationship {
|
||||
parent_table,
|
||||
@@ -41,8 +39,7 @@ fn parenthesized_compound_endpoint_parses_to_column_lists() {
|
||||
|
||||
#[test]
|
||||
fn single_column_endpoint_still_parses_unparenthesized() {
|
||||
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid")
|
||||
.expect("parses");
|
||||
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid").expect("parses");
|
||||
match cmd {
|
||||
Command::AddRelationship {
|
||||
parent_columns,
|
||||
@@ -148,7 +145,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
|
||||
db.insert(
|
||||
"Region".to_string(),
|
||||
Some(vec!["country".to_string(), "code".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("10".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -157,7 +157,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
|
||||
.insert(
|
||||
"City".to_string(),
|
||||
Some(vec!["country".to_string(), "region_code".to_string()]),
|
||||
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
|
||||
vec![
|
||||
Value::Number("9".to_string()),
|
||||
Value::Number("9".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
@@ -176,8 +179,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -241,7 +243,10 @@ fn compound_fk_declares_enforces_and_round_trips() {
|
||||
db.insert(
|
||||
"Region".to_string(),
|
||||
Some(vec!["country".to_string(), "code".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("10".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -253,7 +258,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
|
||||
"region_code".to_string(),
|
||||
"name".to_string(),
|
||||
]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("10".to_string()), Value::Text("Metropolis".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("10".to_string()),
|
||||
Value::Text("Metropolis".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -266,7 +275,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
|
||||
"region_code".to_string(),
|
||||
"name".to_string(),
|
||||
]),
|
||||
vec![Value::Number("9".to_string()), Value::Number("9".to_string()), Value::Text("Nowhere".to_string())],
|
||||
vec![
|
||||
Value::Number("9".to_string()),
|
||||
Value::Number("9".to_string()),
|
||||
Value::Text("Nowhere".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
@@ -360,7 +373,10 @@ fn compound_fk_arity_mismatch_is_refused() {
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(err.is_err(), "mismatched child/parent arity must be refused");
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"mismatched child/parent arity must be refused"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -386,10 +402,8 @@ fn inline_fk_referencing_compound_pk_points_at_table_level_form() {
|
||||
.expect("create Region");
|
||||
|
||||
// Parse the inline form so the `inline` flag is set by the grammar.
|
||||
let cmd = parse_command(
|
||||
"create table City (country int references Region(country, code))",
|
||||
)
|
||||
.expect("parses");
|
||||
let cmd = parse_command("create table City (country int references Region(country, code))")
|
||||
.expect("parses");
|
||||
let Command::SqlCreateTable {
|
||||
name,
|
||||
columns,
|
||||
@@ -465,7 +479,10 @@ fn compound_fk_type_mismatch_per_pair_is_refused() {
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(err.is_err(), "a type-incompatible column pair must be refused");
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"a type-incompatible column pair must be refused"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -478,11 +495,8 @@ fn compound_fk_survives_rebuild_from_text() {
|
||||
let path = project.path().to_path_buf();
|
||||
let rt = rt();
|
||||
{
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.expect("open db");
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.expect("open db");
|
||||
rt.block_on(async {
|
||||
seed_compound(&db).await;
|
||||
db.add_relationship(
|
||||
@@ -512,7 +526,10 @@ fn compound_fk_survives_rebuild_from_text() {
|
||||
db.insert(
|
||||
"Region".to_string(),
|
||||
Some(vec!["country".to_string(), "code".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("10".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -521,11 +538,17 @@ fn compound_fk_survives_rebuild_from_text() {
|
||||
.insert(
|
||||
"City".to_string(),
|
||||
Some(vec!["country".to_string(), "region_code".to_string()]),
|
||||
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
|
||||
vec![
|
||||
Value::Number("9".to_string()),
|
||||
Value::Number("9".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
|
||||
assert!(
|
||||
bad.is_err(),
|
||||
"compound FK still enforced after rebuild from text"
|
||||
);
|
||||
// Endpoints survived the round-trip intact.
|
||||
let city = db.describe_table("City".to_string()).await.unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -32,10 +32,8 @@ use rdbms_playground::event::AppEvent;
|
||||
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
// Product names.
|
||||
"SQLite", "sqlite",
|
||||
// Crate name.
|
||||
"rusqlite",
|
||||
// Engine-specific keywords / idioms.
|
||||
"SQLite", "sqlite", // Crate name.
|
||||
"rusqlite", // Engine-specific keywords / idioms.
|
||||
"STRICT", "PRAGMA",
|
||||
];
|
||||
|
||||
@@ -52,9 +50,7 @@ fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> {
|
||||
|
||||
fn assert_clean(label: &str, s: &str) {
|
||||
if let Some((needle, pos)) = engine_vocab_leak(s) {
|
||||
panic!(
|
||||
"ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}"
|
||||
);
|
||||
panic!("ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,8 +114,7 @@ fn parse_errors_use_no_engine_vocabulary() {
|
||||
"this is not a command",
|
||||
];
|
||||
for input in inputs {
|
||||
let err = parse_command(input)
|
||||
.expect_err(&format!("expected parse failure for `{input}`"));
|
||||
let err = parse_command(input).expect_err(&format!("expected parse failure for `{input}`"));
|
||||
let rendered = format!("{err:?}");
|
||||
assert_clean(&format!("parse error for `{input}`"), &rendered);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use rdbms_playground::db::{Database, DbError, SqliteErrorKind};
|
||||
use rdbms_playground::dsl::{
|
||||
action::ReferentialAction, ColumnSpec, Command, RowFilter, Type, Value,
|
||||
};
|
||||
use rdbms_playground::dsl::parser::parse_command;
|
||||
use rdbms_playground::dsl::{
|
||||
ColumnSpec, Command, RowFilter, Type, Value, action::ReferentialAction,
|
||||
};
|
||||
use rdbms_playground::runtime::enrich_dsl_failure;
|
||||
|
||||
fn rt() -> Runtime {
|
||||
@@ -57,7 +57,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
|
||||
vec![
|
||||
Value::Number("5".to_string()),
|
||||
Value::Text("Alice".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -86,7 +89,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
|
||||
DbError::Sqlite {
|
||||
kind: SqliteErrorKind::UniqueViolation,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
@@ -169,7 +175,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
|
||||
vec![
|
||||
Value::Number("5".to_string()),
|
||||
Value::Text("Alice".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -189,7 +198,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
|
||||
else {
|
||||
panic!("expected Command::SqlInsert, got {cmd:?}");
|
||||
};
|
||||
assert!(listed_columns.is_empty(), "natural-order form has no column list");
|
||||
assert!(
|
||||
listed_columns.is_empty(),
|
||||
"natural-order form has no column list"
|
||||
);
|
||||
let err = db
|
||||
.run_sql_insert_with_literals(
|
||||
sql,
|
||||
@@ -204,7 +216,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
|
||||
DbError::Sqlite {
|
||||
kind: SqliteErrorKind::UniqueViolation,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
@@ -235,7 +250,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Text("Alice".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -243,7 +261,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
|
||||
vec![
|
||||
Value::Number("2".to_string()),
|
||||
Value::Text("Bob".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -294,7 +315,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Text("Alice".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -302,7 +326,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
|
||||
vec![
|
||||
Value::Number("2".to_string()),
|
||||
Value::Text("Bob".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -328,7 +355,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
|
||||
DbError::Sqlite {
|
||||
kind: SqliteErrorKind::UniqueViolation,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
@@ -666,7 +696,10 @@ fn enrich_fk_delete_resolves_child_table() {
|
||||
db.insert(
|
||||
"Orders".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("1".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -708,16 +741,15 @@ fn enrich_check_insert_resolves_table_column_value_and_rule() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let score_spec = match parse_command(
|
||||
"create table __probe with pk score(int) check (score >= 0)",
|
||||
)
|
||||
.expect("probe create parses")
|
||||
{
|
||||
Command::CreateTable { columns, .. } => {
|
||||
columns.into_iter().next().expect("one column")
|
||||
}
|
||||
other => panic!("expected CreateTable, got {other:?}"),
|
||||
};
|
||||
let score_spec =
|
||||
match parse_command("create table __probe with pk score(int) check (score >= 0)")
|
||||
.expect("probe create parses")
|
||||
{
|
||||
Command::CreateTable { columns, .. } => {
|
||||
columns.into_iter().next().expect("one column")
|
||||
}
|
||||
other => panic!("expected CreateTable, got {other:?}"),
|
||||
};
|
||||
db.add_column("Scores".to_string(), score_spec, None)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -757,7 +789,9 @@ fn enrich_unsupported_returns_default_facts() {
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
let err = DbError::Unsupported("nope".to_string());
|
||||
let cmd = Command::DropTable { name: "X".to_string() };
|
||||
let cmd = Command::DropTable {
|
||||
name: "X".to_string(),
|
||||
};
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert!(facts.table.is_none());
|
||||
assert!(facts.column.is_none());
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
use rdbms_playground::app::App;
|
||||
use rdbms_playground::dsl::{parse_command, AppCommand, Command};
|
||||
use rdbms_playground::dsl::{AppCommand, Command, parse_command};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
|
||||
const fn key(code: KeyCode) -> AppEvent {
|
||||
|
||||
@@ -14,9 +14,7 @@ use std::path::Path;
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{
|
||||
self, DATA_DIR, PROJECT_YAML,
|
||||
};
|
||||
use rdbms_playground::project::{self, DATA_DIR, PROJECT_YAML};
|
||||
|
||||
fn tempdir() -> tempfile::TempDir {
|
||||
tempfile::tempdir().expect("create tempdir")
|
||||
@@ -33,9 +31,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
/// `Database` (with persistence wired) plus the path so the
|
||||
/// test can inspect on-disk state. The project is held alive
|
||||
/// implicitly via the leaked `TempDir` returned alongside.
|
||||
fn open_project(
|
||||
data: &tempfile::TempDir,
|
||||
) -> (project::Project, Database, std::path::PathBuf) {
|
||||
fn open_project(data: &tempfile::TempDir) -> (project::Project, Database, std::path::PathBuf) {
|
||||
let project = project::open_or_create(None, Some(data.path())).expect("open project");
|
||||
let path = project.path().to_path_buf();
|
||||
let persistence = Persistence::new(path.clone());
|
||||
@@ -72,7 +68,10 @@ fn create_table_writes_yaml_and_history() {
|
||||
});
|
||||
|
||||
let yaml = read_yaml(&path);
|
||||
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
|
||||
assert!(
|
||||
yaml.contains("- name: Customers"),
|
||||
"yaml missing table:\n{yaml}"
|
||||
);
|
||||
assert!(yaml.contains("primary_key: [id]"), "yaml: {yaml}");
|
||||
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
|
||||
assert!(yaml.contains("type: text"), "yaml: {yaml}");
|
||||
@@ -151,9 +150,15 @@ fn drop_table_removes_its_csv() {
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
assert!(read_csv(&path, "Customers").is_none(), "CSV should be deleted");
|
||||
assert!(
|
||||
read_csv(&path, "Customers").is_none(),
|
||||
"CSV should be deleted"
|
||||
);
|
||||
let yaml = read_yaml(&path);
|
||||
assert!(!yaml.contains("- name: Customers"), "table should be gone from yaml:\n{yaml}");
|
||||
assert!(
|
||||
!yaml.contains("- name: Customers"),
|
||||
"table should be gone from yaml:\n{yaml}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -263,7 +268,10 @@ fn create_table_does_not_write_csv_for_empty_table() {
|
||||
|
||||
// Schema landed in YAML.
|
||||
let yaml = read_yaml(&path);
|
||||
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
|
||||
assert!(
|
||||
yaml.contains("- name: Customers"),
|
||||
"yaml missing table:\n{yaml}"
|
||||
);
|
||||
// ...but no CSV until there's data.
|
||||
assert!(
|
||||
read_csv(&path, "Customers").is_none(),
|
||||
@@ -394,7 +402,10 @@ fn project_yaml_carries_relationship_after_add() {
|
||||
});
|
||||
|
||||
let yaml = read_yaml(&path);
|
||||
assert!(yaml.contains("- name: Customers_id_to_Orders_CustId"), "yaml: {yaml}");
|
||||
assert!(
|
||||
yaml.contains("- name: Customers_id_to_Orders_CustId"),
|
||||
"yaml: {yaml}"
|
||||
);
|
||||
assert!(yaml.contains("on_delete: cascade"), "yaml: {yaml}");
|
||||
assert!(yaml.contains("on_update: no_action"), "yaml: {yaml}");
|
||||
}
|
||||
|
||||
@@ -35,11 +35,8 @@ fn rebuild_restores_schema_only_project() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
@@ -89,11 +86,8 @@ fn rebuild_restores_rows_from_csv() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
@@ -157,11 +151,8 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
@@ -244,7 +235,11 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
|
||||
})
|
||||
.expect("delete");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
assert_eq!(result.cascade.len(), 1, "expected one cascade entry: {result:?}");
|
||||
assert_eq!(
|
||||
result.cascade.len(),
|
||||
1,
|
||||
"expected one cascade entry: {result:?}"
|
||||
);
|
||||
assert_eq!(result.cascade[0].child_table, "Orders");
|
||||
}
|
||||
|
||||
@@ -256,11 +251,8 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Numbers".to_string(),
|
||||
@@ -303,13 +295,17 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
|
||||
.unwrap();
|
||||
let err = rt()
|
||||
.block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None).await
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
})
|
||||
.expect_err("must fail with row-level error");
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("row 2"), "msg should name the row: {msg}");
|
||||
assert!(msg.contains("Numbers"), "msg should name the table: {msg}");
|
||||
assert!(msg.contains("integer"), "msg should explain the type mismatch: {msg}");
|
||||
assert!(
|
||||
msg.contains("integer"),
|
||||
"msg should explain the type mismatch: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -318,11 +314,8 @@ fn rebuild_preserves_created_at_from_yaml() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"T".to_string(),
|
||||
@@ -371,9 +364,7 @@ fn rebuild_preserves_created_at_from_yaml() {
|
||||
// Trigger any successful command so project.yaml is
|
||||
// rewritten from the now-rebuilt db state.
|
||||
rt().block_on(async {
|
||||
db.describe_table("T".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
db.describe_table("T".to_string()).await.unwrap();
|
||||
// describe is read-only; force a rewrite by adding a column.
|
||||
db.add_column(
|
||||
"T".to_string(),
|
||||
@@ -400,11 +391,8 @@ fn rebuild_restores_indexes() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
@@ -434,7 +422,10 @@ fn rebuild_restores_indexes() {
|
||||
// The index must be recorded in project.yaml — the `.db` is
|
||||
// a derived artifact and gets discarded next.
|
||||
let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap();
|
||||
assert!(yaml.contains("idx_email"), "yaml should record the index:\n{yaml}");
|
||||
assert!(
|
||||
yaml.contains("idx_email"),
|
||||
"yaml should record the index:\n{yaml}"
|
||||
);
|
||||
|
||||
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
|
||||
|
||||
@@ -113,7 +113,10 @@ fn modal_swallows_unrelated_keys() {
|
||||
// field while the modal is up.
|
||||
app.update(key(KeyCode::Char('x')));
|
||||
assert!(app.input.is_empty(), "modal should swallow key input");
|
||||
assert!(app.modal.is_some(), "modal still active after unrelated key");
|
||||
assert!(
|
||||
app.modal.is_some(),
|
||||
"modal still active after unrelated key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,11 +125,8 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
@@ -156,7 +156,9 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
|
||||
// Hand-edit the CSV to introduce a different row content.
|
||||
// Rebuild should pick up the edited content.
|
||||
let csv_path = project_path.join("data").join("Customers.csv");
|
||||
let edited = fs::read_to_string(&csv_path).unwrap().replace("Alice", "Edna");
|
||||
let edited = fs::read_to_string(&csv_path)
|
||||
.unwrap()
|
||||
.replace("Alice", "Edna");
|
||||
fs::write(&csv_path, edited).unwrap();
|
||||
|
||||
// Reopen with persistence (the .db still exists but has
|
||||
|
||||
@@ -16,9 +16,9 @@ use rdbms_playground::app::{
|
||||
App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal,
|
||||
PathEntryPurpose,
|
||||
};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{ColumnSpec, Type};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{
|
||||
self, Project, ProjectKind, copy_project, safely_delete_temp_project,
|
||||
@@ -462,11 +462,8 @@ fn temp_with_a_table_is_no_longer_unmodified() {
|
||||
let data = tempdir();
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let db =
|
||||
Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())).unwrap();
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
|
||||
@@ -14,8 +14,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use rdbms_playground::action::Action;
|
||||
use rdbms_playground::app::App;
|
||||
use rdbms_playground::archive::{
|
||||
default_export_filename, export_project, extract_into, inspect_zip,
|
||||
next_export_sequence, resolve_import_target,
|
||||
default_export_filename, export_project, extract_into, inspect_zip, next_export_sequence,
|
||||
resolve_import_target,
|
||||
};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML};
|
||||
@@ -295,11 +295,9 @@ fn end_to_end_export_then_import_real_project() {
|
||||
// Build a populated source project.
|
||||
let src_path = {
|
||||
let p = project::Project::create_named(&data.path().join("Source")).unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
p.db_path(),
|
||||
Persistence::new(p.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
let db =
|
||||
Database::open_with_persistence(p.db_path(), Persistence::new(p.path().to_path_buf()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
@@ -362,7 +360,11 @@ fn end_to_end_export_then_import_real_project() {
|
||||
|
||||
// Round-trip: the inserted row is back.
|
||||
let data_view = rt()
|
||||
.block_on(async { imported_db.query_data("Customers".to_string(), None, None).await })
|
||||
.block_on(async {
|
||||
imported_db
|
||||
.query_data("Customers".to_string(), None, None)
|
||||
.await
|
||||
})
|
||||
.expect("query data");
|
||||
assert_eq!(data_view.rows.len(), 1);
|
||||
// Serial id auto-filled to 1; Name was the inserted value.
|
||||
|
||||
@@ -166,7 +166,8 @@ fn hydration_reads_both_ok_and_err_records() {
|
||||
let project = Project::create_temp(tmp.path()).unwrap();
|
||||
let p = Persistence::new(project.path().to_path_buf());
|
||||
p.append_history("create table A with pk", false).unwrap();
|
||||
p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
|
||||
p.append_history_failure("insert into A (1, 2, 3)", false)
|
||||
.unwrap();
|
||||
p.append_history("show data A", false).unwrap();
|
||||
let entries = p.read_recent_history(10).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
+165
-45
@@ -8,7 +8,7 @@
|
||||
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::command::RowFilter;
|
||||
use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{self, PLAYGROUND_DB};
|
||||
|
||||
@@ -22,8 +22,11 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("project");
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
|
||||
.expect("db");
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.expect("db");
|
||||
(project, db, dir)
|
||||
}
|
||||
|
||||
@@ -45,7 +48,10 @@ fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
|
||||
async fn serial_pk_table(db: &Database, name: &str) {
|
||||
db.create_table(
|
||||
name.to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
ColumnSpec::new("label", Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
@@ -84,7 +90,9 @@ fn parses_with_as_name() {
|
||||
match parse_command("create m:n relationship from Students to Courses as Enrollments")
|
||||
.expect("parses")
|
||||
{
|
||||
Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")),
|
||||
Command::CreateM2nRelationship { name, .. } => {
|
||||
assert_eq!(name.as_deref(), Some("Enrollments"))
|
||||
}
|
||||
other => panic!("expected CreateM2nRelationship, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -104,12 +112,21 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
||||
|
||||
// Auto-named `Students_Courses` exists.
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
|
||||
assert!(
|
||||
tables.contains(&"Students_Courses".to_string()),
|
||||
"tables: {tables:?}"
|
||||
);
|
||||
|
||||
// Two FK columns, both part of the compound PK.
|
||||
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
|
||||
let cols: Vec<(&str, bool)> =
|
||||
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
|
||||
let desc = db
|
||||
.describe_table("Students_Courses".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let cols: Vec<(&str, bool)> = desc
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| (c.name.as_str(), c.primary_key))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
cols,
|
||||
vec![("Students_id", true), ("Courses_id", true)],
|
||||
@@ -124,7 +141,10 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
||||
db.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("1".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -134,21 +154,33 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
||||
.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("1".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused");
|
||||
assert!(
|
||||
dup.is_err(),
|
||||
"duplicate (Students_id, Courses_id) must be refused"
|
||||
);
|
||||
// A link to a non-existent parent is refused by the FK.
|
||||
let orphan = db
|
||||
.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("99".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("99".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(orphan.is_err(), "link to a non-existent Course must be refused");
|
||||
assert!(
|
||||
orphan.is_err(),
|
||||
"link to a non-existent Course must be refused"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,7 +199,10 @@ fn as_name_overrides_the_junction_table_name() {
|
||||
.await
|
||||
.expect("create m:n as Enrollments");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
||||
assert!(
|
||||
tables.contains(&"Enrollments".to_string()),
|
||||
"tables: {tables:?}"
|
||||
);
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()));
|
||||
});
|
||||
}
|
||||
@@ -179,7 +214,10 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
|
||||
// Sections has a 2-column PK (course_id, term).
|
||||
db.create_table(
|
||||
"Sections".to_string(),
|
||||
vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)],
|
||||
vec![
|
||||
ColumnSpec::new("course_id", Type::Int),
|
||||
ColumnSpec::new("term", Type::Int),
|
||||
],
|
||||
vec!["course_id".to_string(), "term".to_string()],
|
||||
None,
|
||||
)
|
||||
@@ -191,11 +229,20 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
|
||||
.await
|
||||
.expect("create m:n");
|
||||
|
||||
let desc = db.describe_table("Students_Sections".to_string()).await.unwrap();
|
||||
let desc = db
|
||||
.describe_table("Students_Sections".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["Students_id", "Sections_course_id", "Sections_term"]
|
||||
);
|
||||
// All three form the compound PK.
|
||||
assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}");
|
||||
assert!(
|
||||
desc.columns.iter().all(|c| c.primary_key),
|
||||
"all columns are PK: {names:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,16 +260,28 @@ fn deleting_a_parent_cascades_to_the_junction() {
|
||||
db.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("1".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Deleting the student cascades to the junction (ON DELETE CASCADE).
|
||||
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
|
||||
let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap();
|
||||
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
|
||||
db.delete("Students".to_string(), RowFilter::AllRows, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = db
|
||||
.query_data("Students_Courses".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
rows.rows.is_empty(),
|
||||
"junction rows should cascade-delete, got {:?}",
|
||||
rows.rows
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,15 +301,26 @@ fn create_m2n_is_one_undo_step() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string()));
|
||||
assert!(
|
||||
db.list_tables()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains(&"Students_Courses".to_string())
|
||||
);
|
||||
|
||||
// One undo removes the junction table AND both relationships.
|
||||
db.undo().await.unwrap();
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
|
||||
assert!(
|
||||
!tables.contains(&"Students_Courses".to_string()),
|
||||
"undo should remove the junction: {tables:?}"
|
||||
);
|
||||
// The parents' relationships are gone too (the junction held them).
|
||||
let students = db.describe_table("Students".to_string()).await.unwrap();
|
||||
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
|
||||
assert!(
|
||||
students.inbound_relationships.is_empty(),
|
||||
"no leftover relationship after undo"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,7 +335,10 @@ fn self_referential_m2n_is_refused() {
|
||||
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("self m:n must be refused");
|
||||
assert!(format!("{err}").contains("two different tables"), "got: {err}");
|
||||
assert!(
|
||||
format!("{err}").contains("two different tables"),
|
||||
"got: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,11 +348,19 @@ fn missing_parent_table_is_refused() {
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
let err = db
|
||||
.create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None)
|
||||
.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Nonexistent".to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("a missing parent table must be refused");
|
||||
// The standard "no such table" guard (require_canonical_table).
|
||||
assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}");
|
||||
assert!(
|
||||
format!("{err}").to_lowercase().contains("no such table"),
|
||||
"got: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,7 +378,10 @@ fn junction_name_collision_is_refused() {
|
||||
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("a junction-name collision must be refused");
|
||||
assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}");
|
||||
assert!(
|
||||
format!("{err}").to_lowercase().contains("exist"),
|
||||
"got: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,15 +398,26 @@ fn the_junction_can_be_renamed() {
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None)
|
||||
.await
|
||||
.expect("rename the junction");
|
||||
db.rename_table(
|
||||
"Students_Courses".to_string(),
|
||||
"Enrollments".to_string(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("rename the junction");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
||||
assert!(
|
||||
tables.contains(&"Enrollments".to_string()),
|
||||
"tables: {tables:?}"
|
||||
);
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()));
|
||||
// Both relationships survive the rename (rebuild-preserving).
|
||||
let desc = db.describe_table("Enrollments".to_string()).await.unwrap();
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
|
||||
assert_eq!(
|
||||
desc.outbound_relationships.len(),
|
||||
2,
|
||||
"FKs preserved across rename"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,16 +450,33 @@ fn junction_survives_save_and_rebuild() {
|
||||
// Discard the derived .db so the next open rebuilds from text.
|
||||
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db =
|
||||
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
|
||||
.unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
.expect("rebuild");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
|
||||
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
|
||||
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
|
||||
assert!(
|
||||
tables.contains(&"Students_Courses".to_string()),
|
||||
"junction survived: {tables:?}"
|
||||
);
|
||||
let desc = db
|
||||
.describe_table("Students_Courses".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
desc.outbound_relationships.len(),
|
||||
2,
|
||||
"both FKs reconstructed"
|
||||
);
|
||||
assert!(
|
||||
desc.columns.iter().all(|c| c.primary_key),
|
||||
"compound PK reconstructed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -387,7 +499,12 @@ fn as_an_internal_name_is_refused() {
|
||||
.await
|
||||
.expect_err("an internal junction name must be refused");
|
||||
assert!(format!("{err}").contains("no such table"), "got: {err}");
|
||||
assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string()));
|
||||
assert!(
|
||||
!db.list_tables()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains(&"__rdbms_evil".to_string())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,7 +559,10 @@ fn read_all_relationships_returns_the_junction_relationships() {
|
||||
);
|
||||
// Both have the junction (Students_Courses) as their child.
|
||||
for r in &rels {
|
||||
assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}");
|
||||
assert_eq!(
|
||||
r.child_table, "Students_Courses",
|
||||
"child is the junction: {r:?}"
|
||||
);
|
||||
}
|
||||
// One points back to each parent.
|
||||
let parents: std::collections::BTreeSet<&str> =
|
||||
|
||||
+1
-1
@@ -24,6 +24,7 @@ mod parse_error_pedagogy;
|
||||
mod project_lifecycle;
|
||||
mod replay_command;
|
||||
mod seed;
|
||||
mod show_list;
|
||||
mod sql_alter_table;
|
||||
mod sql_create_index;
|
||||
mod sql_create_table;
|
||||
@@ -34,6 +35,5 @@ mod sql_drop_table;
|
||||
mod sql_insert;
|
||||
mod sql_select;
|
||||
mod sql_update;
|
||||
mod show_list;
|
||||
mod undo_snapshots;
|
||||
mod walking_skeleton;
|
||||
|
||||
@@ -54,10 +54,7 @@ fn error_lines_for(input: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn dump(input: &str, lines: &[String]) -> String {
|
||||
format!(
|
||||
"INPUT: {input:?}\nERROR LINES:\n{}",
|
||||
lines.join("\n"),
|
||||
)
|
||||
format!("INPUT: {input:?}\nERROR LINES:\n{}", lines.join("\n"),)
|
||||
}
|
||||
|
||||
/// The simple-mode near-miss matrix (ADR-0042 §1). Each row is a
|
||||
@@ -71,57 +68,228 @@ fn near_miss_matrix_simple_mode() {
|
||||
// app-lifecycle arg errors. The arg-less commands all reject
|
||||
// trailing junk with "expected end of input" + their usage
|
||||
// (audited 2026-06-05); locked here as regression insurance.
|
||||
("quit now", &["after `quit`, expected end of input", " quit"]),
|
||||
(
|
||||
"quit now",
|
||||
&["after `quit`, expected end of input", " quit"],
|
||||
),
|
||||
// `help` now takes an optional single-word topic (H3), so
|
||||
// `help foo` parses (topic lookup); only a *multi-word*
|
||||
// topic is the near-miss that rejects trailing junk.
|
||||
("help foo bar", &["after `help foo`, expected end of input", "help [<command>]"]),
|
||||
("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]),
|
||||
(
|
||||
"help foo bar",
|
||||
&[
|
||||
"after `help foo`, expected end of input",
|
||||
"help [<command>]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"rebuild now",
|
||||
&["after `rebuild`, expected end of input", " rebuild"],
|
||||
),
|
||||
("new foo", &["after `new`, expected end of input", " new"]),
|
||||
("load foo", &["after `load`, expected end of input", " load"]),
|
||||
("undo foo", &["after `undo`, expected end of input", " undo"]),
|
||||
("redo foo", &["after `redo`, expected end of input", " redo"]),
|
||||
("export foo bar", &["after `export foo`, expected end of input", "export [<path>]"]),
|
||||
("import a b c", &["after `import a`, expected end of input", "import <zip-path>"]),
|
||||
("save sideways", &["after `save`, expected end of input", "save | save as"]),
|
||||
("mode sideways", &["unknown mode 'sideways'", "mode simple | mode advanced"]),
|
||||
("messages louder", &["unknown messages mode 'louder'", "messages short"]),
|
||||
("copy everything", &["unknown copy target 'everything'", "copy all"]),
|
||||
(
|
||||
"load foo",
|
||||
&["after `load`, expected end of input", " load"],
|
||||
),
|
||||
(
|
||||
"undo foo",
|
||||
&["after `undo`, expected end of input", " undo"],
|
||||
),
|
||||
(
|
||||
"redo foo",
|
||||
&["after `redo`, expected end of input", " redo"],
|
||||
),
|
||||
(
|
||||
"export foo bar",
|
||||
&[
|
||||
"after `export foo`, expected end of input",
|
||||
"export [<path>]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"import a b c",
|
||||
&[
|
||||
"after `import a`, expected end of input",
|
||||
"import <zip-path>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"save sideways",
|
||||
&["after `save`, expected end of input", "save | save as"],
|
||||
),
|
||||
(
|
||||
"mode sideways",
|
||||
&["unknown mode 'sideways'", "mode simple | mode advanced"],
|
||||
),
|
||||
(
|
||||
"messages louder",
|
||||
&["unknown messages mode 'louder'", "messages short"],
|
||||
),
|
||||
(
|
||||
"copy everything",
|
||||
&["unknown copy target 'everything'", "copy all"],
|
||||
),
|
||||
// DDL bare + missing-slot
|
||||
("create", &["after `create`, expected `table`", "create table <Name> with pk"]),
|
||||
("create table", &["after `create table`, expected identifier", "create table <Name> with pk"]),
|
||||
("create table T", &["with pk", "create table <Name> with pk"]),
|
||||
(
|
||||
"create",
|
||||
&[
|
||||
"after `create`, expected `table`",
|
||||
"create table <Name> with pk",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create table",
|
||||
&[
|
||||
"after `create table`, expected identifier",
|
||||
"create table <Name> with pk",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create table T",
|
||||
&["with pk", "create table <Name> with pk"],
|
||||
),
|
||||
// G1: relationship cardinality reads as the named construct.
|
||||
("add", &["after `add`, expected `column`, `1:n relationship`", "add 1:n relationship"]),
|
||||
("drop table", &["after `drop table`, expected table name", "drop table <Name>"]),
|
||||
("add column", &["after `add column`, expected table name", "add column [to] [table]"]),
|
||||
("rename", &["after `rename`, expected `column`", "rename column [in] [table]"]),
|
||||
("rename column", &["after `rename column`, expected table name", "rename column [in] [table]"]),
|
||||
("change", &["after `change`, expected `column`", "change column [in] [table]"]),
|
||||
("change column", &["after `change column`, expected table name", "change column [in] [table]"]),
|
||||
(
|
||||
"add",
|
||||
&[
|
||||
"after `add`, expected `column`, `1:n relationship`",
|
||||
"add 1:n relationship",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop table",
|
||||
&[
|
||||
"after `drop table`, expected table name",
|
||||
"drop table <Name>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add column",
|
||||
&[
|
||||
"after `add column`, expected table name",
|
||||
"add column [to] [table]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"rename",
|
||||
&[
|
||||
"after `rename`, expected `column`",
|
||||
"rename column [in] [table]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"rename column",
|
||||
&[
|
||||
"after `rename column`, expected table name",
|
||||
"rename column [in] [table]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"change",
|
||||
&[
|
||||
"after `change`, expected `column`",
|
||||
"change column [in] [table]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"change column",
|
||||
&[
|
||||
"after `change column`, expected table name",
|
||||
"change column [in] [table]",
|
||||
],
|
||||
),
|
||||
// data bare + missing-clause
|
||||
("insert", &["after `insert`, expected `into`", "insert into <Table>"]),
|
||||
("insert into", &["after `insert into`, expected table name", "insert into <Table>"]),
|
||||
("insert into T", &["after `insert into T`, expected `values` or `(`", "insert into <Table>"]),
|
||||
("update", &["after `update`, expected table name", "update <Table> set"]),
|
||||
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
|
||||
("update T set x=1", &["expected `where` or `--all-rows`", "update <Table> set"]),
|
||||
("delete", &["after `delete`, expected `from`", "delete from <Table>"]),
|
||||
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
|
||||
("delete from T", &["expected `where` or `--all-rows`", "delete from <Table>"]),
|
||||
("seed", &["after `seed`, expected table name", "seed <Table> [count]"]),
|
||||
(
|
||||
"insert",
|
||||
&["after `insert`, expected `into`", "insert into <Table>"],
|
||||
),
|
||||
(
|
||||
"insert into",
|
||||
&[
|
||||
"after `insert into`, expected table name",
|
||||
"insert into <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"insert into T",
|
||||
&[
|
||||
"after `insert into T`, expected `values` or `(`",
|
||||
"insert into <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"update",
|
||||
&["after `update`, expected table name", "update <Table> set"],
|
||||
),
|
||||
(
|
||||
"update T",
|
||||
&["after `update T`, expected `set`", "update <Table> set"],
|
||||
),
|
||||
(
|
||||
"update T set x=1",
|
||||
&["expected `where` or `--all-rows`", "update <Table> set"],
|
||||
),
|
||||
(
|
||||
"delete",
|
||||
&["after `delete`, expected `from`", "delete from <Table>"],
|
||||
),
|
||||
(
|
||||
"delete from",
|
||||
&[
|
||||
"after `delete from`, expected table name",
|
||||
"delete from <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"delete from T",
|
||||
&["expected `where` or `--all-rows`", "delete from <Table>"],
|
||||
),
|
||||
(
|
||||
"seed",
|
||||
&["after `seed`, expected table name", "seed <Table> [count]"],
|
||||
),
|
||||
// Phase 2 (ADR-0048 D2/D1): malformed `set` clause + column-fill.
|
||||
("seed T set", &["after `seed T set`, expected column name", "seed <Table>.<col>"]),
|
||||
(
|
||||
"seed T set",
|
||||
&[
|
||||
"after `seed T set`, expected column name",
|
||||
"seed <Table>.<col>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"seed T set role",
|
||||
&["after `seed T set role`, expected `=`, `in`, `between`, or `as`", "seed <Table>.<col>"],
|
||||
&[
|
||||
"after `seed T set role`, expected `=`, `in`, `between`, or `as`",
|
||||
"seed <Table>.<col>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"seed T.",
|
||||
&[
|
||||
"after `seed T.`, expected column name",
|
||||
"seed <Table>.<col>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"replay",
|
||||
&[
|
||||
"after `replay`, expected string literal or path",
|
||||
"replay <path>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"explain",
|
||||
&[
|
||||
"after `explain`, expected `show`, `update`, or `delete`",
|
||||
"explain show data",
|
||||
],
|
||||
),
|
||||
("seed T.", &["after `seed T.`, expected column name", "seed <Table>.<col>"]),
|
||||
("replay", &["after `replay`, expected string literal or path", "replay <path>"]),
|
||||
("explain", &["after `explain`, expected `show`, `update`, or `delete`", "explain show data"]),
|
||||
// advanced-only entry word typed in simple mode → "this is SQL" rail
|
||||
("select * from T", &["`select` is SQL", "mode advanced"]),
|
||||
("alter table T add column c int", &["`alter` is SQL", "mode advanced"]),
|
||||
(
|
||||
"alter table T add column c int",
|
||||
&["`alter` is SQL", "mode advanced"],
|
||||
),
|
||||
];
|
||||
for (input, needles) in matrix {
|
||||
let lines = error_lines_for(input);
|
||||
@@ -164,26 +332,160 @@ fn near_miss_matrix_committed_multiforms() {
|
||||
// (input, advanced?, required-substrings)
|
||||
let matrix: &[(&str, bool, &[&str])] = &[
|
||||
// add / drop multi-forms (simple)
|
||||
("add index", false, &["after `add index`, expected `on` or `as`", "add index [as <Name>] on"]),
|
||||
("add index on T", false, &["after `add index on T`, expected `(`", "add index [as <Name>] on"]),
|
||||
("add constraint", false, &["after `add constraint`, expected `not`, `unique`, `default`, or `check`", "add constraint not null to"]),
|
||||
("add constraint not null", false, &["after `add constraint not null`, expected `to`", "add constraint not null to"]),
|
||||
("add 1:n relationship", false, &["after `add 1:n relationship`, expected `from` or `as`", "add 1:n relationship"]),
|
||||
("add 1:n relationship from", false, &["after `add 1:n relationship from`, expected table name", "from <Parent>.<col>"]),
|
||||
("drop constraint", false, &["after `drop constraint`, expected `not`, `unique`, `default`, or `check`", "drop constraint (not null"]),
|
||||
("drop constraint not null", false, &["after `drop constraint not null`, expected `from`", "drop constraint (not null"]),
|
||||
("drop index", false, &["after `drop index`, expected `on` or index name", "drop index <Name>", "drop index on <Table>"]),
|
||||
("drop index on T", false, &["after `drop index on T`, expected `(`", "drop index on <Table>"]),
|
||||
("drop relationship", false, &["after `drop relationship`, expected `from` or relationship name", "drop relationship <Name>"]),
|
||||
("show table", false, &["after `show table`, expected table name", "show table <Table>"]),
|
||||
("show relationship", false, &["after `show relationship`, expected relationship name", "show relationship <name>"]),
|
||||
("show index", false, &["after `show index`, expected index name", "show index <name>"]),
|
||||
("change column in table T: c", false, &["after `change column in table T: c`, expected `(`", "change column [in] [table]"]),
|
||||
(
|
||||
"add index",
|
||||
false,
|
||||
&[
|
||||
"after `add index`, expected `on` or `as`",
|
||||
"add index [as <Name>] on",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add index on T",
|
||||
false,
|
||||
&[
|
||||
"after `add index on T`, expected `(`",
|
||||
"add index [as <Name>] on",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add constraint",
|
||||
false,
|
||||
&[
|
||||
"after `add constraint`, expected `not`, `unique`, `default`, or `check`",
|
||||
"add constraint not null to",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add constraint not null",
|
||||
false,
|
||||
&[
|
||||
"after `add constraint not null`, expected `to`",
|
||||
"add constraint not null to",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add 1:n relationship",
|
||||
false,
|
||||
&[
|
||||
"after `add 1:n relationship`, expected `from` or `as`",
|
||||
"add 1:n relationship",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add 1:n relationship from",
|
||||
false,
|
||||
&[
|
||||
"after `add 1:n relationship from`, expected table name",
|
||||
"from <Parent>.<col>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop constraint",
|
||||
false,
|
||||
&[
|
||||
"after `drop constraint`, expected `not`, `unique`, `default`, or `check`",
|
||||
"drop constraint (not null",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop constraint not null",
|
||||
false,
|
||||
&[
|
||||
"after `drop constraint not null`, expected `from`",
|
||||
"drop constraint (not null",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop index",
|
||||
false,
|
||||
&[
|
||||
"after `drop index`, expected `on` or index name",
|
||||
"drop index <Name>",
|
||||
"drop index on <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop index on T",
|
||||
false,
|
||||
&[
|
||||
"after `drop index on T`, expected `(`",
|
||||
"drop index on <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop relationship",
|
||||
false,
|
||||
&[
|
||||
"after `drop relationship`, expected `from` or relationship name",
|
||||
"drop relationship <Name>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"show table",
|
||||
false,
|
||||
&[
|
||||
"after `show table`, expected table name",
|
||||
"show table <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"show relationship",
|
||||
false,
|
||||
&[
|
||||
"after `show relationship`, expected relationship name",
|
||||
"show relationship <name>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"show index",
|
||||
false,
|
||||
&[
|
||||
"after `show index`, expected index name",
|
||||
"show index <name>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"change column in table T: c",
|
||||
false,
|
||||
&[
|
||||
"after `change column in table T: c`, expected `(`",
|
||||
"change column [in] [table]",
|
||||
],
|
||||
),
|
||||
// advanced committed multi-forms
|
||||
("create index on", true, &["after `create index on`, expected table name", "create [unique] index"]),
|
||||
("create unique index", true, &["after `create unique index`, expected `on`, identifier, or `if`", "create [unique] index"]),
|
||||
("alter table T add", true, &["after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`", "alter table <Table> add column"]),
|
||||
("alter table T drop", true, &["after `alter table T drop`, expected `column` or `constraint`", "alter table <Table> drop column"]),
|
||||
(
|
||||
"create index on",
|
||||
true,
|
||||
&[
|
||||
"after `create index on`, expected table name",
|
||||
"create [unique] index",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create unique index",
|
||||
true,
|
||||
&[
|
||||
"after `create unique index`, expected `on`, identifier, or `if`",
|
||||
"create [unique] index",
|
||||
],
|
||||
),
|
||||
(
|
||||
"alter table T add",
|
||||
true,
|
||||
&[
|
||||
"after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`",
|
||||
"alter table <Table> add column",
|
||||
],
|
||||
),
|
||||
(
|
||||
"alter table T drop",
|
||||
true,
|
||||
&[
|
||||
"after `alter table T drop`, expected `column` or `constraint`",
|
||||
"alter table <Table> drop column",
|
||||
],
|
||||
),
|
||||
];
|
||||
for (input, advanced, needles) in matrix {
|
||||
let lines = if *advanced {
|
||||
@@ -265,7 +567,10 @@ fn advanced_mode_usage_block_shows_sql_and_dsl_forms() {
|
||||
// (mode-primary first).
|
||||
let sql_at = joined.find("create table [if not exists]").unwrap();
|
||||
let dsl_at = joined.find("create table <Name> with pk").unwrap();
|
||||
assert!(sql_at < dsl_at, "SQL form should precede the DSL form\n{dump_msg}");
|
||||
assert!(
|
||||
sql_at < dsl_at,
|
||||
"SQL form should precede the DSL form\n{dump_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -307,23 +612,94 @@ fn advanced_cross_join_with_on_teaches_no_on_clause() {
|
||||
fn near_miss_matrix_advanced_mode() {
|
||||
let matrix: &[(&str, &[&str])] = &[
|
||||
// SQL select / with (G2, G4)
|
||||
("select", &["expected a projection: `*`, a column, or an expression", "select (* |"]),
|
||||
("select * from", &["after `select * from`, expected table name", "select (* |"]),
|
||||
("with", &["after `with`, expected identifier or `recursive`", "with [recursive]", "as ("]),
|
||||
(
|
||||
"select",
|
||||
&[
|
||||
"expected a projection: `*`, a column, or an expression",
|
||||
"select (* |",
|
||||
],
|
||||
),
|
||||
(
|
||||
"select * from",
|
||||
&["after `select * from`, expected table name", "select (* |"],
|
||||
),
|
||||
(
|
||||
"with",
|
||||
&[
|
||||
"after `with`, expected identifier or `recursive`",
|
||||
"with [recursive]",
|
||||
"as (",
|
||||
],
|
||||
),
|
||||
// create / drop / alter — SQL forms AND the still-valid DSL
|
||||
// fallback forms, SQL-primary first (G3).
|
||||
("create", &["after `create`, expected `table`", "create table [if not exists]", "create [unique] index", "create table <Name> with pk"]),
|
||||
("create table", &["after `create table`, expected identifier or `if`", "create table [if not exists]"]),
|
||||
("create index", &["after `create index`, expected `on`", "create [unique] index"]),
|
||||
("drop", &["after `drop`, expected `table`", "drop table [if exists]", "drop column [from]", "drop relationship"]),
|
||||
("alter", &["after `alter`, expected `table`", "alter table <Table> add column"]),
|
||||
("alter table T", &["expected `add`, `drop`, `rename`, or `alter`", "alter table <Table>"]),
|
||||
(
|
||||
"create",
|
||||
&[
|
||||
"after `create`, expected `table`",
|
||||
"create table [if not exists]",
|
||||
"create [unique] index",
|
||||
"create table <Name> with pk",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create table",
|
||||
&[
|
||||
"after `create table`, expected identifier or `if`",
|
||||
"create table [if not exists]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create index",
|
||||
&[
|
||||
"after `create index`, expected `on`",
|
||||
"create [unique] index",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop",
|
||||
&[
|
||||
"after `drop`, expected `table`",
|
||||
"drop table [if exists]",
|
||||
"drop column [from]",
|
||||
"drop relationship",
|
||||
],
|
||||
),
|
||||
(
|
||||
"alter",
|
||||
&[
|
||||
"after `alter`, expected `table`",
|
||||
"alter table <Table> add column",
|
||||
],
|
||||
),
|
||||
(
|
||||
"alter table T",
|
||||
&[
|
||||
"expected `add`, `drop`, `rename`, or `alter`",
|
||||
"alter table <Table>",
|
||||
],
|
||||
),
|
||||
// shared insert/update/delete — must show usage, not the
|
||||
// available-commands fallback (regression guard for the
|
||||
// empty-usage_ids SQL nodes).
|
||||
("insert into T", &["after `insert into T`, expected `values`, `with`, `select`, or `(`", "insert into <Table>"]),
|
||||
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
|
||||
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
|
||||
(
|
||||
"insert into T",
|
||||
&[
|
||||
"after `insert into T`, expected `values`, `with`, `select`, or `(`",
|
||||
"insert into <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"update T",
|
||||
&["after `update T`, expected `set`", "update <Table> set"],
|
||||
),
|
||||
(
|
||||
"delete from",
|
||||
&[
|
||||
"after `delete from`, expected table name",
|
||||
"delete from <Table>",
|
||||
],
|
||||
),
|
||||
];
|
||||
for (input, needles) in matrix {
|
||||
let lines = advanced_error_lines_for(input);
|
||||
@@ -365,7 +741,9 @@ fn with_alone_renders_cte_usage_not_select() {
|
||||
.collect();
|
||||
let dump_msg = dump("with", &lines);
|
||||
assert!(
|
||||
lines.iter().any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
|
||||
"missing CTE-specific `with … as (…)` usage template\n{dump_msg}",
|
||||
);
|
||||
}
|
||||
@@ -383,7 +761,9 @@ fn create_alone_renders_create_table_usage() {
|
||||
"missing usage: header\n{dump_msg}",
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| l.contains("create table") && l.contains("with pk")),
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l.contains("create table") && l.contains("with pk")),
|
||||
"missing create_table usage template\n{dump_msg}",
|
||||
);
|
||||
}
|
||||
@@ -464,8 +844,7 @@ fn unknown_command_falls_back_to_available_commands_list() {
|
||||
.unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}"));
|
||||
// The list must include all ten command-entry keywords.
|
||||
for cmd in [
|
||||
"add", "change", "create", "delete", "drop", "insert",
|
||||
"rename", "replay", "show", "update",
|
||||
"add", "change", "create", "delete", "drop", "insert", "rename", "replay", "show", "update",
|
||||
] {
|
||||
assert!(
|
||||
available.contains(&format!("`{cmd}`")),
|
||||
@@ -543,8 +922,3 @@ fn caret_aligns_under_offending_token() {
|
||||
"caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22,14 +22,16 @@ fn tempdir() -> tempfile::TempDir {
|
||||
#[test]
|
||||
fn no_args_creates_temp_project_under_data_root() {
|
||||
let data = tempdir();
|
||||
let project = project::open_or_create(None, Some(data.path()))
|
||||
.expect("open_or_create with empty CLI");
|
||||
let project =
|
||||
project::open_or_create(None, Some(data.path())).expect("open_or_create with empty CLI");
|
||||
|
||||
let path = project.path();
|
||||
assert!(path.exists(), "project dir should exist");
|
||||
assert!(path.starts_with(data.path()));
|
||||
assert_eq!(
|
||||
path.parent().and_then(|p| p.file_name()).map(|s| s.to_string_lossy().into_owned()),
|
||||
path.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|s| s.to_string_lossy().into_owned()),
|
||||
Some(PROJECTS_SUBDIR.to_string()),
|
||||
);
|
||||
|
||||
@@ -96,8 +98,7 @@ fn positional_path_opens_existing_project() {
|
||||
|
||||
// Now drive open_or_create with the path as if it were a
|
||||
// CLI positional argument.
|
||||
let project = project::open_or_create(Some(&path), None)
|
||||
.expect("open via positional path");
|
||||
let project = project::open_or_create(Some(&path), None).expect("open via positional path");
|
||||
assert_eq!(project.path(), path);
|
||||
}
|
||||
|
||||
@@ -142,7 +143,10 @@ fn data_dir_override_does_not_touch_default_os_dir() {
|
||||
|
||||
assert!(p1_path.starts_with(data.path()));
|
||||
assert!(p2_path.starts_with(data.path()));
|
||||
assert_ne!(p1_path, p2_path, "two temp projects must have distinct names");
|
||||
assert_ne!(
|
||||
p1_path, p2_path,
|
||||
"two temp projects must have distinct names"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -167,11 +171,18 @@ fn db_persists_across_open_close_cycles() {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![
|
||||
rdbms_playground::dsl::ColumnSpec::new("id".to_string(), rdbms_playground::dsl::Type::Serial),
|
||||
rdbms_playground::dsl::ColumnSpec::new("Name".to_string(), rdbms_playground::dsl::Type::Text),
|
||||
rdbms_playground::dsl::ColumnSpec::new(
|
||||
"id".to_string(),
|
||||
rdbms_playground::dsl::Type::Serial,
|
||||
),
|
||||
rdbms_playground::dsl::ColumnSpec::new(
|
||||
"Name".to_string(),
|
||||
rdbms_playground::dsl::Type::Text,
|
||||
),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None)
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create_table");
|
||||
});
|
||||
@@ -187,7 +198,9 @@ fn db_persists_across_open_close_cycles() {
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let tables = rt.block_on(async { db.list_tables().await }).expect("list_tables");
|
||||
let tables = rt
|
||||
.block_on(async { db.list_tables().await })
|
||||
.expect("list_tables");
|
||||
assert!(tables.iter().any(|t| t == "Customers"), "got: {tables:?}");
|
||||
|
||||
// Sanity: the project.yaml and history.log are still empty
|
||||
|
||||
+61
-52
@@ -47,8 +47,7 @@ fn tempdir() -> tempfile::TempDir {
|
||||
/// harness — most tests only need to write a script file and
|
||||
/// call `run_replay`.
|
||||
fn open_project_db(data_root: &Path) -> (project::Project, Database) {
|
||||
let project = project::open_or_create(None, Some(data_root))
|
||||
.expect("open_or_create");
|
||||
let project = project::open_or_create(None, Some(data_root)).expect("open_or_create");
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
@@ -132,9 +131,7 @@ fn replay_three_lines_dispatches_three_commands() {
|
||||
insert into T (1, 'Alice')\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "seed.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
|
||||
assert_completed(&events, 3);
|
||||
|
||||
// The dispatched commands actually mutated state.
|
||||
@@ -167,8 +164,7 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
|
||||
2026-05-24T10:00:03Z|ok|insert into T (id, v) values (1, 'alpha')\n",
|
||||
);
|
||||
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
// Three `ok` records replayed; the `err` record is skipped (not
|
||||
// counted, not a failure).
|
||||
assert_completed(&events, 3);
|
||||
@@ -215,14 +211,21 @@ fn replay_skips_app_lifecycle_commands_silently() {
|
||||
2026-05-24T10:00:13Z|ok|add column T: v (text)\n\
|
||||
2026-05-24T10:00:14Z|ok|insert into T (id, v) values (1, 'alpha')\n",
|
||||
);
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
// Three data/schema commands ran; every app-lifecycle line was
|
||||
// skipped silently (no panic, no abort, no warnings, no quit).
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
assert_eq!(*count, 3, "only the 3 write commands ran; events: {events:?}");
|
||||
assert!(warnings.is_empty(), "these skips are silent; got {warnings:?}");
|
||||
AppEvent::ReplayCompleted {
|
||||
count, warnings, ..
|
||||
} => {
|
||||
assert_eq!(
|
||||
*count, 3,
|
||||
"only the 3 write commands ran; events: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
warnings.is_empty(),
|
||||
"these skips are silent; got {warnings:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected ReplayCompleted, got {other:?}"),
|
||||
}
|
||||
@@ -251,10 +254,11 @@ fn replay_skips_import_with_a_warning() {
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
|
||||
2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n",
|
||||
);
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
AppEvent::ReplayCompleted {
|
||||
count, warnings, ..
|
||||
} => {
|
||||
assert_eq!(*count, 1, "only the create ran; events: {events:?}");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("import shared.zip")),
|
||||
@@ -282,9 +286,7 @@ fn replay_skips_blank_lines_and_comments() {
|
||||
\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "seed.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
|
||||
// Only two non-blank, non-comment lines.
|
||||
assert_completed(&events, 2);
|
||||
}
|
||||
@@ -295,9 +297,7 @@ fn replay_empty_file_completes_with_zero_commands() {
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(project.path(), "empty.commands", "");
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "empty.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "empty.commands").await });
|
||||
assert_completed(&events, 0);
|
||||
}
|
||||
|
||||
@@ -311,9 +311,8 @@ fn replay_only_comments_completes_with_zero_commands() {
|
||||
"# just\n# comments\n\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "comments.commands").await
|
||||
});
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "comments.commands").await });
|
||||
assert_completed(&events, 0);
|
||||
}
|
||||
|
||||
@@ -350,8 +349,14 @@ fn replay_constraint_failure_shows_real_names_not_placeholders() {
|
||||
// INSERT command — the **real offending value** is shown too (it used
|
||||
// to degrade to the neutral "that value" because `SqlInsert` discarded
|
||||
// its literals).
|
||||
assert!(error.contains("T.email"), "names the real table.column; got: {error}");
|
||||
assert!(error.contains("a@b.com"), "shows the real offending value; got: {error}");
|
||||
assert!(
|
||||
error.contains("T.email"),
|
||||
"names the real table.column; got: {error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("a@b.com"),
|
||||
"shows the real offending value; got: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -359,9 +364,8 @@ fn replay_missing_file_fails_with_line_number_zero() {
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "no-such-file.commands").await
|
||||
});
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "no-such-file.commands").await });
|
||||
let failed = assert_failed_at(&events, 0);
|
||||
let AppEvent::ReplayFailed { error, .. } = failed else {
|
||||
unreachable!()
|
||||
@@ -387,9 +391,7 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
|
||||
insert into T (1, 'should not happen')\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "bad.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
|
||||
let failed = assert_failed_at(&events, 3);
|
||||
let AppEvent::ReplayFailed { error, command, .. } = failed else {
|
||||
unreachable!()
|
||||
@@ -452,9 +454,7 @@ fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
|
||||
insert into T values (1, 'not a number')\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "typed.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "typed.commands").await });
|
||||
let failed = assert_failed_at(&events, 3);
|
||||
let AppEvent::ReplayFailed { error, .. } = failed else {
|
||||
unreachable!()
|
||||
@@ -489,9 +489,7 @@ fn replay_aborts_on_first_runtime_failure_and_reports_line() {
|
||||
insert into T (1)\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "bad.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
|
||||
let _ = assert_failed_at(&events, 2);
|
||||
}
|
||||
|
||||
@@ -504,23 +502,29 @@ fn replay_skips_nested_replay_with_a_warning() {
|
||||
// because the nested file's commands are not reconstructed.
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(project.path(), "inner.commands", "create table T with pk id(int)\n");
|
||||
write_script(
|
||||
project.path(),
|
||||
"inner.commands",
|
||||
"create table T with pk id(int)\n",
|
||||
);
|
||||
write_script(
|
||||
project.path(),
|
||||
"outer.commands",
|
||||
"create table U with pk id(int)\nreplay inner.commands\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "outer.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "outer.commands").await });
|
||||
// The outer `create table U` ran; the nested `replay` was
|
||||
// skipped (count 1), with a warning.
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
AppEvent::ReplayCompleted {
|
||||
count, warnings, ..
|
||||
} => {
|
||||
assert_eq!(*count, 1, "only the outer create ran; events: {events:?}");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("nested") && w.contains("replay inner.commands")),
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("nested") && w.contains("replay inner.commands")),
|
||||
"expected a nested-replay skip warning; got {warnings:?}",
|
||||
);
|
||||
}
|
||||
@@ -528,7 +532,10 @@ fn replay_skips_nested_replay_with_a_warning() {
|
||||
}
|
||||
// The nested file's table was NOT created (the replay was skipped).
|
||||
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None).await });
|
||||
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
|
||||
assert!(
|
||||
cols.is_err(),
|
||||
"inner.commands' table T must not exist (nested replay skipped)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -546,20 +553,22 @@ fn replay_history_log_records_subcommands_only() {
|
||||
"create table T with pk id(int)\nadd column T: name (text)\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "seed.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
|
||||
assert_completed(&events, 2);
|
||||
|
||||
let history = fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log exists");
|
||||
let history =
|
||||
fs::read_to_string(project.path().join("history.log")).expect("history.log exists");
|
||||
// Per-command entries landed.
|
||||
assert!(
|
||||
history.lines().any(|l| l.contains("create table T with pk id(int)")),
|
||||
history
|
||||
.lines()
|
||||
.any(|l| l.contains("create table T with pk id(int)")),
|
||||
"history.log missing create line:\n{history}"
|
||||
);
|
||||
assert!(
|
||||
history.lines().any(|l| l.contains("add column T: name (text)")),
|
||||
history
|
||||
.lines()
|
||||
.any(|l| l.contains("add column T: name (text)")),
|
||||
"history.log missing add column line:\n{history}"
|
||||
);
|
||||
// The replay invocation itself did NOT land — that's
|
||||
|
||||
+376
-93
@@ -18,8 +18,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -76,7 +75,10 @@ fn seed_parses_with_and_without_count() {
|
||||
match parse_command("seed People").expect("`seed People` parses") {
|
||||
Command::Seed { table, count, .. } => {
|
||||
assert_eq!(table, "People");
|
||||
assert_eq!(count, None, "omitted count is None (executor defaults to 20)");
|
||||
assert_eq!(
|
||||
count, None,
|
||||
"omitted count is None (executor defaults to 20)"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Command::Seed, got {other:?}"),
|
||||
}
|
||||
@@ -134,7 +136,10 @@ fn seed_set_fixed_value_override_parses() {
|
||||
let (_t, ov) = seed_overrides("seed users 5 set status = 'active'");
|
||||
assert_eq!(ov.len(), 1);
|
||||
assert_eq!(ov[0].column, "status");
|
||||
assert_eq!(ov[0].kind, SeedOverrideKind::Fixed(Value::Text("active".into())));
|
||||
assert_eq!(
|
||||
ov[0].kind,
|
||||
SeedOverrideKind::Fixed(Value::Text("active".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -177,8 +182,7 @@ fn seed_set_numeric_range_override_parses() {
|
||||
#[test]
|
||||
fn seed_set_date_range_override_parses_with_quoted_dates() {
|
||||
// ADR-0048 D2 amendment: dates in the range form are quoted strings.
|
||||
let (_t, ov) =
|
||||
seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
|
||||
let (_t, ov) = seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
|
||||
assert_eq!(
|
||||
ov[0].kind,
|
||||
SeedOverrideKind::Range {
|
||||
@@ -207,7 +211,9 @@ fn seed_count_is_not_confused_by_a_range_value() {
|
||||
// No positional count, but `between 18 and 80` carries NumberLits —
|
||||
// they must not be read as the count (bounded to before `set`).
|
||||
match parse_command("seed users set age between 18 and 80").expect("parses") {
|
||||
Command::Seed { count, overrides, .. } => {
|
||||
Command::Seed {
|
||||
count, overrides, ..
|
||||
} => {
|
||||
assert_eq!(count, None, "the count is None, not 18");
|
||||
assert_eq!(overrides.len(), 1);
|
||||
}
|
||||
@@ -267,7 +273,14 @@ fn seed_populates_a_table_and_persists_rows() {
|
||||
create_people(&db, &rt);
|
||||
|
||||
let result = rt
|
||||
.block_on(db.seed("People".into(), None, Some(7), Vec::new(), Some(42), Some("seed People 7".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(7),
|
||||
Vec::new(),
|
||||
Some(42),
|
||||
Some("seed People 7".into()),
|
||||
))
|
||||
.expect("seed succeeds");
|
||||
assert_eq!(result.produced, 7);
|
||||
|
||||
@@ -278,22 +291,34 @@ fn seed_populates_a_table_and_persists_rows() {
|
||||
"CSV should hold 7 generated rows:\n{csv}"
|
||||
);
|
||||
// The generated `email` column produces address-shaped values.
|
||||
assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}");
|
||||
assert!(
|
||||
csv.contains('@'),
|
||||
"seeded emails should appear in the CSV:\n{csv}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a seeded table's CSV into per-column value lists (simple
|
||||
/// comma-split — the values under test carry no commas/quotes).
|
||||
fn csv_columns(csv: &str) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let mut lines = csv.lines().filter(|l| !l.trim().is_empty());
|
||||
let header: Vec<String> = lines.next().unwrap().split(',').map(str::to_string).collect();
|
||||
let rows: Vec<Vec<String>> =
|
||||
lines.map(|l| l.split(',').map(str::to_string).collect()).collect();
|
||||
let header: Vec<String> = lines
|
||||
.next()
|
||||
.unwrap()
|
||||
.split(',')
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let rows: Vec<Vec<String>> = lines
|
||||
.map(|l| l.split(',').map(str::to_string).collect())
|
||||
.collect();
|
||||
(header, rows)
|
||||
}
|
||||
|
||||
fn column_values(csv: &str, col: &str) -> Vec<String> {
|
||||
let (header, rows) = csv_columns(csv);
|
||||
let idx = header.iter().position(|h| h == col).expect("column present");
|
||||
let idx = header
|
||||
.iter()
|
||||
.position(|h| h == col)
|
||||
.expect("column present");
|
||||
rows.iter().map(|r| r[idx].clone()).collect()
|
||||
}
|
||||
|
||||
@@ -321,20 +346,36 @@ fn seed_year_and_choice_set_heuristics() {
|
||||
))
|
||||
.expect("create Records");
|
||||
|
||||
rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into())))
|
||||
.expect("seed succeeds");
|
||||
rt.block_on(db.seed(
|
||||
"Records".into(),
|
||||
None,
|
||||
Some(30),
|
||||
Vec::new(),
|
||||
Some(99),
|
||||
Some("seed Records 30".into()),
|
||||
))
|
||||
.expect("seed succeeds");
|
||||
let csv = read_csv(&project, "Records").expect("Records CSV exists");
|
||||
|
||||
for y in column_values(&csv, "birth_year") {
|
||||
let n: i32 = y.parse().expect("birth_year is an int");
|
||||
assert!((1945..=2007).contains(&n), "birth_year {n} must be a plausible birth year");
|
||||
assert!(
|
||||
(1945..=2007).contains(&n),
|
||||
"birth_year {n} must be a plausible birth year"
|
||||
);
|
||||
}
|
||||
for y in column_values(&csv, "published") {
|
||||
let n: i32 = y.parse().expect("published is an int");
|
||||
assert!((1950..=2025).contains(&n), "published {n} must be a plausible recent year");
|
||||
assert!(
|
||||
(1950..=2025).contains(&n),
|
||||
"published {n} must be a plausible recent year"
|
||||
);
|
||||
}
|
||||
for p in column_values(&csv, "priority") {
|
||||
assert!(["low", "medium", "high"].contains(&p.as_str()), "priority `{p}` must be low/medium/high");
|
||||
assert!(
|
||||
["low", "medium", "high"].contains(&p.as_str()),
|
||||
"priority `{p}` must be low/medium/high"
|
||||
);
|
||||
}
|
||||
for s in column_values(&csv, "severity") {
|
||||
assert!(
|
||||
@@ -405,7 +446,14 @@ fn seed_count_defaults_to_twenty() {
|
||||
create_people(&db, &rt);
|
||||
|
||||
let result = rt
|
||||
.block_on(db.seed("People".into(), None, None, Vec::new(), Some(1), Some("seed People".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
None,
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People".into()),
|
||||
))
|
||||
.expect("seed succeeds");
|
||||
assert_eq!(result.produced, 20, "omitted count defaults to 20");
|
||||
let csv = read_csv(&project, "People").expect("People CSV exists");
|
||||
@@ -420,10 +468,24 @@ fn seed_is_reproducible_with_a_fixed_seed() {
|
||||
create_people(&db1, &rt);
|
||||
create_people(&db2, &rt);
|
||||
|
||||
rt.block_on(db1.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
|
||||
.expect("seed run 1");
|
||||
rt.block_on(db2.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
|
||||
.expect("seed run 2");
|
||||
rt.block_on(db1.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(4),
|
||||
Vec::new(),
|
||||
Some(123),
|
||||
Some("seed People 4".into()),
|
||||
))
|
||||
.expect("seed run 1");
|
||||
rt.block_on(db2.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(4),
|
||||
Vec::new(),
|
||||
Some(123),
|
||||
Some("seed People 4".into()),
|
||||
))
|
||||
.expect("seed run 2");
|
||||
|
||||
let csv1 = read_csv(&p1, "People").expect("csv 1");
|
||||
let csv2 = read_csv(&p2, "People").expect("csv 2");
|
||||
@@ -493,10 +555,24 @@ fn seed_fills_foreign_keys_from_existing_parents() {
|
||||
create_users_and_orders(&db, &rt, true);
|
||||
|
||||
// 5 parents → serial ids 1..=5.
|
||||
rt.block_on(db.seed("Users".into(), None, Some(5), Vec::new(), Some(1), Some("seed Users 5".into())))
|
||||
.expect("seed Users");
|
||||
rt.block_on(db.seed(
|
||||
"Users".into(),
|
||||
None,
|
||||
Some(5),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Users 5".into()),
|
||||
))
|
||||
.expect("seed Users");
|
||||
let res = rt
|
||||
.block_on(db.seed("Orders".into(), None, Some(10), Vec::new(), Some(2), Some("seed Orders 10".into())))
|
||||
.block_on(db.seed(
|
||||
"Orders".into(),
|
||||
None,
|
||||
Some(10),
|
||||
Vec::new(),
|
||||
Some(2),
|
||||
Some("seed Orders 10".into()),
|
||||
))
|
||||
.expect("seed Orders");
|
||||
assert_eq!(res.produced, 10, "every child row must insert (valid FK)");
|
||||
|
||||
@@ -520,10 +596,20 @@ fn seed_refuses_when_a_parent_table_is_empty() {
|
||||
|
||||
// Users is empty — no valid FK can be fabricated.
|
||||
let err = rt
|
||||
.block_on(db.seed("Orders".into(), None, Some(3), Vec::new(), Some(1), Some("seed Orders 3".into())))
|
||||
.block_on(db.seed(
|
||||
"Orders".into(),
|
||||
None,
|
||||
Some(3),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Orders 3".into()),
|
||||
))
|
||||
.expect_err("seed must refuse an empty parent");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Users"), "error should name the empty parent: {msg}");
|
||||
assert!(
|
||||
msg.contains("Users"),
|
||||
"error should name the empty parent: {msg}"
|
||||
);
|
||||
let lower = msg.to_lowercase();
|
||||
assert!(
|
||||
lower.contains("no rows") || lower.contains("first"),
|
||||
@@ -546,7 +632,14 @@ fn seed_refuses_a_not_null_blob_column() {
|
||||
.expect("create Files");
|
||||
|
||||
let err = rt
|
||||
.block_on(db.seed("Files".into(), None, Some(2), Vec::new(), Some(1), Some("seed Files 2".into())))
|
||||
.block_on(db.seed(
|
||||
"Files".into(),
|
||||
None,
|
||||
Some(2),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Files 2".into()),
|
||||
))
|
||||
.expect_err("seed must refuse a NOT NULL blob");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
@@ -573,7 +666,14 @@ fn seed_omits_a_nullable_blob_column() {
|
||||
.expect("create Files");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Files".into(), None, Some(3), Vec::new(), Some(1), Some("seed Files 3".into())))
|
||||
.block_on(db.seed(
|
||||
"Files".into(),
|
||||
None,
|
||||
Some(3),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Files 3".into()),
|
||||
))
|
||||
.expect("seed succeeds despite the nullable blob");
|
||||
assert_eq!(res.produced, 3);
|
||||
let csv = read_csv(&project, "Files").expect("Files CSV");
|
||||
@@ -607,14 +707,25 @@ fn seed_keeps_unique_columns_distinct() {
|
||||
.expect("create Tags");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Tags".into(), None, Some(8), Vec::new(), Some(3), Some("seed Tags 8".into())))
|
||||
.block_on(db.seed(
|
||||
"Tags".into(),
|
||||
None,
|
||||
Some(8),
|
||||
Vec::new(),
|
||||
Some(3),
|
||||
Some("seed Tags 8".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 8);
|
||||
|
||||
let csv = read_csv(&project, "Tags").expect("Tags CSV");
|
||||
let labels = nth_column_values(&csv, 1);
|
||||
let distinct: std::collections::HashSet<&String> = labels.iter().collect();
|
||||
assert_eq!(distinct.len(), labels.len(), "UNIQUE column has duplicates:\n{csv}");
|
||||
assert_eq!(
|
||||
distinct.len(),
|
||||
labels.len(),
|
||||
"UNIQUE column has duplicates:\n{csv}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -636,7 +747,14 @@ fn seed_sequences_identifier_int_columns() {
|
||||
.expect("create Items");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Items".into(), None, Some(5), Vec::new(), Some(1), Some("seed Items 5".into())))
|
||||
.block_on(db.seed(
|
||||
"Items".into(),
|
||||
None,
|
||||
Some(5),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Items 5".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 5);
|
||||
|
||||
@@ -646,7 +764,11 @@ fn seed_sequences_identifier_int_columns() {
|
||||
.map(|s| s.parse().expect("code is an int"))
|
||||
.collect();
|
||||
let distinct: std::collections::HashSet<i64> = codes.iter().copied().collect();
|
||||
assert_eq!(distinct.len(), 5, "identifier ints must be unique: {codes:?}");
|
||||
assert_eq!(
|
||||
distinct.len(),
|
||||
5,
|
||||
"identifier ints must be unique: {codes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -667,14 +789,24 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
|
||||
)
|
||||
.await
|
||||
.expect("create parent");
|
||||
db.seed(t.into(), None, Some(2), Vec::new(), Some(1), Some(format!("seed {t} 2")))
|
||||
.await
|
||||
.expect("seed parent");
|
||||
db.seed(
|
||||
t.into(),
|
||||
None,
|
||||
Some(2),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some(format!("seed {t} 2")),
|
||||
)
|
||||
.await
|
||||
.expect("seed parent");
|
||||
}
|
||||
// Junction with a compound PK over its two FK columns.
|
||||
db.create_table(
|
||||
"J".to_string(),
|
||||
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
|
||||
vec![
|
||||
ColumnSpec::new("a", Type::Int),
|
||||
ColumnSpec::new("b", Type::Int),
|
||||
],
|
||||
vec!["a".to_string(), "b".to_string()],
|
||||
None,
|
||||
)
|
||||
@@ -709,11 +841,21 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
|
||||
|
||||
// Requesting 10 caps at the 4 available distinct combinations.
|
||||
let res = db
|
||||
.seed("J".into(), None, Some(10), Vec::new(), Some(7), Some("seed J 10".into()))
|
||||
.seed(
|
||||
"J".into(),
|
||||
None,
|
||||
Some(10),
|
||||
Vec::new(),
|
||||
Some(7),
|
||||
Some("seed J 10".into()),
|
||||
)
|
||||
.await
|
||||
.expect("seed J");
|
||||
assert_eq!(res.produced, 4, "junction caps at available combos");
|
||||
assert_eq!(res.requested, 10, "the requested count is reported for the cap note");
|
||||
assert_eq!(
|
||||
res.requested, 10,
|
||||
"the requested count is reported for the cap note"
|
||||
);
|
||||
});
|
||||
|
||||
let csv = read_csv(&project, "J").expect("J CSV");
|
||||
@@ -724,7 +866,11 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let distinct: std::collections::HashSet<&String> = pairs.iter().collect();
|
||||
assert_eq!(distinct.len(), pairs.len(), "junction rows must be distinct:\n{csv}");
|
||||
assert_eq!(
|
||||
distinct.len(),
|
||||
pairs.len(),
|
||||
"junction rows must be distinct:\n{csv}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -743,9 +889,19 @@ fn seed_draws_enum_values_from_an_in_check() {
|
||||
|
||||
// Every generated status must satisfy the CHECK, so all rows insert.
|
||||
let res = rt
|
||||
.block_on(db.seed("Tickets".into(), None, Some(12), Vec::new(), Some(2), Some("seed Tickets 12".into())))
|
||||
.block_on(db.seed(
|
||||
"Tickets".into(),
|
||||
None,
|
||||
Some(12),
|
||||
Vec::new(),
|
||||
Some(2),
|
||||
Some("seed Tickets 12".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 12, "all rows insert — values satisfy the CHECK");
|
||||
assert_eq!(
|
||||
res.produced, 12,
|
||||
"all rows insert — values satisfy the CHECK"
|
||||
);
|
||||
|
||||
let csv = read_csv(&project, "Tickets").expect("Tickets CSV");
|
||||
for v in nth_column_values(&csv, 1) {
|
||||
@@ -780,7 +936,14 @@ fn seed_advises_on_enum_ish_columns() {
|
||||
.expect("create Tasks");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Tasks".into(), None, Some(3), Vec::new(), Some(1), Some("seed Tasks 3".into())))
|
||||
.block_on(db.seed(
|
||||
"Tasks".into(),
|
||||
None,
|
||||
Some(3),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Tasks 3".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert!(
|
||||
res.advisory_columns.contains(&"status".to_string()),
|
||||
@@ -795,7 +958,14 @@ fn seed_refuses_an_excessive_count() {
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
let err = rt
|
||||
.block_on(db.seed("People".into(), None, Some(1_000_000), Vec::new(), Some(1), Some("seed People 1000000".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(1_000_000),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People 1000000".into()),
|
||||
))
|
||||
.expect_err("an excessive count must be refused");
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("maximum"),
|
||||
@@ -810,7 +980,14 @@ fn seed_preview_is_capped_but_count_is_full() {
|
||||
create_people(&db, &rt);
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("People".into(), None, Some(25), Vec::new(), Some(1), Some("seed People 25".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(25),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People 25".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 25, "the full count is produced");
|
||||
assert_eq!(res.data.rows.len(), 20, "the preview is capped at 20 rows");
|
||||
@@ -860,14 +1037,24 @@ fn seed_is_one_undo_step() {
|
||||
.expect("open db with undo");
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
rt.block_on(db.seed("People".into(), None, Some(6), Vec::new(), Some(1), Some("seed People 6".into())))
|
||||
.expect("seed");
|
||||
rt.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(6),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People 6".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(data_row_count(&read_csv(&project, "People").unwrap()), 6);
|
||||
|
||||
// One undo removes the whole seed batch (ADR-0048 D15).
|
||||
rt.block_on(db.undo()).unwrap().expect("undo applied");
|
||||
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
|
||||
assert_eq!(rows, 0, "one undo must remove every seeded row in a single step");
|
||||
assert_eq!(
|
||||
rows, 0,
|
||||
"one undo must remove every seeded row in a single step"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -882,10 +1069,17 @@ fn seed_column_fill_is_one_undo_step() {
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 5 --seed 1").expect("seed");
|
||||
// Fill `status` across all 5 rows with a constant, then undo once.
|
||||
run_seed(&db, &rt, "seed Members.status set status = 'flagged' --seed 2")
|
||||
.expect("column-fill");
|
||||
run_seed(
|
||||
&db,
|
||||
&rt,
|
||||
"seed Members.status set status = 'flagged' --seed 2",
|
||||
)
|
||||
.expect("column-fill");
|
||||
let before = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
|
||||
assert!(before.iter().all(|s| s == "flagged"), "all rows filled: {before:?}");
|
||||
assert!(
|
||||
before.iter().all(|s| s == "flagged"),
|
||||
"all rows filled: {before:?}"
|
||||
);
|
||||
|
||||
rt.block_on(db.undo()).unwrap().expect("undo applied");
|
||||
let after = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
|
||||
@@ -893,7 +1087,11 @@ fn seed_column_fill_is_one_undo_step() {
|
||||
after.iter().all(|s| s != "flagged"),
|
||||
"one undo reverts the whole column-fill in a single step: {after:?}"
|
||||
);
|
||||
assert_eq!(after.len(), 5, "undo restores the original rows, not removes them");
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
5,
|
||||
"undo restores the original rows, not removes them"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -930,10 +1128,23 @@ fn seed_rolls_back_atomically_on_a_constraint_failure() {
|
||||
))
|
||||
.expect("create Bad");
|
||||
|
||||
let res = rt.block_on(db.seed("Bad".into(), None, Some(5), Vec::new(), Some(1), Some("seed Bad 5".into())));
|
||||
assert!(res.is_err(), "seed must fail when generated rows violate the CHECK");
|
||||
let res = rt.block_on(db.seed(
|
||||
"Bad".into(),
|
||||
None,
|
||||
Some(5),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Bad 5".into()),
|
||||
));
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"seed must fail when generated rows violate the CHECK"
|
||||
);
|
||||
let rows = read_csv(&project, "Bad").map_or(0, |c| data_row_count(&c));
|
||||
assert_eq!(rows, 0, "a failed seed must leave the table unchanged (atomic)");
|
||||
assert_eq!(
|
||||
rows, 0,
|
||||
"a failed seed must leave the table unchanged (atomic)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -942,7 +1153,14 @@ fn seed_zero_is_a_no_op() {
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
let res = rt
|
||||
.block_on(db.seed("People".into(), None, Some(0), Vec::new(), Some(1), Some("seed People 0".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(0),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People 0".into()),
|
||||
))
|
||||
.expect("seed 0 succeeds");
|
||||
assert_eq!(res.produced, 0);
|
||||
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
|
||||
@@ -967,7 +1185,14 @@ fn seed_advises_on_a_complex_check_column() {
|
||||
.expect("create Widgets");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Widgets".into(), None, Some(3), Vec::new(), Some(1), Some("seed Widgets 3".into())))
|
||||
.block_on(db.seed(
|
||||
"Widgets".into(),
|
||||
None,
|
||||
Some(3),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Widgets 3".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert!(
|
||||
res.advisory_columns.contains(&"label".to_string()),
|
||||
@@ -981,10 +1206,24 @@ fn seed_foreign_keys_are_reproducible_with_a_fixed_seed() {
|
||||
let rt = rt();
|
||||
let seed_one = |db: &Database| {
|
||||
create_users_and_orders(db, &rt, true);
|
||||
rt.block_on(db.seed("Users".into(), None, Some(4), Vec::new(), Some(1), Some("seed Users 4".into())))
|
||||
.expect("seed users");
|
||||
rt.block_on(db.seed("Orders".into(), None, Some(8), Vec::new(), Some(99), Some("seed Orders 8".into())))
|
||||
.expect("seed orders");
|
||||
rt.block_on(db.seed(
|
||||
"Users".into(),
|
||||
None,
|
||||
Some(4),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Users 4".into()),
|
||||
))
|
||||
.expect("seed users");
|
||||
rt.block_on(db.seed(
|
||||
"Orders".into(),
|
||||
None,
|
||||
Some(8),
|
||||
Vec::new(),
|
||||
Some(99),
|
||||
Some("seed Orders 8".into()),
|
||||
))
|
||||
.expect("seed orders");
|
||||
};
|
||||
let (p1, db1, _d1) = open_project_db();
|
||||
let (p2, db2, _d2) = open_project_db();
|
||||
@@ -1013,8 +1252,15 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
|
||||
None,
|
||||
))
|
||||
.expect("create Contacts");
|
||||
rt.block_on(db.seed("Contacts".into(), None, Some(5), Vec::new(), Some(42), Some("seed Contacts 5".into())))
|
||||
.expect("seed");
|
||||
rt.block_on(db.seed(
|
||||
"Contacts".into(),
|
||||
None,
|
||||
Some(5),
|
||||
Vec::new(),
|
||||
Some(42),
|
||||
Some("seed Contacts 5".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
};
|
||||
let (p1, db1, _d1) = open_project_db();
|
||||
let (p2, db2, _d2) = open_project_db();
|
||||
@@ -1023,13 +1269,20 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
|
||||
|
||||
let csv1 = read_csv(&p1, "Contacts").unwrap();
|
||||
let csv2 = read_csv(&p2, "Contacts").unwrap();
|
||||
assert_eq!(csv1, csv2, "shortid values must reproduce under a fixed --seed");
|
||||
assert_eq!(
|
||||
csv1, csv2,
|
||||
"shortid values must reproduce under a fixed --seed"
|
||||
);
|
||||
|
||||
// The shortid PK is populated with distinct 10-char base58 ids.
|
||||
let codes = nth_column_values(&csv1, 0);
|
||||
assert_eq!(codes.len(), 5);
|
||||
let distinct: std::collections::HashSet<&String> = codes.iter().collect();
|
||||
assert_eq!(distinct.len(), 5, "shortid PK values must be distinct: {codes:?}");
|
||||
assert_eq!(
|
||||
distinct.len(),
|
||||
5,
|
||||
"shortid PK values must be distinct: {codes:?}"
|
||||
);
|
||||
for code in &codes {
|
||||
assert_eq!(code.len(), 10, "shortid should be 10 chars: {code}");
|
||||
}
|
||||
@@ -1105,7 +1358,10 @@ fn seed_set_fixed_value_fills_every_row() {
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
let statuses = named_column_values(&csv, "status");
|
||||
assert_eq!(statuses.len(), 6);
|
||||
assert!(statuses.iter().all(|s| s == "active"), "every status pinned: {statuses:?}");
|
||||
assert!(
|
||||
statuses.iter().all(|s| s == "active"),
|
||||
"every status pinned: {statuses:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1113,7 +1369,12 @@ fn seed_set_pick_list_draws_only_from_the_list() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 20 set role in ('admin', 'user') --seed 2").expect("seed");
|
||||
run_seed(
|
||||
&db,
|
||||
&rt,
|
||||
"seed Members 20 set role in ('admin', 'user') --seed 2",
|
||||
)
|
||||
.expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
let roles = named_column_values(&csv, "role");
|
||||
assert!(
|
||||
@@ -1131,7 +1392,10 @@ fn seed_set_as_generator_forces_the_shape() {
|
||||
run_seed(&db, &rt, "seed Members 5 set name as email --seed 3").expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
let names = named_column_values(&csv, "name");
|
||||
assert!(names.iter().all(|n| n.contains('@')), "name forced to email shape: {names:?}");
|
||||
assert!(
|
||||
names.iter().all(|n| n.contains('@')),
|
||||
"name forced to email shape: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1139,7 +1403,12 @@ fn seed_set_numeric_range_stays_within_bounds() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 30 set age between 30 and 40 --seed 4").expect("seed");
|
||||
run_seed(
|
||||
&db,
|
||||
&rt,
|
||||
"seed Members 30 set age between 30 and 40 --seed 4",
|
||||
)
|
||||
.expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
for a in named_column_values(&csv, "age") {
|
||||
let n: i64 = a.parse().unwrap_or_else(|_| panic!("age `{a}` not an int"));
|
||||
@@ -1190,7 +1459,10 @@ fn seed_incompatible_range_is_a_friendly_error() {
|
||||
// A numeric range on a text column (`name`) is rejected.
|
||||
let err = run_seed(&db, &rt, "seed Members 3 set name between 1 and 10").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("between"), "range error should mention `between`: {msg}");
|
||||
assert!(
|
||||
msg.contains("between"),
|
||||
"range error should mention `between`: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1221,8 +1493,12 @@ fn seed_column_fill_updates_existing_rows_without_adding() {
|
||||
let before = data_row_count(&read_csv(&project, "Members").unwrap());
|
||||
assert_eq!(before, 5);
|
||||
|
||||
let res = run_seed(&db, &rt, "seed Members.status set status in ('x', 'y') --seed 2")
|
||||
.expect("column-fill");
|
||||
let res = run_seed(
|
||||
&db,
|
||||
&rt,
|
||||
"seed Members.status set status in ('x', 'y') --seed 2",
|
||||
)
|
||||
.expect("column-fill");
|
||||
assert_eq!(res.produced, 5, "column-fill touches the 5 existing rows");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
assert_eq!(data_row_count(&csv), 5, "no new rows added");
|
||||
@@ -1240,7 +1516,10 @@ fn seed_column_fill_refuses_a_pk_target() {
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 3 --seed 1").expect("seed");
|
||||
let err = run_seed(&db, &rt, "seed Members.id").unwrap_err();
|
||||
assert!(format!("{err}").contains("primary key"), "PK target refused: {err}");
|
||||
assert!(
|
||||
format!("{err}").contains("primary key"),
|
||||
"PK target refused: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1282,7 +1561,10 @@ fn seed_column_fill_rejects_a_row_count() {
|
||||
Some("seed Members.status 5".into()),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("no row count"), "count refused: {err}");
|
||||
assert!(
|
||||
format!("{err}").contains("no row count"),
|
||||
"count refused: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1298,7 +1580,11 @@ fn seed_column_fill_fk_target_samples_the_parent() {
|
||||
assert_eq!(res.produced, 8);
|
||||
let csv = read_csv(&project, "Orders").unwrap();
|
||||
let user_ids = named_column_values(&csv, "user_id");
|
||||
assert!(user_ids.iter().all(|v| (1..=4).contains(&v.parse::<i64>().unwrap())));
|
||||
assert!(
|
||||
user_ids
|
||||
.iter()
|
||||
.all(|v| (1..=4).contains(&v.parse::<i64>().unwrap()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1310,14 +1596,11 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
|
||||
let rt = rt();
|
||||
rt.block_on(db.create_table(
|
||||
"U".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
{
|
||||
let mut c = ColumnSpec::new("email", Type::Text);
|
||||
c.unique = true;
|
||||
c
|
||||
},
|
||||
],
|
||||
vec![ColumnSpec::new("id", Type::Serial), {
|
||||
let mut c = ColumnSpec::new("email", Type::Text);
|
||||
c.unique = true;
|
||||
c
|
||||
}],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
@@ -1330,7 +1613,10 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
|
||||
);
|
||||
// A short pick-list (< count) is likewise refused...
|
||||
let err2 = run_seed(&db, &rt, "seed U 5 set email in ('a@b.c', 'd@e.f')").unwrap_err();
|
||||
assert!(format!("{err2}").contains("distinct"), "short list refused: {err2}");
|
||||
assert!(
|
||||
format!("{err2}").contains("distinct"),
|
||||
"short list refused: {err2}"
|
||||
);
|
||||
// ...but a pick-list with enough distinct values succeeds.
|
||||
let ok = run_seed(
|
||||
&db,
|
||||
@@ -1354,14 +1640,11 @@ fn seed_column_fill_fixed_on_unique_column_is_a_friendly_error() {
|
||||
let rt = rt();
|
||||
rt.block_on(db.create_table(
|
||||
"U".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
{
|
||||
let mut c = ColumnSpec::new("email", Type::Text);
|
||||
c.unique = true;
|
||||
c
|
||||
},
|
||||
],
|
||||
vec![ColumnSpec::new("id", Type::Serial), {
|
||||
let mut c = ColumnSpec::new("email", Type::Text);
|
||||
c.unique = true;
|
||||
c
|
||||
}],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
|
||||
+43
-27
@@ -17,7 +17,7 @@ use rdbms_playground::action::Action;
|
||||
use rdbms_playground::app::App;
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{
|
||||
parse_command, ColumnSpec, Command, ReferentialAction, ShowListKind, Type,
|
||||
ColumnSpec, Command, ReferentialAction, ShowListKind, Type, parse_command,
|
||||
};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::mode::Mode;
|
||||
@@ -108,8 +108,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -195,8 +194,7 @@ fn show_relationships_lists_name_endpoints_and_nondefault_action() {
|
||||
// Name, both endpoints, and the non-default ON DELETE CASCADE
|
||||
// (ON UPDATE NO ACTION is the default and is omitted).
|
||||
assert_eq!(
|
||||
lines[1],
|
||||
" orders_customer: Customers.id → Orders.customer_id on delete cascade",
|
||||
lines[1], " orders_customer: Customers.id → Orders.customer_id on delete cascade",
|
||||
"relationship summary line: {lines:?}",
|
||||
);
|
||||
}
|
||||
@@ -222,7 +220,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
|
||||
let rt = rt();
|
||||
// No schema seeded — every kind is empty.
|
||||
assert_eq!(
|
||||
rt.block_on(db.show_list(ShowListKind::Tables, None)).unwrap(),
|
||||
rt.block_on(db.show_list(ShowListKind::Tables, None))
|
||||
.unwrap(),
|
||||
vec!["No tables in this project yet.".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -231,7 +230,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
|
||||
vec!["No relationships in this project yet.".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
rt.block_on(db.show_list(ShowListKind::Indexes, None)).unwrap(),
|
||||
rt.block_on(db.show_list(ShowListKind::Indexes, None))
|
||||
.unwrap(),
|
||||
vec!["No indexes in this project yet.".to_string()],
|
||||
);
|
||||
}
|
||||
@@ -246,7 +246,10 @@ fn show_one_relationship_renders_detail_block() {
|
||||
let rt = rt();
|
||||
rt.block_on(seed_schema(&db));
|
||||
let lines = rt
|
||||
.block_on(db.show_list(ShowListKind::Relationships, Some("orders_customer".to_string())))
|
||||
.block_on(db.show_list(
|
||||
ShowListKind::Relationships,
|
||||
Some("orders_customer".to_string()),
|
||||
))
|
||||
.expect("show relationship");
|
||||
assert_eq!(lines[0], "Relationship `orders_customer`:");
|
||||
assert_eq!(lines[1], " Customers.id → Orders.customer_id");
|
||||
@@ -262,7 +265,10 @@ fn show_one_index_renders_detail_block() {
|
||||
let rt = rt();
|
||||
rt.block_on(seed_schema(&db));
|
||||
let lines = rt
|
||||
.block_on(db.show_list(ShowListKind::Indexes, Some("idx_orders_customer".to_string())))
|
||||
.block_on(db.show_list(
|
||||
ShowListKind::Indexes,
|
||||
Some("idx_orders_customer".to_string()),
|
||||
))
|
||||
.expect("show index");
|
||||
assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:");
|
||||
assert!(
|
||||
@@ -329,7 +335,10 @@ fn app_show_tables_dispatches_show_list_command() {
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(dispatched, "submit dispatches ShowList(Tables): {actions:?}");
|
||||
assert!(
|
||||
dispatched,
|
||||
"submit dispatches ShowList(Tables): {actions:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -337,10 +346,11 @@ fn app_renders_show_list_lines_as_system_output() {
|
||||
// Feed the success event directly so the test stays
|
||||
// self-contained (the worker round-trip is covered above).
|
||||
let mut app = App::new();
|
||||
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show tables",
|
||||
Mode::Simple,
|
||||
));
|
||||
app.output
|
||||
.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show tables",
|
||||
Mode::Simple,
|
||||
));
|
||||
app.update(AppEvent::DslShowListSucceeded {
|
||||
command: Command::ShowList {
|
||||
kind: ShowListKind::Tables,
|
||||
@@ -403,10 +413,11 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
|
||||
.expect("found");
|
||||
|
||||
let mut app = App::new();
|
||||
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show relationship orders_customer",
|
||||
Mode::Simple,
|
||||
));
|
||||
app.output
|
||||
.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show relationship orders_customer",
|
||||
Mode::Simple,
|
||||
));
|
||||
app.update(AppEvent::DslShowRelationshipSucceeded {
|
||||
command: Command::ShowList {
|
||||
kind: ShowListKind::Relationships,
|
||||
@@ -423,7 +434,10 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
|
||||
// Both tables, box-drawing, the connector arrow, the actions line.
|
||||
assert!(text.contains("Orders"), "child box: {text}");
|
||||
assert!(text.contains("Customers"), "parent box: {text}");
|
||||
assert!(text.contains('┌') && text.contains('│'), "box drawing: {text}");
|
||||
assert!(
|
||||
text.contains('┌') && text.contains('│'),
|
||||
"box drawing: {text}"
|
||||
);
|
||||
assert!(text.contains('▶'), "connector arrow: {text}");
|
||||
assert!(text.contains("on delete cascade"), "actions: {text}");
|
||||
// The diagram lines are styled (per-span runs), not plain system.
|
||||
@@ -436,10 +450,11 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
|
||||
#[test]
|
||||
fn app_show_relationship_not_found_shows_friendly_line() {
|
||||
let mut app = App::new();
|
||||
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show relationship nope",
|
||||
Mode::Simple,
|
||||
));
|
||||
app.output
|
||||
.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show relationship nope",
|
||||
Mode::Simple,
|
||||
));
|
||||
app.update(AppEvent::DslShowRelationshipSucceeded {
|
||||
command: Command::ShowList {
|
||||
kind: ShowListKind::Relationships,
|
||||
@@ -466,10 +481,11 @@ fn app_show_table_renders_relationships_as_compact_diagrams() {
|
||||
.expect("describe Orders");
|
||||
|
||||
let mut app = App::new();
|
||||
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show table Orders",
|
||||
Mode::Simple,
|
||||
));
|
||||
app.output
|
||||
.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show table Orders",
|
||||
Mode::Simple,
|
||||
));
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::ShowTable {
|
||||
name: "Orders".to_string(),
|
||||
|
||||
+200
-56
@@ -30,8 +30,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
@@ -42,8 +41,7 @@ fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
|
||||
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let db = Database::open_with_persistence_and_undo(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
@@ -159,7 +157,10 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
|
||||
// Final schema: id, label (renamed from v), qty; `note` added then
|
||||
// dropped.
|
||||
let cols = column_names(&db, &r);
|
||||
assert_eq!(cols, vec!["id".to_string(), "label".to_string(), "qty".to_string()]);
|
||||
assert_eq!(
|
||||
cols,
|
||||
vec!["id".to_string(), "label".to_string(), "qty".to_string()]
|
||||
);
|
||||
|
||||
// The DEFAULT backfilled the pre-existing row to qty = 0.
|
||||
let rows = r
|
||||
@@ -168,14 +169,21 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
|
||||
.rows;
|
||||
assert_eq!(rows.len(), 1);
|
||||
// qty is the third column; the rebuild backfilled the default.
|
||||
assert_eq!(rows[0][2].as_deref(), Some("0"), "DEFAULT 0 backfilled the existing row");
|
||||
assert_eq!(
|
||||
rows[0][2].as_deref(),
|
||||
Some("0"),
|
||||
"DEFAULT 0 backfilled the existing row"
|
||||
);
|
||||
|
||||
// The CHECK (qty >= 0) is enforced: a negative qty is refused.
|
||||
assert!(
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "qty".to_string()]),
|
||||
vec![Value::Number("2".to_string()), Value::Number("-1".to_string())],
|
||||
vec![
|
||||
Value::Number("2".to_string()),
|
||||
Value::Number("-1".to_string())
|
||||
],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.is_err(),
|
||||
@@ -185,7 +193,10 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "qty".to_string()]),
|
||||
vec![Value::Number("3".to_string()), Value::Number("7".to_string())],
|
||||
vec![
|
||||
Value::Number("3".to_string()),
|
||||
Value::Number("7".to_string()),
|
||||
],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.expect("qty = 7 satisfies the CHECK");
|
||||
@@ -214,7 +225,10 @@ fn e2e_alter_add_column_survives_rebuild() {
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "qty".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("-5".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("-5".to_string())
|
||||
],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.is_err(),
|
||||
@@ -257,9 +271,17 @@ fn e2e_alter_column_type_clean_and_lossy_convert() {
|
||||
.rows;
|
||||
assert_eq!(rows.len(), 1);
|
||||
// v (col 1): lossy real→int performed → 3.7 stored as 3.
|
||||
assert_eq!(rows[0][1].as_deref(), Some("3"), "lossy real→int performed (3.7→3)");
|
||||
assert_eq!(
|
||||
rows[0][1].as_deref(),
|
||||
Some("3"),
|
||||
"lossy real→int performed (3.7→3)"
|
||||
);
|
||||
// w (col 2): clean int→text stringified → "42".
|
||||
assert_eq!(rows[0][2].as_deref(), Some("42"), "clean int→text stringified");
|
||||
assert_eq!(
|
||||
rows[0][2].as_deref(),
|
||||
Some("42"),
|
||||
"clean int→text stringified"
|
||||
);
|
||||
|
||||
// The columns now carry the new user-facing types (round-tripped
|
||||
// through the metadata).
|
||||
@@ -290,12 +312,20 @@ fn e2e_alter_column_type_int_to_serial_is_allowed() {
|
||||
}
|
||||
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
|
||||
}
|
||||
assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column");
|
||||
assert_eq!(
|
||||
col_type(&db, &r, "n"),
|
||||
Some(Type::Serial),
|
||||
"int→serial converted the column"
|
||||
);
|
||||
let rows = r
|
||||
.block_on(db.query_data("T".to_string(), None, None))
|
||||
.expect("query")
|
||||
.rows;
|
||||
assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved");
|
||||
assert_eq!(
|
||||
rows[0][1].as_deref(),
|
||||
Some("100"),
|
||||
"the existing value is preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -368,11 +398,19 @@ fn e2e_alter_column_type_survives_rebuild() {
|
||||
)
|
||||
.expect("write script");
|
||||
r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
||||
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "converted before rebuild");
|
||||
assert_eq!(
|
||||
col_type(&db, &r, "v"),
|
||||
Some(Type::Int),
|
||||
"converted before rebuild"
|
||||
);
|
||||
|
||||
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
||||
.expect("rebuild");
|
||||
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the converted type survives rebuild");
|
||||
assert_eq!(
|
||||
col_type(&db, &r, "v"),
|
||||
Some(Type::Int),
|
||||
"the converted type survives rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -393,14 +431,22 @@ fn e2e_alter_column_type_is_one_undo_step() {
|
||||
)
|
||||
.expect("write script");
|
||||
r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
||||
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the SQL ALTER COLUMN TYPE converted v");
|
||||
assert_eq!(
|
||||
col_type(&db, &r, "v"),
|
||||
Some(Type::Int),
|
||||
"the SQL ALTER COLUMN TYPE converted v"
|
||||
);
|
||||
|
||||
// A single undo reverts the whole conversion.
|
||||
assert!(
|
||||
r.block_on(db.undo()).expect("undo").is_some(),
|
||||
"the conversion was one undo step"
|
||||
);
|
||||
assert_eq!(col_type(&db, &r, "v"), Some(Type::Real), "one undo restored the pre-conversion type");
|
||||
assert_eq!(
|
||||
col_type(&db, &r, "v"),
|
||||
Some(Type::Real),
|
||||
"one undo restored the pre-conversion type"
|
||||
);
|
||||
}
|
||||
|
||||
// --- 4g: ADD/DROP constraint + ADD foreign key (ADR-0035 §4g) -----------
|
||||
@@ -410,7 +456,10 @@ fn insert_t_qty_ok(db: &Database, r: &tokio::runtime::Runtime, id: i64, qty: i64
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "qty".to_string()]),
|
||||
vec![Value::Number(id.to_string()), Value::Number(qty.to_string())],
|
||||
vec![
|
||||
Value::Number(id.to_string()),
|
||||
Value::Number(qty.to_string()),
|
||||
],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.is_ok()
|
||||
@@ -436,14 +485,23 @@ fn e2e_add_named_check_enforced_and_survives_rebuild_with_its_name() {
|
||||
"events: {events:?}"
|
||||
);
|
||||
// Enforced: qty = -1 refused, qty = 5 accepted.
|
||||
assert!(!insert_t_qty_ok(&db, &r, 1, -1), "the CHECK rejects qty = -1");
|
||||
assert!(insert_t_qty_ok(&db, &r, 2, 5), "qty = 5 satisfies the CHECK");
|
||||
assert!(
|
||||
!insert_t_qty_ok(&db, &r, 1, -1),
|
||||
"the CHECK rejects qty = -1"
|
||||
);
|
||||
assert!(
|
||||
insert_t_qty_ok(&db, &r, 2, 5),
|
||||
"qty = 5 satisfies the CHECK"
|
||||
);
|
||||
|
||||
// Rebuild from text, then DROP CONSTRAINT by name must still work →
|
||||
// the name survived the round-trip.
|
||||
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
||||
.expect("rebuild");
|
||||
assert!(!insert_t_qty_ok(&db, &r, 3, -2), "the CHECK is intact after rebuild");
|
||||
assert!(
|
||||
!insert_t_qty_ok(&db, &r, 3, -2),
|
||||
"the CHECK is intact after rebuild"
|
||||
);
|
||||
std::fs::write(
|
||||
project.path().join("drop.commands"),
|
||||
"alter table T drop constraint qty_positive\n",
|
||||
@@ -502,13 +560,19 @@ fn e2e_add_composite_unique_enforced_and_survives_rebuild() {
|
||||
))
|
||||
.is_ok()
|
||||
};
|
||||
assert!(!dup_ok(2, 1, 2), "the composite UNIQUE rejects the duplicate (1, 2)");
|
||||
assert!(
|
||||
!dup_ok(2, 1, 2),
|
||||
"the composite UNIQUE rejects the duplicate (1, 2)"
|
||||
);
|
||||
assert!(dup_ok(3, 1, 3), "(1, 3) is distinct and accepted");
|
||||
|
||||
// Survives rebuild (the unique_constraints yaml path).
|
||||
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
||||
.expect("rebuild");
|
||||
assert!(!dup_ok(4, 1, 2), "the composite UNIQUE is intact after rebuild");
|
||||
assert!(
|
||||
!dup_ok(4, 1, 2),
|
||||
"the composite UNIQUE is intact after rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -559,7 +623,10 @@ fn e2e_drop_composite_unique_by_derived_name() {
|
||||
.is_ok()
|
||||
};
|
||||
assert!(dup_ok(1, 1, 2), "first (1, 2) accepted");
|
||||
assert!(!dup_ok(2, 1, 2), "duplicate (1, 2) rejected while the UNIQUE stands");
|
||||
assert!(
|
||||
!dup_ok(2, 1, 2),
|
||||
"duplicate (1, 2) rejected while the UNIQUE stands"
|
||||
);
|
||||
|
||||
// Drop the UNIQUE by its derived name through the existing DROP
|
||||
// CONSTRAINT grammar.
|
||||
@@ -572,7 +639,10 @@ fn e2e_drop_composite_unique_by_derived_name() {
|
||||
|
||||
// The UNIQUE no longer enforces: the previously-rejected duplicate is
|
||||
// now accepted.
|
||||
assert!(dup_ok(3, 1, 2), "duplicate (1, 2) accepted after the UNIQUE was dropped");
|
||||
assert!(
|
||||
dup_ok(3, 1, 2),
|
||||
"duplicate (1, 2) accepted after the UNIQUE was dropped"
|
||||
);
|
||||
|
||||
// And it stays gone across a rebuild from text.
|
||||
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
||||
@@ -676,8 +746,14 @@ fn e2e_add_foreign_key_missing_child_column_refuses_without_dsl_flag() {
|
||||
let AppEvent::ReplayFailed { error, .. } = events.last().expect("an event") else {
|
||||
panic!("expected ReplayFailed; events: {events:?}");
|
||||
};
|
||||
assert!(!error.contains("--create-fk"), "no DSL flag in the SQL refusal; got: {error}");
|
||||
assert!(error.contains("pid"), "names the missing column; got: {error}");
|
||||
assert!(
|
||||
!error.contains("--create-fk"),
|
||||
"no DSL flag in the SQL refusal; got: {error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("pid"),
|
||||
"names the missing column; got: {error}"
|
||||
);
|
||||
assert!(
|
||||
error.to_lowercase().contains("add it first")
|
||||
|| error.to_lowercase().contains("does not exist"),
|
||||
@@ -709,12 +785,21 @@ fn e2e_add_foreign_key_creates_an_enforced_relationship() {
|
||||
r.block_on(db.insert(
|
||||
"C".to_string(),
|
||||
Some(vec!["cid".to_string(), "pid".to_string()]),
|
||||
vec![Value::Number(cid.to_string()), Value::Number(pid.to_string())],
|
||||
vec![
|
||||
Value::Number(cid.to_string()),
|
||||
Value::Number(pid.to_string()),
|
||||
],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
};
|
||||
assert!(insert_c(10, 1).is_ok(), "a child referencing parent id=1 is accepted");
|
||||
assert!(insert_c(11, 999).is_err(), "a child referencing a missing parent is rejected");
|
||||
assert!(
|
||||
insert_c(10, 1).is_ok(),
|
||||
"a child referencing parent id=1 is accepted"
|
||||
);
|
||||
assert!(
|
||||
insert_c(11, 999).is_err(),
|
||||
"a child referencing a missing parent is rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -740,7 +825,10 @@ fn e2e_drop_constraint_removes_a_named_foreign_key() {
|
||||
r.block_on(db.insert(
|
||||
"C".to_string(),
|
||||
Some(vec!["cid".to_string(), "pid".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("999".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("999".to_string())
|
||||
],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.is_ok(),
|
||||
@@ -798,7 +886,10 @@ fn e2e_add_constraint_is_one_undo_step() {
|
||||
"the ADD CONSTRAINT was one undo step"
|
||||
);
|
||||
// After undo the CHECK is gone: qty = -1 is accepted.
|
||||
assert!(insert_t_qty_ok(&db, &r, 3, -1), "one undo removed the CHECK");
|
||||
assert!(
|
||||
insert_t_qty_ok(&db, &r, 3, -1),
|
||||
"one undo removed the CHECK"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -819,7 +910,10 @@ fn e2e_named_check_metadata_survives_a_fresh_rebuild() {
|
||||
.expect("db");
|
||||
r.block_on(db.sql_create_table(
|
||||
"T".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("qty", Type::Int)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Int),
|
||||
ColumnSpec::new("qty", Type::Int),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
@@ -846,7 +940,8 @@ fn e2e_named_check_metadata_survives_a_fresh_rebuild() {
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), None)).expect("rebuild");
|
||||
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), None))
|
||||
.expect("rebuild");
|
||||
|
||||
// The named CHECK metadata survived: DROP CONSTRAINT by name resolves.
|
||||
r.block_on(db.alter_drop_constraint(
|
||||
@@ -878,7 +973,9 @@ fn e2e_describe_shows_table_level_constraints() {
|
||||
"events: {events:?}"
|
||||
);
|
||||
|
||||
let desc = r.block_on(db.describe_table("T".to_string())).expect("describe");
|
||||
let desc = r
|
||||
.block_on(db.describe_table("T".to_string()))
|
||||
.expect("describe");
|
||||
assert_eq!(
|
||||
desc.unique_constraints,
|
||||
vec![vec!["a".to_string(), "b".to_string()]],
|
||||
@@ -890,7 +987,9 @@ fn e2e_describe_shows_table_level_constraints() {
|
||||
.map(|c| (c.name.clone(), c.expr.clone()))
|
||||
.collect();
|
||||
assert!(
|
||||
checks.iter().any(|(n, e)| n.is_none() && e.contains("a < b")),
|
||||
checks
|
||||
.iter()
|
||||
.any(|(n, e)| n.is_none() && e.contains("a < b")),
|
||||
"unnamed table CHECK surfaced: {checks:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -972,8 +1071,14 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() {
|
||||
tables.contains(&"Purchases".to_string()) && !tables.contains(&"Orders".to_string()),
|
||||
"the table is now Purchases, not Orders: {tables:?}"
|
||||
);
|
||||
assert!(csv_path(&project, "Purchases").exists(), "data/Purchases.csv written");
|
||||
assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed");
|
||||
assert!(
|
||||
csv_path(&project, "Purchases").exists(),
|
||||
"data/Purchases.csv written"
|
||||
);
|
||||
assert!(
|
||||
!csv_path(&project, "Orders").exists(),
|
||||
"data/Orders.csv removed"
|
||||
);
|
||||
|
||||
let rows = r
|
||||
.block_on(db.query_data("Purchases".to_string(), None, None))
|
||||
@@ -1052,7 +1157,10 @@ fn e2e_rename_table_with_table_qualified_check_survives_fresh_rebuild() {
|
||||
],
|
||||
Some("i".into()),
|
||||
));
|
||||
assert!(bad_after.is_err(), "the rewritten CHECK enforces after a fresh rebuild");
|
||||
assert!(
|
||||
bad_after.is_err(),
|
||||
"the rewritten CHECK enforces after a fresh rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1077,7 +1185,9 @@ fn e2e_rename_fk_parent_updates_metadata_and_still_enforces() {
|
||||
);
|
||||
|
||||
// The child's outbound relationship now points at the new parent name.
|
||||
let c = r.block_on(db.describe_table("C".to_string())).expect("describe C");
|
||||
let c = r
|
||||
.block_on(db.describe_table("C".to_string()))
|
||||
.expect("describe C");
|
||||
assert_eq!(c.outbound_relationships.len(), 1);
|
||||
assert_eq!(c.outbound_relationships[0].other_table, "Parent");
|
||||
|
||||
@@ -1129,7 +1239,9 @@ fn e2e_rename_fk_child_updates_metadata_and_still_enforces() {
|
||||
);
|
||||
|
||||
// The parent's inbound relationship now names the renamed child.
|
||||
let p = r.block_on(db.describe_table("P".to_string())).expect("describe P");
|
||||
let p = r
|
||||
.block_on(db.describe_table("P".to_string()))
|
||||
.expect("describe P");
|
||||
assert_eq!(p.inbound_relationships.len(), 1);
|
||||
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
||||
|
||||
@@ -1168,7 +1280,9 @@ fn e2e_rename_self_referential_table_updates_both_ends() {
|
||||
);
|
||||
|
||||
// Both ends of the self-reference now name `Tree`.
|
||||
let t = r.block_on(db.describe_table("Tree".to_string())).expect("describe Tree");
|
||||
let t = r
|
||||
.block_on(db.describe_table("Tree".to_string()))
|
||||
.expect("describe Tree");
|
||||
assert_eq!(t.outbound_relationships[0].other_table, "Tree");
|
||||
assert_eq!(t.inbound_relationships[0].other_table, "Tree");
|
||||
|
||||
@@ -1216,7 +1330,9 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
|
||||
"events: {events:?}"
|
||||
);
|
||||
|
||||
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users");
|
||||
let u = r
|
||||
.block_on(db.describe_table("Users".to_string()))
|
||||
.expect("describe Users");
|
||||
assert_eq!(u.indexes.len(), 1, "the index followed the rename");
|
||||
assert_eq!(
|
||||
u.indexes[0].name, "T_email_idx",
|
||||
@@ -1226,7 +1342,9 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
|
||||
|
||||
// Survives a fresh rebuild (recreated from IndexSchema on table Users).
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users");
|
||||
let u = r
|
||||
.block_on(db.describe_table("Users".to_string()))
|
||||
.expect("describe Users");
|
||||
assert_eq!(u.indexes.len(), 1);
|
||||
assert_eq!(u.indexes[0].name, "T_email_idx");
|
||||
}
|
||||
@@ -1248,14 +1366,20 @@ fn e2e_rename_table_is_one_undo_step() {
|
||||
assert!(table_names(&db, &r).contains(&"Purchases".to_string()));
|
||||
|
||||
// One undo reverts the rename.
|
||||
assert!(r.block_on(db.undo()).expect("undo").is_some(), "rename was one undo step");
|
||||
assert!(
|
||||
r.block_on(db.undo()).expect("undo").is_some(),
|
||||
"rename was one undo step"
|
||||
);
|
||||
let tables = table_names(&db, &r);
|
||||
assert!(
|
||||
tables.contains(&"Orders".to_string()) && !tables.contains(&"Purchases".to_string()),
|
||||
"undo restored the old table name: {tables:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
r.block_on(db.query_data("Orders".to_string(), None, None)).expect("query").rows.len(),
|
||||
r.block_on(db.query_data("Orders".to_string(), None, None))
|
||||
.expect("query")
|
||||
.rows
|
||||
.len(),
|
||||
1,
|
||||
"the row is back under the old name"
|
||||
);
|
||||
@@ -1286,19 +1410,23 @@ fn e2e_rename_table_refusals() {
|
||||
r.block_on(run_replay(&db, project.path(), "setup.commands"));
|
||||
|
||||
assert!(
|
||||
r.block_on(db.rename_table("T".into(), "X".into(), Some("rn".into()))).is_err(),
|
||||
r.block_on(db.rename_table("T".into(), "X".into(), Some("rn".into())))
|
||||
.is_err(),
|
||||
"rename to an existing other table is refused"
|
||||
);
|
||||
assert!(
|
||||
r.block_on(db.rename_table("T".into(), "T".into(), Some("rn".into()))).is_err(),
|
||||
r.block_on(db.rename_table("T".into(), "T".into(), Some("rn".into())))
|
||||
.is_err(),
|
||||
"rename to the same name is refused"
|
||||
);
|
||||
assert!(
|
||||
r.block_on(db.rename_table("Ghost".into(), "G".into(), Some("rn".into()))).is_err(),
|
||||
r.block_on(db.rename_table("Ghost".into(), "G".into(), Some("rn".into())))
|
||||
.is_err(),
|
||||
"rename of a non-existent table is refused"
|
||||
);
|
||||
assert!(
|
||||
r.block_on(db.rename_table("T".into(), "__rdbms_evil".into(), Some("rn".into()))).is_err(),
|
||||
r.block_on(db.rename_table("T".into(), "__rdbms_evil".into(), Some("rn".into())))
|
||||
.is_err(),
|
||||
"rename to an internal table name is refused at the executor"
|
||||
);
|
||||
|
||||
@@ -1315,7 +1443,8 @@ fn e2e_rename_table_refusals() {
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
r.block_on(db.rename_table("T".into(), "x".into(), Some("rn".into()))).is_err(),
|
||||
r.block_on(db.rename_table("T".into(), "x".into(), Some("rn".into())))
|
||||
.is_err(),
|
||||
"rename to a name colliding case-insensitively with another table (X) is refused"
|
||||
);
|
||||
|
||||
@@ -1348,7 +1477,10 @@ fn e2e_alter_column_set_not_null_enforced() {
|
||||
.expect("write");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
|
||||
matches!(
|
||||
events.last(),
|
||||
Some(AppEvent::ReplayCompleted { count: 4, .. })
|
||||
),
|
||||
"set not null on a clean column succeeds; events: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -1390,7 +1522,10 @@ fn e2e_alter_column_drop_not_null_allows_nulls() {
|
||||
.expect("write");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
|
||||
matches!(
|
||||
events.last(),
|
||||
Some(AppEvent::ReplayCompleted { count: 4, .. })
|
||||
),
|
||||
"events: {events:?}"
|
||||
);
|
||||
r.block_on(db.insert(
|
||||
@@ -1416,7 +1551,10 @@ fn e2e_alter_column_set_default_applies() {
|
||||
.expect("write");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 3, .. })),
|
||||
matches!(
|
||||
events.last(),
|
||||
Some(AppEvent::ReplayCompleted { count: 3, .. })
|
||||
),
|
||||
"events: {events:?}"
|
||||
);
|
||||
r.block_on(db.insert(
|
||||
@@ -1462,7 +1600,10 @@ fn e2e_alter_column_drop_default_removes_it() {
|
||||
.expect("write");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
|
||||
matches!(
|
||||
events.last(),
|
||||
Some(AppEvent::ReplayCompleted { count: 4, .. })
|
||||
),
|
||||
"events: {events:?}"
|
||||
);
|
||||
r.block_on(db.insert(
|
||||
@@ -1499,7 +1640,10 @@ fn e2e_alter_column_set_data_type_converts() {
|
||||
.expect("write");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
|
||||
matches!(
|
||||
events.last(),
|
||||
Some(AppEvent::ReplayCompleted { count: 4, .. })
|
||||
),
|
||||
"events: {events:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -21,8 +21,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo)
|
||||
.expect("open db with persistence");
|
||||
@@ -33,7 +32,10 @@ fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
|
||||
fn make_t(db: &Database, r: &tokio::runtime::Runtime) {
|
||||
r.block_on(db.sql_create_table(
|
||||
"T".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Int),
|
||||
ColumnSpec::new("email", Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
@@ -48,8 +50,13 @@ fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str)
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "email".to_string()]),
|
||||
vec![Value::Number(id.to_string()), Value::Text(email.to_string())],
|
||||
Some(format!("insert into T (id, email) values ({id}, '{email}')")),
|
||||
vec![
|
||||
Value::Number(id.to_string()),
|
||||
Value::Text(email.to_string()),
|
||||
],
|
||||
Some(format!(
|
||||
"insert into T (id, email) values ({id}, '{email}')"
|
||||
)),
|
||||
))
|
||||
.is_ok()
|
||||
}
|
||||
@@ -79,7 +86,10 @@ fn create_plain_index() {
|
||||
))
|
||||
.expect("create index");
|
||||
assert!(matches!(out, CreateIndexOutcome::Created(_)));
|
||||
assert_eq!(index(&db, &r, "ix"), Some((vec!["email".to_string()], false)));
|
||||
assert_eq!(
|
||||
index(&db, &r, "ix"),
|
||||
Some((vec!["email".to_string()], false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -97,14 +107,20 @@ fn create_unique_index_round_trips_and_survives_rebuild_and_enforces() {
|
||||
))
|
||||
.expect("create unique index");
|
||||
// Reported as unique.
|
||||
assert_eq!(index(&db, &r, "ux"), Some((vec!["email".to_string()], true)));
|
||||
assert_eq!(
|
||||
index(&db, &r, "ux"),
|
||||
Some((vec!["email".to_string()], true))
|
||||
);
|
||||
// Persisted to project.yaml as a unique index.
|
||||
let yaml = std::fs::read_to_string(p.path().join("project.yaml")).expect("read project.yaml");
|
||||
assert!(yaml.contains("unique: true"), "project.yaml:\n{yaml}");
|
||||
|
||||
// Uniqueness is enforced by the engine.
|
||||
assert!(insert_row(&db, &r, 1, "a@x"));
|
||||
assert!(!insert_row(&db, &r, 2, "a@x"), "duplicate email refused by the unique index");
|
||||
assert!(
|
||||
!insert_row(&db, &r, 2, "a@x"),
|
||||
"duplicate email refused by the unique index"
|
||||
);
|
||||
|
||||
// Rebuild from the text artifacts: the index comes back UNIQUE
|
||||
// (the rebuild re-emits CREATE UNIQUE INDEX), not demoted to plain.
|
||||
@@ -116,7 +132,10 @@ fn create_unique_index_round_trips_and_survives_rebuild_and_enforces() {
|
||||
"the unique flag survived rebuild"
|
||||
);
|
||||
// Still enforced after rebuild.
|
||||
assert!(!insert_row(&db, &r, 3, "a@x"), "uniqueness enforced after rebuild too");
|
||||
assert!(
|
||||
!insert_row(&db, &r, 3, "a@x"),
|
||||
"uniqueness enforced after rebuild too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -264,7 +283,10 @@ fn plain_duplicate_name_errors() {
|
||||
false,
|
||||
Some("create index ix on T (id)".to_string()),
|
||||
));
|
||||
assert!(res.is_err(), "duplicate index name without IF NOT EXISTS errors");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"duplicate index name without IF NOT EXISTS errors"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -307,7 +329,10 @@ fn plain_and_unique_over_the_same_columns_are_not_duplicates() {
|
||||
false,
|
||||
Some("create index ix_plain2 on T (email)".to_string()),
|
||||
));
|
||||
assert!(res.is_err(), "a second plain index over the same columns is redundant");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"a second plain index over the same columns is redundant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -328,7 +353,10 @@ fn create_index_on_an_internal_table_is_refused_on_both_surfaces() {
|
||||
false,
|
||||
Some("create index bad on __rdbms_playground_columns (table_name)".to_string()),
|
||||
));
|
||||
assert!(sql.is_err(), "SQL CREATE INDEX on an internal table is refused");
|
||||
assert!(
|
||||
sql.is_err(),
|
||||
"SQL CREATE INDEX on an internal table is refused"
|
||||
);
|
||||
// Simple `add index` on an internal table → error (same guard).
|
||||
let dsl = r.block_on(db.add_index(
|
||||
Some("bad2".to_string()),
|
||||
@@ -336,7 +364,10 @@ fn create_index_on_an_internal_table_is_refused_on_both_surfaces() {
|
||||
vec!["table_name".to_string()],
|
||||
Some("add index as bad2 on __rdbms_playground_columns (table_name)".to_string()),
|
||||
));
|
||||
assert!(dsl.is_err(), "simple add index on an internal table is refused");
|
||||
assert!(
|
||||
dsl.is_err(),
|
||||
"simple add index on an internal table is refused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -355,6 +386,9 @@ fn create_index_is_one_undo_step() {
|
||||
.expect("create index");
|
||||
assert!(index(&db, &r, "ix").is_some());
|
||||
// One undo removes the index.
|
||||
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the create was one undo step");
|
||||
assert!(
|
||||
r.block_on(db.undo()).expect("undo").is_some(),
|
||||
"the create was one undo step"
|
||||
);
|
||||
assert!(index(&db, &r, "ix").is_none(), "undo removed the index");
|
||||
}
|
||||
|
||||
+448
-161
File diff suppressed because it is too large
Load Diff
+261
-64
@@ -34,8 +34,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -83,9 +82,11 @@ fn run_delete(
|
||||
input: &str,
|
||||
) -> Result<DeleteResult, DbError> {
|
||||
match parse_command(input).expect("parse delete") {
|
||||
Command::SqlDelete { sql, target_table, returning } => {
|
||||
rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning))
|
||||
}
|
||||
Command::SqlDelete {
|
||||
sql,
|
||||
target_table,
|
||||
returning,
|
||||
} => rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning)),
|
||||
other => panic!("expected Command::SqlDelete, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -95,8 +96,20 @@ fn run_delete(
|
||||
/// `ON DELETE CASCADE`. Seeds Alice (1) with two orders (10, 11)
|
||||
/// and Bob (2) with one order (12).
|
||||
fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
|
||||
create_cols(db, rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]);
|
||||
create_cols(db, rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
|
||||
create_cols(
|
||||
db,
|
||||
rt,
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
create_cols(
|
||||
db,
|
||||
rt,
|
||||
"Orders",
|
||||
&[("id", Type::Int), ("CustId", Type::Int)],
|
||||
&["id"],
|
||||
);
|
||||
rt.block_on(db.add_relationship(
|
||||
Some("places".to_string()),
|
||||
"Customers".to_string(),
|
||||
@@ -109,16 +122,28 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
|
||||
None,
|
||||
))
|
||||
.expect("add cascade relationship");
|
||||
seed(db, rt, "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')", "Customers");
|
||||
seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)", "Orders");
|
||||
seed(
|
||||
db,
|
||||
rt,
|
||||
"insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')",
|
||||
"Customers",
|
||||
);
|
||||
seed(
|
||||
db,
|
||||
rt,
|
||||
"insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)",
|
||||
"Orders",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_lowers_sql_delete_to_command() {
|
||||
let command = parse_command("delete from Orders where id = 1")
|
||||
.expect("delete parses in advanced mode");
|
||||
let command =
|
||||
parse_command("delete from Orders where id = 1").expect("delete parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlDelete { sql, target_table, .. } => {
|
||||
Command::SqlDelete {
|
||||
sql, target_table, ..
|
||||
} => {
|
||||
assert_eq!(sql, "delete from Orders where id = 1");
|
||||
assert_eq!(target_table, "Orders");
|
||||
}
|
||||
@@ -130,14 +155,28 @@ fn parse_path_lowers_sql_delete_to_command() {
|
||||
fn delete_with_where_persists() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, v) values (1, 'gone'), (2, 'keep')",
|
||||
"t",
|
||||
);
|
||||
let result = run_delete(&db, &rt, "delete from t where id = 1").expect("delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row deleted");
|
||||
assert!(result.cascade.is_empty(), "no children, no cascade");
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
assert!(csv.contains("keep"), "untouched row preserved: {csv:?}");
|
||||
assert!(!csv.contains("gone"), "deleted row removed from CSV: {csv:?}");
|
||||
assert!(
|
||||
!csv.contains("gone"),
|
||||
"deleted row removed from CSV: {csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -145,18 +184,35 @@ fn delete_without_where_runs_across_all_rows() {
|
||||
// ADR-0030 §12: no `--all-rows` rail.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'a'), (2, 'b'), (3, 'c')", "t");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, v) values (1, 'a'), (2, 'b'), (3, 'c')",
|
||||
"t",
|
||||
);
|
||||
let result = run_delete(&db, &rt, "delete from t").expect("unfiltered delete runs");
|
||||
assert_eq!(result.rows_affected, 3, "all rows deleted");
|
||||
// Empty tables produce no CSV (CLAUDE.md persistence note), so the
|
||||
// file is either absent or has only a header — either way, no data.
|
||||
let csv = read_csv(&project, "t").unwrap_or_default();
|
||||
assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}");
|
||||
assert!(
|
||||
!csv.contains('a') && !csv.contains('b') && !csv.contains('c'),
|
||||
"no rows left: {csv:?}"
|
||||
);
|
||||
let remaining = rt
|
||||
.block_on(db.query_data("t".to_string(), None, None))
|
||||
.expect("query t");
|
||||
assert!(remaining.rows.is_empty(), "table empty after unfiltered delete");
|
||||
assert!(
|
||||
remaining.rows.is_empty(),
|
||||
"table empty after unfiltered delete"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -165,8 +221,8 @@ fn cascade_delete_reports_summary_and_repersists_child() {
|
||||
let rt = rt();
|
||||
cascade_fixture(&db, &rt);
|
||||
// Delete Alice (customer 1) — cascades to her two orders (10, 11).
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
|
||||
.expect("cascading delete runs");
|
||||
let result =
|
||||
run_delete(&db, &rt, "delete from Customers where id = 1").expect("cascading delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "one parent row deleted");
|
||||
assert_eq!(result.cascade.len(), 1, "one cascade relationship reported");
|
||||
let effect = &result.cascade[0];
|
||||
@@ -177,9 +233,14 @@ fn cascade_delete_reports_summary_and_repersists_child() {
|
||||
assert_eq!(effect.rows_changed, 2, "both of Alice's orders cascaded");
|
||||
// The child CSV must be re-persisted to reflect the cascade.
|
||||
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
|
||||
assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}");
|
||||
assert!(!orders_csv.contains("10") && !orders_csv.contains("11"),
|
||||
"Alice's cascaded orders gone from CSV: {orders_csv:?}");
|
||||
assert!(
|
||||
orders_csv.contains("12"),
|
||||
"Bob's order (12) preserved: {orders_csv:?}"
|
||||
);
|
||||
assert!(
|
||||
!orders_csv.contains("10") && !orders_csv.contains("11"),
|
||||
"Alice's cascaded orders gone from CSV: {orders_csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,8 +254,8 @@ fn cascade_parity_with_dsl() {
|
||||
|
||||
let (_p_sql, db_sql, _d_sql) = open_project_db();
|
||||
cascade_fixture(&db_sql, &rt);
|
||||
let sql_result = run_delete(&db_sql, &rt, "delete from Customers where id = 1")
|
||||
.expect("SQL delete runs");
|
||||
let sql_result =
|
||||
run_delete(&db_sql, &rt, "delete from Customers where id = 1").expect("SQL delete runs");
|
||||
|
||||
let (_p_dsl, db_dsl, _d_dsl) = open_project_db();
|
||||
cascade_fixture(&db_dsl, &rt);
|
||||
@@ -206,8 +267,14 @@ fn cascade_parity_with_dsl() {
|
||||
))
|
||||
.expect("DSL delete runs");
|
||||
|
||||
assert_eq!(sql_result.rows_affected, dsl_result.rows_affected, "row counts match");
|
||||
assert_eq!(sql_result.cascade, dsl_result.cascade, "cascade summaries identical");
|
||||
assert_eq!(
|
||||
sql_result.rows_affected, dsl_result.rows_affected,
|
||||
"row counts match"
|
||||
);
|
||||
assert_eq!(
|
||||
sql_result.cascade, dsl_result.cascade,
|
||||
"cascade summaries identical"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -229,9 +296,14 @@ fn r2_where_with_subquery() {
|
||||
assert_eq!(result.rows_affected, 2, "Alice's two orders deleted");
|
||||
assert!(result.cascade.is_empty(), "Orders has no cascade children");
|
||||
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
|
||||
assert!(orders_csv.contains("12"), "Bob's order preserved: {orders_csv:?}");
|
||||
assert!(!orders_csv.contains("10") && !orders_csv.contains("11"),
|
||||
"Alice's orders deleted: {orders_csv:?}");
|
||||
assert!(
|
||||
orders_csv.contains("12"),
|
||||
"Bob's order preserved: {orders_csv:?}"
|
||||
);
|
||||
assert!(
|
||||
!orders_csv.contains("10") && !orders_csv.contains("11"),
|
||||
"Alice's orders deleted: {orders_csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -255,10 +327,15 @@ fn r2_cascade_with_subquery_where() {
|
||||
.expect("cascade + subquery-WHERE delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "Alice deleted");
|
||||
assert_eq!(result.cascade.len(), 1, "one cascade relationship");
|
||||
assert_eq!(result.cascade[0].rows_changed, 2, "both Alice orders cascaded");
|
||||
assert_eq!(
|
||||
result.cascade[0].rows_changed, 2,
|
||||
"both Alice orders cascaded"
|
||||
);
|
||||
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
|
||||
assert!(orders_csv.contains("12") && !orders_csv.contains("10") && !orders_csv.contains("11"),
|
||||
"only Bob's order remains: {orders_csv:?}");
|
||||
assert!(
|
||||
orders_csv.contains("12") && !orders_csv.contains("10") && !orders_csv.contains("11"),
|
||||
"only Bob's order remains: {orders_csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -269,9 +346,27 @@ fn cascade_to_two_children_reports_both() {
|
||||
// emitting more than one effect.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]);
|
||||
create_cols(&db, &rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
|
||||
create_cols(&db, &rt, "Reviews", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"Orders",
|
||||
&[("id", Type::Int), ("CustId", Type::Int)],
|
||||
&["id"],
|
||||
);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"Reviews",
|
||||
&[("id", Type::Int), ("CustId", Type::Int)],
|
||||
&["id"],
|
||||
);
|
||||
for (child, name) in [("Orders", "places"), ("Reviews", "writes")] {
|
||||
rt.block_on(db.add_relationship(
|
||||
Some(name.to_string()),
|
||||
@@ -286,14 +381,33 @@ fn cascade_to_two_children_reports_both() {
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("add rel {name}: {e:?}"));
|
||||
}
|
||||
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers");
|
||||
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1), (11, 1)", "Orders");
|
||||
seed(&db, &rt, "insert into Reviews (id, CustId) values (20, 1)", "Reviews");
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into Customers (id, Name) values (1, 'Alice')",
|
||||
"Customers",
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into Orders (id, CustId) values (10, 1), (11, 1)",
|
||||
"Orders",
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into Reviews (id, CustId) values (20, 1)",
|
||||
"Reviews",
|
||||
);
|
||||
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
|
||||
.expect("cascade-to-two delete runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
assert_eq!(result.cascade.len(), 2, "both cascade relationships reported");
|
||||
assert_eq!(
|
||||
result.cascade.len(),
|
||||
2,
|
||||
"both cascade relationships reported"
|
||||
);
|
||||
let by_child: std::collections::HashMap<&str, i64> = result
|
||||
.cascade
|
||||
.iter()
|
||||
@@ -302,9 +416,16 @@ fn cascade_to_two_children_reports_both() {
|
||||
assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded");
|
||||
assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded");
|
||||
// Both child CSVs re-persisted to the post-cascade (empty) state.
|
||||
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None)).unwrap();
|
||||
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None)).unwrap();
|
||||
assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied");
|
||||
let orders = rt
|
||||
.block_on(db.query_data("Orders".to_string(), None, None))
|
||||
.unwrap();
|
||||
let reviews = rt
|
||||
.block_on(db.query_data("Reviews".to_string(), None, None))
|
||||
.unwrap();
|
||||
assert!(
|
||||
orders.rows.is_empty() && reviews.rows.is_empty(),
|
||||
"both children emptied"
|
||||
);
|
||||
let _ = &project;
|
||||
}
|
||||
|
||||
@@ -318,11 +439,19 @@ fn delete_childless_parent_reports_no_cascade() {
|
||||
let rt = rt();
|
||||
cascade_fixture(&db, &rt);
|
||||
// Carol (3) exists with no orders; deleting her cascades nothing.
|
||||
seed(&db, &rt, "insert into Customers (id, Name) values (3, 'Carol')", "Customers");
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into Customers (id, Name) values (3, 'Carol')",
|
||||
"Customers",
|
||||
);
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 3")
|
||||
.expect("childless-parent delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "Carol deleted");
|
||||
assert!(result.cascade.is_empty(), "no children → no cascade effect reported");
|
||||
assert!(
|
||||
result.cascade.is_empty(),
|
||||
"no children → no cascade effect reported"
|
||||
);
|
||||
// All three orders untouched.
|
||||
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
|
||||
assert!(
|
||||
@@ -340,8 +469,20 @@ fn delete_violating_fk_fails_and_persists_nothing() {
|
||||
// parent row survives and history records no line.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]);
|
||||
create_cols(&db, &rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"Orders",
|
||||
&[("id", Type::Int), ("CustId", Type::Int)],
|
||||
&["id"],
|
||||
);
|
||||
rt.block_on(db.add_relationship(
|
||||
Some("places".to_string()),
|
||||
"Customers".to_string(),
|
||||
@@ -354,18 +495,40 @@ fn delete_violating_fk_fails_and_persists_nothing() {
|
||||
None,
|
||||
))
|
||||
.expect("add NO ACTION relationship");
|
||||
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers");
|
||||
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1)", "Orders");
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into Customers (id, Name) values (1, 'Alice')",
|
||||
"Customers",
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into Orders (id, CustId) values (10, 1)",
|
||||
"Orders",
|
||||
);
|
||||
|
||||
let input = "delete from Customers where id = 1";
|
||||
let result = run_delete(&db, &rt, input);
|
||||
assert!(result.is_err(), "delete of a referenced parent must be rejected");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"delete of a referenced parent must be rejected"
|
||||
);
|
||||
// Rolled back: Alice survives.
|
||||
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None)).unwrap();
|
||||
assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete");
|
||||
let customers = rt
|
||||
.block_on(db.query_data("Customers".to_string(), None, None))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
customers.rows.len(),
|
||||
1,
|
||||
"parent row preserved after rejected delete"
|
||||
);
|
||||
// No history line for the failed statement (written only on success).
|
||||
let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default();
|
||||
assert!(!history.contains(input), "failed delete not logged: {history:?}");
|
||||
assert!(
|
||||
!history.contains(input),
|
||||
"failed delete not logged: {history:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -378,7 +541,13 @@ fn self_referential_cascade_counts_only_cascaded_rows() {
|
||||
// direct delete). Without the self-ref correction this reports 3.
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "T", &[("id", Type::Int), ("ParentId", Type::Int)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"T",
|
||||
&[("id", Type::Int), ("ParentId", Type::Int)],
|
||||
&["id"],
|
||||
);
|
||||
rt.block_on(db.add_relationship(
|
||||
Some("parent_of".to_string()),
|
||||
"T".to_string(),
|
||||
@@ -391,11 +560,22 @@ fn self_referential_cascade_counts_only_cascaded_rows() {
|
||||
None,
|
||||
))
|
||||
.expect("add self-referential relationship");
|
||||
seed(&db, &rt, "insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)", "T");
|
||||
let result =
|
||||
run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row matched the WHERE directly");
|
||||
assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once");
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)",
|
||||
"T",
|
||||
);
|
||||
let result = run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs");
|
||||
assert_eq!(
|
||||
result.rows_affected, 1,
|
||||
"one row matched the WHERE directly"
|
||||
);
|
||||
assert_eq!(
|
||||
result.cascade.len(),
|
||||
1,
|
||||
"self-ref relationship reported once"
|
||||
);
|
||||
assert_eq!(
|
||||
result.cascade[0].rows_changed, 2,
|
||||
"only the 2 cascaded rows, not the directly-deleted root too"
|
||||
@@ -421,8 +601,19 @@ fn internal_target_table_rejected_at_parse() {
|
||||
fn delete_returning_yields_predelete_row() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, v) values (1, 'gone'), (2, 'keep')",
|
||||
"t",
|
||||
);
|
||||
let result = run_delete(&db, &rt, "delete from t where id = 1 returning *")
|
||||
.expect("DELETE … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row deleted");
|
||||
@@ -431,7 +622,10 @@ fn delete_returning_yields_predelete_row() {
|
||||
assert_eq!(result.data.rows[0][1], Some("gone".to_string()));
|
||||
// And it really is gone from the table.
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
assert!(!csv.contains("gone") && csv.contains("keep"), "row actually deleted: {csv:?}");
|
||||
assert!(
|
||||
!csv.contains("gone") && csv.contains("keep"),
|
||||
"row actually deleted: {csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -449,5 +643,8 @@ fn delete_returning_with_cascade_surfaces_both() {
|
||||
// Cascade summary still computed alongside the result set.
|
||||
assert_eq!(result.cascade.len(), 1, "cascade reported");
|
||||
assert_eq!(result.cascade[0].child_table, "Orders");
|
||||
assert_eq!(result.cascade[0].rows_changed, 2, "both Alice's orders cascaded");
|
||||
assert_eq!(
|
||||
result.cascade[0].rows_changed, 2,
|
||||
"both Alice's orders cascaded"
|
||||
);
|
||||
}
|
||||
|
||||
+233
-63
@@ -55,8 +55,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -116,15 +115,18 @@ fn run_update(
|
||||
input: &str,
|
||||
) -> Result<UpdateResult, DbError> {
|
||||
match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) {
|
||||
Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on(
|
||||
db.run_sql_update_with_literals(
|
||||
sql,
|
||||
Some(input.to_string()),
|
||||
target_table,
|
||||
returning,
|
||||
set_literals,
|
||||
),
|
||||
),
|
||||
Command::SqlUpdate {
|
||||
sql,
|
||||
target_table,
|
||||
returning,
|
||||
set_literals,
|
||||
} => rt.block_on(db.run_sql_update_with_literals(
|
||||
sql,
|
||||
Some(input.to_string()),
|
||||
target_table,
|
||||
returning,
|
||||
set_literals,
|
||||
)),
|
||||
other => panic!("expected Command::SqlUpdate from {input:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -135,9 +137,11 @@ fn run_delete(
|
||||
input: &str,
|
||||
) -> Result<DeleteResult, DbError> {
|
||||
match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) {
|
||||
Command::SqlDelete { sql, target_table, returning } => rt.block_on(
|
||||
db.run_sql_delete(sql, Some(input.to_string()), target_table, returning),
|
||||
),
|
||||
Command::SqlDelete {
|
||||
sql,
|
||||
target_table,
|
||||
returning,
|
||||
} => rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning)),
|
||||
other => panic!("expected Command::SqlDelete from {input:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -162,9 +166,25 @@ fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec<Vec<Op
|
||||
fn e2e_insert_select_cross_table_copies_rows_and_persists_both() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "source", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
create_cols(&db, &rt, "archive", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into source (id, v) values (1, 'x'), (2, 'y')");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"source",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"archive",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into source (id, v) values (1, 'x'), (2, 'y')",
|
||||
);
|
||||
|
||||
let result = run_insert(&db, &rt, "insert into archive select * from source")
|
||||
.expect("INSERT … SELECT runs");
|
||||
@@ -252,15 +272,30 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
|
||||
let rows = query(&db, &rt, "allten");
|
||||
assert_eq!(rows.len(), 2, "both rows persisted");
|
||||
let csv = read_csv(&project, "allten").expect("allten.csv");
|
||||
assert!(csv.contains("hi") && csv.contains("yo"), "text round-trips: {csv:?}");
|
||||
assert!(csv.contains("2026-05-23") && csv.contains("2025-01-01"), "dates round-trip: {csv:?}");
|
||||
assert!(
|
||||
csv.contains("hi") && csv.contains("yo"),
|
||||
"text round-trips: {csv:?}"
|
||||
);
|
||||
assert!(
|
||||
csv.contains("2026-05-23") && csv.contains("2025-01-01"),
|
||||
"dates round-trip: {csv:?}"
|
||||
);
|
||||
|
||||
let sids: Vec<&str> = rows.iter().filter_map(|r| r[9].as_deref()).collect();
|
||||
assert_eq!(sids.len(), 2, "both shortids present");
|
||||
assert!(sids.iter().all(|s| !s.is_empty()), "shortids non-empty: {sids:?}");
|
||||
assert_ne!(sids[0], sids[1], "auto-filled shortids are distinct: {sids:?}");
|
||||
assert!(
|
||||
sids.iter().all(|s| !s.is_empty()),
|
||||
"shortids non-empty: {sids:?}"
|
||||
);
|
||||
assert_ne!(
|
||||
sids[0], sids[1],
|
||||
"auto-filled shortids are distinct: {sids:?}"
|
||||
);
|
||||
let sers: Vec<&str> = rows.iter().filter_map(|r| r[0].as_deref()).collect();
|
||||
assert!(sers.contains(&"1") && sers.contains(&"2"), "serial auto-incremented: {sers:?}");
|
||||
assert!(
|
||||
sers.contains(&"1") && sers.contains(&"2"),
|
||||
"serial auto-incremented: {sers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
@@ -275,18 +310,34 @@ fn e2e_update_with_subquery_in_set() {
|
||||
&db,
|
||||
&rt,
|
||||
"customers",
|
||||
&[("id", Type::Int), ("name", Type::Text), ("last_order", Type::Int)],
|
||||
&[
|
||||
("id", Type::Int),
|
||||
("name", Type::Text),
|
||||
("last_order", Type::Int),
|
||||
],
|
||||
&["id"],
|
||||
);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"orders",
|
||||
&[("id", Type::Int), ("cust", Type::Int), ("amount", Type::Int)],
|
||||
&[
|
||||
("id", Type::Int),
|
||||
("cust", Type::Int),
|
||||
("amount", Type::Int),
|
||||
],
|
||||
&["id"],
|
||||
);
|
||||
seed(&db, &rt, "insert into customers (id, name, last_order) values (1, 'A', 0), (2, 'B', 0)");
|
||||
seed(&db, &rt, "insert into orders (id, cust, amount) values (10, 1, 50), (11, 1, 30), (12, 2, 99)");
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into customers (id, name, last_order) values (1, 'A', 0), (2, 'B', 0)",
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into orders (id, cust, amount) values (10, 1, 50), (11, 1, 30), (12, 2, 99)",
|
||||
);
|
||||
|
||||
let result = run_update(
|
||||
&db,
|
||||
@@ -298,13 +349,26 @@ fn e2e_update_with_subquery_in_set() {
|
||||
assert_eq!(result.rows_affected, 2, "both customers updated");
|
||||
|
||||
let rows = query(&db, &rt, "customers");
|
||||
let c1 = rows.iter().find(|r| r[0].as_deref() == Some("1")).expect("customer 1");
|
||||
let c2 = rows.iter().find(|r| r[0].as_deref() == Some("2")).expect("customer 2");
|
||||
assert_eq!(c1[2].as_deref(), Some("50"), "customer 1 → max(50, 30) = 50");
|
||||
let c1 = rows
|
||||
.iter()
|
||||
.find(|r| r[0].as_deref() == Some("1"))
|
||||
.expect("customer 1");
|
||||
let c2 = rows
|
||||
.iter()
|
||||
.find(|r| r[0].as_deref() == Some("2"))
|
||||
.expect("customer 2");
|
||||
assert_eq!(
|
||||
c1[2].as_deref(),
|
||||
Some("50"),
|
||||
"customer 1 → max(50, 30) = 50"
|
||||
);
|
||||
assert_eq!(c2[2].as_deref(), Some("99"), "customer 2 → max(99) = 99");
|
||||
|
||||
let csv = read_csv(&project, "customers").expect("customers.csv");
|
||||
assert!(csv.contains("50") && csv.contains("99"), "CSV reflects the update: {csv:?}");
|
||||
assert!(
|
||||
csv.contains("50") && csv.contains("99"),
|
||||
"CSV reflects the update: {csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
@@ -313,8 +377,20 @@ fn e2e_update_with_subquery_in_set() {
|
||||
// ===============================================================
|
||||
|
||||
fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
|
||||
create_cols(db, rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]);
|
||||
create_cols(db, rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]);
|
||||
create_cols(
|
||||
db,
|
||||
rt,
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
create_cols(
|
||||
db,
|
||||
rt,
|
||||
"Orders",
|
||||
&[("id", Type::Int), ("CustId", Type::Int)],
|
||||
&["id"],
|
||||
);
|
||||
rt.block_on(db.add_relationship(
|
||||
Some("places".to_string()),
|
||||
"Customers".to_string(),
|
||||
@@ -327,8 +403,16 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
|
||||
None,
|
||||
))
|
||||
.expect("add cascade relationship");
|
||||
seed(db, rt, "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')");
|
||||
seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)");
|
||||
seed(
|
||||
db,
|
||||
rt,
|
||||
"insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')",
|
||||
);
|
||||
seed(
|
||||
db,
|
||||
rt,
|
||||
"insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -337,8 +421,8 @@ fn e2e_delete_with_cascade_reports_summary_and_repersists_children() {
|
||||
let rt = rt();
|
||||
cascade_fixture(&db, &rt);
|
||||
|
||||
let result = run_delete(&db, &rt, "delete from Customers where id = 1")
|
||||
.expect("cascading DELETE runs");
|
||||
let result =
|
||||
run_delete(&db, &rt, "delete from Customers where id = 1").expect("cascading DELETE runs");
|
||||
assert_eq!(result.rows_affected, 1, "one parent row deleted");
|
||||
assert_eq!(result.cascade.len(), 1, "one cascade relationship affected");
|
||||
let effect = &result.cascade[0];
|
||||
@@ -346,9 +430,18 @@ fn e2e_delete_with_cascade_reports_summary_and_repersists_children() {
|
||||
assert_eq!(effect.rows_changed, 2, "Alice's two orders cascaded");
|
||||
|
||||
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv re-persisted");
|
||||
assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}");
|
||||
assert!(!orders_csv.contains("10"), "Alice's order 10 cascaded away: {orders_csv:?}");
|
||||
assert!(!orders_csv.contains("11"), "Alice's order 11 cascaded away: {orders_csv:?}");
|
||||
assert!(
|
||||
orders_csv.contains("12"),
|
||||
"Bob's order (12) preserved: {orders_csv:?}"
|
||||
);
|
||||
assert!(
|
||||
!orders_csv.contains("10"),
|
||||
"Alice's order 10 cascaded away: {orders_csv:?}"
|
||||
);
|
||||
assert!(
|
||||
!orders_csv.contains("11"),
|
||||
"Alice's order 11 cascaded away: {orders_csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
@@ -359,7 +452,13 @@ fn e2e_delete_with_cascade_reports_summary_and_repersists_children() {
|
||||
fn e2e_upsert_round_trip_do_update_then_do_nothing() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "kv", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"kv",
|
||||
&[("id", Type::Int), ("name", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
seed(&db, &rt, "insert into kv (id, name) values (1, 'old')");
|
||||
|
||||
// DO UPDATE on a conflict mutates the existing row.
|
||||
@@ -369,9 +468,15 @@ fn e2e_upsert_round_trip_do_update_then_do_nothing() {
|
||||
"insert into kv (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name",
|
||||
)
|
||||
.expect("UPSERT DO UPDATE runs");
|
||||
assert_eq!(upd.rows_affected, 1, "DO UPDATE touches the conflicting row");
|
||||
assert_eq!(
|
||||
upd.rows_affected, 1,
|
||||
"DO UPDATE touches the conflicting row"
|
||||
);
|
||||
let csv = read_csv(&project, "kv").expect("kv.csv");
|
||||
assert!(csv.contains("new") && !csv.contains("old"), "row updated to 'new': {csv:?}");
|
||||
assert!(
|
||||
csv.contains("new") && !csv.contains("old"),
|
||||
"row updated to 'new': {csv:?}"
|
||||
);
|
||||
|
||||
// DO NOTHING on a conflict is a no-op.
|
||||
let nothing = run_insert(
|
||||
@@ -382,7 +487,10 @@ fn e2e_upsert_round_trip_do_update_then_do_nothing() {
|
||||
.expect("UPSERT DO NOTHING runs");
|
||||
assert_eq!(nothing.rows_affected, 0, "DO NOTHING changes no rows");
|
||||
let csv = read_csv(&project, "kv").expect("kv.csv");
|
||||
assert!(csv.contains("new") && !csv.contains("ignored"), "row unchanged by DO NOTHING: {csv:?}");
|
||||
assert!(
|
||||
csv.contains("new") && !csv.contains("ignored"),
|
||||
"row unchanged by DO NOTHING: {csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
@@ -393,23 +501,52 @@ fn e2e_upsert_round_trip_do_update_then_do_nothing() {
|
||||
fn e2e_returning_on_insert_update_delete() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
|
||||
let ins = run_insert(&db, &rt, "insert into t (id, v) values (1, 'a') returning id, v")
|
||||
.expect("INSERT … RETURNING runs");
|
||||
assert_eq!(ins.data.rows.len(), 1, "INSERT RETURNING yields the inserted row");
|
||||
let ins = run_insert(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, v) values (1, 'a') returning id, v",
|
||||
)
|
||||
.expect("INSERT … RETURNING runs");
|
||||
assert_eq!(
|
||||
ins.data.rows.len(),
|
||||
1,
|
||||
"INSERT RETURNING yields the inserted row"
|
||||
);
|
||||
assert_eq!(ins.data.rows[0][1].as_deref(), Some("a"));
|
||||
|
||||
let upd = run_update(&db, &rt, "update t set v = 'b' where id = 1 returning v")
|
||||
.expect("UPDATE … RETURNING runs");
|
||||
assert_eq!(upd.data.rows.len(), 1, "UPDATE RETURNING yields the modified row");
|
||||
assert_eq!(
|
||||
upd.data.rows.len(),
|
||||
1,
|
||||
"UPDATE RETURNING yields the modified row"
|
||||
);
|
||||
assert_eq!(upd.data.rows[0][0].as_deref(), Some("b"));
|
||||
|
||||
let del = run_delete(&db, &rt, "delete from t where id = 1 returning *")
|
||||
.expect("DELETE … RETURNING runs");
|
||||
assert_eq!(del.data.rows.len(), 1, "DELETE RETURNING yields the pre-delete row");
|
||||
assert_eq!(del.data.rows[0][1].as_deref(), Some("b"), "pre-delete value surfaced");
|
||||
assert!(query(&db, &rt, "t").is_empty(), "row is gone after the DELETE");
|
||||
assert_eq!(
|
||||
del.data.rows.len(),
|
||||
1,
|
||||
"DELETE RETURNING yields the pre-delete row"
|
||||
);
|
||||
assert_eq!(
|
||||
del.data.rows[0][1].as_deref(),
|
||||
Some("b"),
|
||||
"pre-delete value surfaced"
|
||||
);
|
||||
assert!(
|
||||
query(&db, &rt, "t").is_empty(),
|
||||
"row is gone after the DELETE"
|
||||
);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
@@ -463,7 +600,7 @@ fn e2e_replay_phase3_dml_forms_from_a_script() {
|
||||
("1".to_string(), Some("a".to_string())),
|
||||
("11".to_string(), Some("a".to_string())), // INSERT…SELECT id+10
|
||||
("2".to_string(), Some("z".to_string())), // UPDATE
|
||||
// id 3 was DELETEd
|
||||
// id 3 was DELETEd
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeMap<_, _>>()
|
||||
@@ -481,15 +618,42 @@ fn e2e_replay_phase3_dml_forms_from_a_script() {
|
||||
fn e2e_out_of_scope_dml_forms_parse_reject() {
|
||||
let cases = [
|
||||
("OOS-1 DEFAULT VALUES", "insert into t default values"),
|
||||
("OOS-2 INSERT OR REPLACE", "insert or replace into t values (1)"),
|
||||
("OOS-2 INSERT OR IGNORE", "insert or ignore into t values (1)"),
|
||||
("OOS-3 UPDATE … FROM", "update t set a = b.x from other b where t.id = b.id"),
|
||||
("OOS-4 WITH … UPDATE", "with x as (select 1) update t set a = 1 where id = 1"),
|
||||
("OOS-4 WITH … DELETE", "with x as (select 1) delete from t where id = 1"),
|
||||
("OOS-5 INDEXED BY", "delete from t indexed by idx where id = 1"),
|
||||
("OOS-5 NOT INDEXED", "update t not indexed set a = 1 where id = 1"),
|
||||
("OOS-6 multi-statement (DELETE; DELETE)", "delete from t where id = 1; delete from t where id = 2"),
|
||||
("OOS-6 multi-statement (INSERT; INSERT)", "insert into t values (1); insert into t values (2)"),
|
||||
(
|
||||
"OOS-2 INSERT OR REPLACE",
|
||||
"insert or replace into t values (1)",
|
||||
),
|
||||
(
|
||||
"OOS-2 INSERT OR IGNORE",
|
||||
"insert or ignore into t values (1)",
|
||||
),
|
||||
(
|
||||
"OOS-3 UPDATE … FROM",
|
||||
"update t set a = b.x from other b where t.id = b.id",
|
||||
),
|
||||
(
|
||||
"OOS-4 WITH … UPDATE",
|
||||
"with x as (select 1) update t set a = 1 where id = 1",
|
||||
),
|
||||
(
|
||||
"OOS-4 WITH … DELETE",
|
||||
"with x as (select 1) delete from t where id = 1",
|
||||
),
|
||||
(
|
||||
"OOS-5 INDEXED BY",
|
||||
"delete from t indexed by idx where id = 1",
|
||||
),
|
||||
(
|
||||
"OOS-5 NOT INDEXED",
|
||||
"update t not indexed set a = 1 where id = 1",
|
||||
),
|
||||
(
|
||||
"OOS-6 multi-statement (DELETE; DELETE)",
|
||||
"delete from t where id = 1; delete from t where id = 2",
|
||||
),
|
||||
(
|
||||
"OOS-6 multi-statement (INSERT; INSERT)",
|
||||
"insert into t values (1); insert into t values (2)",
|
||||
),
|
||||
];
|
||||
for (label, src) in cases {
|
||||
assert!(
|
||||
@@ -525,7 +689,10 @@ fn e2e_update_all_rows_in_advanced_falls_back_to_dsl() {
|
||||
assert!(
|
||||
matches!(
|
||||
parse_command_in_mode("update Orders set total = 42 --all-rows", Mode::Advanced),
|
||||
Ok(Command::Update { filter: RowFilter::AllRows, .. })
|
||||
Ok(Command::Update {
|
||||
filter: RowFilter::AllRows,
|
||||
..
|
||||
})
|
||||
),
|
||||
"advanced `update … --all-rows` falls back to the DSL Update",
|
||||
);
|
||||
@@ -599,5 +766,8 @@ fn e2e_validity_indicator_fires_for_sql_dml_diagnostic() {
|
||||
// And the indicator renders the `[WRN]` label.
|
||||
app.input_indicator = app.input_validity_verdict();
|
||||
let text = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(text.contains("[WRN]"), "the SQL DML warning surfaces as [WRN]:\n{text}");
|
||||
assert!(
|
||||
text.contains("[WRN]"),
|
||||
"the SQL DML warning surfaces as [WRN]:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo)
|
||||
.expect("open db with persistence");
|
||||
@@ -33,7 +32,10 @@ fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
|
||||
fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String {
|
||||
r.block_on(db.sql_create_table(
|
||||
"T".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Int),
|
||||
ColumnSpec::new("email", Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
@@ -75,7 +77,10 @@ fn drop_index_removes_an_existing_index_and_shows_the_table() {
|
||||
match out {
|
||||
DropIndexOutcome::Dropped(desc) => {
|
||||
assert_eq!(desc.name, "T");
|
||||
assert!(desc.indexes.is_empty(), "the index is gone from the structure");
|
||||
assert!(
|
||||
desc.indexes.is_empty(),
|
||||
"the index is gone from the structure"
|
||||
);
|
||||
}
|
||||
DropIndexOutcome::Skipped => panic!("expected Dropped, got Skipped"),
|
||||
}
|
||||
@@ -105,7 +110,10 @@ fn plain_drop_of_an_absent_index_errors() {
|
||||
false,
|
||||
Some("drop index ghost_idx".to_string()),
|
||||
));
|
||||
assert!(res.is_err(), "plain DROP INDEX on an absent index errors (no IF EXISTS)");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"plain DROP INDEX on an absent index errors (no IF EXISTS)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -113,11 +121,18 @@ fn drop_index_is_one_undo_step_and_restores_the_index() {
|
||||
let (_p, db, _d) = open(true); // undo enabled
|
||||
let r = rt();
|
||||
let name = make_t_with_index(&db, &r);
|
||||
r.block_on(db.sql_drop_index(name.clone(), false, Some("drop index T_email_idx".to_string())))
|
||||
.expect("drop index");
|
||||
r.block_on(db.sql_drop_index(
|
||||
name.clone(),
|
||||
false,
|
||||
Some("drop index T_email_idx".to_string()),
|
||||
))
|
||||
.expect("drop index");
|
||||
assert!(index_names(&db, &r).is_empty());
|
||||
|
||||
// One undo brings the index back.
|
||||
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
|
||||
assert!(
|
||||
r.block_on(db.undo()).expect("undo").is_some(),
|
||||
"the drop was one undo step"
|
||||
);
|
||||
assert_eq!(index_names(&db, &r), vec![name], "undo restored the index");
|
||||
}
|
||||
|
||||
+66
-19
@@ -22,8 +22,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo)
|
||||
.expect("open db with persistence");
|
||||
@@ -34,7 +33,10 @@ fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
|
||||
fn make_t(db: &Database, r: &tokio::runtime::Runtime) {
|
||||
r.block_on(db.sql_create_table(
|
||||
"T".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("body", Type::Text)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Int),
|
||||
ColumnSpec::new("body", Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
@@ -54,7 +56,11 @@ fn drop_table_removes_an_existing_table() {
|
||||
.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string())))
|
||||
.expect("drop");
|
||||
assert!(matches!(out, DropOutcome::Dropped));
|
||||
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
|
||||
assert!(
|
||||
!r.block_on(db.list_tables())
|
||||
.unwrap()
|
||||
.contains(&"T".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -75,8 +81,15 @@ fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
|
||||
fn plain_drop_of_an_absent_table_errors() {
|
||||
let (_p, db, _d) = open(false);
|
||||
let r = rt();
|
||||
let res = r.block_on(db.sql_drop_table("Ghost".to_string(), false, Some("drop table Ghost".to_string())));
|
||||
assert!(res.is_err(), "plain DROP TABLE on an absent table errors (no IF EXISTS)");
|
||||
let res = r.block_on(db.sql_drop_table(
|
||||
"Ghost".to_string(),
|
||||
false,
|
||||
Some("drop table Ghost".to_string()),
|
||||
));
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"plain DROP TABLE on an absent table errors (no IF EXISTS)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,7 +100,10 @@ fn dropping_a_referenced_parent_is_refused() {
|
||||
let r = rt();
|
||||
r.block_on(db.sql_create_table(
|
||||
"parent".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
ColumnSpec::new("label", Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
@@ -98,7 +114,10 @@ fn dropping_a_referenced_parent_is_refused() {
|
||||
.expect("create parent");
|
||||
r.block_on(db.sql_create_table(
|
||||
"child".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
ColumnSpec::new("pid", Type::Int),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
@@ -112,22 +131,36 @@ fn dropping_a_referenced_parent_is_refused() {
|
||||
inline: true,
|
||||
}],
|
||||
false,
|
||||
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
|
||||
Some(
|
||||
"create table child (id serial primary key, pid int references parent(id))".to_string(),
|
||||
),
|
||||
))
|
||||
.expect("create child with FK");
|
||||
|
||||
// The parent is referenced — refused (even with IF EXISTS, since the
|
||||
// table *does* exist; the refusal is about the relationship).
|
||||
assert!(
|
||||
r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string())))
|
||||
.is_err(),
|
||||
r.block_on(db.sql_drop_table(
|
||||
"parent".to_string(),
|
||||
false,
|
||||
Some("drop table parent".to_string())
|
||||
))
|
||||
.is_err(),
|
||||
"a referenced parent can't be dropped"
|
||||
);
|
||||
// Dropping the child first succeeds, then the parent.
|
||||
r.block_on(db.sql_drop_table("child".to_string(), false, Some("drop table child".to_string())))
|
||||
.expect("drop child");
|
||||
r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string())))
|
||||
.expect("now the parent drops");
|
||||
r.block_on(db.sql_drop_table(
|
||||
"child".to_string(),
|
||||
false,
|
||||
Some("drop table child".to_string()),
|
||||
))
|
||||
.expect("drop child");
|
||||
r.block_on(db.sql_drop_table(
|
||||
"parent".to_string(),
|
||||
false,
|
||||
Some("drop table parent".to_string()),
|
||||
))
|
||||
.expect("now the parent drops");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -138,17 +171,31 @@ fn drop_table_is_one_undo_step_and_restores_data() {
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "body".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Text("hi".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Text("hi".to_string()),
|
||||
],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.expect("row");
|
||||
r.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string())))
|
||||
.expect("drop");
|
||||
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
|
||||
assert!(
|
||||
!r.block_on(db.list_tables())
|
||||
.unwrap()
|
||||
.contains(&"T".to_string())
|
||||
);
|
||||
|
||||
// One undo brings the table — and its row — back.
|
||||
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
|
||||
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
|
||||
assert!(
|
||||
r.block_on(db.undo()).expect("undo").is_some(),
|
||||
"the drop was one undo step"
|
||||
);
|
||||
assert!(
|
||||
r.block_on(db.list_tables())
|
||||
.unwrap()
|
||||
.contains(&"T".to_string())
|
||||
);
|
||||
let data = r
|
||||
.block_on(db.query_data("T".to_string(), None, None))
|
||||
.expect("query");
|
||||
|
||||
+422
-101
@@ -41,8 +41,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -86,7 +85,10 @@ fn single_row_insert_persists_and_counts() {
|
||||
.expect("insert runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row inserted");
|
||||
let csv = read_csv(&project, "T").expect("T.csv written after insert");
|
||||
assert!(csv.contains("Ada"), "CSV reflects the inserted row: {csv:?}");
|
||||
assert!(
|
||||
csv.contains("Ada"),
|
||||
"CSV reflects the inserted row: {csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,7 +195,10 @@ fn failed_multi_row_insert_is_atomic() {
|
||||
String::new(),
|
||||
false,
|
||||
));
|
||||
assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}");
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"multi-row PK conflict must fail: {outcome:?}"
|
||||
);
|
||||
let csv = read_csv(&project, "T").expect("T.csv still present");
|
||||
assert!(
|
||||
csv.contains("existing") && !csv.contains("fresh") && !csv.contains("collides"),
|
||||
@@ -208,7 +213,9 @@ fn parse_path_lowers_sqlinsert_scaffold_to_command() {
|
||||
let command = parse_command("insert into Orders (id, total) values (1, 99.5)")
|
||||
.expect("insert parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlInsert { sql, target_table, .. } => {
|
||||
Command::SqlInsert {
|
||||
sql, target_table, ..
|
||||
} => {
|
||||
assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)");
|
||||
assert_eq!(target_table, "Orders");
|
||||
}
|
||||
@@ -248,7 +255,9 @@ fn parse_path_lowers_insert_select_to_command() {
|
||||
let command = parse_command("insert into archive select * from source")
|
||||
.expect("INSERT … SELECT parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlInsert { sql, target_table, .. } => {
|
||||
Command::SqlInsert {
|
||||
sql, target_table, ..
|
||||
} => {
|
||||
assert_eq!(sql, "insert into archive select * from source");
|
||||
assert_eq!(target_table, "archive");
|
||||
}
|
||||
@@ -259,12 +268,13 @@ fn parse_path_lowers_insert_select_to_command() {
|
||||
#[test]
|
||||
fn parse_path_lowers_with_prefixed_insert_select() {
|
||||
// R4: a WITH-prefixed SELECT row source lowers verbatim.
|
||||
let command = parse_command(
|
||||
"insert into archive with t as (select * from orders) select * from t",
|
||||
)
|
||||
.expect("WITH-prefixed INSERT … SELECT parses");
|
||||
let command =
|
||||
parse_command("insert into archive with t as (select * from orders) select * from t")
|
||||
.expect("WITH-prefixed INSERT … SELECT parses");
|
||||
match command {
|
||||
Command::SqlInsert { sql, target_table, .. } => {
|
||||
Command::SqlInsert {
|
||||
sql, target_table, ..
|
||||
} => {
|
||||
assert_eq!(
|
||||
sql,
|
||||
"insert into archive with t as (select * from orders) select * from t",
|
||||
@@ -472,7 +482,13 @@ fn csv_rows(project: &project::Project, table: &str) -> Vec<Vec<String>> {
|
||||
fn values_autofills_omitted_shortid_pk() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
|
||||
.expect("auto-fill insert runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
@@ -486,25 +502,36 @@ fn values_autofills_omitted_shortid_pk() {
|
||||
fn values_multirow_autofills_distinct_shortids() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
let result = run_sqlinsert(
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (label) values ('a'), ('b'), ('c')",
|
||||
)
|
||||
.expect("multi-row auto-fill runs");
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('a'), ('b'), ('c')")
|
||||
.expect("multi-row auto-fill runs");
|
||||
assert_eq!(result.rows_affected, 3);
|
||||
let rows = csv_rows(&project, "t");
|
||||
let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect();
|
||||
assert_eq!(ids.len(), 3, "three DISTINCT non-empty shortids: {rows:?}");
|
||||
assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}");
|
||||
assert!(
|
||||
rows.iter().all(|r| !r[0].is_empty()),
|
||||
"no empty id: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_shortid_value_is_respected() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
// The user provided `id` explicitly — it must be honoured
|
||||
// verbatim (the override WARNING is sub-phase 3i).
|
||||
run_sqlinsert(
|
||||
@@ -521,10 +548,21 @@ fn explicit_shortid_value_is_respected() {
|
||||
fn insert_select_autofills_distinct_shortids() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "source", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
create_cols(&db, &rt, "target", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
run_sqlinsert(&db, &rt, "insert into source (label) values ('a'), ('b')")
|
||||
.expect("seed source");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"source",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"target",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into source (label) values ('a'), ('b')").expect("seed source");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
@@ -535,7 +573,10 @@ fn insert_select_autofills_distinct_shortids() {
|
||||
let rows = csv_rows(&project, "target");
|
||||
let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect();
|
||||
assert_eq!(ids.len(), 2, "two DISTINCT fresh shortids: {rows:?}");
|
||||
assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}");
|
||||
assert!(
|
||||
rows.iter().all(|r| !r[0].is_empty()),
|
||||
"no empty id: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -547,11 +588,14 @@ fn combined_serial_and_shortid_autofill() {
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Serial), ("code", Type::ShortId), ("name", Type::Text)],
|
||||
&[
|
||||
("id", Type::Serial),
|
||||
("code", Type::ShortId),
|
||||
("name", Type::Text),
|
||||
],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into t (name) values ('x')")
|
||||
.expect("combined auto-fill runs");
|
||||
run_sqlinsert(&db, &rt, "insert into t (name) values ('x')").expect("combined auto-fill runs");
|
||||
let rows = csv_rows(&project, "t");
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert_eq!(rows[0][0], "1", "serial PK engine-filled: {rows:?}");
|
||||
@@ -566,7 +610,13 @@ fn shortid_autofill_respects_mixed_case_column_name() {
|
||||
// schema name `MyId`, not a lowercased form.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("MyId", Type::ShortId), ("label", Type::Text)], &["MyId"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("MyId", Type::ShortId), ("label", Type::Text)],
|
||||
&["MyId"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
|
||||
.expect("mixed-case shortid auto-fill runs");
|
||||
let rows = csv_rows(&project, "t");
|
||||
@@ -584,7 +634,11 @@ fn two_shortids_pk_and_nonpk_both_autofill_distinctly() {
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)],
|
||||
&[
|
||||
("id", Type::ShortId),
|
||||
("code", Type::ShortId),
|
||||
("label", Type::Text),
|
||||
],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x'), ('y')")
|
||||
@@ -611,7 +665,11 @@ fn two_shortids_one_provided_one_autofilled() {
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)],
|
||||
&[
|
||||
("id", Type::ShortId),
|
||||
("code", Type::ShortId),
|
||||
("label", Type::Text),
|
||||
],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into t (id, label) values ('myid', 'x')")
|
||||
@@ -631,7 +689,11 @@ fn compound_pk_with_shortid_member_autofills() {
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("region", Type::Int), ("label", Type::Text)],
|
||||
&[
|
||||
("id", Type::ShortId),
|
||||
("region", Type::Int),
|
||||
("label", Type::Text),
|
||||
],
|
||||
&["id", "region"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into t (region, label) values (1, 'x')")
|
||||
@@ -653,14 +715,23 @@ fn autofill_does_not_mask_arity_mismatch() {
|
||||
// engine rather than mask the error.)
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
let outcome = run_sqlinsert(&db, &rt, "insert into t (label) values ('a', 'b')");
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"arity mismatch must be rejected, not masked: {outcome:?}",
|
||||
);
|
||||
let rows = csv_rows(&project, "t");
|
||||
assert!(rows.is_empty(), "no row should land on a rejected insert: {rows:?}");
|
||||
assert!(
|
||||
rows.is_empty(),
|
||||
"no row should land on a rejected insert: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -672,14 +743,19 @@ fn sql_insert_autofills_omitted_nonpk_serial() {
|
||||
// shortid auto-fill, which already runs on this path.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("seq", Type::Serial)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("seq", Type::Serial)],
|
||||
&["id"],
|
||||
);
|
||||
|
||||
// Single row, omitting the non-PK serial `seq`.
|
||||
run_sqlinsert(&db, &rt, "insert into t (id) values (10)").expect("single-row insert runs");
|
||||
// Multi-row, omitting `seq` — each row gets a distinct, increasing
|
||||
// serial continuing from the current MAX.
|
||||
run_sqlinsert(&db, &rt, "insert into t (id) values (20), (30)")
|
||||
.expect("multi-row insert runs");
|
||||
run_sqlinsert(&db, &rt, "insert into t (id) values (20), (30)").expect("multi-row insert runs");
|
||||
|
||||
let rows = csv_rows(&project, "t");
|
||||
// No NULL serials, and the sequence is 1, 2, 3 across the three rows.
|
||||
@@ -700,11 +776,26 @@ fn autofill_insert_select_wider_projection_is_rejected() {
|
||||
// to the engine instead of dropping the extra projection.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "src", &[("a", Type::Text), ("b", Type::Text)], &["a"]);
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"src",
|
||||
&[("a", Type::Text), ("b", Type::Text)],
|
||||
&["a"],
|
||||
);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into src (a, b) values ('p', 'q')").expect("seed");
|
||||
let outcome = run_sqlinsert(&db, &rt, "insert into t (label) select a, b from src");
|
||||
assert!(outcome.is_err(), "wider projection must be rejected: {outcome:?}");
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"wider projection must be rejected: {outcome:?}"
|
||||
);
|
||||
assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
|
||||
}
|
||||
|
||||
@@ -724,7 +815,10 @@ fn autofill_insert_select_narrower_projection_is_rejected() {
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into src (a) values ('p')").expect("seed");
|
||||
let outcome = run_sqlinsert(&db, &rt, "insert into t (x, y) select a from src");
|
||||
assert!(outcome.is_err(), "narrower projection must be rejected: {outcome:?}");
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"narrower projection must be rejected: {outcome:?}"
|
||||
);
|
||||
assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
|
||||
}
|
||||
|
||||
@@ -736,11 +830,25 @@ fn autofill_insert_select_narrower_projection_is_rejected() {
|
||||
fn insert_returning_star_returns_inserted_row() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (id, b) values (1, 'Ada') returning *")
|
||||
.expect("INSERT … RETURNING * runs");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("b", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, b) values (1, 'Ada') returning *",
|
||||
)
|
||||
.expect("INSERT … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row inserted");
|
||||
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the inserted row");
|
||||
assert_eq!(
|
||||
result.data.rows.len(),
|
||||
1,
|
||||
"RETURNING yielded the inserted row"
|
||||
);
|
||||
assert_eq!(result.data.columns, vec!["id".to_string(), "b".to_string()]);
|
||||
assert_eq!(result.data.rows[0][1], Some("Ada".to_string()));
|
||||
}
|
||||
@@ -749,7 +857,13 @@ fn insert_returning_star_returns_inserted_row() {
|
||||
fn insert_multirow_returning_id_yields_distinct_rows() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("b", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
@@ -760,7 +874,12 @@ fn insert_multirow_returning_id_yields_distinct_rows() {
|
||||
assert_eq!(result.data.columns, vec!["id".to_string()]);
|
||||
let ids: std::collections::BTreeSet<_> =
|
||||
result.data.rows.iter().map(|r| r[0].clone()).collect();
|
||||
assert_eq!(ids.len(), 3, "three distinct ids returned: {:?}", result.data.rows);
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
3,
|
||||
"three distinct ids returned: {:?}",
|
||||
result.data.rows
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -771,16 +890,33 @@ fn insert_returning_autofills_shortid_and_returns_it() {
|
||||
// surfaces in the returned row.
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x') returning *")
|
||||
.expect("auto-fill INSERT … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row inserted (RETURNING-counted)");
|
||||
assert_eq!(
|
||||
result.rows_affected, 1,
|
||||
"one row inserted (RETURNING-counted)"
|
||||
);
|
||||
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the row");
|
||||
// `id` is the auto-filled shortid column; it must be non-empty in
|
||||
// the returned row (proving the rewrite kept RETURNING).
|
||||
let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column");
|
||||
let id_idx = result
|
||||
.data
|
||||
.columns
|
||||
.iter()
|
||||
.position(|c| c == "id")
|
||||
.expect("id column");
|
||||
let id_val = result.data.rows[0][id_idx].clone();
|
||||
assert!(id_val.is_some_and(|s| !s.is_empty()), "generated shortid surfaced via RETURNING");
|
||||
assert!(
|
||||
id_val.is_some_and(|s| !s.is_empty()),
|
||||
"generated shortid surfaced via RETURNING"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -790,11 +926,29 @@ fn insert_returning_recovers_bare_column_type() {
|
||||
// renders as the word, not 0/1).
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (id, active) values (1, true) returning active")
|
||||
.expect("INSERT … RETURNING active runs");
|
||||
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered");
|
||||
assert_eq!(result.data.rows[0][0], Some("true".to_string()), "rendered as the bool word");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("active", Type::Bool)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, active) values (1, true) returning active",
|
||||
)
|
||||
.expect("INSERT … RETURNING active runs");
|
||||
assert_eq!(
|
||||
result.data.column_types,
|
||||
vec![Some(Type::Bool)],
|
||||
"bool type recovered"
|
||||
);
|
||||
assert_eq!(
|
||||
result.data.rows[0][0],
|
||||
Some("true".to_string()),
|
||||
"rendered as the bool word"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -803,11 +957,29 @@ fn insert_returning_computed_expression_is_typeless() {
|
||||
// so its recovered type is None (renders with neutral alignment).
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("n", Type::Int)], &["id"]);
|
||||
let result = run_sqlinsert(&db, &rt, "insert into t (id, n) values (1, 5) returning n + 1")
|
||||
.expect("INSERT … RETURNING <expr> runs");
|
||||
assert_eq!(result.data.column_types, vec![None], "computed projection is typeless");
|
||||
assert_eq!(result.data.rows[0][0], Some("6".to_string()), "engine evaluated n + 1");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("n", Type::Int)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, n) values (1, 5) returning n + 1",
|
||||
)
|
||||
.expect("INSERT … RETURNING <expr> runs");
|
||||
assert_eq!(
|
||||
result.data.column_types,
|
||||
vec![None],
|
||||
"computed projection is typeless"
|
||||
);
|
||||
assert_eq!(
|
||||
result.data.rows[0][0],
|
||||
Some("6".to_string()),
|
||||
"engine evaluated n + 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -858,7 +1030,13 @@ fn multirow_autofill_returning_yields_distinct_generated_ids() {
|
||||
// rows each carrying its own generated id.
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
@@ -867,11 +1045,25 @@ fn multirow_autofill_returning_yields_distinct_generated_ids() {
|
||||
.expect("multi-row auto-fill INSERT … RETURNING * runs");
|
||||
assert_eq!(result.rows_affected, 3, "three rows inserted");
|
||||
assert_eq!(result.data.rows.len(), 3, "three rows returned");
|
||||
let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column");
|
||||
let id_idx = result
|
||||
.data
|
||||
.columns
|
||||
.iter()
|
||||
.position(|c| c == "id")
|
||||
.expect("id column");
|
||||
let ids: std::collections::BTreeSet<_> =
|
||||
result.data.rows.iter().map(|r| r[id_idx].clone()).collect();
|
||||
assert_eq!(ids.len(), 3, "three DISTINCT generated ids via RETURNING: {:?}", result.data.rows);
|
||||
assert!(ids.iter().all(|v| v.as_ref().is_some_and(|s| !s.is_empty())), "all ids non-empty");
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
3,
|
||||
"three DISTINCT generated ids via RETURNING: {:?}",
|
||||
result.data.rows
|
||||
);
|
||||
assert!(
|
||||
ids.iter()
|
||||
.all(|v| v.as_ref().is_some_and(|s| !s.is_empty())),
|
||||
"all ids non-empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -881,15 +1073,39 @@ fn insert_select_returning_executes_and_returns_rows() {
|
||||
// source feeds the insert, and RETURNING yields the inserted rows).
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "src", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
create_cols(&db, &rt, "dst", &[("id", Type::Int), ("b", Type::Text)], &["id"]);
|
||||
run_sqlinsert(&db, &rt, "insert into src (id, b) values (1, 'x'), (2, 'y')").expect("seed src");
|
||||
let result = run_sqlinsert(&db, &rt, "insert into dst select * from src returning id, b")
|
||||
.expect("INSERT … SELECT … RETURNING runs");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"src",
|
||||
&[("id", Type::Int), ("b", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"dst",
|
||||
&[("id", Type::Int), ("b", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into src (id, b) values (1, 'x'), (2, 'y')",
|
||||
)
|
||||
.expect("seed src");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into dst select * from src returning id, b",
|
||||
)
|
||||
.expect("INSERT … SELECT … RETURNING runs");
|
||||
assert_eq!(result.rows_affected, 2, "two rows copied");
|
||||
assert_eq!(result.data.rows.len(), 2, "RETURNING yielded both inserted rows");
|
||||
let bs: std::collections::BTreeSet<_> =
|
||||
result.data.rows.iter().map(|r| r[1].clone()).collect();
|
||||
assert_eq!(
|
||||
result.data.rows.len(),
|
||||
2,
|
||||
"RETURNING yielded both inserted rows"
|
||||
);
|
||||
let bs: std::collections::BTreeSet<_> = result.data.rows.iter().map(|r| r[1].clone()).collect();
|
||||
assert!(bs.contains(&Some("x".to_string())) && bs.contains(&Some("y".to_string())));
|
||||
}
|
||||
|
||||
@@ -934,32 +1150,59 @@ fn autofill_upsert_real_conflict_preserves_clause_and_excluded() {
|
||||
"t".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::ShortId),
|
||||
ColumnSpec { unique: true, ..ColumnSpec::new("code", Type::Text) },
|
||||
ColumnSpec {
|
||||
unique: true,
|
||||
..ColumnSpec::new("code", Type::Text)
|
||||
},
|
||||
ColumnSpec::new("label", Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
.expect("create table with shortid pk + unique code");
|
||||
run_sqlinsert(&db, &rt, "insert into t (code, label) values ('A', 'first')").expect("seed");
|
||||
run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (code, label) values ('A', 'first')",
|
||||
)
|
||||
.expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label",
|
||||
)
|
||||
.expect("auto-filled UPSERT with a real conflict (clause preserved)");
|
||||
assert_eq!(result.rows_affected, 1, "the conflicting row was updated, not inserted");
|
||||
assert_eq!(
|
||||
result.rows_affected, 1,
|
||||
"the conflicting row was updated, not inserted"
|
||||
);
|
||||
let rows = csv_rows(&project, "t");
|
||||
assert_eq!(rows.len(), 1, "still one row (DO UPDATE, not a second insert)");
|
||||
assert!(rows[0].iter().any(|c| c == "second"), "label updated via excluded: {rows:?}");
|
||||
assert!(!rows[0].iter().any(|c| c == "first"), "old label replaced: {rows:?}");
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
1,
|
||||
"still one row (DO UPDATE, not a second insert)"
|
||||
);
|
||||
assert!(
|
||||
rows[0].iter().any(|c| c == "second"),
|
||||
"label updated via excluded: {rows:?}"
|
||||
);
|
||||
assert!(
|
||||
!rows[0].iter().any(|c| c == "first"),
|
||||
"old label replaced: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_conflict_do_nothing_keeps_existing_row() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("name", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
@@ -970,14 +1213,23 @@ fn on_conflict_do_nothing_keeps_existing_row() {
|
||||
assert_eq!(result.rows_affected, 0, "conflicting row left untouched");
|
||||
let rows = csv_rows(&project, "t");
|
||||
assert_eq!(rows.len(), 1, "still one row");
|
||||
assert!(rows[0].iter().any(|c| c == "orig"), "original value kept: {rows:?}");
|
||||
assert!(
|
||||
rows[0].iter().any(|c| c == "orig"),
|
||||
"original value kept: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_conflict_do_update_applies_excluded() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("name", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
@@ -988,14 +1240,23 @@ fn on_conflict_do_update_applies_excluded() {
|
||||
assert_eq!(result.rows_affected, 1, "the conflicting row was updated");
|
||||
let rows = csv_rows(&project, "t");
|
||||
assert_eq!(rows.len(), 1, "still one row (updated, not inserted)");
|
||||
assert!(rows[0].iter().any(|c| c == "new"), "row updated to excluded.name: {rows:?}");
|
||||
assert!(
|
||||
rows[0].iter().any(|c| c == "new"),
|
||||
"row updated to excluded.name: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_conflict_do_nothing_without_target() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("name", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
@@ -1003,7 +1264,10 @@ fn on_conflict_do_nothing_without_target() {
|
||||
"insert into t (id, name) values (1, 'x') on conflict do nothing",
|
||||
)
|
||||
.expect("ON CONFLICT (no target) DO NOTHING runs");
|
||||
assert_eq!(result.rows_affected, 0, "any-conflict do-nothing absorbed the duplicate");
|
||||
assert_eq!(
|
||||
result.rows_affected, 0,
|
||||
"any-conflict do-nothing absorbed the duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1017,7 +1281,13 @@ fn autofill_preserves_on_conflict_clause() {
|
||||
// the rewrite doesn't prepare-fail and the clause survives.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::ShortId), ("label", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
let result = run_sqlinsert(
|
||||
&db,
|
||||
&rt,
|
||||
@@ -1054,7 +1324,10 @@ fn sql_dml_validates_literal_values_like_the_dsl() {
|
||||
let dsl = r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "d".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Text("2025/01/15".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Text("2025/01/15".to_string()),
|
||||
],
|
||||
Some("insert".to_string()),
|
||||
));
|
||||
assert!(
|
||||
@@ -1187,20 +1460,34 @@ fn advanced_upsert_do_update_set_offers_typed_slot_hint() {
|
||||
// column-typed slot hint (boundary-aware lookahead → typed slot).
|
||||
let mut cache = SchemaCache::default();
|
||||
let cols = vec![
|
||||
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
||||
TableColumn { name: "Name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
|
||||
TableColumn {
|
||||
name: "id".to_string(),
|
||||
user_type: Type::Int,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
TableColumn {
|
||||
name: "Name".to_string(),
|
||||
user_type: Type::Text,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
];
|
||||
cache.tables.push("Customers".to_string());
|
||||
cache.columns.push("id".to_string());
|
||||
cache.columns.push("Name".to_string());
|
||||
cache.table_columns.insert("Customers".to_string(), cols);
|
||||
|
||||
let input = "insert into Customers (id, Name) values (1, 'x') on conflict (id) do update set Name=";
|
||||
let input =
|
||||
"insert into Customers (id, Name) values (1, 'x') on conflict (id) do update set Name=";
|
||||
let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced);
|
||||
let Some(AmbientHint::Prose(prose)) = hint else {
|
||||
panic!("expected a Prose hint at the UPSERT SET value slot, got {hint:?}");
|
||||
};
|
||||
assert!(prose.contains("Name"), "hint names the column `Name`: {prose:?}");
|
||||
assert!(
|
||||
prose.contains("Name"),
|
||||
"hint names the column `Name`: {prose:?}"
|
||||
);
|
||||
assert!(
|
||||
prose.contains("quoted string"),
|
||||
"text-column hint says `quoted string`: {prose:?}"
|
||||
@@ -1251,8 +1538,14 @@ fn advanced_insert_form_a_value_offers_typed_slot_hint() {
|
||||
// user-listed column, so the hint is that column's typed prose.
|
||||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||
let prose = prose_at("insert into Things (note) values (", &schema);
|
||||
assert!(prose.contains("note"), "names listed column `note`: {prose:?}");
|
||||
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}");
|
||||
assert!(
|
||||
prose.contains("note"),
|
||||
"names listed column `note`: {prose:?}"
|
||||
);
|
||||
assert!(
|
||||
prose.contains("quoted string"),
|
||||
"text-column prose: {prose:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1271,8 +1564,14 @@ fn advanced_insert_second_position_hints_second_column() {
|
||||
// hint is the SECOND column's typed prose.
|
||||
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
|
||||
let prose = prose_at("insert into Things (k, note) values (5, ", &schema);
|
||||
assert!(prose.contains("note"), "second position names `note`: {prose:?}");
|
||||
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}");
|
||||
assert!(
|
||||
prose.contains("note"),
|
||||
"second position names `note`: {prose:?}"
|
||||
);
|
||||
assert!(
|
||||
prose.contains("quoted string"),
|
||||
"text-column prose: {prose:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1283,13 +1582,19 @@ fn advanced_insert_value_int_mismatch_is_caught_live() {
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(!matches!(bad, InputState::Valid), "decimal into int rejected live: {bad:?}");
|
||||
assert!(
|
||||
!matches!(bad, InputState::Valid),
|
||||
"decimal into int rejected live: {bad:?}"
|
||||
);
|
||||
let ok = classify_input_with_schema_in_mode(
|
||||
"insert into Things (k) values (5)",
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(matches!(ok, InputState::Valid), "valid int literal parses: {ok:?}");
|
||||
assert!(
|
||||
matches!(ok, InputState::Valid),
|
||||
"valid int literal parses: {ok:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1303,7 +1608,10 @@ fn advanced_insert_string_into_int_is_caught_live() {
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(!matches!(bad, InputState::Valid), "string into int rejected live: {bad:?}");
|
||||
assert!(
|
||||
!matches!(bad, InputState::Valid),
|
||||
"string into int rejected live: {bad:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1314,7 +1622,10 @@ fn advanced_insert_multi_row_typed_and_mismatch_caught() {
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(matches!(ok, InputState::Valid), "well-formed multi-row parses: {ok:?}");
|
||||
assert!(
|
||||
matches!(ok, InputState::Valid),
|
||||
"well-formed multi-row parses: {ok:?}"
|
||||
);
|
||||
let bad = classify_input_with_schema_in_mode(
|
||||
"insert into Things (k, note) values (1, 'a'), (3.14, 'b')",
|
||||
&schema,
|
||||
@@ -1333,14 +1644,21 @@ fn advanced_insert_form_b_maps_all_columns_including_serial() {
|
||||
// takes an int literal (unlike the DSL, which omits auto-gen cols).
|
||||
let schema = vschema(&[(
|
||||
"Customers",
|
||||
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
|
||||
&[
|
||||
("id", Type::Serial),
|
||||
("Name", Type::Text),
|
||||
("Email", Type::Text),
|
||||
],
|
||||
)]);
|
||||
let state = classify_input_with_schema_in_mode(
|
||||
"insert into Customers values (1, 'Bob', 'b@c')",
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(matches!(state, InputState::Valid), "Form B maps all 3 columns: {state:?}");
|
||||
assert!(
|
||||
matches!(state, InputState::Valid),
|
||||
"Form B maps all 3 columns: {state:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1356,6 +1674,9 @@ fn advanced_insert_value_expressions_still_parse_via_sql_expr() {
|
||||
"insert into Things (k) values ((select 1))",
|
||||
] {
|
||||
let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
|
||||
assert!(matches!(state, InputState::Valid), "{input:?} must parse: {state:?}");
|
||||
assert!(
|
||||
matches!(state, InputState::Valid),
|
||||
"{input:?} must parse: {state:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-59
@@ -57,11 +57,13 @@ fn advanced_mode_select_dispatches_as_command_select() {
|
||||
type_str(&mut app, "select 1");
|
||||
let actions = submit(&mut app);
|
||||
match actions.as_slice() {
|
||||
[Action::ExecuteDsl {
|
||||
command: Command::Select { sql },
|
||||
source,
|
||||
..
|
||||
}] => {
|
||||
[
|
||||
Action::ExecuteDsl {
|
||||
command: Command::Select { sql },
|
||||
source,
|
||||
..
|
||||
},
|
||||
] => {
|
||||
assert!(
|
||||
sql.contains("select 1"),
|
||||
"Command::Select carries the validated SQL text: {sql:?}",
|
||||
@@ -118,10 +120,12 @@ fn colon_one_shot_from_simple_mode_dispatches_select() {
|
||||
// Persistent mode is unchanged.
|
||||
assert_eq!(app.mode, Mode::Simple);
|
||||
match actions.as_slice() {
|
||||
[Action::ExecuteDsl {
|
||||
command: Command::Select { sql },
|
||||
..
|
||||
}] => {
|
||||
[
|
||||
Action::ExecuteDsl {
|
||||
command: Command::Select { sql },
|
||||
..
|
||||
},
|
||||
] => {
|
||||
assert!(
|
||||
sql.contains("select 1") && !sql.starts_with(':'),
|
||||
"the `:` is stripped before the SQL is queued: {sql:?}",
|
||||
@@ -167,8 +171,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -315,22 +318,12 @@ fn database_run_select_recovers_bool_column_type() {
|
||||
)
|
||||
.await
|
||||
.expect("create table");
|
||||
db.insert(
|
||||
"Products".to_string(),
|
||||
None,
|
||||
vec![Value::Bool(true)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("insert row");
|
||||
db.insert(
|
||||
"Products".to_string(),
|
||||
None,
|
||||
vec![Value::Bool(false)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("insert row");
|
||||
db.insert("Products".to_string(), None, vec![Value::Bool(true)], None)
|
||||
.await
|
||||
.expect("insert row");
|
||||
db.insert("Products".to_string(), None, vec![Value::Bool(false)], None)
|
||||
.await
|
||||
.expect("insert row");
|
||||
});
|
||||
let data = rt
|
||||
.block_on(db.run_select("select Active from Products".to_string()))
|
||||
@@ -370,9 +363,7 @@ fn database_run_select_recovers_text_type_through_alias() {
|
||||
// origin metadata still points at `Users.Name`, so the
|
||||
// playground type is recovered.
|
||||
let data = rt
|
||||
.block_on(
|
||||
db.run_select("select Name as n from Users".to_string()),
|
||||
)
|
||||
.block_on(db.run_select("select Name as n from Users".to_string()))
|
||||
.expect("SELECT runs");
|
||||
assert_eq!(data.columns, vec!["n".to_string()]);
|
||||
assert_eq!(data.column_types, vec![Some(Type::Text)]);
|
||||
@@ -394,9 +385,14 @@ fn database_run_select_computed_expression_stays_typeless() {
|
||||
)
|
||||
.await
|
||||
.expect("create table");
|
||||
db.insert("T".to_string(), None, vec![Value::Number("5".to_string())], None)
|
||||
.await
|
||||
.expect("insert");
|
||||
db.insert(
|
||||
"T".to_string(),
|
||||
None,
|
||||
vec![Value::Number("5".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("insert");
|
||||
});
|
||||
let data = rt
|
||||
.block_on(db.run_select("select Score + 1 from T".to_string()))
|
||||
@@ -434,17 +430,12 @@ fn engine_aggregate_in_where_routes_through_catalog() {
|
||||
// ADR-0032 §11.4. Run the bad query and confirm the
|
||||
// friendly layer routes the message through engine.aggregate_misuse.
|
||||
let err = rt
|
||||
.block_on(db.run_select(
|
||||
"select id from T where count(score) > 0".to_string(),
|
||||
))
|
||||
.block_on(db.run_select("select id from T where count(score) > 0".to_string()))
|
||||
.expect_err("engine should reject aggregate in WHERE");
|
||||
let DbError::Sqlite { .. } = &err else {
|
||||
panic!("expected Sqlite engine error; got {err:?}");
|
||||
};
|
||||
let friendly = friendly::translate_error(
|
||||
&err,
|
||||
&friendly::TranslateContext::default(),
|
||||
);
|
||||
let friendly = friendly::translate_error(&err, &friendly::TranslateContext::default());
|
||||
let rendered = friendly.render();
|
||||
assert!(
|
||||
rendered.contains("aggregate"),
|
||||
@@ -506,23 +497,17 @@ fn engine_group_by_missing_routes_through_catalog() {
|
||||
// false-positive on phrasings that happen to contain
|
||||
// "group by" elsewhere. Any successful query is fine.
|
||||
let _ = rt
|
||||
.block_on(db.run_select(
|
||||
"select category, count(*) from T group by category".to_string(),
|
||||
))
|
||||
.block_on(db.run_select("select category, count(*) from T group by category".to_string()))
|
||||
.expect("benign GROUP BY query runs");
|
||||
// Direct unit test on the matcher: ensure a message that
|
||||
// mentions GROUP BY routes through the catalog.
|
||||
let synthetic = DbError::Sqlite {
|
||||
message:
|
||||
"column must appear in the GROUP BY clause or be used in an aggregate function"
|
||||
.to_string(),
|
||||
message: "column must appear in the GROUP BY clause or be used in an aggregate function"
|
||||
.to_string(),
|
||||
kind: rdbms_playground::db::SqliteErrorKind::Other,
|
||||
};
|
||||
let rendered = friendly::translate_error(
|
||||
&synthetic,
|
||||
&friendly::TranslateContext::default(),
|
||||
)
|
||||
.render();
|
||||
let rendered =
|
||||
friendly::translate_error(&synthetic, &friendly::TranslateContext::default()).render();
|
||||
assert!(
|
||||
rendered.contains("GROUP BY"),
|
||||
"engine.group_by_required wording missing; got {rendered:?}",
|
||||
@@ -567,19 +552,14 @@ fn engine_scalar_subquery_too_many_rows_routes_through_catalog() {
|
||||
// catalog (the matcher would fire if SQLite ever
|
||||
// surfaced this verbatim).
|
||||
let _ = rt
|
||||
.block_on(db.run_select(
|
||||
"select (select v from T) from T".to_string(),
|
||||
))
|
||||
.block_on(db.run_select("select (select v from T) from T".to_string()))
|
||||
.expect("benign scalar subquery query runs");
|
||||
let synthetic = DbError::Sqlite {
|
||||
message: "scalar subquery returned more than one row".to_string(),
|
||||
kind: rdbms_playground::db::SqliteErrorKind::Other,
|
||||
};
|
||||
let rendered = friendly::translate_error(
|
||||
&synthetic,
|
||||
&friendly::TranslateContext::default(),
|
||||
)
|
||||
.render();
|
||||
let rendered =
|
||||
friendly::translate_error(&synthetic, &friendly::TranslateContext::default()).render();
|
||||
assert!(
|
||||
rendered.contains("more than one row"),
|
||||
"engine.scalar_subquery_too_many_rows wording missing; got {rendered:?}",
|
||||
|
||||
+196
-51
@@ -28,8 +28,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -77,15 +76,18 @@ fn run_update(
|
||||
input: &str,
|
||||
) -> Result<UpdateResult, DbError> {
|
||||
match parse_command(input).expect("parse update") {
|
||||
Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on(
|
||||
db.run_sql_update_with_literals(
|
||||
sql,
|
||||
Some(input.to_string()),
|
||||
target_table,
|
||||
returning,
|
||||
set_literals,
|
||||
),
|
||||
),
|
||||
Command::SqlUpdate {
|
||||
sql,
|
||||
target_table,
|
||||
returning,
|
||||
set_literals,
|
||||
} => rt.block_on(db.run_sql_update_with_literals(
|
||||
sql,
|
||||
Some(input.to_string()),
|
||||
target_table,
|
||||
returning,
|
||||
set_literals,
|
||||
)),
|
||||
other => panic!("expected Command::SqlUpdate, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -95,7 +97,9 @@ fn parse_path_lowers_sql_update_to_command() {
|
||||
let command = parse_command("update Orders set total = 0 where id = 1")
|
||||
.expect("update parses in advanced mode");
|
||||
match command {
|
||||
Command::SqlUpdate { sql, target_table, .. } => {
|
||||
Command::SqlUpdate {
|
||||
sql, target_table, ..
|
||||
} => {
|
||||
assert_eq!(sql, "update Orders set total = 0 where id = 1");
|
||||
assert_eq!(target_table, "Orders");
|
||||
}
|
||||
@@ -107,10 +111,20 @@ fn parse_path_lowers_sql_update_to_command() {
|
||||
fn single_column_update_with_where_persists() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'old'), (2, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1")
|
||||
.expect("update runs");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, v) values (1, 'old'), (2, 'keep')",
|
||||
"t",
|
||||
);
|
||||
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1").expect("update runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row updated");
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
assert!(csv.contains("new"), "updated value present: {csv:?}");
|
||||
@@ -134,7 +148,10 @@ fn multi_column_update_persists() {
|
||||
.expect("multi-col update runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
assert!(csv.contains('9') && csv.contains('y'), "both columns updated: {csv:?}");
|
||||
assert!(
|
||||
csv.contains('9') && csv.contains('y'),
|
||||
"both columns updated: {csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -142,10 +159,21 @@ fn update_without_where_runs_across_all_rows() {
|
||||
// ADR-0030 §12: no `--all-rows` rail.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, active) values (1, true), (2, true)", "t");
|
||||
let result = run_update(&db, &rt, "update t set active = false")
|
||||
.expect("unfiltered update runs");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("active", Type::Bool)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, active) values (1, true), (2, true)",
|
||||
"t",
|
||||
);
|
||||
let result =
|
||||
run_update(&db, &rt, "update t set active = false").expect("unfiltered update runs");
|
||||
assert_eq!(result.rows_affected, 2, "all rows updated");
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
assert!(!csv.contains("true"), "no row left active: {csv:?}");
|
||||
@@ -159,10 +187,20 @@ fn update_with_sql_expr_in_set() {
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("price", Type::Int), ("qty", Type::Int), ("total", Type::Int)],
|
||||
&[
|
||||
("id", Type::Int),
|
||||
("price", Type::Int),
|
||||
("qty", Type::Int),
|
||||
("total", Type::Int),
|
||||
],
|
||||
&["id"],
|
||||
);
|
||||
seed(&db, &rt, "insert into t (id, price, qty, total) values (1, 6, 7, 0)", "t");
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, price, qty, total) values (1, 6, 7, 0)",
|
||||
"t",
|
||||
);
|
||||
let result = run_update(&db, &rt, "update t set total = price * qty where id = 1")
|
||||
.expect("expression update runs");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
@@ -176,8 +214,19 @@ fn update_with_subquery_in_set() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "other", &[("n", Type::Int)], &["n"]);
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Int)], &["id"]);
|
||||
seed(&db, &rt, "insert into other (n) values (3), (8), (5)", "other");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Int)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into other (n) values (3), (8), (5)",
|
||||
"other",
|
||||
);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 0)", "t");
|
||||
let result = run_update(
|
||||
&db,
|
||||
@@ -196,13 +245,22 @@ fn update_matching_no_rows_is_ok() {
|
||||
// the path doesn't crash, and the CSV is unchanged.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "update t set v = 'x' where id = 999")
|
||||
.expect("no-match update is a success");
|
||||
assert_eq!(result.rows_affected, 0, "no rows matched");
|
||||
let csv = read_csv(&project, "t").expect("t.csv");
|
||||
assert!(csv.contains("keep") && !csv.contains('x'), "unchanged: {csv:?}");
|
||||
assert!(
|
||||
csv.contains("keep") && !csv.contains('x'),
|
||||
"unchanged: {csv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
@@ -220,8 +278,19 @@ fn sql_update_validates_set_literals_like_the_dsl() {
|
||||
// STRICT TEXT column accept anything).
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("d", Type::Date)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, d) values (1, '2025-01-15')", "t");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("d", Type::Date)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, d) values (1, '2025-01-15')",
|
||||
"t",
|
||||
);
|
||||
|
||||
// SQL path (advanced mode, full replay pipeline) — REJECTS the bad date.
|
||||
std::fs::write(
|
||||
@@ -300,7 +369,12 @@ fn sql_update_validates_every_assignment_not_just_the_first() {
|
||||
&[("id", Type::Int), ("v", Type::Text), ("d", Type::Date)],
|
||||
&["id"],
|
||||
);
|
||||
seed(&db, &rt, "insert into t (id, v, d) values (1, 'a', '2025-01-01')", "t");
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, v, d) values (1, 'a', '2025-01-01')",
|
||||
"t",
|
||||
);
|
||||
std::fs::write(
|
||||
project.path().join("multi.commands"),
|
||||
"update t set v = 'ok', d = '2025/01/15' where id = 1\n",
|
||||
@@ -321,26 +395,64 @@ fn sql_update_validates_every_assignment_not_just_the_first() {
|
||||
fn update_returning_yields_modified_columns() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'old'), (2, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1 returning id, v")
|
||||
.expect("UPDATE … RETURNING runs");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, v) values (1, 'old'), (2, 'keep')",
|
||||
"t",
|
||||
);
|
||||
let result = run_update(
|
||||
&db,
|
||||
&rt,
|
||||
"update t set v = 'new' where id = 1 returning id, v",
|
||||
)
|
||||
.expect("UPDATE … RETURNING runs");
|
||||
assert_eq!(result.rows_affected, 1, "one row updated");
|
||||
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()]);
|
||||
assert_eq!(result.data.rows.len(), 1);
|
||||
// RETURNING reflects the POST-update value.
|
||||
assert_eq!(result.data.rows[0][1], Some("new".to_string()), "modified value returned");
|
||||
assert_eq!(
|
||||
result.data.rows[0][1],
|
||||
Some("new".to_string()),
|
||||
"modified value returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_returning_recovers_bare_column_type() {
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, active) values (1, false)", "t");
|
||||
let result = run_update(&db, &rt, "update t set active = true where id = 1 returning active")
|
||||
.expect("UPDATE … RETURNING active runs");
|
||||
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered");
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("active", Type::Bool)],
|
||||
&["id"],
|
||||
);
|
||||
seed(
|
||||
&db,
|
||||
&rt,
|
||||
"insert into t (id, active) values (1, false)",
|
||||
"t",
|
||||
);
|
||||
let result = run_update(
|
||||
&db,
|
||||
&rt,
|
||||
"update t set active = true where id = 1 returning active",
|
||||
)
|
||||
.expect("UPDATE … RETURNING active runs");
|
||||
assert_eq!(
|
||||
result.data.column_types,
|
||||
vec![Some(Type::Bool)],
|
||||
"bool type recovered"
|
||||
);
|
||||
assert_eq!(result.data.rows[0][0], Some("true".to_string()));
|
||||
}
|
||||
|
||||
@@ -352,13 +464,27 @@ fn update_returning_matching_no_rows_is_ok_and_empty() {
|
||||
// phantom row.
|
||||
let (_project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Text)],
|
||||
&["id"],
|
||||
);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t");
|
||||
let result = run_update(&db, &rt, "update t set v = 'x' where id = 999 returning id, v")
|
||||
.expect("no-match UPDATE … RETURNING is a success");
|
||||
let result = run_update(
|
||||
&db,
|
||||
&rt,
|
||||
"update t set v = 'x' where id = 999 returning id, v",
|
||||
)
|
||||
.expect("no-match UPDATE … RETURNING is a success");
|
||||
assert_eq!(result.rows_affected, 0, "no rows matched");
|
||||
assert!(result.data.rows.is_empty(), "no rows returned");
|
||||
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()], "columns still present");
|
||||
assert_eq!(
|
||||
result.data.columns,
|
||||
vec!["id".to_string(), "v".to_string()],
|
||||
"columns still present"
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
@@ -399,14 +525,21 @@ fn advanced_update_set_value_offers_typed_slot_hint_for_column() {
|
||||
// instead of the type-blind sql_expr surface.
|
||||
let schema = schema_cache(&[(
|
||||
"Customers",
|
||||
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
|
||||
&[
|
||||
("id", Type::Serial),
|
||||
("Name", Type::Text),
|
||||
("Email", Type::Text),
|
||||
],
|
||||
)]);
|
||||
let input = "update Customers set Email=";
|
||||
let hint = ambient_hint_in_mode(input, input.len(), None, &schema, Mode::Advanced);
|
||||
let Some(AmbientHint::Prose(prose)) = hint else {
|
||||
panic!("expected a Prose hint at the typed value slot, got {hint:?}");
|
||||
};
|
||||
assert!(prose.contains("Email"), "hint names the column `Email`: {prose:?}");
|
||||
assert!(
|
||||
prose.contains("Email"),
|
||||
"hint names the column `Email`: {prose:?}"
|
||||
);
|
||||
assert!(
|
||||
prose.contains("quoted string"),
|
||||
"text-column hint says `quoted string`: {prose:?}"
|
||||
@@ -449,7 +582,10 @@ fn advanced_update_set_int_value_type_mismatch_is_caught_live() {
|
||||
&schema,
|
||||
Mode::Advanced,
|
||||
);
|
||||
assert!(matches!(ok, InputState::Valid), "a valid int literal parses: {ok:?}");
|
||||
assert!(
|
||||
matches!(ok, InputState::Valid),
|
||||
"a valid int literal parses: {ok:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -463,10 +599,10 @@ fn advanced_update_set_expression_still_parses_via_sql_expr() {
|
||||
("other", &[("n", Type::Int)]),
|
||||
]);
|
||||
for input in [
|
||||
"update Things set k = 3 + 2 where k = 0", // literal-prefixed expression
|
||||
"update Things set k = 3 + 2 where k = 0", // literal-prefixed expression
|
||||
"update Things set k = (select max(n) from other) where k = 0", // scalar subquery
|
||||
"update Things set note = upper(note) where k = 0", // function call
|
||||
"update Things set k = -5 where k = 0", // signed number → sql_expr
|
||||
"update Things set k = -5 where k = 0", // signed number → sql_expr
|
||||
] {
|
||||
let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
|
||||
assert!(
|
||||
@@ -488,7 +624,13 @@ fn update_all_rows_flag_in_advanced_updates_every_row() {
|
||||
// parse-level dispatch (covered in tests/sql_dml_e2e.rs).
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Int)], &["id"]);
|
||||
create_cols(
|
||||
&db,
|
||||
&rt,
|
||||
"t",
|
||||
&[("id", Type::Int), ("v", Type::Int)],
|
||||
&["id"],
|
||||
);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 1), (2, 2)", "t");
|
||||
std::fs::write(
|
||||
project.path().join("allrows.commands"),
|
||||
@@ -497,7 +639,10 @@ fn update_all_rows_flag_in_advanced_updates_every_row() {
|
||||
.expect("write script");
|
||||
let events = rt.block_on(run_replay(&db, project.path(), "allrows.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 1, .. })),
|
||||
matches!(
|
||||
events.last(),
|
||||
Some(AppEvent::ReplayCompleted { count: 1, .. })
|
||||
),
|
||||
"the --all-rows update replays through the DSL fall-back; events: {events:?}"
|
||||
);
|
||||
let rows = rt
|
||||
|
||||
@@ -179,7 +179,10 @@ fn undo_disabled_takes_no_snapshots() {
|
||||
.unwrap();
|
||||
|
||||
// Nothing to undo, and no snapshot machinery on disk.
|
||||
assert!(db.undo().await.unwrap().is_none(), "undo is a no-op when disabled");
|
||||
assert!(
|
||||
db.undo().await.unwrap().is_none(),
|
||||
"undo is a no-op when disabled"
|
||||
);
|
||||
assert!(db.peek_undo().await.unwrap().is_none());
|
||||
});
|
||||
|
||||
@@ -360,7 +363,10 @@ fn undo_restores_db_and_csv_consistently() {
|
||||
db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "name".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Text("Alice".to_string()),
|
||||
],
|
||||
Some("insert Alice".to_string()),
|
||||
)
|
||||
.await
|
||||
@@ -444,8 +450,8 @@ fn undo_ring_persists_across_reopen() {
|
||||
// held by `project`). The persisted ring must survive.
|
||||
drop(db);
|
||||
let persistence = Persistence::new(path);
|
||||
let db2 = Database::open_with_persistence_and_undo(&db_path, persistence, true)
|
||||
.expect("reopen db");
|
||||
let db2 =
|
||||
Database::open_with_persistence_and_undo(&db_path, persistence, true).expect("reopen db");
|
||||
|
||||
rt().block_on(async {
|
||||
let peek = db2
|
||||
|
||||
@@ -49,7 +49,11 @@ fn submit(app: &mut App) -> Vec<Action> {
|
||||
/// and don't care about the verbatim user input.
|
||||
#[track_caller]
|
||||
fn assert_one_execute_dsl(actions: &[Action], expected: &Command) {
|
||||
assert_eq!(actions.len(), 1, "expected exactly one action; got {actions:?}");
|
||||
assert_eq!(
|
||||
actions.len(),
|
||||
1,
|
||||
"expected exactly one action; got {actions:?}"
|
||||
);
|
||||
match &actions[0] {
|
||||
Action::ExecuteDsl { command, .. } => assert_eq!(command, expected),
|
||||
other => panic!("expected ExecuteDsl, got {other:?}"),
|
||||
@@ -234,9 +238,18 @@ fn status_bar_is_keystroke_only_and_state_aware() {
|
||||
|
||||
// Default (empty input): nav / complete / history / run keystrokes.
|
||||
let default_view = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(default_view.contains("Ctrl-O sidebar"), "strip lists sidebar:\n{default_view}");
|
||||
assert!(default_view.contains("Enter run"), "strip lists run:\n{default_view}");
|
||||
assert!(!default_view.contains("Ctrl-C"), "quit dropped from the strip:\n{default_view}");
|
||||
assert!(
|
||||
default_view.contains("Ctrl-O sidebar"),
|
||||
"strip lists sidebar:\n{default_view}"
|
||||
);
|
||||
assert!(
|
||||
default_view.contains("Enter run"),
|
||||
"strip lists run:\n{default_view}"
|
||||
);
|
||||
assert!(
|
||||
!default_view.contains("Ctrl-C"),
|
||||
"quit dropped from the strip:\n{default_view}"
|
||||
);
|
||||
assert!(
|
||||
!default_view.contains("advanced once"),
|
||||
"`:` command word dropped from the strip:\n{default_view}",
|
||||
@@ -245,8 +258,14 @@ fn status_bar_is_keystroke_only_and_state_aware() {
|
||||
// Editing (input has text): the #29 readline edit keys appear.
|
||||
type_str(&mut app, "create");
|
||||
let editing = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(editing.contains("Esc clear"), "editing strip lists clear:\n{editing}");
|
||||
assert!(editing.contains("Ctrl-W del word"), "editing strip lists del word:\n{editing}");
|
||||
assert!(
|
||||
editing.contains("Esc clear"),
|
||||
"editing strip lists clear:\n{editing}"
|
||||
);
|
||||
assert!(
|
||||
editing.contains("Ctrl-W del word"),
|
||||
"editing strip lists del word:\n{editing}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -326,7 +345,9 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
|
||||
// `id` row shows both the name and its `serial` type
|
||||
// separated by box-drawing characters.
|
||||
assert!(
|
||||
rendered.lines().any(|l| l.contains("id") && l.contains("serial")),
|
||||
rendered
|
||||
.lines()
|
||||
.any(|l| l.contains("id") && l.contains("serial")),
|
||||
"output should show the id/serial column row:\n{rendered}"
|
||||
);
|
||||
}
|
||||
@@ -336,10 +357,7 @@ fn add_column_flow_updates_structure_view() {
|
||||
let mut app = App::new();
|
||||
// Simulate the prior create_table state.
|
||||
app.tables = vec!["Customers".to_string()];
|
||||
app.current_table = Some(fake_table(
|
||||
"Customers",
|
||||
&[("id", Type::Serial, true)],
|
||||
));
|
||||
app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)]));
|
||||
|
||||
type_str(&mut app, "add column to table Customers: Name (text)");
|
||||
let actions = submit(&mut app);
|
||||
@@ -376,7 +394,9 @@ fn add_column_flow_updates_structure_view() {
|
||||
assert_eq!(app.current_table, Some(updated));
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(
|
||||
rendered.lines().any(|l| l.contains("Name") && l.contains("text")),
|
||||
rendered
|
||||
.lines()
|
||||
.any(|l| l.contains("Name") && l.contains("text")),
|
||||
"expected the Name/text column row:\n{rendered}",
|
||||
);
|
||||
}
|
||||
@@ -496,10 +516,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
||||
assert!(rendered.contains("on delete cascade"), "{rendered}");
|
||||
// The [ok] subject lists the endpoints. Long lines wrap in
|
||||
// the panel, so we check the first half of the phrase only.
|
||||
assert!(
|
||||
rendered.contains("from Customers.Id"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("from Customers.Id"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -551,11 +568,23 @@ fn add_column_confirmation_omits_relationship_prose() {
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
// The structure box still renders (table name + the column box from
|
||||
// the returned description).
|
||||
assert!(rendered.contains("Customers"), "structure header:\n{rendered}");
|
||||
assert!(rendered.contains("Constraints"), "structure box:\n{rendered}");
|
||||
assert!(
|
||||
rendered.contains("Customers"),
|
||||
"structure header:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Constraints"),
|
||||
"structure box:\n{rendered}"
|
||||
);
|
||||
// The relationship block is gone — neither prose heading nor line.
|
||||
assert!(!rendered.contains("Referenced by:"), "no prose heading:\n{rendered}");
|
||||
assert!(!rendered.contains("References:"), "no prose heading:\n{rendered}");
|
||||
assert!(
|
||||
!rendered.contains("Referenced by:"),
|
||||
"no prose heading:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("References:"),
|
||||
"no prose heading:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("Orders.CustId → Id"),
|
||||
"no prose line:\n{rendered}",
|
||||
@@ -682,8 +711,14 @@ fn validity_indicator_renders_err_and_wrn_labels() {
|
||||
let mut app = App::new();
|
||||
|
||||
let clean = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(!clean.contains("[ERR]"), "clean input shows no label:\n{clean}");
|
||||
assert!(!clean.contains("[WRN]"), "clean input shows no label:\n{clean}");
|
||||
assert!(
|
||||
!clean.contains("[ERR]"),
|
||||
"clean input shows no label:\n{clean}"
|
||||
);
|
||||
assert!(
|
||||
!clean.contains("[WRN]"),
|
||||
"clean input shows no label:\n{clean}"
|
||||
);
|
||||
|
||||
app.input_indicator = Some(Severity::Error);
|
||||
let err = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
|
||||
@@ -28,10 +28,7 @@ fn one_n_relationship_keyword_sequence_is_incomplete() {
|
||||
#[test]
|
||||
fn after_from_offers_table_names() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"add 1:n relationship from ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("add 1:n relationship from ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["Customers", "Orders"]);
|
||||
crate::snap!("after_from", a);
|
||||
@@ -41,10 +38,7 @@ fn after_from_offers_table_names() {
|
||||
fn after_parent_table_dot_narrows_to_parent_columns() {
|
||||
// §2.2 follow-up — `from Customers.` narrows to Customers.
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"add 1:n relationship from Customers.",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("add 1:n relationship from Customers.", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["id", "Name"]);
|
||||
assert_no_candidate_named(&a, &["OrderId", "CustId", "Total"]);
|
||||
@@ -54,10 +48,7 @@ fn after_parent_table_dot_narrows_to_parent_columns() {
|
||||
#[test]
|
||||
fn after_child_table_dot_narrows_to_child_columns() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"add 1:n relationship from Customers.id to Orders.",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("add 1:n relationship from Customers.id to Orders.", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["OrderId", "CustId", "Total"]);
|
||||
assert_no_candidate_named(&a, &["Name"]);
|
||||
@@ -101,10 +92,7 @@ fn add_relationship_with_on_delete_clause_parses() {
|
||||
#[test]
|
||||
fn add_relationship_in_progress_after_dot_is_incomplete() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"add 1:n relationship from Customers.id to ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("add 1:n relationship from Customers.id to ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("in_progress_after_to", a);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,10 @@ fn create_m2n_with_as_name_parses() {
|
||||
#[test]
|
||||
fn after_as_keyword_is_incomplete() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema);
|
||||
let a = assess_at_end(
|
||||
"create m:n relationship from Customers to Orders as ",
|
||||
&schema,
|
||||
);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("after_as", a);
|
||||
}
|
||||
|
||||
@@ -83,25 +83,16 @@ fn after_pk_space_with_col_name_typed_expects_paren() {
|
||||
#[test]
|
||||
fn after_paren_expects_type_candidates() {
|
||||
let schema = schema_empty();
|
||||
let a = assess_at_end(
|
||||
"create table Customers with pk Code(",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("create table Customers with pk Code(", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(
|
||||
&a,
|
||||
&["text", "int", "serial", "shortid", "bool"],
|
||||
);
|
||||
assert_candidate_present(&a, &["text", "int", "serial", "shortid", "bool"]);
|
||||
crate::snap!("after_paren", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_with_explicit_pk_parses() {
|
||||
let schema = schema_empty();
|
||||
let a = assess_at_end(
|
||||
"create table Customers with pk Code(text)",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("create table Customers with pk Code(text)", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
crate::snap!("with_explicit_pk", a);
|
||||
}
|
||||
|
||||
@@ -42,13 +42,8 @@ fn after_where_offers_active_table_columns_no_leakage() {
|
||||
#[test]
|
||||
fn after_where_column_equals_offers_typed_prose() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"delete from Customers where Email=",
|
||||
&schema,
|
||||
);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose, got {:?}", a.hint)
|
||||
});
|
||||
let a = assess_at_end("delete from Customers where Email=", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("Email"),
|
||||
"should name `Email`, got prose: {prose:?}",
|
||||
@@ -63,10 +58,7 @@ fn after_where_column_equals_offers_typed_prose() {
|
||||
#[test]
|
||||
fn complete_delete_with_where_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"delete from Customers where id=1",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("delete from Customers where id=1", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Delete"));
|
||||
crate::snap!("complete_delete", a);
|
||||
@@ -76,9 +68,7 @@ fn complete_delete_with_where_parses() {
|
||||
fn delete_with_datetime_column_says_yyyy_mm_dd_t() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("delete from Things where ts=", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose, got {:?}", a.hint)
|
||||
});
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("ts"),
|
||||
"should name `ts`, got prose: {prose:?}",
|
||||
|
||||
@@ -63,10 +63,7 @@ fn complete_drop_column_parses() {
|
||||
#[test]
|
||||
fn drop_column_with_table_keyword_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"drop column from table Customers: Email",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("drop column from table Customers: Email", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
crate::snap!("with_table_keyword", a);
|
||||
}
|
||||
|
||||
@@ -30,10 +30,7 @@ fn after_relationship_keyword_offers_from_and_names() {
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
// Both selector forms discoverable: `from` for endpoints,
|
||||
// the relationship name for the by-name form.
|
||||
assert_candidate_present(
|
||||
&a,
|
||||
&["from", "Orders_CustId_to_Customers"],
|
||||
);
|
||||
assert_candidate_present(&a, &["from", "Orders_CustId_to_Customers"]);
|
||||
crate::snap!("after_relationship_keyword", a);
|
||||
}
|
||||
|
||||
@@ -61,10 +58,7 @@ fn after_parent_table_dot_narrows_to_parent_columns() {
|
||||
#[test]
|
||||
fn after_to_offers_table_names() {
|
||||
let schema = schema_with_relationship();
|
||||
let a = assess_at_end(
|
||||
"drop relationship from Orders.CustId to ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("drop relationship from Orders.CustId to ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["Customers", "Orders"]);
|
||||
crate::snap!("after_to", a);
|
||||
@@ -98,10 +92,7 @@ fn complete_drop_relationship_by_endpoints_parses() {
|
||||
#[test]
|
||||
fn complete_drop_relationship_by_name_parses() {
|
||||
let schema = schema_with_relationship();
|
||||
let a = assess_at_end(
|
||||
"drop relationship Orders_CustId_to_Customers",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("drop relationship Orders_CustId_to_Customers", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("DropRelationship"));
|
||||
crate::snap!("complete_by_name", a);
|
||||
|
||||
@@ -66,10 +66,7 @@ fn complete_explain_show_data_parses_as_explain() {
|
||||
#[test]
|
||||
fn complete_explain_show_data_with_where_and_limit_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"explain show data Customers where id = 1 limit 5",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("explain show data Customers where id = 1 limit 5", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Explain"));
|
||||
crate::snap!("complete_explain_show_data_where_limit", a);
|
||||
@@ -95,10 +92,7 @@ fn after_explain_update_table_expects_set() {
|
||||
#[test]
|
||||
fn complete_explain_update_parses_as_explain() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"explain update Customers set Name='Bo' where id=1",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("explain update Customers set Name='Bo' where id=1", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Explain"));
|
||||
crate::snap!("complete_explain_update", a);
|
||||
|
||||
@@ -143,10 +143,7 @@ fn after_values_keyword_expects_open_paren() {
|
||||
// Trailing space so we're past the `values` word boundary
|
||||
// — without it the partial-prefix logic re-offers `values`
|
||||
// itself as the candidate that matches the typed prefix.
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (Name) values ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers (Name) values ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["("]);
|
||||
crate::snap!("after_values_keyword", a);
|
||||
@@ -159,10 +156,7 @@ fn after_values_keyword_expects_open_paren() {
|
||||
#[test]
|
||||
fn after_values_open_paren_form_a_text_column_prose_names_column() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (Name) values (",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers (Name) values (", &schema);
|
||||
assert!(
|
||||
hint_prose_contains(&a, "Name"),
|
||||
"expected column name in prose, got {:?}",
|
||||
@@ -179,13 +173,12 @@ fn after_values_open_paren_form_a_text_column_prose_names_column() {
|
||||
#[test]
|
||||
fn after_values_open_paren_form_a_serial_column_offers_null_to_auto_generate() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (id, Name) values (",
|
||||
&schema,
|
||||
let a = assess_at_end("insert into Customers (id, Name) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("id"),
|
||||
"prose should name `id`, got {prose:?}"
|
||||
);
|
||||
let prose = hint_prose(&a)
|
||||
.unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
|
||||
assert!(prose.contains("id"), "prose should name `id`, got {prose:?}");
|
||||
assert!(
|
||||
prose.contains("null") && prose.contains("auto-generate"),
|
||||
"prose should mention `null` to auto-generate, got {prose:?}",
|
||||
@@ -200,8 +193,7 @@ fn mid_value_list_after_comma_advances_to_next_column_prose() {
|
||||
"insert into Customers (Name, Email) values ('Alice', ",
|
||||
&schema,
|
||||
);
|
||||
let prose = hint_prose(&a)
|
||||
.unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("Email"),
|
||||
"prose should name `Email`, got {prose:?}",
|
||||
@@ -281,9 +273,8 @@ fn form_a_complete_with_serial_in_list_parses() {
|
||||
fn form_a_int_slot_prose_says_integer() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things (k) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose for int slot, got {:?}", a.hint)
|
||||
});
|
||||
let prose =
|
||||
hint_prose(&a).unwrap_or_else(|| panic!("expected Prose for int slot, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("integer"),
|
||||
"int-slot prose should say `integer`, got {prose:?}",
|
||||
@@ -295,9 +286,8 @@ fn form_a_int_slot_prose_says_integer() {
|
||||
fn form_a_date_slot_prose_says_yyyy_mm_dd() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things (dt) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose for date slot, got {:?}", a.hint)
|
||||
});
|
||||
let prose =
|
||||
hint_prose(&a).unwrap_or_else(|| panic!("expected Prose for date slot, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("YYYY-MM-DD"),
|
||||
"date-slot prose should reference YYYY-MM-DD format, got {prose:?}",
|
||||
@@ -309,9 +299,8 @@ fn form_a_date_slot_prose_says_yyyy_mm_dd() {
|
||||
fn form_a_bool_slot_prose_mentions_true_false() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things (b) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose for bool slot, got {:?}", a.hint)
|
||||
});
|
||||
let prose =
|
||||
hint_prose(&a).unwrap_or_else(|| panic!("expected Prose for bool slot, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("true") && prose.contains("false"),
|
||||
"bool-slot prose should mention `true`/`false`, got {prose:?}",
|
||||
@@ -323,9 +312,8 @@ fn form_a_bool_slot_prose_mentions_true_false() {
|
||||
fn form_a_shortid_slot_prose_mentions_null_to_auto_generate() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things (sid) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose for shortid slot, got {:?}", a.hint)
|
||||
});
|
||||
let prose = hint_prose(&a)
|
||||
.unwrap_or_else(|| panic!("expected Prose for shortid slot, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("null") && prose.contains("auto-generate"),
|
||||
"shortid-slot prose should mention `null` to auto-generate, got {prose:?}",
|
||||
|
||||
@@ -38,7 +38,10 @@ fn form_b_first_value_skips_serial_column() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert into Customers values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose at first Form B value slot, got {:?}", a.hint)
|
||||
panic!(
|
||||
"expected Prose at first Form B value slot, got {:?}",
|
||||
a.hint
|
||||
)
|
||||
});
|
||||
// The value slot itself must be keyed on `Name` — the first
|
||||
// non-auto column — not on the skipped `id`.
|
||||
@@ -60,9 +63,7 @@ fn form_b_first_value_skips_serial_column() {
|
||||
fn form_b_first_value_text_pk_names_first_column() {
|
||||
let schema = schema_text_pk();
|
||||
let a = assess_at_end("insert into Items values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose, got {:?}", a.hint)
|
||||
});
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("Code"),
|
||||
"Form B should name the PK column `Code`, got prose: {prose:?}",
|
||||
@@ -76,9 +77,7 @@ fn form_b_first_value_every_type_first_column_is_int() {
|
||||
// must say `integer` and name `k`.
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose, got {:?}", a.hint)
|
||||
});
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("k"),
|
||||
"should name column `k`, got prose: {prose:?}",
|
||||
@@ -98,13 +97,9 @@ fn form_b_first_value_every_type_first_column_is_int() {
|
||||
#[test]
|
||||
fn form_b_after_first_value_advances_to_next_column() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers values ('Alice', ",
|
||||
&schema,
|
||||
);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose at second slot, got {:?}", a.hint)
|
||||
});
|
||||
let a = assess_at_end("insert into Customers values ('Alice', ", &schema);
|
||||
let prose =
|
||||
hint_prose(&a).unwrap_or_else(|| panic!("expected Prose at second slot, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("Email"),
|
||||
"second slot should name `Email`, got prose: {prose:?}",
|
||||
@@ -121,10 +116,7 @@ fn form_b_after_first_value_advances_to_next_column() {
|
||||
#[test]
|
||||
fn form_b_in_progress_after_comma_is_incomplete() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers values ('Alice', ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers values ('Alice', ", &schema);
|
||||
assert!(
|
||||
matches!(a.state, InputState::IncompleteAtEof),
|
||||
"in-progress Form B should be Incomplete, got {:?}",
|
||||
@@ -136,10 +128,7 @@ fn form_b_in_progress_after_comma_is_incomplete() {
|
||||
#[test]
|
||||
fn form_b_in_progress_without_closing_paren_is_incomplete() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers values ('Alice', 'a@b.c'",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers values ('Alice', 'a@b.c'", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("form_b_in_progress_no_close_paren", a);
|
||||
}
|
||||
@@ -201,10 +190,7 @@ fn form_b_with_extra_value_for_serial_column_is_invalid() {
|
||||
#[test]
|
||||
fn form_b_with_correct_values_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers values ('Alice', 'a@b.c')",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers values ('Alice', 'a@b.c')", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
|
||||
crate::snap!("form_b_valid", a);
|
||||
@@ -213,10 +199,7 @@ fn form_b_with_correct_values_parses() {
|
||||
#[test]
|
||||
fn form_b_text_pk_with_correct_values_parses() {
|
||||
let schema = schema_text_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Items values ('SKU-1', 'Widget')",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Items values ('SKU-1', 'Widget')", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
|
||||
crate::snap!("form_b_text_pk_valid", a);
|
||||
@@ -240,9 +223,8 @@ fn form_b_text_pk_with_correct_values_parses() {
|
||||
fn form_b_first_slot_mentions_skipped_serial_column() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert into Customers values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose at first Form B slot, got {:?}", a.hint)
|
||||
});
|
||||
let prose = hint_prose(&a)
|
||||
.unwrap_or_else(|| panic!("expected Prose at first Form B slot, got {:?}", a.hint));
|
||||
// Names the skipped auto-gen column.
|
||||
assert!(
|
||||
prose.contains("`id`"),
|
||||
@@ -261,13 +243,9 @@ fn form_b_second_slot_omits_skip_note() {
|
||||
// The note fires once, at the first slot only — not at
|
||||
// every comma.
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers values ('Alice', ",
|
||||
&schema,
|
||||
);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose at second slot, got {:?}", a.hint)
|
||||
});
|
||||
let a = assess_at_end("insert into Customers values ('Alice', ", &schema);
|
||||
let prose =
|
||||
hint_prose(&a).unwrap_or_else(|| panic!("expected Prose at second slot, got {:?}", a.hint));
|
||||
assert!(
|
||||
!prose.contains("auto-generated"),
|
||||
"second-slot hint must NOT repeat the skip note, got: {prose:?}",
|
||||
@@ -280,9 +258,7 @@ fn form_b_text_pk_has_no_skip_note() {
|
||||
// No auto-gen columns → no skip note.
|
||||
let schema = schema_text_pk();
|
||||
let a = assess_at_end("insert into Items values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose, got {:?}", a.hint)
|
||||
});
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
|
||||
assert!(
|
||||
!prose.contains("auto-generated"),
|
||||
"text-PK table has no auto-gen column — no skip note expected, got: {prose:?}",
|
||||
@@ -295,13 +271,8 @@ fn form_a_first_slot_has_no_skip_note() {
|
||||
// Form A lists columns explicitly — the user is in control,
|
||||
// no pedagogical pointer needed.
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (Name) values (",
|
||||
&schema,
|
||||
);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose, got {:?}", a.hint)
|
||||
});
|
||||
let a = assess_at_end("insert into Customers (Name) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
|
||||
assert!(
|
||||
!prose.contains("auto-generated"),
|
||||
"Form A must not show the Form-B skip note, got: {prose:?}",
|
||||
@@ -315,9 +286,8 @@ fn form_b_advances_through_every_type_first_to_real() {
|
||||
// first value, prose must name `r` and say `number`.
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things values (1, ", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose at 2nd slot, got {:?}", a.hint)
|
||||
});
|
||||
let prose =
|
||||
hint_prose(&a).unwrap_or_else(|| panic!("expected Prose at 2nd slot, got {:?}", a.hint));
|
||||
assert!(prose.contains("r"), "should name `r`, got prose: {prose:?}");
|
||||
assert!(
|
||||
prose.contains("number"),
|
||||
|
||||
@@ -26,10 +26,7 @@ fn form_c_text_pk_correct_values_parses() {
|
||||
// Items(Code:text, Title:text) — Form C expects two text
|
||||
// values (no auto-gen columns to skip).
|
||||
let schema = schema_text_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Items ('SKU-1', 'Widget')",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Items ('SKU-1', 'Widget')", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
|
||||
crate::snap!("form_c_text_pk_valid", a);
|
||||
@@ -40,10 +37,7 @@ fn form_c_serial_pk_correct_values_parses() {
|
||||
// Customers(id:serial, Name:text, Email:text) — Form C
|
||||
// skips the serial `id`, expects two text values.
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers ('Alice', 'a@b.c')",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers ('Alice', 'a@b.c')", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
|
||||
crate::snap!("form_c_serial_pk_valid", a);
|
||||
@@ -53,10 +47,7 @@ fn form_c_serial_pk_correct_values_parses() {
|
||||
fn form_c_with_null_value_parses() {
|
||||
// null is type-compatible with any slot.
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (null, 'a@b.c')",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers (null, 'a@b.c')", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
crate::snap!("form_c_null_value", a);
|
||||
}
|
||||
@@ -71,10 +62,7 @@ fn form_c_rejects_number_for_text_column() {
|
||||
// rejects it at parse time. Before Form-C type-awareness
|
||||
// this parsed Valid and only failed at bind time.
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (3.14, 'a@b.c')",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers (3.14, 'a@b.c')", &schema);
|
||||
assert!(
|
||||
!matches!(a.state, InputState::Valid),
|
||||
"Form C should now type-check `3.14` against Name(text), got {:?}",
|
||||
@@ -114,13 +102,9 @@ fn form_c_second_slot_shows_typed_prose_for_column() {
|
||||
// First token `'Alice'` is a string literal → Form C. At
|
||||
// the second slot the hint names the Email column.
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers ('Alice', ",
|
||||
&schema,
|
||||
);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose at Form C second slot, got {:?}", a.hint)
|
||||
});
|
||||
let a = assess_at_end("insert into Customers ('Alice', ", &schema);
|
||||
let prose = hint_prose(&a)
|
||||
.unwrap_or_else(|| panic!("expected Prose at Form C second slot, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("Email"),
|
||||
"Form C second slot should name `Email`, got prose: {prose:?}",
|
||||
@@ -143,10 +127,7 @@ fn form_c_in_progress_after_comma_is_incomplete() {
|
||||
#[test]
|
||||
fn form_c_in_progress_without_close_paren_is_incomplete() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers ('Alice', 'a@b.c'",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers ('Alice', 'a@b.c'", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("form_c_in_progress_no_close", a);
|
||||
}
|
||||
|
||||
+23
-28
@@ -22,25 +22,25 @@ use rdbms_playground::input_render::{
|
||||
};
|
||||
use rdbms_playground::mode::Mode;
|
||||
|
||||
pub mod add_relationship;
|
||||
pub mod app_commands;
|
||||
pub mod candidate_ordering;
|
||||
pub mod constraints;
|
||||
pub mod create_m2n;
|
||||
pub mod create_table;
|
||||
pub mod delete_all_rows;
|
||||
pub mod delete_with_where;
|
||||
pub mod drop_column;
|
||||
pub mod drop_relationship;
|
||||
pub mod explain;
|
||||
pub mod index_ops;
|
||||
pub mod insert_form_a;
|
||||
pub mod insert_form_b;
|
||||
pub mod insert_form_c;
|
||||
pub mod update_with_where;
|
||||
pub mod update_all_rows;
|
||||
pub mod delete_with_where;
|
||||
pub mod where_expression;
|
||||
pub mod delete_all_rows;
|
||||
pub mod explain;
|
||||
pub mod create_table;
|
||||
pub mod drop_column;
|
||||
pub mod drop_relationship;
|
||||
pub mod add_relationship;
|
||||
pub mod create_m2n;
|
||||
pub mod index_ops;
|
||||
pub mod constraints;
|
||||
pub mod rename_change_column;
|
||||
pub mod app_commands;
|
||||
pub mod candidate_ordering;
|
||||
pub mod update_all_rows;
|
||||
pub mod update_with_where;
|
||||
pub mod where_expression;
|
||||
|
||||
// =========================================================
|
||||
// Canonical schema shapes (handoff §1 — CANONICAL_SCHEMA_SHAPES)
|
||||
@@ -81,10 +81,7 @@ pub fn schema_serial_pk() -> SchemaCache {
|
||||
/// Exercises the no-auto-gen path: Form A and Form B both
|
||||
/// require values for every column.
|
||||
pub fn schema_text_pk() -> SchemaCache {
|
||||
build_schema(&[(
|
||||
"Items",
|
||||
&[("Code", Type::Text), ("Title", Type::Text)],
|
||||
)])
|
||||
build_schema(&[("Items", &[("Code", Type::Text), ("Title", Type::Text)])])
|
||||
}
|
||||
|
||||
/// Two tables sharing no column names.
|
||||
@@ -96,10 +93,7 @@ pub fn schema_text_pk() -> SchemaCache {
|
||||
/// the other table.
|
||||
pub fn schema_multi_table() -> SchemaCache {
|
||||
build_schema(&[
|
||||
(
|
||||
"Customers",
|
||||
&[("id", Type::Serial), ("Name", Type::Text)],
|
||||
),
|
||||
("Customers", &[("id", Type::Serial), ("Name", Type::Text)]),
|
||||
(
|
||||
"Orders",
|
||||
&[
|
||||
@@ -436,10 +430,7 @@ fn smoke_assess_at_end_returns_each_field() {
|
||||
#[test]
|
||||
fn smoke_assess_parse_label_round_trips() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers values ('Alice', 'a@b.c')",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("insert into Customers values ('Alice', 'a@b.c')", &schema);
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
}
|
||||
@@ -462,7 +453,11 @@ fn seed_completion_and_validity() {
|
||||
// Validity (ADR-0027): a known table seeds clean; an unknown one is
|
||||
// flagged (same table slot as update/delete/show data).
|
||||
let ok = assess_at_end("seed Customers 5", &schema);
|
||||
assert!(matches!(ok.state, InputState::Valid), "known table: {:?}", ok.state);
|
||||
assert!(
|
||||
matches!(ok.state, InputState::Valid),
|
||||
"known table: {:?}",
|
||||
ok.state
|
||||
);
|
||||
// seed's unknown-table behaviour must match its closest sibling
|
||||
// `show data` (same table-only slot), whatever that is.
|
||||
let seed_ghost = assess_at_end("seed Ghost 5", &schema).state;
|
||||
|
||||
@@ -34,10 +34,7 @@ fn rename_after_old_column_expects_to() {
|
||||
#[test]
|
||||
fn rename_after_to_is_new_name_slot() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"rename column in Customers: Email to ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("rename column in Customers: Email to ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("rename_after_to", a);
|
||||
}
|
||||
@@ -45,10 +42,7 @@ fn rename_after_to_is_new_name_slot() {
|
||||
#[test]
|
||||
fn complete_rename_column_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"rename column in Customers: Email to ContactEmail",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("rename column in Customers: Email to ContactEmail", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("RenameColumn"));
|
||||
crate::snap!("complete_rename", a);
|
||||
@@ -78,10 +72,7 @@ fn change_after_open_paren_offers_type_candidates() {
|
||||
#[test]
|
||||
fn complete_change_column_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"change column in Customers: Email (text)",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("change column in Customers: Email (text)", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("ChangeColumnType"));
|
||||
crate::snap!("complete_change", a);
|
||||
|
||||
@@ -7,10 +7,7 @@ use rdbms_playground::input_render::InputState;
|
||||
#[test]
|
||||
fn before_all_rows_flag_is_incomplete() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"update Customers set Email='x' ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("update Customers set Email='x' ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
// Both `where` and `--all-rows` are valid continuations.
|
||||
assert_candidate_present(&a, &["--all-rows"]);
|
||||
@@ -20,10 +17,7 @@ fn before_all_rows_flag_is_incomplete() {
|
||||
#[test]
|
||||
fn complete_update_all_rows_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"update Customers set Email='new@b.c' --all-rows",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("update Customers set Email='new@b.c' --all-rows", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Update"));
|
||||
crate::snap!("complete_all_rows", a);
|
||||
@@ -33,10 +27,7 @@ fn complete_update_all_rows_parses() {
|
||||
fn update_without_filter_clause_is_incomplete() {
|
||||
// Per ADR-0014, update requires WHERE or --all-rows.
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"update Customers set Email='new@b.c'",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("update Customers set Email='new@b.c'", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("no_filter_clause", a);
|
||||
}
|
||||
@@ -44,10 +35,7 @@ fn update_without_filter_clause_is_incomplete() {
|
||||
#[test]
|
||||
fn update_partial_flag_name_is_incomplete() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"update Customers set Email='x' --all",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("update Customers set Email='x' --all", &schema);
|
||||
// Partial flag still in progress.
|
||||
assert!(!matches!(a.state, InputState::Valid));
|
||||
crate::snap!("partial_flag", a);
|
||||
|
||||
@@ -64,9 +64,7 @@ fn after_set_column_expects_equals() {
|
||||
fn after_equals_offers_typed_slot_prose_for_column() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("update Customers set Email=", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose, got {:?}", a.hint)
|
||||
});
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("Email"),
|
||||
"should name `Email`, got prose: {prose:?}",
|
||||
@@ -82,9 +80,7 @@ fn after_equals_offers_typed_slot_prose_for_column() {
|
||||
fn after_equals_for_date_column_says_yyyy_mm_dd() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("update Things set dt=", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose, got {:?}", a.hint)
|
||||
});
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("YYYY-MM-DD"),
|
||||
"date-slot prose should reference YYYY-MM-DD, got {prose:?}",
|
||||
@@ -95,10 +91,7 @@ fn after_equals_for_date_column_says_yyyy_mm_dd() {
|
||||
#[test]
|
||||
fn mid_assignment_list_after_comma_offers_remaining_columns() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"update Customers set Name='x', ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("update Customers set Name='x', ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
// Customers columns offered, no leakage.
|
||||
assert_candidate_present(&a, &["id"]);
|
||||
@@ -109,10 +102,7 @@ fn mid_assignment_list_after_comma_offers_remaining_columns() {
|
||||
#[test]
|
||||
fn after_assignments_expects_where_or_all_rows() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"update Customers set Email='new@b.c' ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("update Customers set Email='new@b.c' ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["where", "--all-rows"]);
|
||||
crate::snap!("after_assignments", a);
|
||||
@@ -125,10 +115,7 @@ fn after_assignments_expects_where_or_all_rows() {
|
||||
#[test]
|
||||
fn after_where_keyword_offers_active_table_columns() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"update Customers set Name='x' where ",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("update Customers set Name='x' where ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["id", "Name"]);
|
||||
assert_no_candidate_named(&a, &["OrderId", "CustId", "Total"]);
|
||||
@@ -138,13 +125,8 @@ fn after_where_keyword_offers_active_table_columns() {
|
||||
#[test]
|
||||
fn after_where_column_equals_offers_typed_prose() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"update Customers set Email='x' where id=",
|
||||
&schema,
|
||||
);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose, got {:?}", a.hint)
|
||||
});
|
||||
let a = assess_at_end("update Customers set Email='x' where id=", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("id"),
|
||||
"should name where column `id`, got prose: {prose:?}",
|
||||
@@ -159,10 +141,7 @@ fn after_where_column_equals_offers_typed_prose() {
|
||||
#[test]
|
||||
fn complete_update_with_where_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"update Customers set Email='new@b.c' where id=1",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("update Customers set Email='new@b.c' where id=1", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Update"));
|
||||
crate::snap!("complete_update", a);
|
||||
|
||||
@@ -95,10 +95,7 @@ fn show_data_after_where_predicate_offers_limit() {
|
||||
#[test]
|
||||
fn complete_show_data_with_where_and_limit_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"show data Customers where id=1 limit 10",
|
||||
&schema,
|
||||
);
|
||||
let a = assess_at_end("show data Customers where id=1 limit 10", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("ShowData"));
|
||||
crate::snap!("complete_show_where_limit", a);
|
||||
|
||||
Reference in New Issue
Block a user