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}