use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "umbral_phase42_event")]
pub struct Event {
pub id: i64,
pub payload: serde_json::Value,
pub meta: Option<serde_json::Value>,
}
#[test]
fn path_text_single_key_renders_single_arrow_text() {
let qs = Event::objects().filter(event::PAYLOAD.path_text(&["author"]).eq("alice"));
let sql = qs.to_sql_pg();
assert!(
sql.contains("\"payload\""),
"column should appear quoted; got {sql}"
);
assert!(sql.contains("->>"), "single-key path uses ->>; got {sql}");
let arrow_count = sql.matches("->").count();
let text_arrow_count = sql.matches("->>").count();
assert_eq!(
arrow_count, text_arrow_count,
"single-key path should have no plain -> steps; got {sql}"
);
assert!(sql.contains("= "), "equality fragment present; got {sql}");
}
#[test]
fn path_text_two_keys_renders_chained_arrows() {
let qs = Event::objects().filter(event::PAYLOAD.path_text(&["author", "name"]).eq("alice"));
let sql = qs.to_sql_pg();
let arrow_count = sql.matches("->").count();
let text_arrow_count = sql.matches("->>").count();
assert_eq!(
text_arrow_count, 1,
"should have exactly one ->> in a two-key path; got {sql}"
);
assert_eq!(
arrow_count - text_arrow_count,
1,
"should have exactly one plain -> step for a 2-key path; got {sql}"
);
}
#[test]
fn path_text_three_keys_renders_two_plain_arrows() {
let qs = Event::objects().filter(event::PAYLOAD.path_text(&["a", "b", "c"]).eq("v"));
let sql = qs.to_sql_pg();
let arrow_count = sql.matches("->").count();
let text_arrow_count = sql.matches("->>").count();
assert_eq!(text_arrow_count, 1);
assert_eq!(
arrow_count - text_arrow_count,
2,
"three-key path should have two plain -> steps; got {sql}"
);
}
#[test]
fn path_text_ne_renders_inequality() {
let qs = Event::objects().filter(event::PAYLOAD.path_text(&["status"]).ne("draft"));
let sql = qs.to_sql_pg();
assert!(sql.contains("<>"), "expected <>; got {sql}");
}
#[test]
fn path_text_is_null_renders_is_null_fragment() {
let qs = Event::objects().filter(event::PAYLOAD.path_text(&["author"]).is_null());
let sql = qs.to_sql_pg();
assert!(sql.contains("IS NULL"), "got {sql}");
}
#[test]
fn path_text_is_not_null_renders_is_not_null_fragment() {
let qs = Event::objects().filter(event::PAYLOAD.path_text(&["author"]).is_not_null());
let sql = qs.to_sql_pg();
assert!(sql.contains("IS NOT NULL"), "got {sql}");
}
#[test]
fn has_key_renders_question_mark_operator() {
let qs = Event::objects().filter(event::PAYLOAD.has_key("author"));
let sql = qs.to_sql_pg();
assert!(sql.contains("?"), "expected ? operator; got {sql}");
assert!(
sql.contains("'author'"),
"key should be inline-quoted; got {sql}"
);
}
#[test]
fn nullable_json_col_path_text_works() {
let qs = Event::objects().filter(event::META.path_text(&["v"]).eq("x"));
let sql = qs.to_sql_pg();
assert!(sql.contains("\"meta\""));
assert!(sql.contains("->>"));
}
#[test]
#[should_panic(expected = "path must have at least one segment")]
fn empty_path_panics() {
let _ = event::PAYLOAD.path_text(&[]);
}
#[test]
fn path_text_typechecks_through_fetch_pg() {
#[allow(dead_code)]
async fn _unreachable(pg_pool: &sqlx::PgPool) -> Result<(), sqlx::Error> {
let _v: Vec<Event> = Event::objects()
.filter(event::PAYLOAD.path_text(&["author", "name"]).eq("alice"))
.filter(event::PAYLOAD.has_key("approved"))
.fetch_pg(pg_pool)
.await?;
Ok(())
}
}
#[test]
fn path_text_renders_json_extract_under_sqlite() {
let qs = Event::objects().filter(event::PAYLOAD.path_text(&["author", "name"]).eq("alice"));
let sql = qs.to_sql();
assert!(
sql.contains("json_extract"),
"SQLite render uses json_extract; got {sql}"
);
assert!(
sql.contains("\"payload\""),
"column should be quoted; got {sql}"
);
assert!(
!sql.contains("->>"),
"SQLite render should NOT use ->>; got {sql}"
);
}
#[test]
fn has_key_renders_json_extract_is_not_null_under_sqlite() {
let qs = Event::objects().filter(event::PAYLOAD.has_key("author"));
let sql = qs.to_sql();
assert!(
sql.contains("json_extract"),
"SQLite has_key uses json_extract; got {sql}"
);
assert!(
sql.contains("IS NOT NULL"),
"SQLite has_key uses IS NOT NULL; got {sql}"
);
}
#[test]
fn path_text_is_null_renders_json_extract_is_null_under_sqlite() {
let qs = Event::objects().filter(event::PAYLOAD.path_text(&["author"]).is_null());
let sql = qs.to_sql();
assert!(
sql.contains("json_extract"),
"SQLite is_null uses json_extract; got {sql}"
);
assert!(sql.contains("IS NULL"), "got {sql}");
}
#[tokio::test]
async fn json_operators_filter_real_sqlite_rows() {
use serde_json::json;
let pool = umbral::db::connect_sqlite("sqlite::memory:").await.unwrap();
sqlx::query(
"CREATE TABLE umbral_phase42_event ( \
id INTEGER PRIMARY KEY AUTOINCREMENT, \
payload TEXT NOT NULL, \
meta TEXT \
)",
)
.execute(&pool)
.await
.unwrap();
let alice = json!({ "author": { "name": "alice" }, "status": "published" });
let bob = json!({ "author": { "name": "bob" }, "status": "draft" });
sqlx::query("INSERT INTO umbral_phase42_event (payload) VALUES (?)")
.bind(alice.to_string())
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO umbral_phase42_event (payload) VALUES (?)")
.bind(bob.to_string())
.execute(&pool)
.await
.unwrap();
let alice_only = Event::objects()
.filter(event::PAYLOAD.path_text(&["author", "name"]).eq("alice"))
.on(&pool)
.fetch()
.await
.unwrap();
assert_eq!(alice_only.len(), 1);
assert_eq!(alice_only[0].payload["author"]["name"], json!("alice"));
let with_author = Event::objects()
.filter(event::PAYLOAD.has_key("author"))
.on(&pool)
.fetch()
.await
.unwrap();
assert_eq!(with_author.len(), 2);
let published_only = Event::objects()
.filter(event::PAYLOAD.path_text(&["status"]).ne("draft"))
.on(&pool)
.fetch()
.await
.unwrap();
assert_eq!(published_only.len(), 1);
}
#[tokio::test]
#[ignore = "needs UMBRAL_TEST_POSTGRES_URL pointing at a Postgres server"]
async fn json_operators_filter_real_postgres_rows() {
use serde_json::json;
let url =
std::env::var("UMBRAL_TEST_POSTGRES_URL").expect("UMBRAL_TEST_POSTGRES_URL must be set");
let pool = sqlx::PgPool::connect(&url).await.unwrap();
sqlx::query("DROP TABLE IF EXISTS umbral_phase42_event")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE umbral_phase42_event ( \
id BIGSERIAL PRIMARY KEY, \
payload JSONB NOT NULL, \
meta JSONB \
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO umbral_phase42_event (payload, meta) VALUES ($1, $2)")
.bind(json!({ "author": { "name": "alice" }, "status": "published" }))
.bind(Some(json!({ "extra": true })))
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO umbral_phase42_event (payload, meta) VALUES ($1, $2)")
.bind(json!({ "author": { "name": "bob" }, "status": "draft" }))
.bind(Option::<serde_json::Value>::None)
.execute(&pool)
.await
.unwrap();
let alice_only = Event::objects()
.filter(event::PAYLOAD.path_text(&["author", "name"]).eq("alice"))
.fetch_pg(&pool)
.await
.unwrap();
assert_eq!(alice_only.len(), 1);
assert_eq!(alice_only[0].payload["author"]["name"], json!("alice"));
let with_author = Event::objects()
.filter(event::PAYLOAD.has_key("author"))
.fetch_pg(&pool)
.await
.unwrap();
assert_eq!(with_author.len(), 2);
let published_only = Event::objects()
.filter(event::PAYLOAD.path_text(&["status"]).ne("draft"))
.fetch_pg(&pool)
.await
.unwrap();
assert_eq!(published_only.len(), 1);
}