Skip to main content

rustrails_record/
base.rs

1use rustrails_macros::{__ModelRecordState, ModelDefinition};
2use sea_orm::{ActiveModelTrait, EntityTrait};
3
4use crate::connection::ConnectionError;
5
6fn from_model_state(state: __ModelRecordState) -> RecordState {
7    match state {
8        __ModelRecordState::New => RecordState::New,
9        __ModelRecordState::Persisted => RecordState::Persisted,
10        __ModelRecordState::Destroyed => RecordState::Destroyed,
11    }
12}
13
14fn to_model_state(state: RecordState) -> __ModelRecordState {
15    match state {
16        RecordState::New => __ModelRecordState::New,
17        RecordState::Persisted => __ModelRecordState::Persisted,
18        RecordState::Destroyed => __ModelRecordState::Destroyed,
19    }
20}
21
22/// Errors returned by record operations.
23#[derive(Debug, thiserror::Error)]
24pub enum RecordError {
25    /// Raised when a lookup cannot find a matching row.
26    #[error("record not found")]
27    NotFound,
28    /// Raised when a query expected exactly one row but found more than one.
29    #[error("multiple records found when exactly one was expected")]
30    SoleRecordExceeded,
31    /// Raised when caller-provided attributes are invalid.
32    #[error("record invalid: {0}")]
33    Invalid(String),
34    /// Raised when a record cannot be saved in its current state.
35    #[error("record not saved")]
36    NotSaved,
37    /// Raised when persistence is attempted on a readonly record.
38    #[error("record is readonly")]
39    ReadOnlyRecord,
40    /// Raised when a stale object is detected during persistence.
41    #[error("stale object")]
42    StaleObject,
43    /// Wraps SeaORM database errors.
44    #[error("database error: {0}")]
45    Database(#[from] sea_orm::DbErr),
46    /// Wraps connection-management errors.
47    #[error("connection error: {0}")]
48    Connection(#[from] ConnectionError),
49}
50
51/// Tracks whether a record has been persisted or destroyed.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum RecordState {
54    /// The record has not been inserted yet.
55    #[default]
56    New,
57    /// The record exists in the database.
58    Persisted,
59    /// The record has been deleted.
60    Destroyed,
61}
62
63/// Bridges a RustRails record wrapper to its SeaORM entity.
64pub trait Record: Sized + Send + Sync + 'static {
65    /// The SeaORM entity backing this record type.
66    type Entity: EntityTrait;
67
68    /// Returns the database table name.
69    fn table_name() -> &'static str;
70
71    /// Returns the primary key column name.
72    fn primary_key_name() -> &'static str {
73        "id"
74    }
75
76    /// Returns the primary key value when present.
77    fn id(&self) -> Option<i64>;
78
79    /// Returns the current lifecycle state.
80    fn record_state(&self) -> RecordState;
81
82    /// Updates the lifecycle state.
83    fn set_record_state(&mut self, state: RecordState);
84
85    /// Returns `true` when the record has not been persisted yet.
86    fn new_record(&self) -> bool {
87        self.record_state() == RecordState::New
88    }
89
90    /// Returns `true` when the record exists in the database.
91    fn persisted(&self) -> bool {
92        self.record_state() == RecordState::Persisted
93    }
94
95    /// Returns `true` when the record has been destroyed.
96    fn destroyed(&self) -> bool {
97        self.record_state() == RecordState::Destroyed
98    }
99
100    /// Builds the record wrapper from a SeaORM model.
101    fn from_sea_model(model: <Self::Entity as EntityTrait>::Model) -> Self;
102
103    /// Converts the record wrapper into a SeaORM active model.
104    fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel
105    where
106        <Self::Entity as EntityTrait>::ActiveModel: ActiveModelTrait;
107}
108
109impl<T: ModelDefinition> Record for T
110where
111    <T::SeaEntity as EntityTrait>::ActiveModel: ActiveModelTrait,
112{
113    type Entity = T::SeaEntity;
114
115    fn table_name() -> &'static str {
116        T::table_name()
117    }
118
119    fn id(&self) -> Option<i64> {
120        self.get_id()
121    }
122
123    fn record_state(&self) -> RecordState {
124        from_model_state(self.get_record_state())
125    }
126
127    fn set_record_state(&mut self, state: RecordState) {
128        self.set_model_record_state(to_model_state(state));
129    }
130
131    fn from_sea_model(model: <T::SeaEntity as EntityTrait>::Model) -> Self {
132        T::from_sea_model(model)
133    }
134
135    fn to_active_model(&self) -> <T::SeaEntity as EntityTrait>::ActiveModel {
136        self.to_sea_active_model()
137    }
138}
139
140impl<T: ModelDefinition> crate::querying::AsyncQuerying for T where
141    <T::SeaEntity as EntityTrait>::ActiveModel: ActiveModelTrait
142{
143}
144
145impl<T: ModelDefinition> crate::persistence::AsyncPersistence for T where
146    <T::SeaEntity as EntityTrait>::ActiveModel: ActiveModelTrait
147{
148}
149
150#[cfg(test)]
151pub(crate) mod test_support {
152    use sea_orm::entity::prelude::*;
153    use sea_orm::{
154        ActiveValue::NotSet, ActiveValue::Set, ConnectionTrait, Database, DatabaseConnection,
155        Schema,
156    };
157    use serde::{Deserialize, Serialize};
158
159    use super::{Record, RecordState};
160    use crate::{persistence::AsyncPersistence, querying::AsyncQuerying};
161
162    /// SeaORM test entity used across record-module tests.
163    pub mod test_user {
164        use sea_orm::entity::prelude::*;
165
166        #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
167        #[sea_orm(table_name = "test_users")]
168        pub struct Model {
169            #[sea_orm(primary_key)]
170            pub id: i32,
171            pub name: String,
172            pub email: String,
173        }
174
175        #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
176        pub enum Relation {}
177
178        impl ActiveModelBehavior for ActiveModel {}
179    }
180
181    fn default_record_state() -> RecordState {
182        RecordState::New
183    }
184
185    /// Concrete test record used to exercise generic record behavior.
186    #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
187    #[serde(deny_unknown_fields)]
188    pub struct TestUser {
189        /// Primary key.
190        pub id: Option<i64>,
191        /// Display name.
192        pub name: String,
193        /// Email address.
194        pub email: String,
195        /// Lifecycle state tracked outside serialized attributes.
196        #[serde(skip, default = "default_record_state")]
197        pub state: RecordState,
198    }
199
200    impl TestUser {
201        /// Creates a persisted test user value.
202        pub fn persisted(id: i64, name: &str, email: &str) -> Self {
203            Self {
204                id: Some(id),
205                name: name.to_owned(),
206                email: email.to_owned(),
207                state: RecordState::Persisted,
208            }
209        }
210    }
211
212    impl Record for TestUser {
213        type Entity = test_user::Entity;
214
215        fn table_name() -> &'static str {
216            "test_users"
217        }
218
219        fn id(&self) -> Option<i64> {
220            self.id
221        }
222
223        fn record_state(&self) -> RecordState {
224            self.state
225        }
226
227        fn set_record_state(&mut self, state: RecordState) {
228            self.state = state;
229        }
230
231        fn from_sea_model(model: <Self::Entity as EntityTrait>::Model) -> Self {
232            Self {
233                id: Some(i64::from(model.id)),
234                name: model.name,
235                email: model.email,
236                state: RecordState::Persisted,
237            }
238        }
239
240        fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel {
241            test_user::ActiveModel {
242                id: match self.id.and_then(|value| i32::try_from(value).ok()) {
243                    Some(value) => Set(value),
244                    None => NotSet,
245                },
246                name: Set(self.name.clone()),
247                email: Set(self.email.clone()),
248            }
249        }
250    }
251
252    impl AsyncPersistence for TestUser {}
253    impl AsyncQuerying for TestUser {}
254
255    /// Creates an in-memory SQLite database with the test table.
256    pub async fn setup_db() -> DatabaseConnection {
257        let db = Database::connect("sqlite::memory:")
258            .await
259            .expect("in-memory sqlite connection should succeed");
260        let backend = db.get_database_backend();
261        let schema = Schema::new(backend);
262        db.execute(&schema.create_table_from_entity(test_user::Entity))
263            .await
264            .expect("test_users table should be created");
265        db
266    }
267
268    pub fn with_sync_test_user_db(test: impl FnOnce() + Send + 'static) {
269        rustrails_support::testing::run_sync_db_test(|| {
270            rustrails_support::runtime::block_on(async {
271                let db = rustrails_support::database::db();
272                let schema = Schema::new(db.get_database_backend());
273                db.execute(&schema.create_table_from_entity(test_user::Entity))
274                    .await
275                    .expect("test_users table should be created");
276            });
277            test();
278        });
279    }
280
281    /// Inserts three ordered fixture rows and returns their record wrappers.
282    pub async fn seed_users(db: &DatabaseConnection) -> Vec<TestUser> {
283        let mut users = Vec::new();
284        for (name, email) in [
285            ("Alice", "alice@example.com"),
286            ("Bob", "bob@example.com"),
287            ("Carol", "carol@example.com"),
288        ] {
289            let model = test_user::ActiveModel {
290                name: Set(name.to_owned()),
291                email: Set(email.to_owned()),
292                ..Default::default()
293            }
294            .insert(db)
295            .await
296            .expect("fixture insert should succeed");
297            users.push(TestUser::from_sea_model(model));
298        }
299        users
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use std::collections::HashMap;
306
307    use super::{Record, RecordState};
308    use crate::{
309        Persistence, Querying,
310        base::test_support::{TestUser, test_user},
311    };
312    use rustrails_macros::model;
313    use rustrails_support::{database, runtime};
314    use serde_json::json;
315
316    model! {
317        MacroBackedPost {
318            title: String,
319            published: bool,
320        }
321        table_name: "macro_backed_posts";
322    }
323
324    #[test]
325    fn macro_backed_models_implement_record_and_sync_traits() {
326        fn assert_record<T: Record>() {}
327        fn assert_querying<T: Querying>() {}
328        fn assert_persistence<T: Persistence>() {}
329
330        assert_record::<MacroBackedPost>();
331        assert_querying::<MacroBackedPost>();
332        assert_persistence::<MacroBackedPost>();
333    }
334
335    #[test]
336    fn state_helpers_match_record_state() {
337        let mut user = TestUser::default();
338        assert!(user.new_record());
339        assert!(!user.persisted());
340        assert!(!user.destroyed());
341
342        user.set_record_state(RecordState::Persisted);
343        assert!(!user.new_record());
344        assert!(user.persisted());
345        assert!(!user.destroyed());
346
347        user.set_record_state(RecordState::Destroyed);
348        assert!(!user.new_record());
349        assert!(!user.persisted());
350        assert!(user.destroyed());
351    }
352
353    #[test]
354    fn primary_key_name_defaults_to_id() {
355        assert_eq!(TestUser::primary_key_name(), "id");
356    }
357
358    #[test]
359    fn table_name_matches_entity_mapping() {
360        assert_eq!(TestUser::table_name(), "test_users");
361    }
362
363    #[test]
364    fn from_sea_model_marks_record_persisted() {
365        let record = TestUser::from_sea_model(test_user::Model {
366            id: 7,
367            name: "Alice".to_owned(),
368            email: "alice@example.com".to_owned(),
369        });
370
371        assert_eq!(record.id(), Some(7));
372        assert_eq!(record.name, "Alice");
373        assert_eq!(record.email, "alice@example.com");
374        assert_eq!(record.record_state(), RecordState::Persisted);
375    }
376
377    #[test]
378    fn to_active_model_preserves_attributes() {
379        let user = TestUser::persisted(11, "Bob", "bob@example.com");
380        let active = user.to_active_model();
381
382        assert_eq!(active.id, sea_orm::ActiveValue::Set(11));
383        assert_eq!(active.name, sea_orm::ActiveValue::Set("Bob".to_owned()));
384        assert_eq!(
385            active.email,
386            sea_orm::ActiveValue::Set("bob@example.com".to_owned())
387        );
388    }
389
390    #[test]
391    fn macro_backed_model_supports_sync_crud_cycle() {
392        let _runtime = runtime::init_runtime();
393        database::establish("sqlite::memory:").expect("sqlite in-memory connection should succeed");
394
395        runtime::block_on(async {
396            let db = database::db();
397            use sea_orm::ConnectionTrait;
398            db.execute_unprepared(
399                "CREATE TABLE macro_backed_posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, published BOOLEAN NOT NULL DEFAULT 0)",
400            )
401            .await
402            .expect("macro_backed_posts table should be created");
403        });
404
405        let mut created = MacroBackedPost::create_sync(HashMap::from([
406            ("title".to_owned(), json!("Hello World")),
407            ("published".to_owned(), json!(false)),
408        ]))
409        .expect("create_sync should persist the record");
410
411        assert!(created.id.is_some());
412        assert_eq!(created.title, "Hello World");
413        assert!(!created.published);
414        assert!(created.persisted());
415
416        let found =
417            MacroBackedPost::find_sync(created.id.expect("created record should have an id"))
418                .expect("find_sync should load the inserted record");
419        assert_eq!(found.title, "Hello World");
420        assert!(!found.published);
421
422        created.title = "Updated".to_owned();
423        created.published = true;
424        created
425            .save_sync()
426            .expect("save_sync should update persisted records");
427
428        let reloaded =
429            MacroBackedPost::find_sync(created.id.expect("updated record should keep its id"))
430                .expect("find_sync should load the updated record");
431        assert_eq!(reloaded.title, "Updated");
432        assert!(reloaded.published);
433        assert_eq!(
434            MacroBackedPost::count_sync().expect("count_sync should work"),
435            1
436        );
437
438        created
439            .destroy_sync()
440            .expect("destroy_sync should delete the row");
441        assert!(created.destroyed());
442        assert_eq!(
443            MacroBackedPost::count_sync().expect("count_sync should work"),
444            0
445        );
446    }
447}