use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use fse_cli::migrate::{MigrateOpts, run};
static NEXT: AtomicU64 = AtomicU64::new(0);
fn project(tables: &[(&str, &str)]) -> (PathBuf, MigrateOpts) {
let root = std::env::temp_dir().join(format!(
"fse-cli-test-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
write_tables(&root, tables);
let opts = MigrateOpts {
assume_yes: true,
no_prepare: true,
database_url: Some(format!("sqlite://{}", root.join("app.db").display())),
..MigrateOpts::default()
};
(root, opts)
}
fn write_tables(root: &Path, tables: &[(&str, &str)]) {
let dir = root.join("src/tables");
fs::create_dir_all(&dir).unwrap();
for (name, code) in tables {
fs::write(dir.join(name), code).unwrap();
}
}
fn migration_files(root: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(root.join("migrations"))
.map(|it| {
it.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default();
names.sort();
names
}
async fn table_columns(opts: &MigrateOpts, table: &str) -> Vec<String> {
let pool = sqlx::SqlitePool::connect(opts.database_url.as_deref().unwrap())
.await
.unwrap();
let rows: Vec<(String,)> =
sqlx::query_as(&format!("SELECT name FROM pragma_table_info('{table}')"))
.fetch_all(&pool)
.await
.unwrap();
pool.close().await;
rows.into_iter().map(|r| r.0).collect()
}
const EVENT: &str = "
#[derive(Table)]
pub struct Event {
pub id: i64,
pub name: String,
}
";
const EVENT_WITH_LOCATION: &str = "
#[derive(Table)]
pub struct Event {
pub id: i64,
pub name: String,
pub location: Option<String>,
}
";
#[tokio::test]
async fn generates_applies_and_stays_idempotent() {
let (root, opts) = project(&[("event.rs", EVENT)]);
let out = run(&root, &opts).await.unwrap();
let generated = out.generated.expect("migration generated");
assert!(
generated
.file_name()
.unwrap()
.to_string_lossy()
.ends_with("_create_events.sql")
);
assert!(root.join(".fse/schema.json").exists());
assert_eq!(table_columns(&opts, "events").await, ["id", "name"]);
let out = run(&root, &opts).await.unwrap();
assert!(out.generated.is_none());
assert_eq!(migration_files(&root).len(), 1);
write_tables(&root, &[("event.rs", EVENT_WITH_LOCATION)]);
let out = run(&root, &opts).await.unwrap();
let sql = fs::read_to_string(out.generated.unwrap()).unwrap();
assert_eq!(
sql,
"ALTER TABLE \"events\" ADD COLUMN \"location\" TEXT;\n"
);
assert_eq!(migration_files(&root).len(), 2);
assert_eq!(
table_columns(&opts, "events").await,
["id", "name", "location"]
);
}
#[tokio::test]
async fn manual_edit_flow_applies_only_after_rerun() {
let (root, opts) = project(&[("event.rs", EVENT)]);
run(&root, &opts).await.unwrap();
write_tables(
&root,
&[(
"event.rs",
"
#[derive(Table)]
pub struct Event {
pub id: i64,
pub name: String,
pub slug: String,
}
",
)],
);
let out = run(&root, &opts).await.unwrap();
assert!(out.needs_manual_edit);
let file = out.generated.unwrap();
let sql = fs::read_to_string(&file).unwrap();
assert!(sql.contains("TODO"));
assert_eq!(table_columns(&opts, "events").await, ["id", "name"]);
fs::write(
&file,
sql.replace("NULL /* TODO: backfill NOT NULL column slug */", "name"),
)
.unwrap();
let out = run(&root, &opts).await.unwrap();
assert!(out.generated.is_none());
assert_eq!(table_columns(&opts, "events").await, ["id", "name", "slug"]);
}
#[tokio::test]
async fn required_columns_contract_is_enforced() {
let (root, opts) = project(&[("event.rs", EVENT)]);
fs::write(
root.join("fse.toml"),
"[orm.required_columns]\nusers = [\"id\", \"email\", \"password\"]\n",
)
.unwrap();
let err = run(&root, &opts).await.unwrap_err();
assert!(err.to_string().contains("`users` table"), "got: {err}");
write_tables(
&root,
&[(
"user.rs",
"
#[derive(Table)]
pub struct User {
pub id: i64,
pub email: String,
}
",
)],
);
let err = run(&root, &opts).await.unwrap_err();
assert!(err.to_string().contains("`password`"), "got: {err}");
}
#[tokio::test]
async fn table_rebuild_preserves_child_rows_behind_cascade_fk() {
const PRODUCT_CASCADE: &str = "
#[derive(Table)]
pub struct Product {
pub id: i64,
pub name: String,
#[orm(references(Event, on_delete = cascade))]
pub event_id: i64,
}
";
const EVENT_UNIQUE_NAME: &str = "
#[derive(Table)]
pub struct Event {
pub id: i64,
#[orm(unique)]
pub name: String,
}
";
let (root, opts) = project(&[("event.rs", EVENT), ("product.rs", PRODUCT_CASCADE)]);
run(&root, &opts).await.unwrap();
let pool = sqlx::SqlitePool::connect(opts.database_url.as_deref().unwrap())
.await
.unwrap();
sqlx::query("INSERT INTO events (name) VALUES ('fair')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO products (name, event_id) VALUES ('mug', 1)")
.execute(&pool)
.await
.unwrap();
pool.close().await;
write_tables(&root, &[("event.rs", EVENT_UNIQUE_NAME)]);
run(&root, &opts).await.unwrap();
let pool = sqlx::SqlitePool::connect(opts.database_url.as_deref().unwrap())
.await
.unwrap();
let events: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events")
.fetch_one(&pool)
.await
.unwrap();
let products: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
.fetch_one(&pool)
.await
.unwrap();
pool.close().await;
assert_eq!(events, 1);
assert_eq!(
products, 1,
"rebuilding a parent table must not cascade-delete child rows"
);
}
#[tokio::test]
async fn table_rebuild_succeeds_behind_restrict_fk() {
const PRODUCT_RESTRICT: &str = "
#[derive(Table)]
pub struct Product {
pub id: i64,
pub name: String,
#[orm(references(Event, on_delete = restrict))]
pub event_id: i64,
}
";
const EVENT_UNIQUE_NAME: &str = "
#[derive(Table)]
pub struct Event {
pub id: i64,
#[orm(unique)]
pub name: String,
}
";
let (root, opts) = project(&[("event.rs", EVENT), ("product.rs", PRODUCT_RESTRICT)]);
run(&root, &opts).await.unwrap();
let pool = sqlx::SqlitePool::connect(opts.database_url.as_deref().unwrap())
.await
.unwrap();
sqlx::query("INSERT INTO events (name) VALUES ('fair')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO products (name, event_id) VALUES ('mug', 1)")
.execute(&pool)
.await
.unwrap();
pool.close().await;
write_tables(&root, &[("event.rs", EVENT_UNIQUE_NAME)]);
run(&root, &opts).await.unwrap();
let pool = sqlx::SqlitePool::connect(opts.database_url.as_deref().unwrap())
.await
.unwrap();
let products: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
.fetch_one(&pool)
.await
.unwrap();
pool.close().await;
assert_eq!(products, 1);
}
#[tokio::test]
async fn dry_run_writes_nothing() {
let (root, mut opts) = project(&[("event.rs", EVENT)]);
opts.dry_run = true;
let out = run(&root, &opts).await.unwrap();
assert!(out.generated.is_none());
assert!(!root.join("migrations").exists());
assert!(!root.join(".fse").exists());
assert!(!root.join("app.db").exists());
}