Skip to main content

rainier_database/
repository.rs

1//! Repositories — the [`Repository`] contract and its generic implementation.
2//!
3//! Rainier ORM already collapses "a repository per engine per table" to zero
4//! hand-written repositories. This layer adds the two things a *service* wants
5//! on top of generic CRUD:
6//!
7//! 1. **A contract to depend on.** A controller takes
8//!    `Arc<dyn Repository<Post>>`, so it can be handed the real thing in
9//!    production and a fake in a test, with neither knowing about the other.
10//! 2. **Model lifecycle hooks.** Writes dispatch `Creating`/`Created`/… through
11//!    the event bus — where audit logs, cache busting and search indexing hang
12//!    off without the repository knowing they exist.
13//!
14//! [`EntityRepository`] implements the contract for *any* [`Model`], so
15//! declaring a repository is still no code:
16//!
17//! ```ignore
18//! let posts = EntityRepository::<Post>::new(db.clone()).with_events(events.clone());
19//! let page = posts.paginate(1, 20).await?;
20//! ```
21
22use std::marker::PhantomData;
23use std::sync::Arc;
24
25use rainier_events::Dispatcher;
26use rainier_orm::sea_query::Value;
27use rainier_support::{Error, Result};
28
29use crate::connection::Database;
30use crate::criteria::Criteria;
31use crate::model::{Created, Creating, Deleted, Deleting, Model, Updated, Updating};
32use crate::pagination::Paginated;
33use crate::relation::{PivotQuery, RelationKey};
34use crate::row::Cell;
35use crate::statement;
36
37/// Read and write access to one model's rows.
38///
39/// Object-safe: keys and values arrive as [`Value`] rather than through
40/// generic parameters, so `Arc<dyn Repository<Post>>` is a usable dependency.
41/// The ergonomic generic wrappers are provided methods bounded on
42/// `Self: Sized`, which keeps them out of the vtable.
43#[async_trait::async_trait]
44pub trait Repository<M: Model>: Send + Sync + 'static {
45    /// Every row. Prefer [`paginate`](Self::paginate) for anything unbounded.
46    async fn all(&self) -> Result<Vec<M>>;
47
48    /// The row with this primary key.
49    async fn find(&self, key: Value) -> Result<Option<M>>;
50
51    /// Every row where `column` equals `value` — how a relationship is
52    /// traversed, since the ORM keeps foreign keys as flat columns.
53    async fn find_by(&self, column: &str, value: Value) -> Result<Vec<M>>;
54
55    /// The first row where `column` equals `value`.
56    async fn first_by(&self, column: &str, value: Value) -> Result<Option<M>>;
57
58    /// Every row matching `criteria`.
59    async fn matching(&self, criteria: Criteria) -> Result<Vec<M>>;
60
61    /// The first row matching `criteria`.
62    async fn first_matching(&self, criteria: Criteria) -> Result<Option<M>>;
63
64    /// How many rows match `criteria`, ignoring its paging.
65    async fn count_matching(&self, criteria: Criteria) -> Result<u64>;
66
67    /// One page of rows matching `criteria`.
68    async fn paginate_matching(
69        &self,
70        criteria: Criteria,
71        page: u64,
72        per_page: u64,
73    ) -> Result<Paginated<M>>;
74
75    /// Insert a row, returning it with any database-assigned key.
76    async fn create(&self, model: M) -> Result<M>;
77
78    /// Update every non-key column of a row, matched by its primary key.
79    async fn update(&self, model: &M) -> Result<u64>;
80
81    /// Insert, or update on a conflict with `conflict_columns`. An empty
82    /// `update_columns` makes it insert-or-ignore.
83    async fn upsert(
84        &self,
85        model: &M,
86        conflict_columns: &[&str],
87        update_columns: &[&str],
88    ) -> Result<u64>;
89
90    /// Delete the row with this primary key.
91    async fn delete(&self, key: Value) -> Result<u64>;
92
93    /// Delete every row matching `criteria`. Returns rows affected.
94    async fn delete_matching(&self, criteria: Criteria) -> Result<u64>;
95
96    /// `SELECT column, COUNT(*) GROUP BY column` — how many rows share each
97    /// value of `column`.
98    ///
99    /// What [`Relation::count`](crate::relation::Relation::count) is: one query
100    /// for every parent's child count, rather than one per parent.
101    ///
102    /// The default loads the rows and tallies them, which is correct for any
103    /// implementation and wrong only in what it transfers.
104    /// [`EntityRepository`] overrides it with a real `GROUP BY`.
105    async fn count_grouped(
106        &self,
107        column: &str,
108        criteria: Criteria,
109    ) -> Result<Vec<(RelationKey, u64)>> {
110        let mut counts: std::collections::HashMap<RelationKey, u64> =
111            std::collections::HashMap::new();
112
113        for model in self.matching(criteria).await? {
114            let Some(value) = model.value_of(column) else { continue };
115            *counts.entry(RelationKey::new(&value)).or_insert(0) += 1;
116        }
117        Ok(counts.into_iter().collect())
118    }
119
120    /// Read a pivot table's `(parent, related)` links.
121    ///
122    /// A pivot is two columns and no model, so it cannot go through the typed
123    /// path. It lives on this trait rather than on `Database` so that a fake
124    /// repository can answer it — a many-to-many is not testable otherwise.
125    ///
126    /// The default returns nothing, which is what a repository with no
127    /// database behind it honestly has.
128    async fn pivot_links(&self, query: PivotQuery) -> Result<Vec<(Value, Value)>> {
129        let _ = query;
130        Ok(Vec::new())
131    }
132
133    // --- provided conveniences (not part of the vtable) --------------------
134
135    /// [`find`](Self::find) with a key of any convertible type.
136    async fn find_key(&self, key: impl Into<Value> + Send) -> Result<Option<M>>
137    where
138        Self: Sized,
139    {
140        self.find(key.into()).await
141    }
142
143    /// [`find`](Self::find), failing with a `404` naming the model when there
144    /// is no such row — what a controller almost always wants.
145    async fn find_or_fail(&self, key: impl Into<Value> + Send) -> Result<M>
146    where
147        Self: Sized,
148    {
149        self.find(key.into()).await?.ok_or_else(|| {
150            Error::not_found(format!("No {} matches the given key.", M::model_name()))
151        })
152    }
153
154    /// The row whose [route key](Model::route_key_name) equals `value` —
155    /// route-model binding's lookup.
156    async fn find_by_route_key(&self, value: impl Into<Value> + Send) -> Result<Option<M>>
157    where
158        Self: Sized,
159    {
160        self.first_by(M::route_key_name(), value.into()).await
161    }
162
163    /// Every row where `column` equals a value of any convertible type.
164    async fn where_eq(&self, column: &str, value: impl Into<Value> + Send) -> Result<Vec<M>>
165    where
166        Self: Sized,
167    {
168        self.find_by(column, value.into()).await
169    }
170
171    /// How many rows there are in total.
172    async fn count(&self) -> Result<u64>
173    where
174        Self: Sized,
175    {
176        self.count_matching(Criteria::new()).await
177    }
178
179    /// One page of every row.
180    async fn paginate(&self, page: u64, per_page: u64) -> Result<Paginated<M>>
181    where
182        Self: Sized,
183    {
184        self.paginate_matching(Criteria::new(), page, per_page).await
185    }
186
187    /// Whether any row matches `criteria`.
188    async fn exists(&self, criteria: Criteria) -> Result<bool>
189    where
190        Self: Sized,
191    {
192        Ok(self.count_matching(criteria).await? > 0)
193    }
194}
195
196/// The generic repository: one implementation serving every [`Model`].
197pub struct EntityRepository<M> {
198    db: Database,
199    events: Option<Arc<Dispatcher>>,
200    _model: PhantomData<fn() -> M>,
201}
202
203impl<M> Clone for EntityRepository<M> {
204    fn clone(&self) -> Self {
205        Self { db: self.db.clone(), events: self.events.clone(), _model: PhantomData }
206    }
207}
208
209impl<M: Model> EntityRepository<M> {
210    /// A repository over `db`, with no lifecycle hooks.
211    pub fn new(db: Database) -> Self {
212        Self { db, events: None, _model: PhantomData }
213    }
214
215    /// Dispatch lifecycle hooks through `events`.
216    pub fn with_events(mut self, events: Arc<Dispatcher>) -> Self {
217        self.events = Some(events);
218        self
219    }
220
221    /// The underlying database handle — the escape hatch for queries this
222    /// contract does not cover. Reach for it rather than growing the trait.
223    pub fn database(&self) -> &Database {
224        &self.db
225    }
226
227    /// Dispatch a lifecycle hook.
228    ///
229    /// The event is only *built* when something is listening, so the model
230    /// clone a hook needs costs nothing in an application that uses none.
231    async fn fire<E>(&self, build: impl FnOnce() -> E) -> Result<()>
232    where
233        E: Send + Sync + 'static,
234    {
235        let Some(events) = &self.events else { return Ok(()) };
236        if !events.has_listeners::<E>() {
237            return Ok(());
238        }
239        events.dispatch(build()).await
240    }
241}
242
243#[async_trait::async_trait]
244impl<M: Model> Repository<M> for EntityRepository<M> {
245    async fn all(&self) -> Result<Vec<M>> {
246        self.db.fetch_all(statement::select_all::<M>(self.db.dialect())).await
247    }
248
249    async fn find(&self, key: Value) -> Result<Option<M>> {
250        self.db.fetch_one(statement::select_by_pk::<M>(self.db.dialect(), key)).await
251    }
252
253    async fn find_by(&self, column: &str, value: Value) -> Result<Vec<M>> {
254        let prepared = statement::select_by_column::<M>(self.db.dialect(), column, value, None);
255        self.db.fetch_all(prepared).await
256    }
257
258    async fn first_by(&self, column: &str, value: Value) -> Result<Option<M>> {
259        let prepared = statement::select_by_column::<M>(self.db.dialect(), column, value, Some(1));
260        self.db.fetch_one(prepared).await
261    }
262
263    async fn matching(&self, criteria: Criteria) -> Result<Vec<M>> {
264        self.db.fetch_all(statement::select_matching::<M>(self.db.dialect(), &criteria)).await
265    }
266
267    async fn first_matching(&self, criteria: Criteria) -> Result<Option<M>> {
268        let criteria = criteria.limit(1);
269        self.db.fetch_one(statement::select_matching::<M>(self.db.dialect(), &criteria)).await
270    }
271
272    async fn count_matching(&self, criteria: Criteria) -> Result<u64> {
273        self.db.fetch_count(statement::count_matching::<M>(self.db.dialect(), &criteria)).await
274    }
275
276    async fn count_grouped(
277        &self,
278        column: &str,
279        criteria: Criteria,
280    ) -> Result<Vec<(RelationKey, u64)>> {
281        let Some(spec) = M::columns().iter().find(|spec| spec.name == column) else {
282            return Err(Error::internal(format!(
283                "`{}` has no column `{column}` to group by",
284                M::table()
285            )));
286        };
287
288        let prepared = statement::count_grouped::<M>(self.db.dialect(), column, &criteria);
289        let columns = vec![
290            crate::row::ColumnRequest::new(column, spec.ty),
291            crate::row::ColumnRequest::new("cnt", rainier_orm::ColumnType::BigInt),
292        ];
293
294        let rows = self.db.fetch(prepared, columns).await?;
295        Ok(rows
296            .iter()
297            .filter_map(|row| {
298                let key = row.cell(column).map(RelationKey::from_cell)?;
299                let count = row.cell("cnt").and_then(Cell::as_u64).unwrap_or(0);
300                Some((key, count))
301            })
302            .collect())
303    }
304
305    async fn pivot_links(&self, query: PivotQuery) -> Result<Vec<(Value, Value)>> {
306        if query.parent_keys.is_empty() {
307            return Ok(Vec::new());
308        }
309
310        let prepared = statement::select_pivot(self.db.dialect(), &query);
311        // A pivot's columns are whatever the keys they mirror are, and this
312        // layer cannot see the far entity — so both are read as the near
313        // side's own key type, which is what a foreign key must be.
314        let key_type = M::columns()
315            .iter()
316            .find(|spec| spec.pk)
317            .map(|spec| spec.ty)
318            .unwrap_or(rainier_orm::ColumnType::BigInt);
319
320        let columns = vec![
321            crate::row::ColumnRequest::new(&query.parent_column, key_type),
322            crate::row::ColumnRequest::new(&query.related_column, key_type),
323        ];
324
325        let rows = self.db.fetch(prepared, columns).await?;
326        Ok(rows
327            .iter()
328            .filter_map(|row| {
329                let parent = row.cell(&query.parent_column).and_then(Cell::to_value)?;
330                let related = row.cell(&query.related_column).and_then(Cell::to_value)?;
331                Some((parent, related))
332            })
333            .collect())
334    }
335
336    async fn paginate_matching(
337        &self,
338        criteria: Criteria,
339        page: u64,
340        per_page: u64,
341    ) -> Result<Paginated<M>> {
342        let page = page.max(1);
343        let per_page = per_page.max(1);
344
345        let total = self.count_matching(criteria.clone()).await?;
346        if total == 0 {
347            // No point selecting rows we already know are not there.
348            return Ok(Paginated::empty(page, per_page));
349        }
350
351        let window = criteria.limit(per_page).offset((page - 1) * per_page);
352        let data = self.matching(window).await?;
353        Ok(Paginated::new(data, total, page, per_page))
354    }
355
356    async fn create(&self, model: M) -> Result<M> {
357        self.fire(|| Creating { model: model.clone() }).await?;
358
359        // A sharded entity whose primary key is itself the shard key has no
360        // auto-increment to fall back on, so the connector mints the id.
361        let assigned = statement::shard_key_for_insert::<M>(&model)
362            .and_then(|shard_key| self.db.connection().allocate_id(shard_key));
363
364        let prepared = statement::insert::<M>(self.db.dialect(), &model, assigned);
365        let outcome = self.db.execute(prepared).await?;
366
367        let id = assigned.map(|id| id as i64).unwrap_or(outcome.last_insert_id);
368
369        // Re-read only when the *database* assigned the key, so the caller
370        // sees the row as it actually is — defaults, triggers and generated
371        // columns included — rather than the struct it passed in with a stale
372        // key.
373        //
374        // The auto-increment check is load-bearing: a model with an
375        // application-assigned key (a string id, a shard-encoded id) still
376        // gets a `last_insert_id` from some drivers, and following it would
377        // fetch an unrelated row and hand it back as the one just created.
378        let stored = if assigned.is_none() && id > 0 && has_auto_increment_key::<M>() {
379            self.find(id.into()).await?.unwrap_or(model)
380        } else {
381            model
382        };
383
384        self.fire(|| Created { model: stored.clone(), id }).await?;
385        Ok(stored)
386    }
387
388    async fn update(&self, model: &M) -> Result<u64> {
389        self.fire(|| Updating { model: model.clone() }).await?;
390
391        let prepared = statement::update::<M>(self.db.dialect(), model);
392        let rows_affected = self.db.execute(prepared).await?.rows_affected;
393
394        self.fire(|| Updated { model: model.clone(), rows_affected }).await?;
395        Ok(rows_affected)
396    }
397
398    async fn upsert(
399        &self,
400        model: &M,
401        conflict_columns: &[&str],
402        update_columns: &[&str],
403    ) -> Result<u64> {
404        let prepared =
405            statement::upsert::<M>(self.db.dialect(), model, conflict_columns, update_columns);
406        Ok(self.db.execute(prepared).await?.rows_affected)
407    }
408
409    async fn delete(&self, key: Value) -> Result<u64> {
410        let described = describe(&key);
411        self.fire(|| Deleting::<M> { key: described.clone(), model: PhantomData }).await?;
412
413        let prepared = statement::delete_by_pk::<M>(self.db.dialect(), key);
414        let rows_affected = self.db.execute(prepared).await?.rows_affected;
415
416        self.fire(|| Deleted::<M> { key: described, rows_affected, model: PhantomData }).await?;
417        Ok(rows_affected)
418    }
419
420    async fn delete_matching(&self, criteria: Criteria) -> Result<u64> {
421        let prepared = statement::delete_matching::<M>(self.db.dialect(), &criteria);
422        Ok(self.db.execute(prepared).await?.rows_affected)
423    }
424}
425
426/// Whether `M`'s primary key is assigned by the database.
427fn has_auto_increment_key<M: Model>() -> bool {
428    M::columns().iter().any(|column| column.name == M::primary_key() && column.auto_increment)
429}
430
431/// A readable rendering of a primary key, for the delete hooks.
432fn describe(key: &Value) -> String {
433    match key {
434        Value::String(Some(text)) => text.to_string(),
435        Value::BigInt(Some(n)) => n.to_string(),
436        Value::Int(Some(n)) => n.to_string(),
437        Value::BigUnsigned(Some(n)) => n.to_string(),
438        Value::Unsigned(Some(n)) => n.to_string(),
439        other => format!("{other:?}"),
440    }
441}
442
443impl<M> std::fmt::Debug for EntityRepository<M> {
444    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
445        f.debug_struct("EntityRepository")
446            .field("model", &std::any::type_name::<M>())
447            .field("hooks", &self.events.is_some())
448            .finish()
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use crate::row::OwnedRow;
456    use crate::testing::{fake_database, MemoryConnection};
457    use rainier_orm::{Dialect, ShardRoute};
458    use std::sync::atomic::{AtomicUsize, Ordering};
459    use std::sync::Mutex;
460
461    #[derive(rainier_orm::Entity, Clone, Debug, PartialEq)]
462    #[orm(table = "posts")]
463    struct Post {
464        #[orm(pk, auto_increment)]
465        id: u64,
466        title: String,
467        published: bool,
468    }
469
470    impl Model for Post {}
471
472    #[derive(rainier_orm::Entity, Clone, Debug, PartialEq)]
473    #[orm(table = "tokens")]
474    struct Token {
475        #[orm(pk, shard_key)]
476        id: u64,
477        #[orm(shard_key)]
478        user_id: u64,
479        hash: String,
480    }
481
482    impl Model for Token {}
483
484    fn row(id: u64, title: &str) -> OwnedRow {
485        OwnedRow::new().with("id", id).with("title", title).with("published", true)
486    }
487
488    fn count(n: i64) -> OwnedRow {
489        OwnedRow::new().with("cnt", n)
490    }
491
492    fn repository(connection: MemoryConnection) -> (EntityRepository<Post>, Arc<MemoryConnection>) {
493        let (db, handle) = fake_database(connection);
494        (EntityRepository::<Post>::new(db), handle)
495    }
496
497    #[test]
498    fn repository_calls_produce_send_futures() {
499        // The property that lets a handler await a repository at all.
500        fn assert_send<T: Send>(_: T) {}
501
502        let (posts, _) = repository(MemoryConnection::new(Dialect::Sqlite));
503        assert_send(async move {
504            let _ = posts.all().await;
505        });
506    }
507
508    #[tokio::test]
509    async fn all_decodes_every_row() {
510        let (posts, _) = repository(
511            MemoryConnection::new(Dialect::Sqlite).returning([row(1, "a"), row(2, "b")]),
512        );
513
514        let found = posts.all().await.unwrap();
515        assert_eq!(found.len(), 2);
516        assert_eq!(found[0], Post { id: 1, title: "a".into(), published: true });
517    }
518
519    #[tokio::test]
520    async fn find_returns_none_when_nothing_matches() {
521        let (posts, _) = repository(MemoryConnection::new(Dialect::Sqlite));
522        assert!(posts.find(1_i64.into()).await.unwrap().is_none());
523    }
524
525    #[tokio::test]
526    async fn find_or_fail_reports_a_404_naming_the_model() {
527        let (posts, _) = repository(MemoryConnection::new(Dialect::Sqlite));
528
529        let err = posts.find_or_fail(1_i64).await.unwrap_err();
530        assert_eq!(err.status(), 404);
531        assert!(err.message().contains("Post"), "{}", err.message());
532    }
533
534    #[tokio::test]
535    async fn find_or_fail_returns_the_row_when_it_exists() {
536        let (posts, _) =
537            repository(MemoryConnection::new(Dialect::Sqlite).returning([row(1, "found")]));
538        assert_eq!(posts.find_or_fail(1_i64).await.unwrap().title, "found");
539    }
540
541    #[tokio::test]
542    async fn first_by_limits_to_one_row() {
543        let (posts, connection) =
544            repository(MemoryConnection::new(Dialect::Sqlite).returning([row(1, "a")]));
545
546        posts.first_by("title", "a".into()).await.unwrap();
547        assert!(connection.last_statement().unwrap().contains("LIMIT"));
548    }
549
550    #[tokio::test]
551    async fn criteria_reach_the_generated_sql() {
552        let (posts, connection) = repository(MemoryConnection::new(Dialect::Sqlite));
553
554        posts
555            .matching(Criteria::new().where_eq("published", true).order_by_desc("id").limit(5))
556            .await
557            .unwrap();
558
559        let sql = connection.last_statement().unwrap();
560        assert!(sql.contains("published"), "{sql}");
561        assert!(sql.contains("ORDER BY"), "{sql}");
562        assert!(sql.contains("LIMIT"), "{sql}");
563    }
564
565    #[tokio::test]
566    async fn counting_ignores_the_criterias_paging() {
567        // Regression guard: a `LIMIT 5` in the criteria must not make the
568        // count come back as 5.
569        let (posts, connection) =
570            repository(MemoryConnection::new(Dialect::Sqlite).returning([count(42)]));
571
572        let total = posts
573            .count_matching(Criteria::new().where_eq("published", true).limit(5).offset(10))
574            .await
575            .unwrap();
576
577        assert_eq!(total, 42);
578        let sql = connection.last_statement().unwrap();
579        assert!(!sql.contains("LIMIT"), "the count must not be limited: {sql}");
580        assert!(sql.contains("published"), "but it must keep the filters: {sql}");
581    }
582
583    #[tokio::test]
584    async fn paginate_reports_the_window() {
585        let connection = MemoryConnection::new(Dialect::Sqlite)
586            .returning([count(25)])
587            .returning([row(11, "k"), row(12, "l")]);
588        let (posts, _) = repository(connection);
589
590        let page = posts.paginate(2, 10).await.unwrap();
591        assert_eq!(page.total, 25);
592        assert_eq!(page.current_page, 2);
593        assert_eq!(page.len(), 2);
594        assert_eq!(page.last_page(), 3);
595        assert_eq!(page.from(), Some(11));
596    }
597
598    #[tokio::test]
599    async fn paginating_no_results_skips_the_row_query() {
600        let (posts, connection) =
601            repository(MemoryConnection::new(Dialect::Sqlite).returning([count(0)]));
602
603        let page = posts.paginate(1, 10).await.unwrap();
604        assert!(page.is_empty());
605        assert_eq!(connection.statement_count(), 1);
606    }
607
608    #[tokio::test]
609    async fn exists_is_a_count() {
610        let (posts, _) = repository(MemoryConnection::new(Dialect::Sqlite).returning([count(3)]));
611        assert!(posts.exists(Criteria::new()).await.unwrap());
612
613        let (posts, _) = repository(MemoryConnection::new(Dialect::Sqlite).returning([count(0)]));
614        assert!(!posts.exists(Criteria::new()).await.unwrap());
615    }
616
617    #[tokio::test]
618    async fn create_reads_the_row_back_when_the_database_assigned_a_key() {
619        let connection =
620            MemoryConnection::new(Dialect::Sqlite).with_outcome(1, 7).returning([row(7, "stored")]);
621        let (posts, _) = repository(connection);
622
623        let created =
624            posts.create(Post { id: 0, title: "sent".into(), published: false }).await.unwrap();
625
626        assert_eq!(created.id, 7);
627        assert_eq!(created.title, "stored", "the stored row wins over the struct we sent");
628    }
629
630    #[tokio::test]
631    async fn create_does_not_re_read_a_model_with_an_application_assigned_key() {
632        // Regression guard: some drivers report a `last_insert_id` even for a
633        // string key. Following it would fetch an unrelated row.
634        #[derive(rainier_orm::Entity, Clone, Debug, PartialEq)]
635        #[orm(table = "sessions")]
636        struct Session {
637            #[orm(pk)]
638            id: String,
639            user_id: u64,
640        }
641        impl Model for Session {}
642
643        let (db, connection) =
644            fake_database(MemoryConnection::new(Dialect::Sqlite).with_outcome(1, 99));
645        let sessions = EntityRepository::<Session>::new(db);
646
647        let created = sessions.create(Session { id: "abc".into(), user_id: 1 }).await.unwrap();
648
649        assert_eq!(created.id, "abc");
650        assert_eq!(connection.statement_count(), 1, "the insert only — no re-read");
651        assert!(connection.last_statement().unwrap().starts_with("INSERT INTO"));
652    }
653
654    #[tokio::test]
655    async fn a_sharded_insert_asks_the_connector_for_an_id_and_routes_to_it() {
656        let (db, connection) =
657            fake_database(MemoryConnection::new(Dialect::Sqlite).sharded("users").allocating(4242));
658        let tokens = EntityRepository::<Token>::new(db);
659
660        let created = tokens.create(Token { id: 0, user_id: 42, hash: "h".into() }).await.unwrap();
661
662        // No re-read: the id was app-assigned, so the struct is already right.
663        assert_eq!(created.id, 0);
664        assert_eq!(connection.last_route(), Some(ShardRoute::Key(4242)));
665        assert_eq!(connection.statement_count(), 1);
666    }
667
668    #[tokio::test]
669    async fn a_database_failure_surfaces() {
670        let (posts, _) = repository(MemoryConnection::new(Dialect::Sqlite).failing("disk full"));
671        let err = posts.all().await.unwrap_err();
672        assert!(err.message().contains("disk full"), "{}", err.message());
673    }
674
675    // --- lifecycle hooks ---------------------------------------------------
676
677    #[allow(clippy::type_complexity)]
678    fn with_hooks() -> (EntityRepository<Post>, Arc<Dispatcher>, Arc<MemoryConnection>) {
679        let (db, connection) = fake_database(
680            MemoryConnection::new(Dialect::Sqlite).with_outcome(1, 7).returning([row(7, "stored")]),
681        );
682        let events = Arc::new(Dispatcher::new());
683        (EntityRepository::<Post>::new(db).with_events(Arc::clone(&events)), events, connection)
684    }
685
686    #[tokio::test]
687    async fn create_fires_creating_then_created() {
688        let (posts, events, _) = with_hooks();
689        let log = Arc::new(Mutex::new(Vec::new()));
690
691        let sink = Arc::clone(&log);
692        events.listen(move |event: Arc<Creating<Post>>| {
693            let sink = Arc::clone(&sink);
694            async move {
695                sink.lock().unwrap().push(format!("creating:{}", event.model.title));
696                Ok(())
697            }
698        });
699
700        let sink = Arc::clone(&log);
701        events.listen(move |event: Arc<Created<Post>>| {
702            let sink = Arc::clone(&sink);
703            async move {
704                sink.lock().unwrap().push(format!("created:{}", event.id));
705                Ok(())
706            }
707        });
708
709        posts.create(Post { id: 0, title: "sent".into(), published: false }).await.unwrap();
710        assert_eq!(*log.lock().unwrap(), vec!["creating:sent", "created:7"]);
711    }
712
713    #[tokio::test]
714    async fn a_creating_listener_can_veto_the_insert() {
715        let (posts, events, connection) = with_hooks();
716        events.listen(|_: Arc<Creating<Post>>| async { Err(Error::unauthorized("read only")) });
717
718        let err = posts
719            .create(Post { id: 0, title: "blocked".into(), published: false })
720            .await
721            .unwrap_err();
722
723        assert_eq!(err.status(), 403);
724        assert_eq!(
725            connection.statement_count(),
726            0,
727            "a vetoed insert must never reach the database"
728        );
729    }
730
731    #[tokio::test]
732    async fn update_and_delete_fire_their_hooks() {
733        let (posts, events, _) = with_hooks();
734        let seen = Arc::new(AtomicUsize::new(0));
735
736        let counter = Arc::clone(&seen);
737        events.listen(move |_: Arc<Updating<Post>>| {
738            let counter = Arc::clone(&counter);
739            async move {
740                counter.fetch_add(1, Ordering::SeqCst);
741                Ok(())
742            }
743        });
744        let counter = Arc::clone(&seen);
745        events.listen(move |_: Arc<Updated<Post>>| {
746            let counter = Arc::clone(&counter);
747            async move {
748                counter.fetch_add(10, Ordering::SeqCst);
749                Ok(())
750            }
751        });
752        let counter = Arc::clone(&seen);
753        events.listen(move |_: Arc<Deleting<Post>>| {
754            let counter = Arc::clone(&counter);
755            async move {
756                counter.fetch_add(100, Ordering::SeqCst);
757                Ok(())
758            }
759        });
760        let counter = Arc::clone(&seen);
761        events.listen(move |event: Arc<Deleted<Post>>| {
762            let counter = Arc::clone(&counter);
763            async move {
764                assert_eq!(event.key, "1");
765                counter.fetch_add(1000, Ordering::SeqCst);
766                Ok(())
767            }
768        });
769
770        posts.update(&Post { id: 1, title: "x".into(), published: true }).await.unwrap();
771        posts.delete(1_i64.into()).await.unwrap();
772
773        assert_eq!(seen.load(Ordering::SeqCst), 1111);
774    }
775
776    #[tokio::test]
777    async fn hooks_are_skipped_entirely_when_nothing_listens() {
778        let (posts, _, connection) = with_hooks();
779        // No listeners registered: `create` must still work, and must not have
780        // paid for a model clone it never used.
781        posts.create(Post { id: 0, title: "x".into(), published: false }).await.unwrap();
782        assert!(connection.statement_count() >= 1);
783    }
784
785    #[tokio::test]
786    async fn the_contract_is_object_safe() {
787        let (db, _) = fake_database(MemoryConnection::new(Dialect::Sqlite));
788        let boxed: Arc<dyn Repository<Post>> = Arc::new(EntityRepository::<Post>::new(db));
789        assert!(boxed.all().await.unwrap().is_empty());
790    }
791}