use rustrails_macros::{__ModelRecordState, ModelDefinition};
use sea_orm::{ActiveModelTrait, EntityTrait};
use crate::connection::ConnectionError;
fn from_model_state(state: __ModelRecordState) -> RecordState {
match state {
__ModelRecordState::New => RecordState::New,
__ModelRecordState::Persisted => RecordState::Persisted,
__ModelRecordState::Destroyed => RecordState::Destroyed,
}
}
fn to_model_state(state: RecordState) -> __ModelRecordState {
match state {
RecordState::New => __ModelRecordState::New,
RecordState::Persisted => __ModelRecordState::Persisted,
RecordState::Destroyed => __ModelRecordState::Destroyed,
}
}
#[derive(Debug, thiserror::Error)]
pub enum RecordError {
#[error("record not found")]
NotFound,
#[error("multiple records found when exactly one was expected")]
SoleRecordExceeded,
#[error("record invalid: {0}")]
Invalid(String),
#[error("record not saved")]
NotSaved,
#[error("record is readonly")]
ReadOnlyRecord,
#[error("stale object")]
StaleObject,
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("connection error: {0}")]
Connection(#[from] ConnectionError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RecordState {
#[default]
New,
Persisted,
Destroyed,
}
pub trait Record: Sized + Send + Sync + 'static {
type Entity: EntityTrait;
fn table_name() -> &'static str;
fn primary_key_name() -> &'static str {
"id"
}
fn id(&self) -> Option<i64>;
fn record_state(&self) -> RecordState;
fn set_record_state(&mut self, state: RecordState);
fn new_record(&self) -> bool {
self.record_state() == RecordState::New
}
fn persisted(&self) -> bool {
self.record_state() == RecordState::Persisted
}
fn destroyed(&self) -> bool {
self.record_state() == RecordState::Destroyed
}
fn from_sea_model(model: <Self::Entity as EntityTrait>::Model) -> Self;
fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel
where
<Self::Entity as EntityTrait>::ActiveModel: ActiveModelTrait;
}
impl<T: ModelDefinition> Record for T
where
<T::SeaEntity as EntityTrait>::ActiveModel: ActiveModelTrait,
{
type Entity = T::SeaEntity;
fn table_name() -> &'static str {
T::table_name()
}
fn id(&self) -> Option<i64> {
self.get_id()
}
fn record_state(&self) -> RecordState {
from_model_state(self.get_record_state())
}
fn set_record_state(&mut self, state: RecordState) {
self.set_model_record_state(to_model_state(state));
}
fn from_sea_model(model: <T::SeaEntity as EntityTrait>::Model) -> Self {
T::from_sea_model(model)
}
fn to_active_model(&self) -> <T::SeaEntity as EntityTrait>::ActiveModel {
self.to_sea_active_model()
}
}
impl<T: ModelDefinition> crate::querying::AsyncQuerying for T where
<T::SeaEntity as EntityTrait>::ActiveModel: ActiveModelTrait
{
}
impl<T: ModelDefinition> crate::persistence::AsyncPersistence for T where
<T::SeaEntity as EntityTrait>::ActiveModel: ActiveModelTrait
{
}
#[cfg(test)]
pub(crate) mod test_support {
use sea_orm::entity::prelude::*;
use sea_orm::{
ActiveValue::NotSet, ActiveValue::Set, ConnectionTrait, Database, DatabaseConnection,
Schema,
};
use serde::{Deserialize, Serialize};
use super::{Record, RecordState};
use crate::{persistence::AsyncPersistence, querying::AsyncQuerying};
pub mod test_user {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "test_users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub email: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
fn default_record_state() -> RecordState {
RecordState::New
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TestUser {
pub id: Option<i64>,
pub name: String,
pub email: String,
#[serde(skip, default = "default_record_state")]
pub state: RecordState,
}
impl TestUser {
pub fn persisted(id: i64, name: &str, email: &str) -> Self {
Self {
id: Some(id),
name: name.to_owned(),
email: email.to_owned(),
state: RecordState::Persisted,
}
}
}
impl Record for TestUser {
type Entity = test_user::Entity;
fn table_name() -> &'static str {
"test_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,
state: RecordState::Persisted,
}
}
fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel {
test_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()),
}
}
}
impl AsyncPersistence for TestUser {}
impl AsyncQuerying for TestUser {}
pub async fn setup_db() -> DatabaseConnection {
let db = Database::connect("sqlite::memory:")
.await
.expect("in-memory sqlite connection should succeed");
let backend = db.get_database_backend();
let schema = Schema::new(backend);
db.execute(&schema.create_table_from_entity(test_user::Entity))
.await
.expect("test_users table should be created");
db
}
pub fn with_sync_test_user_db(test: impl FnOnce() + Send + 'static) {
rustrails_support::testing::run_sync_db_test(|| {
rustrails_support::runtime::block_on(async {
let db = rustrails_support::database::db();
let schema = Schema::new(db.get_database_backend());
db.execute(&schema.create_table_from_entity(test_user::Entity))
.await
.expect("test_users table should be created");
});
test();
});
}
pub async fn seed_users(db: &DatabaseConnection) -> Vec<TestUser> {
let mut users = Vec::new();
for (name, email) in [
("Alice", "alice@example.com"),
("Bob", "bob@example.com"),
("Carol", "carol@example.com"),
] {
let model = test_user::ActiveModel {
name: Set(name.to_owned()),
email: Set(email.to_owned()),
..Default::default()
}
.insert(db)
.await
.expect("fixture insert should succeed");
users.push(TestUser::from_sea_model(model));
}
users
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{Record, RecordState};
use crate::{
Persistence, Querying,
base::test_support::{TestUser, test_user},
};
use rustrails_macros::model;
use rustrails_support::{database, runtime};
use serde_json::json;
model! {
MacroBackedPost {
title: String,
published: bool,
}
table_name: "macro_backed_posts";
}
#[test]
fn macro_backed_models_implement_record_and_sync_traits() {
fn assert_record<T: Record>() {}
fn assert_querying<T: Querying>() {}
fn assert_persistence<T: Persistence>() {}
assert_record::<MacroBackedPost>();
assert_querying::<MacroBackedPost>();
assert_persistence::<MacroBackedPost>();
}
#[test]
fn state_helpers_match_record_state() {
let mut user = TestUser::default();
assert!(user.new_record());
assert!(!user.persisted());
assert!(!user.destroyed());
user.set_record_state(RecordState::Persisted);
assert!(!user.new_record());
assert!(user.persisted());
assert!(!user.destroyed());
user.set_record_state(RecordState::Destroyed);
assert!(!user.new_record());
assert!(!user.persisted());
assert!(user.destroyed());
}
#[test]
fn primary_key_name_defaults_to_id() {
assert_eq!(TestUser::primary_key_name(), "id");
}
#[test]
fn table_name_matches_entity_mapping() {
assert_eq!(TestUser::table_name(), "test_users");
}
#[test]
fn from_sea_model_marks_record_persisted() {
let record = TestUser::from_sea_model(test_user::Model {
id: 7,
name: "Alice".to_owned(),
email: "alice@example.com".to_owned(),
});
assert_eq!(record.id(), Some(7));
assert_eq!(record.name, "Alice");
assert_eq!(record.email, "alice@example.com");
assert_eq!(record.record_state(), RecordState::Persisted);
}
#[test]
fn to_active_model_preserves_attributes() {
let user = TestUser::persisted(11, "Bob", "bob@example.com");
let active = user.to_active_model();
assert_eq!(active.id, sea_orm::ActiveValue::Set(11));
assert_eq!(active.name, sea_orm::ActiveValue::Set("Bob".to_owned()));
assert_eq!(
active.email,
sea_orm::ActiveValue::Set("bob@example.com".to_owned())
);
}
#[test]
fn macro_backed_model_supports_sync_crud_cycle() {
let _runtime = runtime::init_runtime();
database::establish("sqlite::memory:").expect("sqlite in-memory connection should succeed");
runtime::block_on(async {
let db = database::db();
use sea_orm::ConnectionTrait;
db.execute_unprepared(
"CREATE TABLE macro_backed_posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, published BOOLEAN NOT NULL DEFAULT 0)",
)
.await
.expect("macro_backed_posts table should be created");
});
let mut created = MacroBackedPost::create_sync(HashMap::from([
("title".to_owned(), json!("Hello World")),
("published".to_owned(), json!(false)),
]))
.expect("create_sync should persist the record");
assert!(created.id.is_some());
assert_eq!(created.title, "Hello World");
assert!(!created.published);
assert!(created.persisted());
let found =
MacroBackedPost::find_sync(created.id.expect("created record should have an id"))
.expect("find_sync should load the inserted record");
assert_eq!(found.title, "Hello World");
assert!(!found.published);
created.title = "Updated".to_owned();
created.published = true;
created
.save_sync()
.expect("save_sync should update persisted records");
let reloaded =
MacroBackedPost::find_sync(created.id.expect("updated record should keep its id"))
.expect("find_sync should load the updated record");
assert_eq!(reloaded.title, "Updated");
assert!(reloaded.published);
assert_eq!(
MacroBackedPost::count_sync().expect("count_sync should work"),
1
);
created
.destroy_sync()
.expect("destroy_sync should delete the row");
assert!(created.destroyed());
assert_eq!(
MacroBackedPost::count_sync().expect("count_sync should work"),
0
);
}
}