Skip to main content

rainier_database/
model.rs

1//! The [`Model`] contract and its lifecycle **hooks**.
2//!
3//! A model is a Rainier ORM [`Entity`] with the extra facts the framework needs:
4//! a display name for error messages, and the column route-model binding looks
5//! up by. Declaring one is a single line, because everything else is derived:
6//!
7//! ```
8//! use rainier_orm::Entity;
9//! use rainier_database::Model;
10//!
11//! #[derive(Entity, Clone)]
12//! #[orm(table = "posts")]
13//! struct Post {
14//!     #[orm(pk, auto_increment)]
15//!     id: u64,
16//!     #[orm(unique)]
17//!     slug: String,
18//!     title: String,
19//! }
20//!
21//! impl Model for Post {
22//!     // Bind `/posts/{post}` by slug rather than by id.
23//!     fn route_key_name() -> &'static str { "slug" }
24//! }
25//! ```
26//!
27//! ## Lifecycle hooks
28//!
29//! Every repository write dispatches events through
30//! [`rainier_events`](rainier_events::Dispatcher), which is how the model
31//! lifecycle hooks — creating, created, saved, deleted — are spelled here. Each is a
32//! distinct type, so a listener registers for exactly the moment it cares
33//! about rather than matching on a discriminant:
34//!
35//! ```
36//! # use rainier_orm::Entity;
37//! # use rainier_database::{Created, Model};
38//! # use rainier_events::Dispatcher;
39//! # use std::sync::Arc;
40//! # #[derive(Entity, Clone)]
41//! # struct Post { #[orm(pk)] id: u64, title: String }
42//! # impl Model for Post {}
43//! # fn wire(events: &Dispatcher) {
44//! events.listen(|event: Arc<Created<Post>>| async move {
45//!     println!("created post {}", event.model.title);
46//!     Ok(())
47//! });
48//! # }
49//! ```
50//!
51//! ### What a hook can and cannot do
52//!
53//! A `-ing` hook (`Creating`, `Updating`, `Deleting`) runs **before** the write
54//! and can **veto** it by returning `Err` — the repository propagates the error
55//! and never touches the database. It cannot *modify* the model: listeners
56//! receive a shared `Arc`, and letting several listeners mutate one row in an
57//! unspecified order would make the outcome depend on registration order.
58//! Derive values in the model's own constructor, or in the repository, where
59//! there is exactly one place to look.
60//!
61//! A `-ed` hook (`Created`, `Updated`, `Deleted`) runs **after** a successful
62//! write. Returning `Err` from one does not roll anything back; it only
63//! surfaces to the caller.
64
65use std::any::type_name;
66
67use rainier_orm::Entity;
68use rainier_support::str::class_basename;
69
70/// An entity the framework manages.
71///
72/// `Clone` is required because lifecycle hooks receive the model by value:
73/// a repository hands a copy to the event bus and keeps the original for the
74/// write. The clone is skipped entirely when nothing is listening.
75pub trait Model: Entity + Clone + Send + Sync + 'static {
76    /// The model's name, for error messages — `"Post"`.
77    fn model_name() -> &'static str {
78        class_basename(type_name::<Self>())
79    }
80
81    /// The column route-model binding looks a member up by.
82    ///
83    /// Defaults to the primary key; override to bind by slug or UUID.
84    fn route_key_name() -> &'static str {
85        Self::primary_key()
86    }
87}
88
89/// Fired before a row is inserted. Returning `Err` cancels the insert.
90#[derive(Debug, Clone)]
91pub struct Creating<M> {
92    /// The model about to be inserted.
93    pub model: M,
94}
95
96/// Fired after a row is inserted.
97#[derive(Debug, Clone)]
98pub struct Created<M> {
99    /// The inserted model, with its assigned key where the backend reported
100    /// one.
101    pub model: M,
102    /// The key the database assigned, or `0` for an app-assigned key.
103    pub id: i64,
104}
105
106/// Fired before a row is updated. Returning `Err` cancels the update.
107#[derive(Debug, Clone)]
108pub struct Updating<M> {
109    /// The model about to be written.
110    pub model: M,
111}
112
113/// Fired after a row is updated.
114#[derive(Debug, Clone)]
115pub struct Updated<M> {
116    /// The model as written.
117    pub model: M,
118    /// How many rows the update touched.
119    pub rows_affected: u64,
120}
121
122/// Fired before a row is deleted. Returning `Err` cancels the delete.
123#[derive(Debug, Clone)]
124pub struct Deleting<M> {
125    /// The primary key about to be deleted.
126    pub key: String,
127    /// The model type, for listeners registered generically.
128    pub model: std::marker::PhantomData<M>,
129}
130
131/// Fired after a row is deleted.
132#[derive(Debug, Clone)]
133pub struct Deleted<M> {
134    /// The primary key that was deleted.
135    pub key: String,
136    /// How many rows the delete removed.
137    pub rows_affected: u64,
138    /// The model type, for listeners registered generically.
139    pub model: std::marker::PhantomData<M>,
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[derive(rainier_orm::Entity, Clone, Debug)]
147    #[orm(table = "posts")]
148    struct Post {
149        #[orm(pk, auto_increment)]
150        id: u64,
151        #[orm(unique)]
152        slug: String,
153        title: String,
154    }
155
156    impl Model for Post {}
157
158    #[derive(rainier_orm::Entity, Clone, Debug)]
159    #[orm(table = "pages")]
160    struct Page {
161        #[orm(pk, auto_increment)]
162        id: u64,
163        #[orm(unique)]
164        slug: String,
165    }
166
167    impl Model for Page {
168        fn route_key_name() -> &'static str {
169            "slug"
170        }
171    }
172
173    #[test]
174    fn the_model_name_is_the_type_name_without_its_module_path() {
175        assert_eq!(Post::model_name(), "Post");
176    }
177
178    #[test]
179    fn the_route_key_defaults_to_the_primary_key() {
180        assert_eq!(Post::route_key_name(), "id");
181        assert_eq!(Post::route_key_name(), Post::primary_key());
182    }
183
184    #[test]
185    fn the_route_key_can_be_overridden() {
186        assert_eq!(Page::route_key_name(), "slug");
187        assert_eq!(Page::primary_key(), "id", "the primary key is unchanged");
188    }
189
190    #[test]
191    fn the_entity_metadata_still_comes_from_the_derive() {
192        assert_eq!(Post::table(), "posts");
193        assert_eq!(Post::columns().len(), 3);
194    }
195
196    #[test]
197    fn hook_events_carry_the_model() {
198        let post = Post { id: 1, slug: "hi".into(), title: "Hi".into() };
199        let creating = Creating { model: post.clone() };
200        assert_eq!(creating.model.title, "Hi");
201
202        let created = Created { model: post, id: 7 };
203        assert_eq!(created.id, 7);
204    }
205}