#![allow(dead_code)]
use sqlx::SqlitePool;
use umbral::migrate::{Column, Operation, render_operation_for};
use umbral::orm::{ForeignKey, Model, SqlType};
#[derive(
Debug, Clone, PartialEq, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model,
)]
#[umbral(table = "fk_user")]
pub struct User {
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]
#[umbral(table = "fk_post")]
pub struct Post {
pub id: i64,
pub title: String,
pub author: ForeignKey<User>,
}
#[test]
fn derive_classifies_foreign_key_sqltype() {
let by_name: std::collections::HashMap<&str, &umbral::orm::FieldSpec> = <Post as Model>::FIELDS
.iter()
.map(|f| (f.name, f))
.collect();
let author_field = by_name.get("author").expect("author field must exist");
assert_eq!(
author_field.ty,
SqlType::ForeignKey,
"ForeignKey<User> should classify as SqlType::ForeignKey"
);
assert_eq!(
author_field.fk_target,
Some("fk_user"),
"fk_target should be User::TABLE"
);
assert!(
!author_field.nullable,
"non-Option ForeignKey is not nullable"
);
assert!(!author_field.primary_key, "FK field is not the primary key");
}
#[test]
fn foreign_key_col_constant_eq_compiles() {
use post::AUTHOR;
let qs = Post::objects().filter(AUTHOR.eq(1));
let sql = qs.to_sql();
assert!(
sql.contains("author"),
"WHERE clause should reference `author`; got: {sql}"
);
}
#[test]
fn foreign_key_col_constant_in_compiles() {
use post::AUTHOR;
let qs = Post::objects().filter(AUTHOR.in_(&[1i64, 2i64]));
let sql = qs.to_sql();
assert!(
sql.contains("author"),
"IN predicate should reference `author`; got: {sql}"
);
}
#[test]
fn create_table_emits_references_sqlite() {
let op = Operation::CreateTable {
table: "fk_post".to_string(),
columns: vec![
Column {
name: "id".to_string(),
ty: SqlType::BigInt,
primary_key: true,
nullable: false,
fk_target: None,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral_core::orm::FkAction::NoAction,
on_update: umbral_core::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
},
Column {
name: "title".to_string(),
ty: SqlType::Text,
primary_key: false,
nullable: false,
fk_target: None,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral_core::orm::FkAction::NoAction,
on_update: umbral_core::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
},
Column {
name: "author".to_string(),
ty: SqlType::ForeignKey,
primary_key: false,
nullable: false,
fk_target: Some("fk_user".to_string()),
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral_core::orm::FkAction::NoAction,
on_update: umbral_core::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
},
],
unique_together: Vec::new(),
indexes: Vec::new(),
};
let stmts = render_operation_for(&op, "sqlite");
let sql = stmts
.iter()
.find(|s| s.to_ascii_uppercase().contains("CREATE TABLE"))
.expect("a CREATE TABLE statement");
let lower = sql.to_ascii_lowercase();
assert!(
lower.contains("references"),
"SQLite DDL should contain REFERENCES; got: {sql}"
);
assert!(
lower.contains("\"fk_user\""),
"SQLite DDL should reference `fk_user`; got: {sql}"
);
assert!(
lower.contains("(\"id\")"),
"SQLite DDL should reference column `id`; got: {sql}"
);
assert!(
lower.contains("bigint"),
"FK column should be BIGINT; got: {sql}"
);
}
#[test]
fn create_table_emits_references_postgres() {
let op = Operation::CreateTable {
table: "fk_post".to_string(),
columns: vec![
Column {
name: "id".to_string(),
ty: SqlType::BigInt,
primary_key: true,
nullable: false,
fk_target: None,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral_core::orm::FkAction::NoAction,
on_update: umbral_core::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
},
Column {
name: "title".to_string(),
ty: SqlType::Text,
primary_key: false,
nullable: false,
fk_target: None,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral_core::orm::FkAction::NoAction,
on_update: umbral_core::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
},
Column {
name: "author".to_string(),
ty: SqlType::ForeignKey,
primary_key: false,
nullable: false,
fk_target: Some("fk_user".to_string()),
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral_core::orm::FkAction::NoAction,
on_update: umbral_core::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
},
],
unique_together: Vec::new(),
indexes: Vec::new(),
};
let stmts = render_operation_for(&op, "postgres");
let sql = stmts
.iter()
.find(|s| s.to_ascii_uppercase().contains("CREATE TABLE"))
.expect("a CREATE TABLE statement");
let lower = sql.to_ascii_lowercase();
assert!(
lower.contains("references"),
"Postgres DDL should contain REFERENCES; got: {sql}"
);
assert!(
lower.contains("\"fk_user\""),
"Postgres DDL should reference `fk_user`; got: {sql}"
);
assert!(
lower.contains("(\"id\")"),
"Postgres DDL should reference column `id`; got: {sql}"
);
assert!(
lower.contains("bigint"),
"FK column should be BIGINT on Postgres; got: {sql}"
);
}
async fn fresh_pool() -> SqlitePool {
let pool = umbral_core::db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory SQLite should always connect");
sqlx::query(
"CREATE TABLE fk_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE fk_user");
sqlx::query(
"CREATE TABLE fk_post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author INTEGER NOT NULL REFERENCES fk_user(id)
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE fk_post");
pool
}
#[tokio::test]
async fn resolve_returns_referenced_user() {
let pool = fresh_pool().await;
let user: User = sqlx::query_as::<sqlx::Sqlite, User>(
"INSERT INTO fk_user (name) VALUES ('Alice') RETURNING id, name",
)
.fetch_one(&pool)
.await
.expect("insert user");
let post: Post = sqlx::query_as::<sqlx::Sqlite, Post>(
"INSERT INTO fk_post (title, author) VALUES ('Hello', ?) RETURNING id, title, author",
)
.bind(user.id)
.fetch_one(&pool)
.await
.expect("insert post");
assert_eq!(
post.author.id(),
user.id,
"post.author.id() should equal user.id"
);
let resolved = post
.author
.resolve(&pool)
.await
.expect("resolve should succeed");
assert_eq!(resolved.id, user.id, "resolved.id should match");
assert_eq!(resolved.name, "Alice", "resolved.name should match");
}
#[test]
fn foreign_key_from_and_id_roundtrip() {
let fk: ForeignKey<User> = ForeignKey::new(42i64);
assert_eq!(fk.id(), 42);
}
#[test]
fn foreign_key_set_updates_value() {
let mut fk: ForeignKey<User> = ForeignKey::new(1i64);
fk.set(99);
assert_eq!(fk.id(), 99);
}
#[test]
fn foreign_key_serialises_as_integer() {
let fk: ForeignKey<User> = ForeignKey::new(7i64);
let json = serde_json::to_string(&fk).unwrap();
assert_eq!(json, "7", "ForeignKey should serialise as a bare integer");
}
#[test]
fn foreign_key_deserialises_from_integer() {
let fk: ForeignKey<User> = serde_json::from_str("42").unwrap();
assert_eq!(fk.id(), 42);
}
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]
#[umbral(table = "fk_category")]
pub struct Category {
pub id: i64,
pub name: String,
#[umbral(on_delete = "cascade")]
pub parent_id: Option<ForeignKey<Category>>,
}
#[test]
fn self_referential_fk_target_resolves_to_own_table() {
let by_name: std::collections::HashMap<&str, &umbral::orm::FieldSpec> =
<Category as Model>::FIELDS
.iter()
.map(|f| (f.name, f))
.collect();
let parent = by_name.get("parent_id").expect("parent_id field present");
assert_eq!(parent.ty, SqlType::ForeignKey);
assert_eq!(parent.fk_target, Some("fk_category"));
assert!(parent.nullable, "Option<ForeignKey<T>> should be nullable");
assert!(
matches!(parent.on_delete, umbral::orm::FkAction::Cascade),
"ON DELETE CASCADE should round-trip from the attribute"
);
}
#[test]
fn self_referential_fk_renders_inline_references() {
let meta = umbral::migrate::ModelMeta::for_::<Category>();
let op = Operation::CreateTable {
table: Category::TABLE.to_string(),
columns: meta.fields.clone(),
unique_together: Vec::new(),
indexes: Vec::new(),
};
for backend in ["sqlite", "postgres"] {
let sql = render_operation_for(&op, backend).join("\n");
assert!(
sql.contains("REFERENCES \"fk_category\"(\"id\")"),
"{backend}: expected self-reference in REFERENCES tail; got: {sql}"
);
assert!(
sql.to_uppercase().contains("ON DELETE CASCADE"),
"{backend}: expected ON DELETE CASCADE; got: {sql}"
);
}
}
#[tokio::test]
async fn self_referential_fk_round_trips_through_sqlite_with_cascade() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query("PRAGMA foreign_keys = ON;")
.execute(&pool)
.await
.unwrap();
let meta = umbral::migrate::ModelMeta::for_::<Category>();
let op = Operation::CreateTable {
table: Category::TABLE.to_string(),
columns: meta.fields.clone(),
unique_together: Vec::new(),
indexes: Vec::new(),
};
for stmt in render_operation_for(&op, "sqlite") {
sqlx::query(&stmt).execute(&pool).await.unwrap();
}
sqlx::query("INSERT INTO fk_category (id, name, parent_id) VALUES (1, 'root', NULL)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO fk_category (id, name, parent_id) VALUES (2, 'child', 1)")
.execute(&pool)
.await
.unwrap();
let (children,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM fk_category WHERE parent_id = 1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(children, 1, "child should reference the root");
sqlx::query("DELETE FROM fk_category WHERE id = 1")
.execute(&pool)
.await
.unwrap();
let (remaining,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM fk_category")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
remaining, 0,
"CASCADE should have pruned the child after the root was deleted"
);
}
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]
#[umbral(table = "fk_profile")]
pub struct Profile {
pub id: i64,
#[umbral(unique, on_delete = "cascade")]
pub user_id: ForeignKey<User>,
pub bio: String,
}
#[test]
fn one_to_one_pattern_emits_unique_and_references() {
let meta = umbral::migrate::ModelMeta::for_::<Profile>();
let user_id = meta
.fields
.iter()
.find(|c| c.name == "user_id")
.expect("user_id present");
assert_eq!(user_id.ty, SqlType::ForeignKey);
assert_eq!(user_id.fk_target.as_deref(), Some("fk_user"));
assert!(
user_id.unique,
"the #[umbral(unique)] flag is what makes this a 1:1",
);
for backend in ["sqlite", "postgres"] {
let op = Operation::CreateTable {
table: Profile::TABLE.to_string(),
columns: meta.fields.clone(),
unique_together: Vec::new(),
indexes: Vec::new(),
};
let sql = render_operation_for(&op, backend).join("\n");
assert!(
sql.to_uppercase().contains("UNIQUE"),
"{backend}: should emit UNIQUE for the 1:1 column; got: {sql}",
);
assert!(
sql.contains("REFERENCES \"fk_user\"(\"id\")"),
"{backend}: should still emit the FK REFERENCES clause; got: {sql}",
);
}
}
#[tokio::test]
async fn one_to_one_rejects_second_reference_to_same_target() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query("PRAGMA foreign_keys = ON;")
.execute(&pool)
.await
.unwrap();
sqlx::query("CREATE TABLE fk_user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)")
.execute(&pool)
.await
.unwrap();
let meta = umbral::migrate::ModelMeta::for_::<Profile>();
let op = Operation::CreateTable {
table: Profile::TABLE.to_string(),
columns: meta.fields.clone(),
unique_together: Vec::new(),
indexes: Vec::new(),
};
for stmt in render_operation_for(&op, "sqlite") {
sqlx::query(&stmt).execute(&pool).await.unwrap();
}
sqlx::query("INSERT INTO fk_user (id, name) VALUES (1, 'alice')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO fk_profile (id, user_id, bio) VALUES (1, 1, 'first')")
.execute(&pool)
.await
.unwrap();
let dupe = sqlx::query("INSERT INTO fk_profile (id, user_id, bio) VALUES (2, 1, 'second')")
.execute(&pool)
.await;
assert!(
dupe.is_err(),
"a second profile pointing at the same user must fail the UNIQUE constraint; succeeded instead",
);
let msg = format!("{:?}", dupe.unwrap_err()).to_lowercase();
assert!(
msg.contains("unique"),
"the error should mention the UNIQUE violation; got: {msg}",
);
}