#![allow(clippy::await_holding_lock)]
use dbnexus::db_entity;
use dbnexus::foundation::DatabaseType;
use dbnexus::{Migration, MigrationExecutor, TableChange};
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveModelTrait, DbBackend, EntityTrait};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
static TEST_MUTEX: Mutex<()> = Mutex::new(());
static BEFORE_INSERT_CALLS: AtomicUsize = AtomicUsize::new(0);
static AFTER_INSERT_CALLS: AtomicUsize = AtomicUsize::new(0);
static BEFORE_UPDATE_CALLS: AtomicUsize = AtomicUsize::new(0);
static AFTER_UPDATE_CALLS: AtomicUsize = AtomicUsize::new(0);
static BEFORE_DELETE_CALLS: AtomicUsize = AtomicUsize::new(0);
static AFTER_DELETE_CALLS: AtomicUsize = AtomicUsize::new(0);
static BEFORE_INSERT_UPDATED_AT_SET: AtomicUsize = AtomicUsize::new(0);
static AFTER_INSERT_MODEL_ID: AtomicUsize = AtomicUsize::new(0);
fn reset_counters() {
BEFORE_INSERT_CALLS.store(0, Ordering::SeqCst);
AFTER_INSERT_CALLS.store(0, Ordering::SeqCst);
BEFORE_UPDATE_CALLS.store(0, Ordering::SeqCst);
AFTER_UPDATE_CALLS.store(0, Ordering::SeqCst);
BEFORE_DELETE_CALLS.store(0, Ordering::SeqCst);
AFTER_DELETE_CALLS.store(0, Ordering::SeqCst);
BEFORE_INSERT_UPDATED_AT_SET.store(0, Ordering::SeqCst);
AFTER_INSERT_MODEL_ID.store(0, Ordering::SeqCst);
}
fn before_insert_hook(am: &mut ActiveModel) -> Result<(), sea_orm::DbErr> {
BEFORE_INSERT_CALLS.fetch_add(1, Ordering::SeqCst);
match &am.updated_at {
sea_orm::ActiveValue::Set(Some(_)) => {
BEFORE_INSERT_UPDATED_AT_SET.fetch_add(1, Ordering::SeqCst);
}
sea_orm::ActiveValue::Set(None) => {
return Err(sea_orm::DbErr::Custom(
"updated_at is Set(None): timestamps should have set it to Some(now) before hook".to_string(),
));
}
sea_orm::ActiveValue::Unchanged(_) => {
return Err(sea_orm::DbErr::Custom(
"updated_at is Unchanged: timestamps should have set it before hook".to_string(),
));
}
sea_orm::ActiveValue::NotSet => {
return Err(sea_orm::DbErr::Custom(
"updated_at is NotSet: timestamps should have set it before hook".to_string(),
));
}
}
Ok(())
}
fn after_insert_hook(model: &Model) -> Result<(), sea_orm::DbErr> {
AFTER_INSERT_CALLS.fetch_add(1, Ordering::SeqCst);
AFTER_INSERT_MODEL_ID.store(model.id as usize, Ordering::SeqCst);
if model.created_at.is_none() {
return Err(sea_orm::DbErr::Custom(
"after_insert: model.created_at should be set".to_string(),
));
}
if model.updated_at.is_none() {
return Err(sea_orm::DbErr::Custom(
"after_insert: model.updated_at should be set".to_string(),
));
}
Ok(())
}
fn before_update_hook(am: &mut ActiveModel) -> Result<(), sea_orm::DbErr> {
BEFORE_UPDATE_CALLS.fetch_add(1, Ordering::SeqCst);
match &am.updated_at {
sea_orm::ActiveValue::Set(Some(_)) => {}
_ => {
return Err(sea_orm::DbErr::Custom(
"before_update: updated_at should be Set(Some(_)) by timestamps".to_string(),
));
}
}
Ok(())
}
fn after_update_hook(model: &Model) -> Result<(), sea_orm::DbErr> {
AFTER_UPDATE_CALLS.fetch_add(1, Ordering::SeqCst);
if model.updated_at.is_none() {
return Err(sea_orm::DbErr::Custom(
"after_update: model.updated_at should be set".to_string(),
));
}
Ok(())
}
fn before_delete_hook(_am: &mut ActiveModel) -> Result<(), sea_orm::DbErr> {
BEFORE_DELETE_CALLS.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn after_delete_hook(_model: &Model) -> Result<(), sea_orm::DbErr> {
AFTER_DELETE_CALLS.fetch_add(1, Ordering::SeqCst);
Ok(())
}
#[db_entity(
table_name = "products",
primary_key = "id",
timestamps = true,
hooks(
before_insert = "before_insert_hook",
after_insert = "after_insert_hook",
before_update = "before_update_hook",
after_update = "after_update_hook",
before_delete = "before_delete_hook",
after_delete = "after_delete_hook",
)
)]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "products")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
pub price: f64,
pub created_at: Option<time::OffsetDateTime>,
pub updated_at: Option<time::OffsetDateTime>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
async fn setup() -> (dbnexus::DbPool, std::sync::MutexGuard<'static, ()>) {
let guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
reset_counters();
let pool = dbnexus::DbPool::new("sqlite::memory:")
.await
.expect("Failed to create pool");
let session = pool.get_session("admin").await.expect("Failed to get session");
let table = Model::schema(DbBackend::Sqlite);
let conn = session.connection().expect("Failed to get connection");
let mut executor = MigrationExecutor::new(conn.clone(), DatabaseType::Sqlite);
let mut migration = Migration::new(1, "create_products_table".to_string());
migration.add_table_change(TableChange::CreateTable(table));
executor
.apply_migration(&migration)
.await
.expect("Migration should succeed");
(pool, guard)
}
#[tokio::test]
async fn test_before_insert_and_before_update_hooks_trigger() {
let (pool, _guard) = setup().await;
let session = pool.get_session("admin").await.expect("Failed to get session");
let conn = session.connection().expect("conn");
let am: ActiveModel = Model {
id: 1,
name: "Widget".to_string(),
price: 9.99,
created_at: None,
updated_at: None,
}
.into();
am.insert(conn).await.expect("insert should succeed");
assert_eq!(
BEFORE_INSERT_CALLS.load(Ordering::SeqCst),
1,
"before_insert should be called once on insert"
);
assert_eq!(
BEFORE_UPDATE_CALLS.load(Ordering::SeqCst),
0,
"before_update should NOT be called on insert"
);
let existing = Entity::find_by_id(1)
.one(conn)
.await
.expect("find should succeed")
.expect("record should exist");
let mut am: ActiveModel = existing.into();
am.name = sea_orm::ActiveValue::Set("Updated Widget".to_string());
am.update(conn).await.expect("update should succeed");
assert_eq!(
BEFORE_INSERT_CALLS.load(Ordering::SeqCst),
1,
"before_insert count should remain 1 (not called on update)"
);
assert_eq!(
BEFORE_UPDATE_CALLS.load(Ordering::SeqCst),
1,
"before_update should be called once on update"
);
}
#[tokio::test]
async fn test_after_insert_hook_reads_saved_data() {
let (pool, _guard) = setup().await;
let session = pool.get_session("admin").await.expect("Failed to get session");
let conn = session.connection().expect("conn");
let am: ActiveModel = Model {
id: 42,
name: "Gadget".to_string(),
price: 19.99,
created_at: None,
updated_at: None,
}
.into();
am.insert(conn).await.expect("insert should succeed");
assert_eq!(
AFTER_INSERT_CALLS.load(Ordering::SeqCst),
1,
"after_insert should be called once"
);
assert_eq!(
AFTER_INSERT_MODEL_ID.load(Ordering::SeqCst),
42,
"after_insert hook should read model.id = 42"
);
assert_eq!(
AFTER_UPDATE_CALLS.load(Ordering::SeqCst),
0,
"after_update should NOT be called on insert"
);
}
#[tokio::test]
async fn test_after_update_hook_triggers_on_update() {
let (pool, _guard) = setup().await;
let session = pool.get_session("admin").await.expect("Failed to get session");
let conn = session.connection().expect("conn");
let am: ActiveModel = Model {
id: 7,
name: "Initial".to_string(),
price: 5.00,
created_at: None,
updated_at: None,
}
.into();
am.insert(conn).await.expect("insert should succeed");
let existing = Entity::find_by_id(7).one(conn).await.expect("find").expect("record");
let mut am: ActiveModel = existing.into();
am.price = sea_orm::ActiveValue::Set(15.00);
am.update(conn).await.expect("update should succeed");
assert_eq!(
AFTER_INSERT_CALLS.load(Ordering::SeqCst),
1,
"after_insert called once (from insert)"
);
assert_eq!(
AFTER_UPDATE_CALLS.load(Ordering::SeqCst),
1,
"after_update should be called once on update"
);
}
#[tokio::test]
async fn test_hook_orchestration_order_timestamps_before_hooks() {
let (pool, _guard) = setup().await;
let session = pool.get_session("admin").await.expect("Failed to get session");
let conn = session.connection().expect("conn");
let am: ActiveModel = Model {
id: 100,
name: "Orchestration Test".to_string(),
price: 1.00,
created_at: None,
updated_at: None,
}
.into();
let result = am.insert(conn).await;
assert!(
result.is_ok(),
"insert should succeed if orchestration order is correct (timestamps → hooks), got error: {:?}",
result.err()
);
assert_eq!(
BEFORE_INSERT_CALLS.load(Ordering::SeqCst),
1,
"before_insert should be called"
);
assert_eq!(
BEFORE_INSERT_UPDATED_AT_SET.load(Ordering::SeqCst),
1,
"before_insert should have observed updated_at = Set(Some(_)) — timestamps ran before hooks"
);
}
#[tokio::test]
async fn test_delete_hooks_trigger() {
let (pool, _guard) = setup().await;
let session = pool.get_session("admin").await.expect("Failed to get session");
let conn = session.connection().expect("conn");
let am: ActiveModel = Model {
id: 99,
name: "To Be Deleted".to_string(),
price: 0.99,
created_at: None,
updated_at: None,
}
.into();
am.insert(conn).await.expect("insert should succeed");
BEFORE_DELETE_CALLS.store(0, Ordering::SeqCst);
AFTER_DELETE_CALLS.store(0, Ordering::SeqCst);
let model = Entity::find_by_id(99).one(conn).await.expect("find").expect("record");
model.delete(conn).await.expect("delete should succeed");
assert_eq!(
BEFORE_DELETE_CALLS.load(Ordering::SeqCst),
1,
"before_delete should be called once"
);
assert_eq!(
AFTER_DELETE_CALLS.load(Ordering::SeqCst),
1,
"after_delete should be called once"
);
}
#[tokio::test]
async fn test_hook_failure_short_circuits() {
let (pool, _guard) = setup().await;
let session = pool.get_session("admin").await.expect("Failed to get session");
let conn = session.connection().expect("conn");
let am: ActiveModel = Model {
id: 200,
name: "Success Path".to_string(),
price: 10.00,
created_at: None,
updated_at: None,
}
.into();
let result = am.insert(conn).await;
assert!(result.is_ok(), "insert should succeed");
assert_eq!(
AFTER_INSERT_CALLS.load(Ordering::SeqCst),
1,
"after_insert should be called when before_insert succeeds"
);
}