hexser/ports/
repository.rs

1//! Repository trait for persistence abstractions.
2//!
3//! Repositories provide a collection-like interface for accessing domain entities.
4//! They abstract the underlying persistence mechanism, allowing the domain layer
5//! to remain independent of infrastructure concerns. Repositories work with
6//! aggregates and typically provide CRUD operations plus domain-specific queries.
7//!
8//! Revision History
9//! - 2025-10-01T00:00:00Z @AI: Initial Repository trait definition with generic entity type.
10//! - 2025-10-06T00:00:00Z @AI: Introduced filter-based generic query API (separate QueryRepository trait), sorting and pagination.
11//! - 2025-10-06T17:22:00Z @AI: Tests: add justifications; remove super import; fully qualify paths per no-use rule.
12//! - 2025-10-07T10:00:00Z @AI: Decouple QueryRepository from ID-centric Repository to enable generic, filter-first repositories.
13//! - 2025-10-07T10:59:00Z @AI: Remove deprecated id-centric methods; focus Repository on save only; update tests for v0.4.
14
15/// Generic query options for fetching collections.
16#[derive(Debug, Clone)]
17pub struct FindOptions<K> {
18    pub sort: Option<Vec<Sort<K>>>,
19    pub limit: Option<u32>,
20    pub offset: Option<u64>,
21}
22
23impl<K> Default for FindOptions<K> {
24    fn default() -> Self {
25        Self { sort: None, limit: None, offset: None }
26    }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Direction {
31    Asc,
32    Desc,
33}
34
35#[derive(Debug, Clone)]
36pub struct Sort<K> {
37    pub key: K,
38    pub direction: Direction,
39}
40
41/// Trait for repository ports that abstract persistence save operations (v0.4+).
42///
43/// Starting in v0.4, id-centric methods were removed in favor of the generic,
44/// filter-based `QueryRepository` API. This trait now focuses solely on the
45/// write-side persistence concern of saving aggregates. For read operations and
46/// deletions by criteria, implement `QueryRepository` on the same adapter.
47///
48/// # Type Parameters
49///
50/// * `T` - The entity type this repository manages (must implement `Entity`)
51pub trait Repository<T>
52where
53    T: crate::domain::entity::Entity,
54{
55    /// Save an entity to the repository.
56    fn save(&mut self, entity: T) -> crate::result::hex_result::HexResult<()>;
57}
58
59/// Generic query-capable repository port for expressive, domain-owned filters.
60pub trait QueryRepository<T>
61where
62    T: crate::domain::entity::Entity,
63{
64    /// Domain-owned filter type the adapter understands.
65    type Filter;
66
67    /// Domain-owned sort key type (e.g., enum of sortable fields).
68    type SortKey;
69
70    /// Fetch a single entity matching a filter (ideally unique).
71    fn find_one(&self, filter: &Self::Filter) -> crate::result::hex_result::HexResult<Option<T>>;
72
73    /// Fetch many entities matching `filter` with optional sort/pagination.
74    fn find(
75        &self,
76        filter: &Self::Filter,
77        options: FindOptions<Self::SortKey>,
78    ) -> crate::result::hex_result::HexResult<Vec<T>>;
79
80    /// Check existence of at least one entity matching `filter`.
81    fn exists(&self, filter: &Self::Filter) -> crate::result::hex_result::HexResult<bool> {
82        Ok(self.find_one(filter)?.is_some())
83    }
84
85    /// Count entities matching `filter`.
86    fn count(&self, filter: &Self::Filter) -> crate::result::hex_result::HexResult<u64> {
87        Ok(self.find(filter, FindOptions::default())?.len() as u64)
88    }
89
90    /// Delete by filter; returns number of removed entities.
91    fn delete_where(&mut self, _filter: &Self::Filter) -> crate::result::hex_result::HexResult<u64> {
92        // Default no-op for backward compatibility in simple adapters.
93        Ok(0)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    // Note: Per NO `use` STATEMENTS rule, tests reference items via fully qualified paths.
100    // This ensures clarity for multi-agent analysis and avoids ambiguous imports.
101
102    #[derive(Clone, Debug)]
103    struct TestEntity {
104        id: u64,
105        name: String,
106    }
107
108    impl crate::domain::entity::Entity for TestEntity {
109        type Id = u64;
110    }
111
112    #[derive(Clone, Debug)]
113    enum TestFilter {
114        ById(u64),
115        NameEquals(String),
116        All,
117        And(Vec<TestFilter>),
118    }
119
120    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
121    enum TestSortKey { Id, Name }
122
123    #[derive(Default)]
124    struct TestRepository {
125        entities: Vec<TestEntity>,
126    }
127
128    impl crate::ports::repository::Repository<TestEntity> for TestRepository {
129        fn save(&mut self, entity: TestEntity) -> crate::result::hex_result::HexResult<()> {
130            if let Some(i) = self.entities.iter().position(|e| e.id == entity.id) {
131                self.entities[i] = entity;
132            } else {
133                self.entities.push(entity);
134            }
135            Ok(())
136        }
137    }
138
139    impl crate::ports::repository::QueryRepository<TestEntity> for TestRepository {
140        type Filter = TestFilter;
141        type SortKey = TestSortKey;
142
143        fn find_one(&self, filter: &Self::Filter) -> crate::result::hex_result::HexResult<Option<TestEntity>> {
144            Ok(self.entities.iter().find(|e| matches_filter(e, filter)).cloned())
145        }
146
147        fn find(
148            &self,
149            filter: &Self::Filter,
150            options: crate::ports::repository::FindOptions<Self::SortKey>,
151        ) -> crate::result::hex_result::HexResult<Vec<TestEntity>> {
152            let mut items: Vec<_> = self
153                .entities
154                .iter()
155                .filter(|e| matches_filter(e, filter))
156                .cloned()
157                .collect();
158
159            if let Some(sorts) = options.sort {
160                for s in sorts.into_iter().rev() {
161                    match (s.key, s.direction) {
162                        (TestSortKey::Id, crate::ports::repository::Direction::Asc) => items.sort_by_key(|e| e.id),
163                        (TestSortKey::Id, crate::ports::repository::Direction::Desc) => items.sort_by_key(|e| std::cmp::Reverse(e.id)),
164                        (TestSortKey::Name, crate::ports::repository::Direction::Asc) => items.sort_by(|a,b| a.name.cmp(&b.name)),
165                        (TestSortKey::Name, crate::ports::repository::Direction::Desc) => items.sort_by(|a,b| b.name.cmp(&a.name)),
166                    }
167                }
168            }
169
170            let offset = options.offset.unwrap_or(0) as usize;
171            let limit = options.limit.map(|l| l as usize).unwrap_or_else(|| items.len().saturating_sub(offset));
172            let end = offset.saturating_add(limit).min(items.len());
173            Ok(items.into_iter().skip(offset).take(end.saturating_sub(offset)).collect())
174        }
175
176        fn delete_where(&mut self, filter: &Self::Filter) -> crate::result::hex_result::HexResult<u64> {
177            let before = self.entities.len();
178            self.entities.retain(|e| !matches_filter(e, filter));
179            Ok((before - self.entities.len()) as u64)
180        }
181    }
182
183    fn matches_filter(e: &TestEntity, f: &TestFilter) -> bool {
184        match f {
185            TestFilter::ById(id) => e.id == *id,
186            TestFilter::NameEquals(n) => &e.name == n,
187            TestFilter::All => true,
188            TestFilter::And(fs) => fs.iter().all(|x| matches_filter(e, x)),
189        }
190    }
191
192    #[test]
193    fn test_repository_save_and_find_new_api() {
194        // Test: Validates new QueryRepository API (find_one/find with sorting & pagination) and legacy compatibility.
195        // Justification: Ensures migration path is safe; verifies filter matching, stable sorting, and paging behavior.
196        let mut repo = TestRepository { entities: Vec::new() };
197        <TestRepository as crate::ports::repository::Repository<TestEntity>>::save(&mut repo, TestEntity { id: 2, name: String::from("B") }).unwrap();
198        <TestRepository as crate::ports::repository::Repository<TestEntity>>::save(&mut repo, TestEntity { id: 1, name: String::from("A") }).unwrap();
199
200        // find_one by filter
201        let found = <TestRepository as crate::ports::repository::QueryRepository<TestEntity>>::find_one(&repo, &TestFilter::ById(1)).unwrap();
202        assert!(found.is_some());
203
204        // find with sort and pagination
205        let opts = crate::ports::repository::FindOptions { sort: Some(vec![crate::ports::repository::Sort { key: TestSortKey::Name, direction: crate::ports::repository::Direction::Asc }]), limit: Some(1), offset: Some(0) };
206        let page = <TestRepository as crate::ports::repository::QueryRepository<TestEntity>>::find(&repo, &TestFilter::All, opts).unwrap();
207        assert_eq!(page.len(), 1);
208        assert_eq!(page[0].name, "A");
209
210        // delete by filter and re-check via QueryRepository
211        let removed = <TestRepository as crate::ports::repository::QueryRepository<TestEntity>>::delete_where(&mut repo, &TestFilter::ById(2)).unwrap();
212        assert_eq!(removed, 1);
213        let none = <TestRepository as crate::ports::repository::QueryRepository<TestEntity>>::find_one(&repo, &TestFilter::ById(2)).unwrap();
214        assert!(none.is_none());
215    }
216}