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:
claude@clouddev1
2026-06-17 21:39:19 +00:00
parent e9606b5f6d
commit 41b7e9a049
102 changed files with 8017 additions and 4975 deletions
+47 -13
View File
@@ -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");
}
+22 -8
View File
@@ -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
View File
@@ -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!(
+4 -9
View File
@@ -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);
}
+59 -25
View File
@@ -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());
+1 -1
View File
@@ -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 {
+22 -11
View File
@@ -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}");
}
+28 -37
View File
@@ -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();
+9 -7
View File
@@ -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
+3 -6
View File
@@ -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()
+10 -8
View File
@@ -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.
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+457 -83
View File
@@ -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:?}",
);
}
+23 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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!(
+48 -14
View File
@@ -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");
}
File diff suppressed because it is too large Load Diff
+261 -64
View File
@@ -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
View File
@@ -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}"
);
}
+23 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+10 -4
View File
@@ -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
+57 -22
View File
@@ -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);