Skip to main content

rusticx_core/
repository.rs

1use crate::{
2    adapter::DatabaseAdapter,
3    error::Result,
4    model::{Model, TableSchema},
5    query::{CondOp, QueryBuilder},
6    value::Value,
7};
8use std::sync::Arc;
9
10/// Typed, high-level repository for a single model type `M`.
11///
12/// `Repository` is the main entry point for CRUD operations. It wraps an
13/// [`Arc`]-shared [`DatabaseAdapter`] so multiple repositories (or threads)
14/// can share one connection pool.
15///
16/// # Obtaining a repository
17///
18/// ```rust,ignore
19/// use rusticx::prelude::*;
20/// use std::sync::Arc;
21///
22/// let adapter = PostgresAdapter::connect_url("postgres://localhost/mydb").await?;
23/// let repo: Repository<User, _> = Repository::new(Arc::new(adapter));
24/// ```
25///
26/// # Thread safety
27///
28/// `Repository` is `Clone` and `Send + Sync` — share it freely across tasks
29/// and threads. The underlying connection pool handles concurrency.
30pub struct Repository<M: Model, A: DatabaseAdapter> {
31    adapter: Arc<A>,
32    _phantom: std::marker::PhantomData<M>,
33}
34
35impl<M: Model, A: DatabaseAdapter> Repository<M, A> {
36    pub fn new(adapter: Arc<A>) -> Self {
37        Self { adapter, _phantom: std::marker::PhantomData }
38    }
39
40    pub fn adapter(&self) -> &A {
41        &self.adapter
42    }
43
44    // ── Schema ───────────────────────────────────────────────────────────
45
46    /// Create the table / collection for `M` if it does not already exist.
47    ///
48    /// Safe to call on every startup — it is idempotent (`CREATE TABLE IF NOT EXISTS`).
49    pub async fn migrate(&self) -> Result<()> {
50        let schema = TableSchema::from_model::<M>();
51        self.adapter.create_table(&schema).await
52    }
53
54    // ── CRUD ─────────────────────────────────────────────────────────────
55
56    /// Insert one model instance and return it with any generated fields filled in
57    /// (e.g. database-generated timestamps, auto-increment IDs).
58    pub async fn insert(&self, model: &M) -> Result<M> {
59        let row = model.to_row()?;
60        let inserted = self.adapter.insert(M::table_name(), row).await?;
61        M::from_row(inserted)
62    }
63
64    /// Insert a slice of models in a single transaction. Returns the count inserted.
65    pub async fn insert_many(&self, models: &[M]) -> Result<u64> {
66        let rows: Result<Vec<_>> = models.iter().map(|m| m.to_row()).collect();
67        self.adapter.insert_many(M::table_name(), rows?).await
68    }
69
70    /// Fetch every row in the table. Use [`find`] with a [`QueryBuilder`] for filtering.
71    ///
72    /// **Caution:** can return very large result sets on big tables.
73    ///
74    /// [`find`]: Self::find
75    pub async fn find_all(&self) -> Result<Vec<M>> {
76        let qb = QueryBuilder::table(M::table_name());
77        let rows = self.adapter.find(&qb).await?;
78        rows.into_iter().map(M::from_row).collect()
79    }
80
81    /// Find a single record by primary key. Returns `None` if not found.
82    pub async fn find_by_id(&self, id: impl Into<Value>) -> Result<Option<M>> {
83        let qb = QueryBuilder::table(M::table_name())
84            .r#where(M::primary_key(), CondOp::Eq, id.into());
85        let row = self.adapter.find_one(&qb).await?;
86        row.map(M::from_row).transpose()
87    }
88
89    /// Find the first record matching the given query. Returns `None` if no match.
90    pub async fn find_one(&self, qb: QueryBuilder) -> Result<Option<M>> {
91        let row = self.adapter.find_one(&qb).await?;
92        row.map(M::from_row).transpose()
93    }
94
95    /// Find all records matching the given query.
96    ///
97    /// Build the query with [`Repository::query`] for ergonomic chaining:
98    ///
99    /// ```rust,ignore
100    /// let results = repo.find(
101    ///     repo.query()
102    ///         .r#where("age", CondOp::Gte, 18)
103    ///         .order_by("name", Direction::Asc)
104    ///         .limit(50)
105    /// ).await?;
106    /// ```
107    pub async fn find(&self, qb: QueryBuilder) -> Result<Vec<M>> {
108        let rows = self.adapter.find(&qb).await?;
109        rows.into_iter().map(M::from_row).collect()
110    }
111
112    /// Fetch one page of results. `page` is 1-indexed.
113    ///
114    /// ```rust,ignore
115    /// // Page 2, 20 records per page
116    /// let page = repo.paginate(2, 20).await?;
117    /// ```
118    pub async fn paginate(&self, page: u64, per_page: u64) -> Result<Vec<M>> {
119        let qb = QueryBuilder::table(M::table_name())
120            .limit(per_page)
121            .offset((page.saturating_sub(1)) * per_page);
122        let rows = self.adapter.find(&qb).await?;
123        rows.into_iter().map(M::from_row).collect()
124    }
125
126    /// Count records. Pass `None` to count all rows, or `Some(qb)` to count
127    /// only rows matching the query.
128    pub async fn count(&self, qb: Option<QueryBuilder>) -> Result<u64> {
129        let qb = qb.unwrap_or_else(|| QueryBuilder::table(M::table_name()));
130        self.adapter.count(&qb).await
131    }
132
133    /// Update rows matching the query. Returns the number of rows affected.
134    ///
135    /// Use `.set(column, value)` on the query builder to specify new values:
136    ///
137    /// ```rust,ignore
138    /// repo.update(
139    ///     repo.query()
140    ///         .r#where("email", CondOp::Eq, "alice@example.com")
141    ///         .set("age", 31)
142    ///         .set("active", false)
143    /// ).await?;
144    /// ```
145    pub async fn update(&self, qb: QueryBuilder) -> Result<u64> {
146        self.adapter.update(&qb).await
147    }
148
149    /// Upsert: insert if the primary key is null/absent, update if it is set.
150    ///
151    /// This is the idiomatic way to persist a model without knowing whether it
152    /// already exists in the database.
153    pub async fn save(&self, model: &M) -> Result<M> {
154        let pk = model.pk_value();
155        match pk {
156            Ok(Value::Null) | Err(_) => self.insert(model).await,
157            Ok(pk_val) => {
158                let row = model.to_row()?;
159                let mut qb = QueryBuilder::table(M::table_name())
160                    .r#where(M::primary_key(), CondOp::Eq, pk_val)
161                    .operation(crate::query::Operation::Update);
162                for (col, val) in row {
163                    if col != M::primary_key() {
164                        qb = qb.set(col, val);
165                    }
166                }
167                self.adapter.update(&qb).await?;
168                self.find_by_id(model.pk_value()?).await?.ok_or_else(|| {
169                    crate::error::RusticxError::NotFound("record after save".to_owned())
170                })
171            }
172        }
173    }
174
175    /// Delete the record with the given primary key. Returns 1 if deleted, 0 if not found.
176    pub async fn delete_by_id(&self, id: impl Into<Value>) -> Result<u64> {
177        let qb = QueryBuilder::table(M::table_name())
178            .r#where(M::primary_key(), CondOp::Eq, id.into())
179            .operation(crate::query::Operation::Delete);
180        self.adapter.delete(&qb).await
181    }
182
183    /// Delete all records matching the query. Returns the count deleted.
184    pub async fn delete(&self, qb: QueryBuilder) -> Result<u64> {
185        self.adapter.delete(&qb).await
186    }
187
188    /// Start building a query pre-scoped to this model's table.
189    ///
190    /// This is the idiomatic starting point for all filtered operations:
191    ///
192    /// ```rust,ignore
193    /// repo.find(repo.query().r#where("active", CondOp::Eq, true)).await?
194    /// ```
195    pub fn query(&self) -> QueryBuilder {
196        QueryBuilder::table(M::table_name())
197    }
198}