571 lines
17 KiB
Rust
571 lines
17 KiB
Rust
#[path = "support/orm_sqlite.rs"]
|
|
mod orm_sqlite;
|
|
mod support;
|
|
|
|
use dever_runtime::orm::{ErrorKind, Value};
|
|
use dever_runtime::sqlite::Database;
|
|
use orm_sqlite::{
|
|
TestField, bool_default, bytes_field, created_at, database_state, id, index, migration, model,
|
|
text, text_field,
|
|
};
|
|
|
|
fn text_model(
|
|
revision: &str,
|
|
text_maximum: usize,
|
|
bytes_maximum: usize,
|
|
) -> dever_runtime::database::Model {
|
|
model(
|
|
revision,
|
|
"seed-empty",
|
|
vec![
|
|
id(),
|
|
text_field("name", text_maximum, false),
|
|
bytes_field("payload", bytes_maximum, false),
|
|
created_at(),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_database_uses_only_the_final_schema_and_records_real_catalog() {
|
|
let root = support::temp::TemporaryDirectory::new();
|
|
let database = Database::open(root.path().join("new.db"), 1, 50).unwrap();
|
|
let final_model = model(
|
|
"1000000000000001",
|
|
"seed-empty",
|
|
vec![
|
|
id(),
|
|
text_field("display_name", 128, false).renamed_from("nickname"),
|
|
bytes_field("payload", 16, true),
|
|
bool_default("enabled"),
|
|
created_at(),
|
|
],
|
|
vec![index(
|
|
"_dever_idx_user_display_name",
|
|
&["display_name"],
|
|
true,
|
|
)],
|
|
vec![],
|
|
vec![],
|
|
);
|
|
|
|
database.migrate(final_model).await.unwrap();
|
|
let state = database_state(&database).await;
|
|
|
|
let columns = state
|
|
.columns
|
|
.iter()
|
|
.map(|row| text(row, 0))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
columns,
|
|
["id", "display_name", "payload", "enabled", "created_at"]
|
|
);
|
|
assert!(!columns.contains(&"nickname"));
|
|
assert_eq!(state.history.len(), 3);
|
|
assert!(
|
|
state
|
|
.history
|
|
.iter()
|
|
.any(|row| { text(row, 0) == "schema" && text(row, 2) == "1000000000000001" })
|
|
);
|
|
assert!(state.catalog.iter().any(|row| {
|
|
text(row, 0) == "table"
|
|
&& text(row, 2).contains("length(\"display_name\") <= 128")
|
|
&& text(row, 2).contains("DEFAULT 1")
|
|
}));
|
|
assert!(
|
|
database
|
|
.query(
|
|
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name LIKE '_dever_user_%'",
|
|
vec![],
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_widen_and_add_fields_preserve_existing_data_and_are_idempotent() {
|
|
let root = support::temp::TemporaryDirectory::new();
|
|
let database = Database::open(root.path().join("automatic.db"), 1, 50).unwrap();
|
|
let initial = model(
|
|
"2000000000000001",
|
|
"seed-empty",
|
|
vec![
|
|
id(),
|
|
text_field("nickname", 64, false),
|
|
bytes_field("payload", 8, false),
|
|
created_at(),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
);
|
|
database.migrate(initial).await.unwrap();
|
|
database
|
|
.execute_batch(
|
|
"INSERT INTO user (nickname, payload) VALUES ('Administrator', X'0102')".into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let upgraded = || {
|
|
model(
|
|
"2000000000000002",
|
|
"seed-empty",
|
|
vec![
|
|
id(),
|
|
text_field("display_name", 128, false).renamed_from("nickname"),
|
|
bytes_field("payload", 16, false),
|
|
text_field("bio", 32, true),
|
|
bool_default("enabled"),
|
|
created_at(),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
)
|
|
};
|
|
database.migrate(upgraded()).await.unwrap();
|
|
let once = database_state(&database).await;
|
|
database.migrate(upgraded()).await.unwrap();
|
|
assert_eq!(database_state(&database).await, once);
|
|
|
|
let rows = database
|
|
.query(
|
|
"SELECT display_name, payload, bio, enabled FROM user",
|
|
vec![],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
rows[0].get(0).unwrap(),
|
|
&Value::Text("Administrator".into())
|
|
);
|
|
assert_eq!(rows[0].get(1).unwrap(), &Value::Bytes(vec![1, 2]));
|
|
assert_eq!(rows[0].get(2).unwrap(), &Value::Null);
|
|
assert_eq!(rows[0].get(3).unwrap(), &Value::Int(1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn text_and_bytes_tightening_succeeds_or_rolls_back_as_one_unit() {
|
|
let root = support::temp::TemporaryDirectory::new();
|
|
let valid = Database::open(root.path().join("narrow-valid.db"), 1, 50).unwrap();
|
|
valid
|
|
.migrate(text_model("3000000000000001", 16, 8))
|
|
.await
|
|
.unwrap();
|
|
valid
|
|
.execute_batch("INSERT INTO user (name, payload) VALUES ('short', X'0102')".into())
|
|
.await
|
|
.unwrap();
|
|
valid
|
|
.migrate(text_model("3000000000000002", 5, 2))
|
|
.await
|
|
.unwrap();
|
|
let valid_state = database_state(&valid).await;
|
|
assert!(valid_state.catalog.iter().any(|row| {
|
|
text(row, 0) == "table"
|
|
&& text(row, 2).contains("length(\"name\") <= 5")
|
|
&& text(row, 2).contains("length(\"payload\") <= 2")
|
|
}));
|
|
|
|
let invalid = Database::open(root.path().join("narrow-invalid.db"), 1, 50).unwrap();
|
|
invalid
|
|
.migrate(text_model("3000000000000001", 16, 8))
|
|
.await
|
|
.unwrap();
|
|
invalid
|
|
.execute_batch("INSERT INTO user (name, payload) VALUES ('too-long', X'010203')".into())
|
|
.await
|
|
.unwrap();
|
|
let before = database_state(&invalid).await;
|
|
assert_eq!(
|
|
invalid
|
|
.migrate(text_model("3000000000000002", 5, 2))
|
|
.await
|
|
.unwrap_err()
|
|
.kind(),
|
|
ErrorKind::Constraint
|
|
);
|
|
assert_eq!(database_state(&invalid).await, before);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn not_null_and_unique_changes_validate_existing_rows_before_commit() {
|
|
let root = support::temp::TemporaryDirectory::new();
|
|
|
|
let nullable = |revision: &str, required: bool| {
|
|
model(
|
|
revision,
|
|
"seed-empty",
|
|
vec![
|
|
id(),
|
|
text_field("code", 16, false),
|
|
text_field("note", 32, !required),
|
|
created_at(),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
)
|
|
};
|
|
let nullable_valid = Database::open(root.path().join("nullable-valid.db"), 1, 50).unwrap();
|
|
nullable_valid
|
|
.migrate(nullable("4000000000000001", false))
|
|
.await
|
|
.unwrap();
|
|
nullable_valid
|
|
.execute_batch("INSERT INTO user (code, note) VALUES ('one', 'value')".into())
|
|
.await
|
|
.unwrap();
|
|
nullable_valid
|
|
.migrate(nullable("4000000000000002", true))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
orm_sqlite::integer(&database_state(&nullable_valid).await.columns[2], 2),
|
|
1
|
|
);
|
|
|
|
let nullable_invalid = Database::open(root.path().join("nullable-invalid.db"), 1, 50).unwrap();
|
|
nullable_invalid
|
|
.migrate(nullable("4000000000000001", false))
|
|
.await
|
|
.unwrap();
|
|
nullable_invalid
|
|
.execute_batch("INSERT INTO user (code, note) VALUES ('one', NULL)".into())
|
|
.await
|
|
.unwrap();
|
|
let before = database_state(&nullable_invalid).await;
|
|
assert_eq!(
|
|
nullable_invalid
|
|
.migrate(nullable("4000000000000002", true))
|
|
.await
|
|
.unwrap_err()
|
|
.kind(),
|
|
ErrorKind::Constraint
|
|
);
|
|
assert_eq!(database_state(&nullable_invalid).await, before);
|
|
|
|
let unique = |revision: &str, enabled: bool| {
|
|
model(
|
|
revision,
|
|
"seed-empty",
|
|
vec![id(), text_field("code", 16, false), created_at()],
|
|
enabled
|
|
.then(|| index("_dever_idx_user_code", &["code"], true))
|
|
.into_iter()
|
|
.collect(),
|
|
vec![],
|
|
vec![],
|
|
)
|
|
};
|
|
let unique_valid = Database::open(root.path().join("unique-valid.db"), 1, 50).unwrap();
|
|
unique_valid
|
|
.migrate(unique("4000000000000011", false))
|
|
.await
|
|
.unwrap();
|
|
unique_valid
|
|
.execute_batch("INSERT INTO user (code) VALUES ('one'), ('two')".into())
|
|
.await
|
|
.unwrap();
|
|
unique_valid
|
|
.migrate(unique("4000000000000012", true))
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
database_state(&unique_valid)
|
|
.await
|
|
.catalog
|
|
.iter()
|
|
.any(|row| {
|
|
text(row, 1) == "_dever_idx_user_code"
|
|
&& text(row, 2).starts_with("CREATE UNIQUE INDEX")
|
|
})
|
|
);
|
|
|
|
let unique_invalid = Database::open(root.path().join("unique-invalid.db"), 1, 50).unwrap();
|
|
unique_invalid
|
|
.migrate(unique("4000000000000011", false))
|
|
.await
|
|
.unwrap();
|
|
unique_invalid
|
|
.execute_batch("INSERT INTO user (code) VALUES ('same'), ('same')".into())
|
|
.await
|
|
.unwrap();
|
|
let before = database_state(&unique_invalid).await;
|
|
assert_eq!(
|
|
unique_invalid
|
|
.migrate(unique("4000000000000012", true))
|
|
.await
|
|
.unwrap_err()
|
|
.kind(),
|
|
ErrorKind::Constraint
|
|
);
|
|
assert_eq!(database_state(&unique_invalid).await, before);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn adding_a_foreign_key_accepts_valid_rows_and_rolls_back_orphans() {
|
|
let root = support::temp::TemporaryDirectory::new();
|
|
let schema = |revision: &str, foreign_key: bool| {
|
|
let owner = if foreign_key {
|
|
TestField::new(
|
|
"owner_id",
|
|
"id:app.model.team",
|
|
false,
|
|
"\"owner_id\" INTEGER NOT NULL REFERENCES \"team\" (\"id\")",
|
|
)
|
|
} else {
|
|
TestField::new("owner_id", "Int", false, "\"owner_id\" INTEGER NOT NULL")
|
|
};
|
|
model(
|
|
revision,
|
|
"seed-empty",
|
|
vec![id(), owner, created_at()],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
)
|
|
};
|
|
|
|
let valid = Database::open(root.path().join("foreign-key-valid.db"), 1, 50).unwrap();
|
|
valid
|
|
.execute_batch(
|
|
"CREATE TABLE team (id INTEGER PRIMARY KEY); INSERT INTO team (id) VALUES (1)".into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
valid
|
|
.migrate(schema("5000000000000001", false))
|
|
.await
|
|
.unwrap();
|
|
valid
|
|
.execute_batch("INSERT INTO user (owner_id) VALUES (1)".into())
|
|
.await
|
|
.unwrap();
|
|
valid
|
|
.migrate(schema("5000000000000002", true))
|
|
.await
|
|
.unwrap();
|
|
let foreign_keys = valid
|
|
.query(
|
|
"SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('user')",
|
|
vec![],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(foreign_keys.len(), 1);
|
|
assert_eq!(text(&foreign_keys[0], 0), "team");
|
|
assert_eq!(text(&foreign_keys[0], 1), "owner_id");
|
|
assert_eq!(text(&foreign_keys[0], 2), "id");
|
|
drop(valid);
|
|
|
|
let invalid = Database::open(root.path().join("foreign-key-invalid.db"), 1, 50).unwrap();
|
|
invalid
|
|
.execute_batch("CREATE TABLE team (id INTEGER PRIMARY KEY)".into())
|
|
.await
|
|
.unwrap();
|
|
invalid
|
|
.migrate(schema("5000000000000001", false))
|
|
.await
|
|
.unwrap();
|
|
invalid
|
|
.execute_batch("INSERT INTO user (owner_id) VALUES (99)".into())
|
|
.await
|
|
.unwrap();
|
|
let before = database_state(&invalid).await;
|
|
assert_eq!(
|
|
invalid
|
|
.migrate(schema("5000000000000002", true))
|
|
.await
|
|
.unwrap_err()
|
|
.kind(),
|
|
ErrorKind::Migration
|
|
);
|
|
assert_eq!(database_state(&invalid).await, before);
|
|
drop(invalid);
|
|
|
|
let cross_family = Database::open(root.path().join("foreign-key-type.db"), 1, 50).unwrap();
|
|
cross_family
|
|
.execute_batch("CREATE TABLE team (id INTEGER PRIMARY KEY)".into())
|
|
.await
|
|
.unwrap();
|
|
cross_family
|
|
.migrate(model(
|
|
"5000000000000011",
|
|
"seed-empty",
|
|
vec![
|
|
id(),
|
|
TestField::new("owner_id", "Float", false, "\"owner_id\" REAL NOT NULL"),
|
|
created_at(),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
cross_family
|
|
.execute_batch("INSERT INTO user (owner_id) VALUES (1.0)".into())
|
|
.await
|
|
.unwrap();
|
|
let before = database_state(&cross_family).await;
|
|
assert_eq!(
|
|
cross_family
|
|
.migrate(schema("5000000000000012", true))
|
|
.await
|
|
.unwrap_err()
|
|
.kind(),
|
|
ErrorKind::Migration
|
|
);
|
|
assert_eq!(database_state(&cross_family).await, before);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn named_drop_runs_once_and_applied_content_cannot_change_or_disappear() {
|
|
let root = support::temp::TemporaryDirectory::new();
|
|
let database = Database::open(root.path().join("named-drop.db"), 1, 50).unwrap();
|
|
let initial = || {
|
|
model(
|
|
"6000000000000001",
|
|
"seed-empty",
|
|
vec![
|
|
id(),
|
|
text_field("code", 16, false),
|
|
text_field("legacy", 32, true),
|
|
created_at(),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
vec![],
|
|
)
|
|
};
|
|
database.migrate(initial()).await.unwrap();
|
|
database
|
|
.execute_batch("INSERT INTO user (code, legacy) VALUES ('one', 'old')".into())
|
|
.await
|
|
.unwrap();
|
|
|
|
let dropped = |migration_revision: &str, include_migration: bool| {
|
|
model(
|
|
"6000000000000002",
|
|
"seed-empty",
|
|
vec![id(), text_field("code", 16, false), created_at()],
|
|
vec![],
|
|
include_migration
|
|
.then(|| migration("remove_legacy", migration_revision, &["legacy"]))
|
|
.into_iter()
|
|
.collect(),
|
|
vec![],
|
|
)
|
|
};
|
|
let before_drop = database_state(&database).await;
|
|
assert_eq!(
|
|
database
|
|
.migrate(dropped("remove-legacy-v1", false))
|
|
.await
|
|
.unwrap_err()
|
|
.kind(),
|
|
ErrorKind::Migration
|
|
);
|
|
assert_eq!(database_state(&database).await, before_drop);
|
|
|
|
database
|
|
.migrate(dropped("remove-legacy-v1", true))
|
|
.await
|
|
.unwrap();
|
|
let applied = database_state(&database).await;
|
|
assert_eq!(
|
|
applied
|
|
.columns
|
|
.iter()
|
|
.map(|row| text(row, 0))
|
|
.collect::<Vec<_>>(),
|
|
["id", "code", "created_at"]
|
|
);
|
|
assert!(applied.history.iter().any(|row| {
|
|
text(row, 0) == "migration"
|
|
&& text(row, 1) == "remove_legacy"
|
|
&& text(row, 2) == "remove-legacy-v1"
|
|
}));
|
|
|
|
database
|
|
.migrate(dropped("remove-legacy-v1", true))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(database_state(&database).await, applied);
|
|
assert_eq!(
|
|
database
|
|
.migrate(dropped("remove-legacy-v2", true))
|
|
.await
|
|
.unwrap_err()
|
|
.kind(),
|
|
ErrorKind::Migration
|
|
);
|
|
assert_eq!(database_state(&database).await, applied);
|
|
assert_eq!(
|
|
database
|
|
.migrate(dropped("remove-legacy-v1", false))
|
|
.await
|
|
.unwrap_err()
|
|
.kind(),
|
|
ErrorKind::Migration
|
|
);
|
|
assert_eq!(database_state(&database).await, applied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn catalog_drift_is_rejected_without_rewriting_history_or_data() {
|
|
let root = support::temp::TemporaryDirectory::new();
|
|
|
|
let indexed = || {
|
|
model(
|
|
"8000000000000001",
|
|
"seed-empty",
|
|
vec![id(), text_field("code", 16, false), created_at()],
|
|
vec![index("_dever_idx_user_code", &["code"], true)],
|
|
vec![],
|
|
vec![],
|
|
)
|
|
};
|
|
let index_drift = Database::open(root.path().join("index-drift.db"), 1, 50).unwrap();
|
|
index_drift.migrate(indexed()).await.unwrap();
|
|
index_drift
|
|
.execute_batch(
|
|
"INSERT INTO user (code) VALUES ('one'); \
|
|
DROP INDEX _dever_idx_user_code; \
|
|
CREATE INDEX _dever_idx_user_code ON user (code)"
|
|
.into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let before = database_state(&index_drift).await;
|
|
assert_eq!(
|
|
index_drift.migrate(indexed()).await.unwrap_err().kind(),
|
|
ErrorKind::Migration
|
|
);
|
|
assert_eq!(database_state(&index_drift).await, before);
|
|
|
|
let table_drift = Database::open(root.path().join("table-drift.db"), 1, 50).unwrap();
|
|
table_drift.migrate(indexed()).await.unwrap();
|
|
table_drift
|
|
.execute_batch(
|
|
"INSERT INTO user (code) VALUES ('one'); ALTER TABLE user ADD COLUMN rogue TEXT".into(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let before = database_state(&table_drift).await;
|
|
assert_eq!(
|
|
table_drift.migrate(indexed()).await.unwrap_err().kind(),
|
|
ErrorKind::Migration
|
|
);
|
|
assert_eq!(database_state(&table_drift).await, before);
|
|
}
|