use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{
ActiveModelBehavior, ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait,
IntoActiveModel, Iterable, QueryFilter, sea_query::Expr,
};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
use crate::dirty::{self, ChangeMap};
use crate::querying::AsyncQuerying;
use crate::{
Record, RecordError, RecordState, relation::json_to_sea_value, relation::resolve_column,
};
#[allow(async_fn_in_trait)]
pub(crate) trait AsyncPersistence: Record + AsyncQuerying {
async fn save(&mut self, db: &DatabaseConnection) -> Result<(), RecordError>
where
Self: Serialize,
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
<Self::Entity as EntityTrait>::Model:
IntoActiveModel<<Self::Entity as EntityTrait>::ActiveModel>,
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelBehavior + Send,
{
if self.new_record() && !dirty::has_snapshot(self) {
dirty::clear_state(self);
}
if dirty::readonly(self) {
return Err(RecordError::ReadOnlyRecord);
}
if self.destroyed() {
return Err(RecordError::NotSaved);
}
let pending_changes = if self.persisted() {
ensure_persisted_snapshot(self, db).await?;
dirty::current_changes(self)?
} else {
ChangeMap::new()
};
if self.persisted() && pending_changes.is_empty() {
return Ok(());
}
let model = match self.record_state() {
RecordState::New => self.to_active_model().insert(db).await?,
RecordState::Persisted => self.to_active_model().update(db).await?,
RecordState::Destroyed => return Err(RecordError::NotSaved),
};
let mut record = Self::from_sea_model(model);
record.set_record_state(RecordState::Persisted);
*self = record;
dirty::commit_saved_changes(self, pending_changes)?;
Ok(())
}
async fn save_bang(&mut self, db: &DatabaseConnection) -> Result<(), RecordError>
where
Self: Serialize,
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
<Self::Entity as EntityTrait>::Model:
IntoActiveModel<<Self::Entity as EntityTrait>::ActiveModel>,
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelBehavior + Send,
{
self.save(db).await
}
#[allow(dead_code)]
fn readonly_bang(&mut self)
where
Self: Serialize,
{
dirty::mark_readonly(self).expect("readonly records should serialize");
}
fn readonly(&self) -> bool {
dirty::readonly(self)
}
#[allow(dead_code)]
async fn touch(&mut self, fields: &[&str], db: &DatabaseConnection) -> Result<(), RecordError>
where
Self: Serialize + DeserializeOwned,
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
{
if self.readonly() {
return Err(RecordError::ReadOnlyRecord);
}
if !self.persisted() || self.destroyed() {
return Err(RecordError::NotSaved);
}
ensure_persisted_snapshot(self, db).await?;
let id = self.id().ok_or(RecordError::NotSaved)?;
let now = Value::String(Utc::now().to_rfc3339());
let mut updates = HashMap::from([("updated_at".to_owned(), now.clone())]);
for field in fields {
updates.insert((*field).to_owned(), now.clone());
}
let applied_changes = touched_changes(self, &updates);
if applied_changes.is_empty() {
return Ok(());
}
let affected = Self::update_all(
HashMap::from([(Self::primary_key_name().to_owned(), Value::from(id))]),
updates.clone(),
db,
)
.await?;
if affected == 0 {
return Err(RecordError::NotFound);
}
let state = self.record_state();
let mut record = merge_attributes(self, updates)?;
record.set_record_state(state);
*self = record;
dirty::apply_persisted_changes(self, &applied_changes)?;
Ok(())
}
#[allow(dead_code)]
fn becomes<T>(&self) -> Result<T, RecordError>
where
Self: Serialize,
T: Record + Default + Serialize + DeserializeOwned,
{
let projected = project_attributes::<T, _>(self)?;
let mut record: T = serde_json::from_value(projected)
.map_err(|error| RecordError::Invalid(error.to_string()))?;
record.set_record_state(self.record_state());
dirty::copy_tracking_state(self, &record)?;
Ok(record)
}
async fn create(
attrs: HashMap<String, Value>,
db: &DatabaseConnection,
) -> Result<Self, RecordError>
where
Self: Default + Serialize + DeserializeOwned,
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
<Self::Entity as EntityTrait>::Model:
IntoActiveModel<<Self::Entity as EntityTrait>::ActiveModel>,
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelBehavior + Send,
{
let mut record = merge_attributes(&Self::default(), attrs)?;
record.set_record_state(RecordState::New);
record.save(db).await?;
Ok(record)
}
async fn insert_all(
records: Vec<HashMap<String, Value>>,
db: &DatabaseConnection,
) -> Result<Vec<Self>, RecordError>
where
Self: Default + Serialize + DeserializeOwned,
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
<Self::Entity as EntityTrait>::Model:
IntoActiveModel<<Self::Entity as EntityTrait>::ActiveModel>,
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelBehavior + Send,
{
let mut inserted = Vec::with_capacity(records.len());
for attrs in records {
inserted.push(Self::create(attrs, db).await?);
}
Ok(inserted)
}
async fn upsert(
attrs: HashMap<String, Value>,
unique_by: &[&str],
db: &DatabaseConnection,
) -> Result<Self, RecordError>
where
Self: Default + Serialize + DeserializeOwned,
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
<Self::Entity as EntityTrait>::Model:
IntoActiveModel<<Self::Entity as EntityTrait>::ActiveModel>,
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelBehavior + Send,
{
if let Some(conditions) = unique_conditions_from_attrs(unique_by, &attrs)?
&& let Some(mut existing) = Self::find_by(conditions, db).await?
{
existing.update_attributes(attrs, db).await?;
return Ok(existing);
}
Self::create(attrs, db).await
}
async fn upsert_all(
records: Vec<HashMap<String, Value>>,
unique_by: &[&str],
db: &DatabaseConnection,
) -> Result<Vec<Self>, RecordError>
where
Self: Default + Serialize + DeserializeOwned,
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
<Self::Entity as EntityTrait>::Model:
IntoActiveModel<<Self::Entity as EntityTrait>::ActiveModel>,
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelBehavior + Send,
{
let mut upserted = Vec::with_capacity(records.len());
for attrs in records {
upserted.push(Self::upsert(attrs, unique_by, db).await?);
}
Ok(upserted)
}
async fn update_attributes(
&mut self,
attrs: HashMap<String, Value>,
db: &DatabaseConnection,
) -> Result<(), RecordError>
where
Self: Serialize + DeserializeOwned,
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
<Self::Entity as EntityTrait>::Model:
IntoActiveModel<<Self::Entity as EntityTrait>::ActiveModel>,
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelBehavior + Send,
{
if self.new_record() && !dirty::has_snapshot(self) {
dirty::clear_state(self);
}
if self.readonly() {
return Err(RecordError::ReadOnlyRecord);
}
if self.destroyed() {
return Err(RecordError::NotSaved);
}
let state = self.record_state();
let mut record = merge_attributes(self, attrs)?;
record.set_record_state(state);
*self = record;
self.save(db).await
}
async fn destroy(&mut self, db: &DatabaseConnection) -> Result<(), RecordError>
where
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
{
if self.readonly() {
return Err(RecordError::ReadOnlyRecord);
}
let id = self.id().ok_or(RecordError::NotSaved)?;
Self::delete(id, db).await?;
self.set_record_state(RecordState::Destroyed);
Ok(())
}
async fn reload(&mut self, db: &DatabaseConnection) -> Result<(), RecordError>
where
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
{
let id = self.id().ok_or(RecordError::NotSaved)?;
let fresh = Self::find(id, db).await?;
*self = fresh;
Ok(())
}
async fn delete(id: i64, db: &DatabaseConnection) -> Result<(), RecordError>
where
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
{
let mut conditions = HashMap::new();
conditions.insert(Self::primary_key_name().to_owned(), Value::from(id));
let deleted = Self::destroy_all(conditions, db).await?;
if deleted == 0 {
return Err(RecordError::NotFound);
}
Ok(())
}
async fn update_all(
conditions: HashMap<String, Value>,
updates: HashMap<String, Value>,
db: &DatabaseConnection,
) -> Result<u64, RecordError>
where
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
{
let relation = Self::r#where(conditions);
let mut query = Self::Entity::update_many().filter(relation.condition()?);
for (column, value) in updates {
let column = resolve_column::<Self>(&column)?;
query = query.col_expr(column, Expr::value(json_to_sea_value(&value)?));
}
let result = query.exec(db).await?;
Ok(result.rows_affected)
}
async fn destroy_all(
conditions: HashMap<String, Value>,
db: &DatabaseConnection,
) -> Result<u64, RecordError>
where
<Self::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
{
let result = Self::Entity::delete_many()
.filter(Self::r#where(conditions).condition()?)
.exec(db)
.await?;
Ok(result.rows_affected)
}
}
fn merge_attributes<T>(record: &T, attrs: HashMap<String, Value>) -> Result<T, RecordError>
where
T: Serialize + DeserializeOwned,
{
let mut json =
serde_json::to_value(record).map_err(|error| RecordError::Invalid(error.to_string()))?;
let object = json
.as_object_mut()
.ok_or_else(|| RecordError::Invalid("record must serialize to a JSON object".to_owned()))?;
for (key, value) in attrs {
object.insert(key, value);
}
serde_json::from_value(json).map_err(|error| RecordError::Invalid(error.to_string()))
}
fn unique_conditions_from_attrs(
unique_by: &[&str],
attrs: &HashMap<String, Value>,
) -> Result<Option<HashMap<String, Value>>, RecordError> {
if unique_by.is_empty() {
return Err(RecordError::Invalid(
"unique_by must contain at least one column".to_owned(),
));
}
let mut conditions = HashMap::with_capacity(unique_by.len());
for column in unique_by {
if column.is_empty() {
return Err(RecordError::Invalid(
"unique_by columns must be non-empty".to_owned(),
));
}
let Some(value) = attrs.get(*column).cloned() else {
return Ok(None);
};
conditions.insert((*column).to_owned(), value);
}
Ok(Some(conditions))
}
fn serialize_record<T: Serialize + ?Sized>(record: &T) -> Result<Value, RecordError> {
serde_json::to_value(record).map_err(|error| RecordError::Invalid(error.to_string()))
}
async fn ensure_persisted_snapshot<T>(
record: &T,
db: &DatabaseConnection,
) -> Result<(), RecordError>
where
T: Record + AsyncQuerying + Serialize,
<T::Entity as EntityTrait>::Column: ColumnTrait + Iterable,
{
if dirty::has_snapshot(record) {
return Ok(());
}
let id = record.id().ok_or(RecordError::NotSaved)?;
let fresh = T::find(id, db).await?;
dirty::initialize_snapshot_value(record, serialize_record(&fresh)?);
Ok(())
}
#[allow(dead_code)]
fn touched_changes<T>(record: &T, updates: &HashMap<String, Value>) -> ChangeMap
where
T: Record + Serialize,
{
updates
.iter()
.filter_map(|(field, new)| {
let old = dirty::attribute_from_snapshot(record, field).unwrap_or(Value::Null);
(old != *new).then(|| (field.clone(), (old, new.clone())))
})
.collect()
}
#[allow(dead_code)]
fn project_attributes<T, U>(source: &U) -> Result<Value, RecordError>
where
T: Default + Serialize,
U: Serialize + ?Sized,
{
let mut projected = serialize_record(&T::default())?;
let target = projected
.as_object_mut()
.ok_or_else(|| RecordError::Invalid("record must serialize to a JSON object".to_owned()))?;
let source = serialize_record(source)?;
let source = source
.as_object()
.ok_or_else(|| RecordError::Invalid("record must serialize to a JSON object".to_owned()))?;
for key in target.keys().cloned().collect::<Vec<_>>() {
if key == "type" {
continue;
}
if let Some(value) = source.get(&key).cloned() {
target.insert(key, value);
}
}
Ok(projected)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::json;
use super::AsyncPersistence;
use crate::{
RecordError, RecordState,
base::{
Record,
test_support::{TestUser, seed_users, setup_db},
},
querying::AsyncQuerying,
};
#[tokio::test]
async fn create_inserts_row_and_returns_id() {
let db = setup_db().await;
let user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
assert!(user.id().is_some());
assert!(user.persisted());
assert_eq!(TestUser::count(&db).await.expect("count should succeed"), 1);
}
#[tokio::test]
async fn save_new_record_inserts_row() {
let db = setup_db().await;
let mut user = TestUser {
name: "Alice".to_owned(),
email: "alice@example.com".to_owned(),
..Default::default()
};
user.save(&db).await.expect("save should insert");
assert!(user.id().is_some());
assert!(user.persisted());
assert_eq!(TestUser::count(&db).await.expect("count should succeed"), 1);
}
#[tokio::test]
async fn save_existing_record_updates_row() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
user.name = "Alicia".to_owned();
user.save(&db).await.expect("save should update");
let reloaded = TestUser::find(user.id().expect("saved user should have id"), &db)
.await
.expect("find should succeed");
assert_eq!(reloaded.name, "Alicia");
}
#[tokio::test]
async fn save_bang_behaves_like_save() {
let db = setup_db().await;
let mut user = TestUser {
name: "Alice".to_owned(),
email: "alice@example.com".to_owned(),
..Default::default()
};
user.save_bang(&db).await.expect("save! should succeed");
assert!(user.persisted());
}
#[tokio::test]
async fn update_attributes_merges_and_persists_changes() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
user.update_attributes(HashMap::from([("name".to_owned(), json!("Alicia"))]), &db)
.await
.expect("update should succeed");
assert_eq!(user.name, "Alicia");
let from_db = TestUser::find(user.id().expect("user should have id"), &db)
.await
.expect("find should succeed");
assert_eq!(from_db.name, "Alicia");
}
#[tokio::test]
async fn destroy_deletes_row_and_marks_destroyed() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
let id = user.id().expect("created user should have id");
user.destroy(&db).await.expect("destroy should succeed");
assert_eq!(user.record_state(), RecordState::Destroyed);
assert!(matches!(
TestUser::find(id, &db).await,
Err(RecordError::NotFound)
));
}
#[tokio::test]
async fn reload_refreshes_from_database() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
let id = user.id().expect("created user should have id");
TestUser::update_all(
HashMap::from([("id".to_owned(), json!(id))]),
HashMap::from([("name".to_owned(), json!("Alicia"))]),
&db,
)
.await
.expect("update_all should succeed");
user.reload(&db).await.expect("reload should succeed");
assert_eq!(user.name, "Alicia");
}
#[tokio::test]
async fn create_with_invalid_attributes_fails() {
let db = setup_db().await;
let error = TestUser::create(
HashMap::from([
("name".to_owned(), json!(123)),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect_err("invalid attributes should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
#[tokio::test]
async fn delete_by_id_removes_row() {
let db = setup_db().await;
let user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
let id = user.id().expect("created user should have id");
TestUser::delete(id, &db)
.await
.expect("delete should succeed");
assert!(matches!(
TestUser::find(id, &db).await,
Err(RecordError::NotFound)
));
}
#[tokio::test]
async fn delete_missing_id_returns_not_found() {
let db = setup_db().await;
let error = TestUser::delete(999, &db)
.await
.expect_err("missing delete should fail");
assert!(matches!(error, RecordError::NotFound));
}
#[tokio::test]
async fn update_all_returns_affected_row_count() {
let db = setup_db().await;
seed_users(&db).await;
let affected = TestUser::update_all(
HashMap::from([("name".to_owned(), json!("Bob"))]),
HashMap::from([("email".to_owned(), json!("bobby@example.com"))]),
&db,
)
.await
.expect("update_all should succeed");
assert_eq!(affected, 1);
let updated = TestUser::find_by(
HashMap::from([("email".to_owned(), json!("bobby@example.com"))]),
&db,
)
.await
.expect("query should succeed");
assert!(updated.is_some());
}
#[tokio::test]
async fn destroy_all_returns_deleted_row_count() {
let db = setup_db().await;
seed_users(&db).await;
let deleted =
TestUser::destroy_all(HashMap::from([("name".to_owned(), json!("Carol"))]), &db)
.await
.expect("destroy_all should succeed");
assert_eq!(deleted, 1);
assert_eq!(TestUser::count(&db).await.expect("count should succeed"), 2);
}
#[tokio::test]
async fn destroyed_record_cannot_be_saved_again() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
user.destroy(&db).await.expect("destroy should succeed");
let error = user
.save(&db)
.await
.expect_err("saving destroyed record should fail");
assert!(matches!(error, RecordError::NotSaved));
}
#[tokio::test]
async fn update_attributes_rejects_unknown_fields() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
let error = user
.update_attributes(HashMap::from([("missing".to_owned(), json!(true))]), &db)
.await
.expect_err("unknown field should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
#[tokio::test]
async fn create_preserves_supplied_attributes() {
let db = setup_db().await;
let user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
assert_eq!(user.name, "Dana");
assert_eq!(user.email, "dana@example.com");
}
#[tokio::test]
async fn create_allows_explicit_primary_key() {
let db = setup_db().await;
let user = TestUser::create(
HashMap::from([
("id".to_owned(), json!(42)),
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
assert_eq!(user.id(), Some(42));
}
#[tokio::test]
async fn create_rejects_unknown_attributes() {
let db = setup_db().await;
let error = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
("role".to_owned(), json!("admin")),
]),
&db,
)
.await
.expect_err("unknown attributes should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
#[tokio::test]
async fn create_with_duplicate_primary_key_returns_database_error() {
let db = setup_db().await;
seed_users(&db).await;
let error = TestUser::create(
HashMap::from([
("id".to_owned(), json!(1)),
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect_err("duplicate ids should fail");
assert!(matches!(error, RecordError::Database(_)));
}
#[tokio::test]
async fn save_preserves_existing_primary_key_when_updating() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
let id = user.id().expect("created user should have id");
user.email = "updated@example.com".to_owned();
user.save(&db).await.expect("save should update");
assert_eq!(user.id(), Some(id));
}
#[tokio::test]
async fn save_on_destroyed_state_returns_not_saved() {
let db = setup_db().await;
let mut user = TestUser::persisted(1, "Dana", "dana@example.com");
user.set_record_state(RecordState::Destroyed);
let error = user
.save(&db)
.await
.expect_err("destroyed records cannot be saved");
assert!(matches!(error, RecordError::NotSaved));
}
#[tokio::test]
async fn save_with_duplicate_primary_key_returns_database_error() {
let db = setup_db().await;
seed_users(&db).await;
let mut user = TestUser {
id: Some(1),
name: "Dana".to_owned(),
email: "dana@example.com".to_owned(),
..Default::default()
};
let error = user.save(&db).await.expect_err("duplicate ids should fail");
assert!(matches!(error, RecordError::Database(_)));
}
#[tokio::test]
async fn save_bang_on_destroyed_state_returns_not_saved() {
let db = setup_db().await;
let mut user = TestUser::persisted(1, "Dana", "dana@example.com");
user.set_record_state(RecordState::Destroyed);
let error = user
.save_bang(&db)
.await
.expect_err("destroyed records cannot be saved");
assert!(matches!(error, RecordError::NotSaved));
}
#[tokio::test]
async fn save_bang_propagates_duplicate_primary_key_errors() {
let db = setup_db().await;
seed_users(&db).await;
let mut user = TestUser {
id: Some(1),
name: "Dana".to_owned(),
email: "dana@example.com".to_owned(),
..Default::default()
};
let error = user
.save_bang(&db)
.await
.expect_err("duplicate ids should fail");
assert!(matches!(error, RecordError::Database(_)));
}
#[tokio::test]
async fn update_attributes_preserves_unspecified_fields() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
user.update_attributes(HashMap::from([("name".to_owned(), json!("Dani"))]), &db)
.await
.expect("update should succeed");
assert_eq!(user.email, "dana@example.com");
}
#[tokio::test]
async fn update_attributes_keeps_record_persisted() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
user.update_attributes(HashMap::from([("name".to_owned(), json!("Dani"))]), &db)
.await
.expect("update should succeed");
assert_eq!(user.record_state(), RecordState::Persisted);
}
#[tokio::test]
async fn update_attributes_on_new_record_inserts_and_persists() {
let db = setup_db().await;
let mut user = TestUser::default();
user.update_attributes(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("update should insert new records");
assert!(user.id().is_some());
assert!(user.persisted());
}
#[tokio::test]
async fn update_attributes_on_destroyed_record_returns_not_saved() {
let db = setup_db().await;
let mut user = TestUser::persisted(1, "Dana", "dana@example.com");
user.set_record_state(RecordState::Destroyed);
let error = user
.update_attributes(HashMap::from([("name".to_owned(), json!("Dani"))]), &db)
.await
.expect_err("destroyed records cannot be updated");
assert!(matches!(error, RecordError::NotSaved));
}
#[tokio::test]
async fn update_attributes_with_type_mismatch_returns_invalid() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
let error = user
.update_attributes(HashMap::from([("id".to_owned(), json!("oops"))]), &db)
.await
.expect_err("type mismatches should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
#[tokio::test]
async fn destroy_without_id_returns_not_saved() {
let db = setup_db().await;
let mut user = TestUser::default();
let error = user
.destroy(&db)
.await
.expect_err("unsaved records cannot be destroyed");
assert!(matches!(error, RecordError::NotSaved));
}
#[tokio::test]
async fn destroy_after_row_is_missing_returns_not_found() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
let id = user.id().expect("created user should have id");
TestUser::delete(id, &db)
.await
.expect("delete should remove row");
let error = user
.destroy(&db)
.await
.expect_err("missing rows should fail destroy");
assert!(matches!(error, RecordError::NotFound));
assert_eq!(user.record_state(), RecordState::Persisted);
}
#[tokio::test]
async fn reload_without_id_returns_not_saved() {
let db = setup_db().await;
let mut user = TestUser::default();
let error = user
.reload(&db)
.await
.expect_err("unsaved records cannot reload");
assert!(matches!(error, RecordError::NotSaved));
}
#[tokio::test]
async fn reload_after_row_is_deleted_returns_not_found() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
let id = user.id().expect("created user should have id");
TestUser::delete(id, &db)
.await
.expect("delete should remove row");
let error = user
.reload(&db)
.await
.expect_err("missing rows should fail reload");
assert!(matches!(error, RecordError::NotFound));
}
#[tokio::test]
async fn reload_restores_persisted_state_from_database() {
let db = setup_db().await;
let mut user = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Dana")),
("email".to_owned(), json!("dana@example.com")),
]),
&db,
)
.await
.expect("create should succeed");
user.set_record_state(RecordState::Destroyed);
user.reload(&db).await.expect("reload should succeed");
assert_eq!(user.record_state(), RecordState::Persisted);
}
#[tokio::test]
async fn update_all_with_unknown_update_column_returns_invalid() {
let db = setup_db().await;
seed_users(&db).await;
let error = TestUser::update_all(
HashMap::from([("name".to_owned(), json!("Alice"))]),
HashMap::from([("missing".to_owned(), json!(true))]),
&db,
)
.await
.expect_err("unknown update columns should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
#[tokio::test]
async fn update_all_with_unknown_condition_column_returns_invalid() {
let db = setup_db().await;
seed_users(&db).await;
let error = TestUser::update_all(
HashMap::from([("missing".to_owned(), json!(true))]),
HashMap::from([("email".to_owned(), json!("updated@example.com"))]),
&db,
)
.await
.expect_err("unknown condition columns should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
#[tokio::test]
async fn update_all_with_null_value_returns_invalid() {
let db = setup_db().await;
seed_users(&db).await;
let error = TestUser::update_all(
HashMap::from([("name".to_owned(), json!("Alice"))]),
HashMap::from([("email".to_owned(), serde_json::Value::Null)]),
&db,
)
.await
.expect_err("null updates should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
#[tokio::test]
async fn update_all_with_empty_conditions_updates_all_rows() {
let db = setup_db().await;
seed_users(&db).await;
let affected = TestUser::update_all(
HashMap::new(),
HashMap::from([("email".to_owned(), json!("updated@example.com"))]),
&db,
)
.await
.expect("update_all should succeed");
assert_eq!(affected, 3);
let users = TestUser::all(&db).await.expect("all should succeed");
assert!(users.iter().all(|user| user.email == "updated@example.com"));
}
#[tokio::test]
async fn destroy_all_with_unknown_condition_column_returns_invalid() {
let db = setup_db().await;
seed_users(&db).await;
let error =
TestUser::destroy_all(HashMap::from([("missing".to_owned(), json!(true))]), &db)
.await
.expect_err("unknown condition columns should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
#[tokio::test]
async fn destroy_all_with_empty_conditions_deletes_all_rows() {
let db = setup_db().await;
seed_users(&db).await;
let deleted = TestUser::destroy_all(HashMap::new(), &db)
.await
.expect("destroy_all should succeed");
assert_eq!(deleted, 3);
assert_eq!(TestUser::count(&db).await.expect("count should succeed"), 0);
}
#[tokio::test]
async fn destroy_all_with_no_matches_returns_zero() {
let db = setup_db().await;
seed_users(&db).await;
let deleted =
TestUser::destroy_all(HashMap::from([("name".to_owned(), json!("Nobody"))]), &db)
.await
.expect("destroy_all should succeed");
assert_eq!(deleted, 0);
}
#[tokio::test]
async fn upsert_creates_new_record_when_none_exists() {
let db = setup_db().await;
let user = TestUser::upsert(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&["email"],
&db,
)
.await
.expect("upsert should create a missing row");
assert!(user.id().is_some());
assert!(user.persisted());
assert_eq!(TestUser::count(&db).await.expect("count should succeed"), 1);
}
#[tokio::test]
async fn upsert_updates_existing_record_when_match_exists() {
let db = setup_db().await;
let existing = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("seed create should succeed");
let user = TestUser::upsert(
HashMap::from([
("name".to_owned(), json!("Updated Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&["email"],
&db,
)
.await
.expect("upsert should update the matching row");
assert_eq!(user.id(), existing.id());
assert_eq!(TestUser::count(&db).await.expect("count should succeed"), 1);
let reloaded = TestUser::find(existing.id().expect("existing record should have id"), &db)
.await
.expect("find should reload the updated row");
assert_eq!(reloaded.name, "Updated Alice");
assert_eq!(reloaded.email, "alice@example.com");
}
#[tokio::test]
async fn upsert_all_handles_mixed_new_and_existing_records() {
let db = setup_db().await;
let existing = TestUser::create(
HashMap::from([
("name".to_owned(), json!("Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
&db,
)
.await
.expect("seed create should succeed");
let users = TestUser::upsert_all(
vec![
HashMap::from([
("name".to_owned(), json!("Updated Alice")),
("email".to_owned(), json!("alice@example.com")),
]),
HashMap::from([
("name".to_owned(), json!("Bob")),
("email".to_owned(), json!("bob@example.com")),
]),
],
&["email"],
&db,
)
.await
.expect("upsert_all should handle mixed rows");
assert_eq!(users.len(), 2);
assert_eq!(TestUser::count(&db).await.expect("count should succeed"), 2);
let updated = TestUser::find(existing.id().expect("existing record should have id"), &db)
.await
.expect("find should reload the updated row");
assert_eq!(updated.name, "Updated Alice");
let inserted = TestUser::find_by(
HashMap::from([("email".to_owned(), json!("bob@example.com"))]),
&db,
)
.await
.expect("find_by should succeed")
.expect("new row should exist");
assert_eq!(inserted.name, "Bob");
}
#[test]
fn merge_attributes_overwrites_existing_fields() {
let user = TestUser::persisted(7, "Dana", "dana@example.com");
let merged =
super::merge_attributes(&user, HashMap::from([("name".to_owned(), json!("Dani"))]))
.expect("merge should succeed");
assert_eq!(merged.name, "Dani");
}
#[test]
fn merge_attributes_preserves_unmentioned_fields() {
let user = TestUser::persisted(7, "Dana", "dana@example.com");
let merged =
super::merge_attributes(&user, HashMap::from([("name".to_owned(), json!("Dani"))]))
.expect("merge should succeed");
assert_eq!(merged.email, "dana@example.com");
assert_eq!(merged.id(), Some(7));
}
#[test]
fn merge_attributes_rejects_unknown_fields_directly() {
let user = TestUser::persisted(7, "Dana", "dana@example.com");
let error =
super::merge_attributes(&user, HashMap::from([("role".to_owned(), json!("admin"))]))
.expect_err("unknown fields should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
#[test]
fn merge_attributes_rejects_type_mismatches_directly() {
let user = TestUser::persisted(7, "Dana", "dana@example.com");
let error =
super::merge_attributes(&user, HashMap::from([("id".to_owned(), json!("oops"))]))
.expect_err("type mismatches should fail");
assert!(matches!(error, RecordError::Invalid(_)));
}
mod dirty_tracking_and_touch_tests {
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, Ordering};
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ActiveValue::Set, ConnectionTrait, Database,
DatabaseConnection, EntityTrait, Schema,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
Record, RecordError, RecordState,
dirty::{self, DirtyTracking},
querying::AsyncQuerying,
};
use super::AsyncPersistence;
fn persisted_state() -> RecordState {
RecordState::Persisted
}
pub mod tracked_user {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "tracked_users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub email: String,
pub updated_at: String,
pub touched_at: String,
pub save_marker: String,
#[sea_orm(column_name = "type")]
pub record_type: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct TrackedUser {
id: Option<i64>,
name: String,
email: String,
updated_at: String,
touched_at: String,
save_marker: String,
#[serde(rename = "type")]
record_type: String,
#[serde(skip, default = "persisted_state")]
state: RecordState,
}
impl Default for TrackedUser {
fn default() -> Self {
Self {
id: None,
name: String::new(),
email: String::new(),
updated_at: "original-updated".to_owned(),
touched_at: "original-touched".to_owned(),
save_marker: "seed".to_owned(),
record_type: "TrackedUser".to_owned(),
state: RecordState::New,
}
}
}
impl Record for TrackedUser {
type Entity = tracked_user::Entity;
fn table_name() -> &'static str {
"tracked_users"
}
fn id(&self) -> Option<i64> {
self.id
}
fn record_state(&self) -> RecordState {
self.state
}
fn set_record_state(&mut self, state: RecordState) {
self.state = state;
}
fn from_sea_model(model: <Self::Entity as EntityTrait>::Model) -> Self {
Self {
id: Some(i64::from(model.id)),
name: model.name,
email: model.email,
updated_at: model.updated_at,
touched_at: model.touched_at,
save_marker: model.save_marker,
record_type: model.record_type,
state: RecordState::Persisted,
}
}
fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel
where
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelTrait,
{
tracked_user::ActiveModel {
id: match self.id.and_then(|value| i32::try_from(value).ok()) {
Some(value) => Set(value),
None => NotSet,
},
name: Set(self.name.clone()),
email: Set(self.email.clone()),
updated_at: Set(self.updated_at.clone()),
touched_at: Set(self.touched_at.clone()),
save_marker: Set(self.save_marker.clone()),
record_type: Set(self.record_type.clone()),
}
}
}
impl AsyncQuerying for TrackedUser {}
impl AsyncPersistence for TrackedUser {}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct PromotedTrackedUser {
id: Option<i64>,
name: String,
email: String,
updated_at: String,
touched_at: String,
save_marker: String,
nickname: String,
#[serde(rename = "type")]
record_type: String,
#[serde(skip, default = "persisted_state")]
state: RecordState,
}
impl Default for PromotedTrackedUser {
fn default() -> Self {
Self {
id: None,
name: String::new(),
email: String::new(),
updated_at: String::new(),
touched_at: String::new(),
save_marker: String::new(),
nickname: "guest".to_owned(),
record_type: "PromotedTrackedUser".to_owned(),
state: RecordState::New,
}
}
}
impl Record for PromotedTrackedUser {
type Entity = tracked_user::Entity;
fn table_name() -> &'static str {
"tracked_users"
}
fn id(&self) -> Option<i64> {
self.id
}
fn record_state(&self) -> RecordState {
self.state
}
fn set_record_state(&mut self, state: RecordState) {
self.state = state;
}
fn from_sea_model(model: <Self::Entity as EntityTrait>::Model) -> Self {
Self {
id: Some(i64::from(model.id)),
name: model.name,
email: model.email,
updated_at: model.updated_at,
touched_at: model.touched_at,
save_marker: model.save_marker,
record_type: model.record_type,
..Self::default()
}
}
fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel
where
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelTrait,
{
tracked_user::ActiveModel {
id: match self.id.and_then(|value| i32::try_from(value).ok()) {
Some(value) => Set(value),
None => NotSet,
},
name: Set(self.name.clone()),
email: Set(self.email.clone()),
updated_at: Set(self.updated_at.clone()),
touched_at: Set(self.touched_at.clone()),
save_marker: Set(self.save_marker.clone()),
record_type: Set(self.record_type.clone()),
}
}
}
impl AsyncQuerying for PromotedTrackedUser {}
impl AsyncPersistence for PromotedTrackedUser {}
async fn setup_tracked_db() -> DatabaseConnection {
let db = Database::connect("sqlite::memory:")
.await
.expect("sqlite in-memory connection should succeed");
let schema = Schema::new(db.get_database_backend());
db.execute(&schema.create_table_from_entity(tracked_user::Entity))
.await
.expect("tracked_users table should be created");
db.execute_unprepared(
"CREATE TRIGGER tracked_users_update_marker AFTER UPDATE ON tracked_users BEGIN UPDATE tracked_users SET save_marker = save_marker || ':updated' WHERE id = NEW.id; END;",
)
.await
.expect("update trigger should be created");
db
}
static NEXT_TRACKED_ID: AtomicI64 = AtomicI64::new(1);
fn tracked_attrs(name: &str, email: &str) -> HashMap<String, Value> {
let id = NEXT_TRACKED_ID.fetch_add(1, Ordering::Relaxed);
HashMap::from([
("id".to_owned(), Value::from(id)),
("name".to_owned(), Value::String(name.to_owned())),
("email".to_owned(), Value::String(email.to_owned())),
])
}
#[tokio::test]
async fn save_returns_readonly_error_when_record_is_marked_readonly() {
let db = super::setup_db().await;
let mut user =
super::TestUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.readonly_bang();
user.name = "Alicia".to_owned();
let error = user.save(&db).await.expect_err("readonly save should fail");
assert!(matches!(error, RecordError::ReadOnlyRecord));
}
#[tokio::test]
async fn update_attributes_returns_readonly_error_when_record_is_marked_readonly() {
let db = super::setup_db().await;
let mut user =
super::TestUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.readonly_bang();
let error = user
.update_attributes(
HashMap::from([("name".to_owned(), Value::String("Alicia".to_owned()))]),
&db,
)
.await
.expect_err("readonly update should fail");
assert!(matches!(error, RecordError::ReadOnlyRecord));
}
#[tokio::test]
async fn destroy_returns_readonly_error_when_record_is_marked_readonly() {
let db = super::setup_db().await;
let mut user =
super::TestUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.readonly_bang();
let error = user
.destroy(&db)
.await
.expect_err("readonly destroy should fail");
assert!(matches!(error, RecordError::ReadOnlyRecord));
}
#[tokio::test]
async fn failed_save_does_not_clear_dirty_changes() {
let db = super::setup_db().await;
let mut user =
super::TestUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.name = "Alicia".to_owned();
user.readonly_bang();
let error = user.save(&db).await.expect_err("readonly save should fail");
assert!(matches!(error, RecordError::ReadOnlyRecord));
assert!(user.changed());
assert_eq!(
user.attribute_was("name"),
Some(Value::String("Alice".to_owned()))
);
}
#[tokio::test]
async fn save_skips_database_update_when_record_is_unchanged() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
let id = user.id().expect("persisted record should have id");
user.save(&db)
.await
.expect("unchanged save should short-circuit");
let reloaded = TrackedUser::find(id, &db)
.await
.expect("find should succeed");
assert_eq!(reloaded.save_marker, "seed");
}
#[tokio::test]
async fn save_clears_dirty_changes_after_successful_update() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.name = "Alicia".to_owned();
user.save(&db).await.expect("save should persist changes");
assert!(!user.changed());
assert_eq!(
user.previous_changes().get("name"),
Some(&(
Value::String("Alice".to_owned()),
Value::String("Alicia".to_owned())
))
);
}
#[tokio::test]
async fn touch_rejects_new_records() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::default();
let error = user
.touch(&[], &db)
.await
.expect_err("touch should reject new records");
assert!(matches!(error, RecordError::NotSaved));
}
#[tokio::test]
async fn touch_rejects_destroyed_records() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.set_record_state(RecordState::Destroyed);
let error = user
.touch(&[], &db)
.await
.expect_err("touch should reject destroyed records");
assert!(matches!(error, RecordError::NotSaved));
}
#[tokio::test]
async fn touch_rejects_readonly_records() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.readonly_bang();
let error = user
.touch(&[], &db)
.await
.expect_err("touch should reject readonly records");
assert!(matches!(error, RecordError::ReadOnlyRecord));
}
#[tokio::test]
async fn touch_updates_only_updated_at_by_default() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
let id = user.id().expect("persisted record should have id");
let touched_before = user.touched_at.clone();
let updated_before = user.updated_at.clone();
user.touch(&[], &db).await.expect("touch should succeed");
assert_ne!(user.updated_at, updated_before);
assert_eq!(user.touched_at, touched_before);
let reloaded = TrackedUser::find(id, &db)
.await
.expect("find should succeed");
assert_eq!(reloaded.touched_at, touched_before);
}
#[tokio::test]
async fn touch_updates_requested_timestamp_columns() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
let updated_before = user.updated_at.clone();
let touched_before = user.touched_at.clone();
user.touch(&["touched_at"], &db)
.await
.expect("touch should update both timestamp columns");
assert_ne!(user.updated_at, updated_before);
assert_ne!(user.touched_at, touched_before);
assert_eq!(
user.previous_changes()
.get("touched_at")
.map(|(_, new)| new),
Some(&Value::String(user.touched_at.clone()))
);
}
#[tokio::test]
async fn touch_preserves_other_unsaved_dirty_changes() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.name = "Alicia".to_owned();
user.touch(&["touched_at"], &db)
.await
.expect("touch should persist only timestamps");
assert!(user.attribute_changed("name"));
assert!(!user.attribute_changed("updated_at"));
assert!(!user.attribute_changed("touched_at"));
assert_eq!(
user.attribute_was("name"),
Some(Value::String("Alice".to_owned()))
);
}
#[tokio::test]
async fn becomes_projects_shared_attributes_and_preserves_record_state() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.set_record_state(RecordState::Destroyed);
let promoted = user
.becomes::<PromotedTrackedUser>()
.expect("becomes should succeed");
assert_eq!(promoted.id, user.id);
assert_eq!(promoted.name, user.name);
assert_eq!(promoted.email, user.email);
assert_eq!(promoted.record_state(), RecordState::Destroyed);
assert_eq!(promoted.nickname, "guest");
assert_eq!(promoted.record_type, "PromotedTrackedUser");
}
#[tokio::test]
async fn becomes_preserves_dirty_tracking_state_for_shared_fields() {
let db = setup_tracked_db().await;
let mut user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
user.name = "Alicia".to_owned();
user.readonly_bang();
let promoted = user
.becomes::<PromotedTrackedUser>()
.expect("becomes should succeed");
assert!(promoted.attribute_changed("name"));
assert_eq!(
promoted.attribute_was("name"),
Some(Value::String("Alice".to_owned()))
);
assert!(dirty::readonly(&promoted));
}
#[tokio::test]
async fn becomes_uses_target_defaults_for_missing_columns() {
let db = setup_tracked_db().await;
let user = TrackedUser::create(tracked_attrs("Alice", "alice@example.com"), &db)
.await
.expect("create should succeed");
let promoted = user
.becomes::<PromotedTrackedUser>()
.expect("becomes should succeed");
assert_eq!(promoted.nickname, "guest");
assert_eq!(promoted.record_type, "PromotedTrackedUser");
}
}
}