use chrono::{DateTime, Utc};
use sqlx::SqlitePool;
use umbral_core::db;
use umbral_core::orm::Post;
use umbral_core::orm::post::post;
async fn fresh_pool() -> SqlitePool {
let pool = db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite should always connect");
sqlx::query(
"CREATE TABLE post (\
id INTEGER PRIMARY KEY AUTOINCREMENT,\
title TEXT NOT NULL,\
body TEXT NOT NULL,\
published_at DATETIME\
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE post should succeed on a fresh in-memory database");
let seeds: [(i64, &str, &str, Option<DateTime<Utc>>); 5] = [
(
1,
"Hello world",
"first post",
Some(parse_ts("2026-01-01T00:00:00Z")),
),
(
2,
"Rust at last",
"second post",
Some(parse_ts("2026-02-15T00:00:00Z")),
),
(3, "DRAFT: ignore", "unpublished thoughts", None),
(
4,
"rust > all",
"case-different rust",
Some(parse_ts("2026-03-01T00:00:00Z")),
),
(
5,
"The umbral framework",
"long post about umbral",
Some(parse_ts("2026-04-10T00:00:00Z")),
),
];
for (id, title, body, published_at) in seeds {
sqlx::query("INSERT INTO post (id, title, body, published_at) VALUES (?, ?, ?, ?)")
.bind(id)
.bind(title)
.bind(body)
.bind(published_at)
.execute(&pool)
.await
.expect("INSERT into post should succeed");
}
pool
}
fn parse_ts(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s)
.expect("seed timestamps are valid RFC 3339")
.with_timezone(&Utc)
}
#[tokio::test]
async fn fetch_returns_all_rows() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.fetch()
.await
.expect("unfiltered fetch should succeed");
assert_eq!(rows.len(), 5, "expected all 5 seeded posts back");
}
#[tokio::test]
async fn filter_eq_returns_matching_row() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.filter(post::ID.eq(2))
.fetch()
.await
.expect("eq filter should succeed");
assert_eq!(rows.len(), 1, "post::ID.eq(2) should match a single row");
assert_eq!(rows[0].id, 2);
assert_eq!(rows[0].title, "Rust at last");
}
#[tokio::test]
async fn filter_is_not_null_excludes_drafts() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.filter(post::PUBLISHED_AT.is_not_null())
.fetch()
.await
.expect("is_not_null filter should succeed");
assert_eq!(rows.len(), 4, "4 of the 5 seeds have a non-null timestamp");
assert!(
rows.iter().all(|p| p.published_at.is_some()),
"every returned row should have a non-null published_at",
);
}
#[tokio::test]
async fn filter_is_null_returns_drafts() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.filter(post::PUBLISHED_AT.is_null())
.fetch()
.await
.expect("is_null filter should succeed");
assert_eq!(rows.len(), 1, "only the draft row has a null published_at");
assert_eq!(rows[0].id, 3);
assert!(rows[0].published_at.is_none());
}
#[tokio::test]
async fn filter_like_matches_rust_prefix_on_sqlite() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.filter(post::TITLE.like("Rust%"))
.fetch()
.await
.expect("like filter should succeed");
let mut titles: Vec<&str> = rows.iter().map(|p| p.title.as_str()).collect();
titles.sort();
assert_eq!(
titles,
vec!["Rust at last", "rust > all"],
"SQLite's LIKE is ASCII-case-insensitive by default, so 'Rust%' \
matches both 'Rust at last' and 'rust > all'",
);
}
#[tokio::test]
async fn filter_ilike_is_case_insensitive() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.filter(post::TITLE.ilike("rust%"))
.fetch()
.await
.expect("ilike filter should succeed");
assert_eq!(
rows.len(),
2,
"ILIKE 'rust%' should match both 'Rust at last' and 'rust > all'",
);
}
#[tokio::test]
async fn filter_contains_matches_rust_substring_on_sqlite() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.filter(post::TITLE.contains("rust"))
.fetch()
.await
.expect("contains filter should succeed");
let mut titles: Vec<&str> = rows.iter().map(|p| p.title.as_str()).collect();
titles.sort();
assert_eq!(
titles,
vec!["Rust at last", "rust > all"],
"SQLite's LIKE matches ASCII-insensitively, so contains('rust') \
finds both 'Rust at last' and 'rust > all'",
);
}
#[tokio::test]
async fn filter_icontains_is_case_insensitive() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.filter(post::TITLE.icontains("rust"))
.fetch()
.await
.expect("icontains filter should succeed");
assert_eq!(
rows.len(),
2,
"icontains('rust') should pick up both 'Rust at last' and 'rust > all'",
);
let mut titles: Vec<&str> = rows.iter().map(|p| p.title.as_str()).collect();
titles.sort();
assert_eq!(titles, vec!["Rust at last", "rust > all"]);
}
#[tokio::test]
async fn contains_escapes_like_wildcards_in_user_input() {
let pool = fresh_pool().await;
let extra: [(i64, &str); 4] = [
(6, "50% discount today"), (7, "500 items left"), (8, "grep a_b please"), (9, "grep axb please"), ];
for (id, title) in extra {
sqlx::query("INSERT INTO post (id, title, body, published_at) VALUES (?, ?, ?, NULL)")
.bind(id)
.bind(title)
.bind("wildcard seed body")
.execute(&pool)
.await
.expect("seed wildcard row");
}
let pct: Vec<String> = Post::objects()
.on(&pool)
.filter(post::TITLE.contains("50%"))
.fetch()
.await
.expect("contains('50%')")
.into_iter()
.map(|p| p.title)
.collect();
assert_eq!(
pct,
vec!["50% discount today".to_string()],
"contains('50%') must match the literal percent, not '500 items'",
);
let underscore: Vec<String> = Post::objects()
.on(&pool)
.filter(post::TITLE.contains("a_b"))
.fetch()
.await
.expect("contains('a_b')")
.into_iter()
.map(|p| p.title)
.collect();
assert_eq!(
underscore,
vec!["grep a_b please".to_string()],
"contains('a_b') must treat '_' literally, not as a single-char wildcard",
);
let starts: Vec<String> = Post::objects()
.on(&pool)
.filter(post::TITLE.startswith("50%"))
.fetch()
.await
.expect("startswith('50%')")
.into_iter()
.map(|p| p.title)
.collect();
assert_eq!(starts, vec!["50% discount today".to_string()]);
}
#[tokio::test]
async fn compose_predicates_with_and() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.filter(post::PUBLISHED_AT.is_not_null() & post::TITLE.icontains("rust"))
.fetch()
.await
.expect("AND-composed filter should succeed");
assert_eq!(
rows.len(),
2,
"is_not_null AND icontains('rust') should match exactly the two published rust posts",
);
}
#[tokio::test]
async fn compose_predicates_with_or() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.filter(post::ID.eq(1) | post::ID.eq(3))
.fetch()
.await
.expect("OR-composed filter should succeed");
assert_eq!(rows.len(), 2);
let mut ids: Vec<i64> = rows.iter().map(|p| p.id).collect();
ids.sort();
assert_eq!(ids, vec![1, 3]);
}
#[tokio::test]
async fn order_by_desc_and_limit() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.order_by(post::ID.desc())
.limit(2)
.fetch()
.await
.expect("ordered + limited fetch should succeed");
assert_eq!(rows.len(), 2, "limit(2) caps the result at two rows");
assert_eq!(rows[0].id, 5, "first row should be the largest id");
assert_eq!(rows[1].id, 4, "second row should be the next id down");
}
#[tokio::test]
async fn order_by_with_offset_skips_leading_rows() {
let pool = fresh_pool().await;
let rows = Post::objects()
.on(&pool)
.order_by(post::ID.asc())
.offset(2)
.limit(2)
.fetch()
.await
.expect("ordered + offset + limit fetch should succeed");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].id, 3, "offset(2) skips the first two ascending ids");
assert_eq!(rows[1].id, 4);
}
#[tokio::test]
async fn count_returns_total_rows() {
let pool = fresh_pool().await;
let total = Post::objects()
.on(&pool)
.count()
.await
.expect("count() should succeed");
assert_eq!(total, 5);
}
#[tokio::test]
async fn count_with_filter_counts_matching_rows() {
let pool = fresh_pool().await;
let total = Post::objects()
.on(&pool)
.filter(post::PUBLISHED_AT.is_not_null())
.count()
.await
.expect("filtered count() should succeed");
assert_eq!(total, 4, "4 seeded rows have a non-null published_at");
}
#[tokio::test]
async fn exists_returns_true_when_match() {
let pool = fresh_pool().await;
let found = Post::objects()
.on(&pool)
.filter(post::ID.eq(1))
.exists()
.await
.expect("exists() should succeed");
assert!(found, "id=1 is seeded, so exists() must be true");
}
#[tokio::test]
async fn exists_returns_false_when_no_match() {
let pool = fresh_pool().await;
let found = Post::objects()
.on(&pool)
.filter(post::ID.eq(999))
.exists()
.await
.expect("exists() should succeed");
assert!(!found, "id=999 is not seeded, so exists() must be false");
}
#[tokio::test]
async fn get_returns_row_when_exactly_one_matches() {
use umbral_core::orm::GetError;
let pool = fresh_pool().await;
let row = Post::objects()
.on(&pool)
.filter(post::ID.eq(2))
.get()
.await
.expect("get() should succeed on a unique PK match");
assert_eq!(row.id, 2);
assert_eq!(row.title, "Rust at last");
let _ = GetError::NotFound;
}
#[tokio::test]
async fn get_returns_not_found_when_zero_rows_match() {
use umbral_core::orm::GetError;
let pool = fresh_pool().await;
let err = Post::objects()
.on(&pool)
.filter(post::ID.eq(999))
.get()
.await
.expect_err("get() should error on no match");
assert!(matches!(err, GetError::NotFound), "got {err:?}");
}
#[tokio::test]
async fn get_returns_multiple_when_filter_is_not_unique() {
use umbral_core::orm::GetError;
let pool = fresh_pool().await;
let err = Post::objects()
.on(&pool)
.get()
.await
.expect_err("get() with no filter on a multi-row table should error");
assert!(
matches!(err, GetError::MultipleObjectsReturned),
"got {err:?}"
);
}
#[tokio::test]
async fn first_returns_some_when_match() {
let pool = fresh_pool().await;
let row = Post::objects()
.on(&pool)
.filter(post::ID.eq(1))
.first()
.await
.expect("first() should succeed");
let row = row.expect("id=1 is seeded, so first() must return Some");
assert_eq!(row.id, 1);
assert_eq!(row.title, "Hello world");
}
#[tokio::test]
async fn first_returns_none_when_no_match() {
let pool = fresh_pool().await;
let row = Post::objects()
.on(&pool)
.filter(post::ID.eq(999))
.first()
.await
.expect("first() should succeed");
assert!(
row.is_none(),
"id=999 is not seeded, so first() must be None"
);
}
#[test]
fn to_sql_renders_the_select_without_executing() {
let sql = Post::objects()
.filter(post::TITLE.eq("hello"))
.order_by(post::ID.asc())
.limit(5)
.to_sql();
let lower = sql.to_ascii_lowercase();
assert!(
lower.contains("select"),
"expected SELECT keyword; got {sql}"
);
assert!(
lower.contains("from \"post\""),
"expected FROM \"post\"; got {sql}"
);
assert!(
lower.contains("where") && lower.contains("\"title\" = ?"),
"expected WHERE clause with bound placeholder for title; got {sql}",
);
assert!(
lower.contains("order by \"id\" asc"),
"expected ORDER BY id asc; got {sql}",
);
assert!(lower.contains("limit"), "expected LIMIT clause; got {sql}");
assert!(
!sql.contains("hello"),
"the bound value MUST stay out of the SQL string (it's a parameter, not literal); got {sql}",
);
}