acton-service 0.23.0

Production-ready Rust backend framework with type-enforced API versioning
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
//! Repository trait definitions
//!
//! This module provides generic traits for database CRUD operations using RPITIT
//! (Return Position Impl Trait In Traits), available since Rust 1.75.
//!
//! # Overview
//!
//! - [`Repository`]: Base trait for standard CRUD operations
//! - [`SoftDeleteRepository`]: Extended trait for soft delete support
//! - [`RelationLoader`]: Trait for eager loading relationships (N+1 prevention)
//!
//! # Example
//!
//! ```rust,ignore
//! use acton_service::repository::{
//!     FilterCondition, OrderDirection, Pagination, Repository, RepositoryResult,
//! };
//!
//! struct UserRepository {
//!     pool: PgPool,
//! }
//!
//! impl Repository<UserId, User, CreateUser, UpdateUser> for UserRepository {
//!     async fn find_by_id(&self, id: &UserId) -> RepositoryResult<Option<User>> {
//!         sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id.as_str())
//!             .fetch_optional(&self.pool)
//!             .await
//!             .map_err(|e| e.into())
//!     }
//!     // ... other methods
//! }
//! ```

use std::collections::HashMap;
use std::future::Future;
use std::hash::Hash;

use super::error::RepositoryError;
use super::pagination::{FilterCondition, OrderDirection, Pagination};

/// Result type for repository operations
pub type RepositoryResult<T> = std::result::Result<T, RepositoryError>;

/// Base repository trait for CRUD operations
///
/// This trait defines the standard operations for working with entities in a database.
/// It uses Rust 1.75+ RPITIT (Return Position Impl Trait In Traits) for ergonomic async
/// trait methods without requiring `async_trait`.
///
/// # Type Parameters
///
/// - `Id`: The identifier type for the entity (e.g., `UserId`, `Uuid`, `i64`)
/// - `Entity`: The full entity type returned from queries
/// - `Create`: The data transfer object for creating new entities
/// - `Update`: The data transfer object for updating existing entities
///
/// # Example
///
/// ```rust,ignore
/// use acton_service::repository::{Repository, RepositoryResult};
///
/// struct UserRepository { /* ... */ }
///
/// impl Repository<UserId, User, CreateUser, UpdateUser> for UserRepository {
///     async fn find_by_id(&self, id: &UserId) -> RepositoryResult<Option<User>> {
///         // Implementation
///         todo!()
///     }
///
///     async fn find_all(
///         &self,
///         filters: &[FilterCondition],
///         order_by: Option<(&str, OrderDirection)>,
///         pagination: Option<Pagination>,
///     ) -> RepositoryResult<Vec<User>> {
///         // Implementation
///         todo!()
///     }
///
///     // ... other required methods
/// }
/// ```
pub trait Repository<Id, Entity, Create, Update>: Send + Sync {
    /// Find an entity by its unique identifier
    ///
    /// Returns `Ok(Some(entity))` if found, `Ok(None)` if not found.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// let user = repo.find_by_id(&user_id).await?;
    /// match user {
    ///     Some(u) => println!("Found user: {}", u.name),
    ///     None => println!("User not found"),
    /// }
    /// ```
    fn find_by_id(&self, id: &Id) -> impl Future<Output = RepositoryResult<Option<Entity>>> + Send;

    /// Find all entities matching the given filters
    ///
    /// Supports filtering, ordering, and pagination.
    ///
    /// # Arguments
    ///
    /// - `filters`: Zero or more filter conditions to apply
    /// - `order_by`: Optional tuple of (field_name, direction)
    /// - `pagination`: Optional pagination parameters
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// let filters = vec![
    ///     FilterCondition::eq("status", "active"),
    ///     FilterCondition::gte("age", 18),
    /// ];
    /// let users = repo.find_all(
    ///     &filters,
    ///     Some(("created_at", OrderDirection::Descending)),
    ///     Some(Pagination::first_page(20)),
    /// ).await?;
    /// ```
    fn find_all(
        &self,
        filters: &[FilterCondition],
        order_by: Option<(&str, OrderDirection)>,
        pagination: Option<Pagination>,
    ) -> impl Future<Output = RepositoryResult<Vec<Entity>>> + Send;

    /// Count entities matching the given filters
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// let active_count = repo.count(&[FilterCondition::eq("status", "active")]).await?;
    /// println!("Active users: {}", active_count);
    /// ```
    fn count(
        &self,
        filters: &[FilterCondition],
    ) -> impl Future<Output = RepositoryResult<u64>> + Send;

    /// Check if an entity exists by its identifier
    ///
    /// More efficient than `find_by_id` when you only need to check existence.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// if repo.exists(&user_id).await? {
    ///     println!("User exists");
    /// }
    /// ```
    fn exists(&self, id: &Id) -> impl Future<Output = RepositoryResult<bool>> + Send;

    /// Create a new entity
    ///
    /// Returns the created entity with any generated fields (e.g., ID, timestamps).
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// let new_user = CreateUser {
    ///     name: "Alice".to_string(),
    ///     email: "alice@example.com".to_string(),
    /// };
    /// let created = repo.create(new_user).await?;
    /// println!("Created user with ID: {}", created.id);
    /// ```
    fn create(&self, data: Create) -> impl Future<Output = RepositoryResult<Entity>> + Send;

    /// Update an existing entity
    ///
    /// Returns the updated entity.
    ///
    /// # Errors
    ///
    /// Returns `RepositoryError` with `NotFound` kind if the entity doesn't exist.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// let update = UpdateUser {
    ///     name: Some("Alice Smith".to_string()),
    ///     email: None, // Don't update email
    /// };
    /// let updated = repo.update(&user_id, update).await?;
    /// ```
    fn update(
        &self,
        id: &Id,
        data: Update,
    ) -> impl Future<Output = RepositoryResult<Entity>> + Send;

    /// Delete an entity by its identifier (hard delete)
    ///
    /// Returns `true` if the entity was deleted, `false` if it didn't exist.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// let was_deleted = repo.delete(&user_id).await?;
    /// if was_deleted {
    ///     println!("User deleted");
    /// }
    /// ```
    fn delete(&self, id: &Id) -> impl Future<Output = RepositoryResult<bool>> + Send;
}

/// Extended repository trait for soft delete support
///
/// Soft delete is useful for GDPR compliance, audit trails, and data recovery.
/// Entities are marked as deleted rather than being removed from the database.
///
/// # Type Parameters
///
/// Same as [`Repository`].
///
/// # Example
///
/// ```rust,ignore
/// use acton_service::repository::{SoftDeleteRepository, Repository};
///
/// impl SoftDeleteRepository<UserId, User, CreateUser, UpdateUser> for UserRepository {
///     async fn soft_delete(&self, id: &UserId) -> RepositoryResult<bool> {
///         let affected = sqlx::query!(
///             "UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
///             id.as_str()
///         )
///         .execute(&self.pool)
///         .await?
///         .rows_affected();
///         Ok(affected > 0)
///     }
///     // ... other methods
/// }
/// ```
pub trait SoftDeleteRepository<Id, Entity, Create, Update>:
    Repository<Id, Entity, Create, Update>
{
    /// Mark an entity as deleted without removing it from the database
    ///
    /// Returns `true` if the entity was soft-deleted, `false` if not found.
    fn soft_delete(&self, id: &Id) -> impl Future<Output = RepositoryResult<bool>> + Send;

    /// Restore a soft-deleted entity
    ///
    /// Returns `true` if the entity was restored, `false` if not found or not deleted.
    fn restore(&self, id: &Id) -> impl Future<Output = RepositoryResult<bool>> + Send;

    /// Find all entities including soft-deleted ones
    ///
    /// Useful for admin interfaces or audit views.
    fn find_with_deleted(
        &self,
        filters: &[FilterCondition],
        order_by: Option<(&str, OrderDirection)>,
        pagination: Option<Pagination>,
    ) -> impl Future<Output = RepositoryResult<Vec<Entity>>> + Send;

    /// Find only soft-deleted entities
    ///
    /// Useful for "trash" or "recycle bin" views.
    fn find_deleted(
        &self,
        filters: &[FilterCondition],
        order_by: Option<(&str, OrderDirection)>,
        pagination: Option<Pagination>,
    ) -> impl Future<Output = RepositoryResult<Vec<Entity>>> + Send;

    /// Permanently delete a soft-deleted entity
    ///
    /// This is a hard delete that removes the entity from the database entirely.
    /// Returns `true` if the entity was deleted, `false` if not found.
    fn force_delete(&self, id: &Id) -> impl Future<Output = RepositoryResult<bool>> + Send;
}

/// Trait for eager loading relationships (N+1 prevention)
///
/// This trait enables efficient batch loading of related entities,
/// preventing the N+1 query problem common in ORM usage.
///
/// # Type Parameters
///
/// - `Entity`: The parent entity type
/// - `RelatedId`: The identifier type for the related entity
/// - `Related`: The related entity type
///
/// # Example
///
/// ```rust,ignore
/// use acton_service::repository::{RelationLoader, RepositoryResult};
///
/// struct UserOrdersLoader {
///     pool: PgPool,
/// }
///
/// impl RelationLoader<User, OrderId, Order> for UserOrdersLoader {
///     async fn load_one(&self, entity: &User) -> RepositoryResult<Option<Order>> {
///         // Load the most recent order for a user
///         sqlx::query_as!(Order,
///             "SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1",
///             entity.id.as_str()
///         )
///         .fetch_optional(&self.pool)
///         .await
///         .map_err(Into::into)
///     }
///
///     async fn load_many(&self, entity: &User) -> RepositoryResult<Vec<Order>> {
///         // Load all orders for a user
///         sqlx::query_as!(Order,
///             "SELECT * FROM orders WHERE user_id = $1",
///             entity.id.as_str()
///         )
///         .fetch_all(&self.pool)
///         .await
///         .map_err(Into::into)
///     }
///
///     async fn batch_load(
///         &self,
///         ids: &[OrderId],
///     ) -> RepositoryResult<HashMap<OrderId, Order>>
///     where
///         Order: Clone,
///         OrderId: Clone,
///     {
///         // Batch load orders by ID
///         let orders = sqlx::query_as!(Order,
///             "SELECT * FROM orders WHERE id = ANY($1)",
///             &ids.iter().map(|id| id.as_str()).collect::<Vec<_>>()
///         )
///         .fetch_all(&self.pool)
///         .await?;
///
///         Ok(orders.into_iter().map(|o| (o.id.clone(), o)).collect())
///     }
/// }
/// ```
pub trait RelationLoader<Entity, RelatedId, Related>: Send + Sync
where
    RelatedId: Eq + Hash,
{
    /// Load a single related entity for the given parent
    ///
    /// Returns `None` if no related entity exists.
    fn load_one(
        &self,
        entity: &Entity,
    ) -> impl Future<Output = RepositoryResult<Option<Related>>> + Send;

    /// Load multiple related entities for the given parent
    ///
    /// Returns an empty vector if no related entities exist.
    fn load_many(
        &self,
        entity: &Entity,
    ) -> impl Future<Output = RepositoryResult<Vec<Related>>> + Send;

    /// Batch load related entities by their IDs
    ///
    /// This is the key method for preventing N+1 queries. Instead of loading
    /// related entities one at a time, collect all needed IDs and load them
    /// in a single query.
    ///
    /// Returns a map from ID to entity for efficient lookup.
    fn batch_load(
        &self,
        ids: &[RelatedId],
    ) -> impl Future<Output = RepositoryResult<HashMap<RelatedId, Related>>> + Send
    where
        Related: Clone,
        RelatedId: Clone;
}

#[cfg(test)]
mod tests {
    use super::*;

    // Compile-time tests to ensure traits can be object-safe and implemented
    // Note: We can't make the traits object-safe due to RPITIT, but we can
    // verify they compile correctly.

    #[test]
    fn test_repository_result_type() {
        // Verify RepositoryResult type alias works correctly
        let ok_result: RepositoryResult<i32> = Ok(42);
        assert!(ok_result.is_ok());

        let err_result: RepositoryResult<i32> = Err(
            super::super::error::RepositoryError::not_found("Test", "123"),
        );
        assert!(err_result.is_err());
    }

    // The following tests verify that the trait bounds compile correctly.
    // Actual implementations would be tested in integration tests.

    struct MockId(String);
    struct MockEntity {
        id: String,
    }
    struct MockCreate {
        name: String,
    }
    struct MockUpdate {
        name: Option<String>,
    }

    // This test verifies the trait can be implemented
    struct MockRepository;

    impl Repository<MockId, MockEntity, MockCreate, MockUpdate> for MockRepository {
        async fn find_by_id(&self, _id: &MockId) -> RepositoryResult<Option<MockEntity>> {
            Ok(None)
        }

        async fn find_all(
            &self,
            _filters: &[FilterCondition],
            _order_by: Option<(&str, OrderDirection)>,
            _pagination: Option<Pagination>,
        ) -> RepositoryResult<Vec<MockEntity>> {
            Ok(vec![])
        }

        async fn count(&self, _filters: &[FilterCondition]) -> RepositoryResult<u64> {
            Ok(0)
        }

        async fn exists(&self, _id: &MockId) -> RepositoryResult<bool> {
            Ok(false)
        }

        async fn create(&self, data: MockCreate) -> RepositoryResult<MockEntity> {
            Ok(MockEntity { id: data.name })
        }

        async fn update(&self, id: &MockId, data: MockUpdate) -> RepositoryResult<MockEntity> {
            Ok(MockEntity {
                id: data.name.unwrap_or_else(|| id.0.clone()),
            })
        }

        async fn delete(&self, _id: &MockId) -> RepositoryResult<bool> {
            Ok(true)
        }
    }

    impl SoftDeleteRepository<MockId, MockEntity, MockCreate, MockUpdate> for MockRepository {
        async fn soft_delete(&self, _id: &MockId) -> RepositoryResult<bool> {
            Ok(true)
        }

        async fn restore(&self, _id: &MockId) -> RepositoryResult<bool> {
            Ok(true)
        }

        async fn find_with_deleted(
            &self,
            _filters: &[FilterCondition],
            _order_by: Option<(&str, OrderDirection)>,
            _pagination: Option<Pagination>,
        ) -> RepositoryResult<Vec<MockEntity>> {
            Ok(vec![])
        }

        async fn find_deleted(
            &self,
            _filters: &[FilterCondition],
            _order_by: Option<(&str, OrderDirection)>,
            _pagination: Option<Pagination>,
        ) -> RepositoryResult<Vec<MockEntity>> {
            Ok(vec![])
        }

        async fn force_delete(&self, _id: &MockId) -> RepositoryResult<bool> {
            Ok(true)
        }
    }

    #[derive(Clone, Debug, PartialEq, Eq, Hash)]
    struct RelatedId(String);

    #[derive(Clone)]
    struct RelatedEntity {
        id: RelatedId,
    }

    struct MockRelationLoader;

    impl RelationLoader<MockEntity, RelatedId, RelatedEntity> for MockRelationLoader {
        async fn load_one(&self, _entity: &MockEntity) -> RepositoryResult<Option<RelatedEntity>> {
            Ok(None)
        }

        async fn load_many(&self, _entity: &MockEntity) -> RepositoryResult<Vec<RelatedEntity>> {
            Ok(vec![])
        }

        async fn batch_load(
            &self,
            ids: &[RelatedId],
        ) -> RepositoryResult<HashMap<RelatedId, RelatedEntity>>
        where
            RelatedEntity: Clone,
            RelatedId: Clone,
        {
            Ok(ids
                .iter()
                .map(|id| (id.clone(), RelatedEntity { id: id.clone() }))
                .collect())
        }
    }

    #[tokio::test]
    async fn test_mock_repository_find_by_id() {
        let repo = MockRepository;
        let result = repo.find_by_id(&MockId("test".to_string())).await;
        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[tokio::test]
    async fn test_mock_repository_create() {
        let repo = MockRepository;
        let result = repo
            .create(MockCreate {
                name: "test".to_string(),
            })
            .await;
        assert!(result.is_ok());
        assert_eq!(result.unwrap().id, "test");
    }

    #[tokio::test]
    async fn test_mock_soft_delete_repository() {
        let repo = MockRepository;
        let result = repo.soft_delete(&MockId("test".to_string())).await;
        assert!(result.is_ok());
        assert!(result.unwrap());
    }

    #[tokio::test]
    async fn test_mock_relation_loader_batch() {
        let loader = MockRelationLoader;
        let ids = vec![RelatedId("1".to_string()), RelatedId("2".to_string())];
        let result = loader.batch_load(&ids).await;
        assert!(result.is_ok());
        let map = result.unwrap();
        assert_eq!(map.len(), 2);
        let one = RelatedId("1".to_string());
        let two = RelatedId("2".to_string());
        assert!(map.contains_key(&one));
        assert!(map.contains_key(&two));
        // Confirm RelatedEntity.id round-trips through batch_load.
        assert_eq!(map.get(&one).unwrap().id, one);
        assert_eq!(map.get(&two).unwrap().id, two);
    }
}