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#[derive(Debug, thiserror::Error)]
24pub enum RecordError {
25 #[error("record not found")]
27 NotFound,
28 #[error("multiple records found when exactly one was expected")]
30 SoleRecordExceeded,
31 #[error("record invalid: {0}")]
33 Invalid(String),
34 #[error("record not saved")]
36 NotSaved,
37 #[error("record is readonly")]
39 ReadOnlyRecord,
40 #[error("stale object")]
42 StaleObject,
43 #[error("database error: {0}")]
45 Database(#[from] sea_orm::DbErr),
46 #[error("connection error: {0}")]
48 Connection(#[from] ConnectionError),
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum RecordState {
54 #[default]
56 New,
57 Persisted,
59 Destroyed,
61}
62
63pub trait Record: Sized + Send + Sync + 'static {
65 type Entity: EntityTrait;
67
68 fn table_name() -> &'static str;
70
71 fn primary_key_name() -> &'static str {
73 "id"
74 }
75
76 fn id(&self) -> Option<i64>;
78
79 fn record_state(&self) -> RecordState;
81
82 fn set_record_state(&mut self, state: RecordState);
84
85 fn new_record(&self) -> bool {
87 self.record_state() == RecordState::New
88 }
89
90 fn persisted(&self) -> bool {
92 self.record_state() == RecordState::Persisted
93 }
94
95 fn destroyed(&self) -> bool {
97 self.record_state() == RecordState::Destroyed
98 }
99
100 fn from_sea_model(model: <Self::Entity as EntityTrait>::Model) -> Self;
102
103 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 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 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
187 #[serde(deny_unknown_fields)]
188 pub struct TestUser {
189 pub id: Option<i64>,
191 pub name: String,
193 pub email: String,
195 #[serde(skip, default = "default_record_state")]
197 pub state: RecordState,
198 }
199
200 impl TestUser {
201 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 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 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}