1use std::collections::{BTreeMap, HashMap};
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use chrono::{DateTime, SecondsFormat, Utc};
19use serde::de::DeserializeOwned;
20use serde_json::Value;
21
22use crate::migration::DbBackend;
23use crate::validation::ErrorBag;
24use crate::Db;
25
26#[derive(Debug, Clone, PartialEq)]
31#[non_exhaustive]
32pub enum AttrValue {
33 Null,
35 Text(String),
36 Int(i64),
37 Float(f64),
38 Bool(bool),
39 DateTime(DateTime<Utc>),
42 Json(Value),
43}
44
45impl AttrValue {
46 pub fn is_null(&self) -> bool {
48 matches!(self, AttrValue::Null)
49 }
50
51 pub fn to_text(&self) -> Option<String> {
56 match self {
57 AttrValue::Null => None,
58 AttrValue::Text(s) => Some(s.clone()),
59 AttrValue::Int(i) => Some(i.to_string()),
60 AttrValue::Float(f) => Some(f.to_string()),
61 AttrValue::Bool(b) => Some(b.to_string()),
62 AttrValue::DateTime(dt) => Some(dt.to_rfc3339_opts(SecondsFormat::Micros, true)),
63 AttrValue::Json(v) => Some(v.to_string()),
64 }
65 }
66
67 pub fn as_str(&self) -> Option<&str> {
69 match self {
70 AttrValue::Text(s) => Some(s),
71 _ => None,
72 }
73 }
74
75 pub fn as_int(&self) -> Option<i64> {
77 match self {
78 AttrValue::Int(i) => Some(*i),
79 _ => None,
80 }
81 }
82
83 pub fn as_float(&self) -> Option<f64> {
86 match self {
87 AttrValue::Float(f) => Some(*f),
88 AttrValue::Int(i) => Some(*i as f64),
89 _ => None,
90 }
91 }
92
93 pub fn as_bool(&self) -> Option<bool> {
95 match self {
96 AttrValue::Bool(b) => Some(*b),
97 _ => None,
98 }
99 }
100
101 pub fn as_datetime(&self) -> Option<DateTime<Utc>> {
103 match self {
104 AttrValue::DateTime(dt) => Some(*dt),
105 _ => None,
106 }
107 }
108
109 pub fn as_json(&self) -> Option<&Value> {
111 match self {
112 AttrValue::Json(v) => Some(v),
113 _ => None,
114 }
115 }
116
117 fn to_json(&self) -> Value {
120 match self {
121 AttrValue::Null => Value::Null,
122 AttrValue::Text(s) => Value::String(s.clone()),
123 AttrValue::Int(i) => Value::from(*i),
124 AttrValue::Float(f) => Value::from(*f),
125 AttrValue::Bool(b) => Value::Bool(*b),
126 AttrValue::DateTime(dt) => {
127 Value::String(dt.to_rfc3339_opts(SecondsFormat::Micros, true))
128 }
129 AttrValue::Json(v) => v.clone(),
130 }
131 }
132}
133
134impl From<&str> for AttrValue {
135 fn from(v: &str) -> Self {
136 AttrValue::Text(v.to_string())
137 }
138}
139
140impl From<String> for AttrValue {
141 fn from(v: String) -> Self {
142 AttrValue::Text(v)
143 }
144}
145
146impl From<i64> for AttrValue {
147 fn from(v: i64) -> Self {
148 AttrValue::Int(v)
149 }
150}
151
152impl From<f64> for AttrValue {
153 fn from(v: f64) -> Self {
154 AttrValue::Float(v)
155 }
156}
157
158impl From<bool> for AttrValue {
159 fn from(v: bool) -> Self {
160 AttrValue::Bool(v)
161 }
162}
163
164impl From<DateTime<Utc>> for AttrValue {
165 fn from(v: DateTime<Utc>) -> Self {
166 AttrValue::DateTime(v)
167 }
168}
169
170impl From<Value> for AttrValue {
171 fn from(v: Value) -> Self {
172 AttrValue::Json(v)
173 }
174}
175
176impl<T: Into<AttrValue>> From<Option<T>> for AttrValue {
178 fn from(v: Option<T>) -> Self {
179 v.map_or(AttrValue::Null, Into::into)
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187#[non_exhaustive]
188pub enum Op {
189 Create,
190 Update,
191}
192
193#[derive(Debug, Clone, Default)]
195pub struct Record {
196 entity: String,
197 id: Option<i64>,
198 attrs: BTreeMap<String, AttrValue>,
199 original: Option<BTreeMap<String, AttrValue>>,
200}
201
202impl Record {
203 pub fn new(entity: impl Into<String>) -> Self {
205 Self {
206 entity: entity.into(),
207 id: None,
208 attrs: BTreeMap::new(),
209 original: None,
210 }
211 }
212
213 pub fn with_id(entity: impl Into<String>, id: i64) -> Self {
215 Self {
216 entity: entity.into(),
217 id: Some(id),
218 attrs: BTreeMap::new(),
219 original: None,
220 }
221 }
222
223 pub fn entity(&self) -> &str {
225 &self.entity
226 }
227
228 pub fn id(&self) -> Option<i64> {
230 self.id
231 }
232
233 pub fn set_id(&mut self, id: i64) {
235 self.id = Some(id);
236 }
237
238 pub fn set(&mut self, key: impl Into<String>, value: impl Into<AttrValue>) -> &mut Self {
240 self.attrs.insert(key.into(), value.into());
241 self
242 }
243
244 pub fn get(&self, key: &str) -> Option<&AttrValue> {
248 self.attrs.get(key)
249 }
250
251 pub fn remove(&mut self, key: &str) -> Option<AttrValue> {
253 self.attrs.remove(key)
254 }
255
256 pub fn contains(&self, key: &str) -> bool {
258 self.attrs.contains_key(key)
259 }
260
261 pub fn iter(&self) -> impl Iterator<Item = (&str, &AttrValue)> {
263 self.attrs.iter().map(|(k, v)| (k.as_str(), v))
264 }
265
266 pub fn len(&self) -> usize {
268 self.attrs.len()
269 }
270
271 pub fn is_empty(&self) -> bool {
273 self.attrs.is_empty()
274 }
275
276 pub fn text(&self, key: &str) -> Option<&str> {
278 self.get(key).and_then(AttrValue::as_str)
279 }
280
281 pub fn int(&self, key: &str) -> Option<i64> {
283 self.get(key).and_then(AttrValue::as_int)
284 }
285
286 pub fn float(&self, key: &str) -> Option<f64> {
288 self.get(key).and_then(AttrValue::as_float)
289 }
290
291 pub fn bool(&self, key: &str) -> Option<bool> {
293 self.get(key).and_then(AttrValue::as_bool)
294 }
295
296 pub fn datetime(&self, key: &str) -> Option<DateTime<Utc>> {
298 self.get(key).and_then(AttrValue::as_datetime)
299 }
300
301 pub fn json(&self, key: &str) -> Option<&Value> {
303 self.get(key).and_then(AttrValue::as_json)
304 }
305
306 pub fn set_original(&mut self, previous: Record) {
310 self.original = Some(previous.attrs);
311 }
312
313 pub fn has_original(&self) -> bool {
316 self.original.is_some()
317 }
318
319 pub fn original(&self, key: &str) -> Option<&AttrValue> {
321 self.original.as_ref().and_then(|prev| prev.get(key))
322 }
323
324 pub fn changed(&self, key: &str) -> bool {
333 match (&self.original, self.attrs.get(key)) {
334 (Some(prev), Some(next)) => prev.get(key) != Some(next),
335 _ => false,
336 }
337 }
338
339 pub fn deserialize<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
342 let map = self
343 .attrs
344 .iter()
345 .map(|(k, v)| (k.clone(), v.to_json()))
346 .collect::<serde_json::Map<_, _>>();
347 serde_json::from_value(Value::Object(map))
348 }
349
350 pub fn from_text_map(entity: impl Into<String>, data: &HashMap<String, String>) -> Self {
354 let mut rec = Record::new(entity);
355 for (k, v) in data {
356 rec.set(k.clone(), v.clone());
357 }
358 rec
359 }
360
361 pub fn to_text_map(&self) -> HashMap<String, String> {
365 self.attrs
366 .iter()
367 .map(|(k, v)| (k.clone(), v.to_text().unwrap_or_default()))
368 .collect()
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
378#[non_exhaustive]
379pub enum Actor {
380 User { id: i64, username: String },
382 System { process: String },
385}
386
387impl Actor {
388 pub fn user(id: i64, username: impl Into<String>) -> Self {
389 Actor::User {
390 id,
391 username: username.into(),
392 }
393 }
394
395 pub fn system(process: impl Into<String>) -> Self {
396 Actor::System {
397 process: process.into(),
398 }
399 }
400
401 pub fn user_id(&self) -> Option<i64> {
403 match self {
404 Actor::User { id, .. } => Some(*id),
405 _ => None,
406 }
407 }
408
409 pub fn label(&self) -> &str {
411 match self {
412 Actor::User { username, .. } => username,
413 Actor::System { process } => process,
414 }
415 }
416}
417
418pub struct SaveCx<'c> {
428 backend: DbBackend,
429 conn: &'c mut sqlx::AnyConnection,
430 actor: Actor,
431}
432
433impl<'c> SaveCx<'c> {
434 pub fn new(backend: DbBackend, conn: &'c mut sqlx::AnyConnection, actor: Actor) -> Self {
435 Self {
436 backend,
437 conn,
438 actor,
439 }
440 }
441
442 pub fn backend(&self) -> DbBackend {
444 self.backend
445 }
446
447 pub fn conn(&mut self) -> &mut sqlx::AnyConnection {
449 self.conn
450 }
451
452 pub fn actor(&self) -> &Actor {
454 &self.actor
455 }
456}
457
458pub struct SavedCx<'a> {
463 db: &'a Db,
464 actor: &'a Actor,
465}
466
467impl<'a> SavedCx<'a> {
468 pub fn new(db: &'a Db, actor: &'a Actor) -> Self {
469 Self { db, actor }
470 }
471
472 pub fn db(&self) -> &Db {
474 self.db
475 }
476
477 pub fn backend(&self) -> DbBackend {
478 self.db.backend
479 }
480
481 pub fn actor(&self) -> &Actor {
483 self.actor
484 }
485}
486
487#[derive(Debug, Clone, PartialEq, Eq)]
489pub enum ListenerTarget {
490 Entity(String),
492 All,
500}
501
502impl ListenerTarget {
503 pub fn matches(&self, entity: &str) -> bool {
505 match self {
506 ListenerTarget::Entity(name) => name == entity,
507 ListenerTarget::All => true,
508 }
509 }
510}
511
512#[async_trait]
520pub trait ModelListener: Send + Sync + 'static {
521 async fn before_save(
525 &self,
526 cx: &mut SaveCx<'_>,
527 rec: &mut Record,
528 op: Op,
529 ) -> Result<(), ErrorBag> {
530 let _ = (cx, rec, op);
531 Ok(())
532 }
533
534 async fn after_save(&self, cx: &SavedCx<'_>, rec: &Record, op: Op) {
537 let _ = (cx, rec, op);
538 }
539}
540
541pub struct ModelListenerReg {
543 pub target: ListenerTarget,
544 pub listener: Arc<dyn ModelListener>,
545}
546
547impl ModelListenerReg {
548 pub fn for_entity(entity: impl Into<String>, listener: Arc<dyn ModelListener>) -> Self {
550 Self {
551 target: ListenerTarget::Entity(entity.into()),
552 listener,
553 }
554 }
555
556 pub fn all(listener: Arc<dyn ModelListener>) -> Self {
558 Self {
559 target: ListenerTarget::All,
560 listener,
561 }
562 }
563
564 pub fn matches(&self, entity: &str) -> bool {
566 self.target.matches(entity)
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use chrono::TimeZone;
574 use serde::Deserialize;
575
576 fn instant() -> DateTime<Utc> {
577 Utc.with_ymd_and_hms(2026, 9, 8, 12, 30, 45).unwrap()
578 }
579
580 #[test]
581 fn attributes_round_trip_through_typed_accessors() {
582 let mut rec = Record::new("products");
583 rec.set("name", "Chair")
584 .set("stock", 4i64)
585 .set("price", 19.5f64)
586 .set("active", true)
587 .set("created_at", instant())
588 .set("meta", serde_json::json!({"colour": "red"}));
589
590 assert_eq!(rec.entity(), "products");
591 assert_eq!(rec.id(), None);
592 assert_eq!(rec.text("name"), Some("Chair"));
593 assert_eq!(rec.int("stock"), Some(4));
594 assert_eq!(rec.float("price"), Some(19.5));
595 assert_eq!(rec.bool("active"), Some(true));
596 assert_eq!(rec.datetime("created_at"), Some(instant()));
597 assert_eq!(rec.json("meta").unwrap()["colour"], "red");
598 assert_eq!(rec.len(), 6);
599 assert_eq!(rec.int("name"), None);
601 }
602
603 #[test]
604 fn an_int_widens_to_float_but_nothing_else_coerces() {
605 let mut rec = Record::new("products");
606 rec.set("stock", 4i64);
607 assert_eq!(rec.float("stock"), Some(4.0));
608 assert_eq!(rec.bool("stock"), None);
609 assert_eq!(rec.text("stock"), None);
610 }
611
612 #[test]
613 fn absent_and_null_are_different() {
614 let mut rec = Record::new("products");
615 rec.set("note", None::<String>);
616 assert!(rec.contains("note"));
617 assert!(rec.get("note").unwrap().is_null());
618 assert!(rec.get("missing").is_none());
619 assert_eq!(AttrValue::Null.to_text(), None);
621 }
622
623 #[test]
624 fn timestamps_render_in_the_one_stored_format() {
625 assert_eq!(
626 AttrValue::DateTime(instant()).to_text().unwrap(),
627 "2026-09-08T12:30:45.000000Z"
628 );
629 }
630
631 #[test]
632 fn the_text_bridge_survives_a_round_trip() {
633 let mut data = HashMap::new();
634 data.insert("name".to_string(), "Chair".to_string());
635 data.insert("note".to_string(), String::new());
636
637 let rec = Record::from_text_map("products", &data);
638 assert_eq!(rec.text("name"), Some("Chair"));
639 assert_eq!(rec.text("note"), Some(""));
641 assert_eq!(rec.to_text_map(), data);
642 }
643
644 #[test]
645 fn a_null_attribute_writes_empty_through_the_text_bridge() {
646 let mut rec = Record::new("products");
647 rec.set("name", "Chair").set("note", None::<String>);
648 let map = rec.to_text_map();
649 assert_eq!(map.get("note").map(String::as_str), Some(""));
650 }
651
652 #[test]
653 fn typed_values_render_for_a_text_storage_layer() {
654 let mut rec = Record::new("products");
655 rec.set("stock", 4i64).set("active", false);
656 let map = rec.to_text_map();
657 assert_eq!(map.get("stock").map(String::as_str), Some("4"));
658 assert_eq!(map.get("active").map(String::as_str), Some("false"));
659 }
660
661 #[test]
662 fn deserialize_gives_a_struct_view_and_ignores_extra_attributes() {
663 #[derive(Deserialize, PartialEq, Debug)]
664 struct Product {
665 name: String,
666 stock: i64,
667 active: bool,
668 }
669
670 let mut rec = Record::new("products");
671 rec.set("name", "Chair")
672 .set("stock", 4i64)
673 .set("active", true)
674 .set("internal_note", "ignore me");
675
676 let product: Product = rec.deserialize().unwrap();
677 assert_eq!(
678 product,
679 Product {
680 name: "Chair".into(),
681 stock: 4,
682 active: true
683 }
684 );
685 }
686
687 #[test]
688 fn an_id_is_absent_on_create_and_recorded_after() {
689 let mut rec = Record::new("products");
690 assert_eq!(rec.id(), None);
691 rec.set_id(7);
692 assert_eq!(rec.id(), Some(7));
693 assert_eq!(Record::with_id("products", 3).id(), Some(3));
694 }
695
696 #[test]
697 fn a_target_selects_the_entities_it_runs_for() {
698 let one = ListenerTarget::Entity("products".into());
699 assert!(one.matches("products"));
700 assert!(!one.matches("orders"));
701 assert!(ListenerTarget::All.matches("anything"));
702 }
703
704 #[test]
705 fn a_registration_carries_its_target() {
706 struct Noop;
707 impl ModelListener for Noop {}
708
709 let reg = ModelListenerReg::for_entity("products", Arc::new(Noop));
710 assert!(reg.matches("products"));
711 assert!(!reg.matches("orders"));
712 assert!(ModelListenerReg::all(Arc::new(Noop)).matches("orders"));
713 }
714
715 #[test]
716 fn a_snapshot_reports_what_this_write_changes() {
717 let mut previous = Record::with_id("products", 1);
718 previous.set("name", "Chair").set("stock", 4i64);
719
720 let mut rec = Record::with_id("products", 1);
721 rec.set("name", "Stool").set("stock", 4i64);
722 assert!(!rec.has_original());
723 assert!(!rec.changed("name"));
725
726 rec.set_original(previous);
727 assert!(rec.has_original());
728 assert!(rec.changed("name"));
729 assert!(!rec.changed("stock"), "an equal value is not a change");
730 assert!(!rec.changed("absent"));
731 assert_eq!(
732 rec.original("name").and_then(AttrValue::as_str),
733 Some("Chair")
734 );
735 }
736
737 #[test]
738 fn attributes_iterate_in_key_order() {
739 let mut rec = Record::new("products");
740 rec.set("zebra", "z").set("alpha", "a");
741 let keys: Vec<_> = rec.iter().map(|(k, _)| k).collect();
742 assert_eq!(keys, ["alpha", "zebra"]);
743 }
744}