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, Text};
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)]
194#[non_exhaustive]
195pub enum Op {
196 Create,
197 Update,
198}
199
200#[derive(Debug, Clone, Default)]
202pub struct Record {
203 entity: String,
204 id: Option<i64>,
205 attrs: BTreeMap<String, AttrValue>,
206 original: Option<BTreeMap<String, AttrValue>>,
207}
208
209impl Record {
210 pub fn new(entity: impl Into<String>) -> Self {
212 Self {
213 entity: entity.into(),
214 id: None,
215 attrs: BTreeMap::new(),
216 original: None,
217 }
218 }
219
220 pub fn with_id(entity: impl Into<String>, id: i64) -> Self {
222 Self {
223 entity: entity.into(),
224 id: Some(id),
225 attrs: BTreeMap::new(),
226 original: None,
227 }
228 }
229
230 pub fn entity(&self) -> &str {
232 &self.entity
233 }
234
235 pub fn id(&self) -> Option<i64> {
237 self.id
238 }
239
240 pub fn set_id(&mut self, id: i64) {
242 self.id = Some(id);
243 }
244
245 pub fn set(&mut self, key: impl Into<String>, value: impl Into<AttrValue>) -> &mut Self {
247 self.attrs.insert(key.into(), value.into());
248 self
249 }
250
251 pub fn get(&self, key: &str) -> Option<&AttrValue> {
255 self.attrs.get(key)
256 }
257
258 pub fn remove(&mut self, key: &str) -> Option<AttrValue> {
260 self.attrs.remove(key)
261 }
262
263 pub fn contains(&self, key: &str) -> bool {
265 self.attrs.contains_key(key)
266 }
267
268 pub fn iter(&self) -> impl Iterator<Item = (&str, &AttrValue)> {
270 self.attrs.iter().map(|(k, v)| (k.as_str(), v))
271 }
272
273 pub fn len(&self) -> usize {
275 self.attrs.len()
276 }
277
278 pub fn is_empty(&self) -> bool {
280 self.attrs.is_empty()
281 }
282
283 pub fn text(&self, key: &str) -> Option<&str> {
285 self.get(key).and_then(AttrValue::as_str)
286 }
287
288 pub fn int(&self, key: &str) -> Option<i64> {
290 self.get(key).and_then(AttrValue::as_int)
291 }
292
293 pub fn float(&self, key: &str) -> Option<f64> {
295 self.get(key).and_then(AttrValue::as_float)
296 }
297
298 pub fn bool(&self, key: &str) -> Option<bool> {
300 self.get(key).and_then(AttrValue::as_bool)
301 }
302
303 pub fn datetime(&self, key: &str) -> Option<DateTime<Utc>> {
305 self.get(key).and_then(AttrValue::as_datetime)
306 }
307
308 pub fn json(&self, key: &str) -> Option<&Value> {
310 self.get(key).and_then(AttrValue::as_json)
311 }
312
313 pub fn set_original(&mut self, previous: Record) {
317 self.original = Some(previous.attrs);
318 }
319
320 pub fn has_original(&self) -> bool {
323 self.original.is_some()
324 }
325
326 pub fn original(&self, key: &str) -> Option<&AttrValue> {
328 self.original.as_ref().and_then(|prev| prev.get(key))
329 }
330
331 pub fn changed(&self, key: &str) -> bool {
340 match (&self.original, self.attrs.get(key)) {
341 (Some(prev), Some(next)) => prev.get(key) != Some(next),
342 _ => false,
343 }
344 }
345
346 pub fn deserialize<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
349 let map = self
350 .attrs
351 .iter()
352 .map(|(k, v)| (k.clone(), v.to_json()))
353 .collect::<serde_json::Map<_, _>>();
354 serde_json::from_value(Value::Object(map))
355 }
356
357 pub fn from_text_map(entity: impl Into<String>, data: &HashMap<String, String>) -> Self {
361 let mut rec = Record::new(entity);
362 for (k, v) in data {
363 rec.set(k.clone(), v.clone());
364 }
365 rec
366 }
367
368 pub fn to_text_map(&self) -> HashMap<String, String> {
372 self.attrs
373 .iter()
374 .map(|(k, v)| (k.clone(), v.to_text().unwrap_or_default()))
375 .collect()
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
385#[non_exhaustive]
386pub enum Actor {
387 User { id: i64, username: String },
389 System { process: String },
392}
393
394impl Actor {
395 pub fn user(id: i64, username: impl Into<String>) -> Self {
396 Actor::User {
397 id,
398 username: username.into(),
399 }
400 }
401
402 pub fn system(process: impl Into<String>) -> Self {
403 Actor::System {
404 process: process.into(),
405 }
406 }
407
408 pub fn user_id(&self) -> Option<i64> {
410 match self {
411 Actor::User { id, .. } => Some(*id),
412 _ => None,
413 }
414 }
415
416 pub fn label(&self) -> &str {
418 match self {
419 Actor::User { username, .. } => username,
420 Actor::System { process } => process,
421 }
422 }
423}
424
425pub struct SaveCx<'c> {
435 backend: DbBackend,
436 conn: &'c mut sqlx::AnyConnection,
437 actor: Actor,
438}
439
440impl<'c> SaveCx<'c> {
441 pub fn new(backend: DbBackend, conn: &'c mut sqlx::AnyConnection, actor: Actor) -> Self {
442 Self {
443 backend,
444 conn,
445 actor,
446 }
447 }
448
449 pub fn backend(&self) -> DbBackend {
451 self.backend
452 }
453
454 pub fn conn(&mut self) -> &mut sqlx::AnyConnection {
456 self.conn
457 }
458
459 pub fn actor(&self) -> &Actor {
461 &self.actor
462 }
463}
464
465pub struct SavedCx<'a> {
470 db: &'a Db,
471 actor: &'a Actor,
472}
473
474impl<'a> SavedCx<'a> {
475 pub fn new(db: &'a Db, actor: &'a Actor) -> Self {
476 Self { db, actor }
477 }
478
479 pub fn db(&self) -> &Db {
481 self.db
482 }
483
484 pub fn backend(&self) -> DbBackend {
485 self.db.backend
486 }
487
488 pub fn actor(&self) -> &Actor {
490 self.actor
491 }
492}
493
494#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum ListenerTarget {
497 Entity(String),
499 All,
507}
508
509impl ListenerTarget {
510 pub fn matches(&self, entity: &str) -> bool {
512 match self {
513 ListenerTarget::Entity(name) => name == entity,
514 ListenerTarget::All => true,
515 }
516 }
517}
518
519#[async_trait]
527pub trait ModelListener: Send + Sync + 'static {
528 async fn before_save(
532 &self,
533 cx: &mut SaveCx<'_>,
534 rec: &mut Record,
535 op: Op,
536 ) -> Result<(), ErrorBag> {
537 let _ = (cx, rec, op);
538 Ok(())
539 }
540
541 async fn after_save(&self, cx: &SavedCx<'_>, rec: &Record, op: Op) {
544 let _ = (cx, rec, op);
545 }
546
547 async fn before_delete(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<(), Text> {
556 let _ = (cx, rec);
557 Ok(())
558 }
559
560 async fn after_delete(&self, cx: &SavedCx<'_>, rec: &Record) {
564 let _ = (cx, rec);
565 }
566}
567
568pub struct ModelListenerReg {
570 pub target: ListenerTarget,
571 pub listener: Arc<dyn ModelListener>,
572}
573
574impl ModelListenerReg {
575 pub fn for_entity(entity: impl Into<String>, listener: Arc<dyn ModelListener>) -> Self {
577 Self {
578 target: ListenerTarget::Entity(entity.into()),
579 listener,
580 }
581 }
582
583 pub fn all(listener: Arc<dyn ModelListener>) -> Self {
585 Self {
586 target: ListenerTarget::All,
587 listener,
588 }
589 }
590
591 pub fn matches(&self, entity: &str) -> bool {
593 self.target.matches(entity)
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use chrono::TimeZone;
601 use serde::Deserialize;
602
603 fn instant() -> DateTime<Utc> {
604 Utc.with_ymd_and_hms(2026, 9, 8, 12, 30, 45).unwrap()
605 }
606
607 #[test]
608 fn attributes_round_trip_through_typed_accessors() {
609 let mut rec = Record::new("products");
610 rec.set("name", "Chair")
611 .set("stock", 4i64)
612 .set("price", 19.5f64)
613 .set("active", true)
614 .set("created_at", instant())
615 .set("meta", serde_json::json!({"colour": "red"}));
616
617 assert_eq!(rec.entity(), "products");
618 assert_eq!(rec.id(), None);
619 assert_eq!(rec.text("name"), Some("Chair"));
620 assert_eq!(rec.int("stock"), Some(4));
621 assert_eq!(rec.float("price"), Some(19.5));
622 assert_eq!(rec.bool("active"), Some(true));
623 assert_eq!(rec.datetime("created_at"), Some(instant()));
624 assert_eq!(rec.json("meta").unwrap()["colour"], "red");
625 assert_eq!(rec.len(), 6);
626 assert_eq!(rec.int("name"), None);
628 }
629
630 #[test]
631 fn an_int_widens_to_float_but_nothing_else_coerces() {
632 let mut rec = Record::new("products");
633 rec.set("stock", 4i64);
634 assert_eq!(rec.float("stock"), Some(4.0));
635 assert_eq!(rec.bool("stock"), None);
636 assert_eq!(rec.text("stock"), None);
637 }
638
639 #[test]
640 fn absent_and_null_are_different() {
641 let mut rec = Record::new("products");
642 rec.set("note", None::<String>);
643 assert!(rec.contains("note"));
644 assert!(rec.get("note").unwrap().is_null());
645 assert!(rec.get("missing").is_none());
646 assert_eq!(AttrValue::Null.to_text(), None);
648 }
649
650 #[test]
651 fn timestamps_render_in_the_one_stored_format() {
652 assert_eq!(
653 AttrValue::DateTime(instant()).to_text().unwrap(),
654 "2026-09-08T12:30:45.000000Z"
655 );
656 }
657
658 #[test]
659 fn the_text_bridge_survives_a_round_trip() {
660 let mut data = HashMap::new();
661 data.insert("name".to_string(), "Chair".to_string());
662 data.insert("note".to_string(), String::new());
663
664 let rec = Record::from_text_map("products", &data);
665 assert_eq!(rec.text("name"), Some("Chair"));
666 assert_eq!(rec.text("note"), Some(""));
668 assert_eq!(rec.to_text_map(), data);
669 }
670
671 #[test]
672 fn a_null_attribute_writes_empty_through_the_text_bridge() {
673 let mut rec = Record::new("products");
674 rec.set("name", "Chair").set("note", None::<String>);
675 let map = rec.to_text_map();
676 assert_eq!(map.get("note").map(String::as_str), Some(""));
677 }
678
679 #[test]
680 fn typed_values_render_for_a_text_storage_layer() {
681 let mut rec = Record::new("products");
682 rec.set("stock", 4i64).set("active", false);
683 let map = rec.to_text_map();
684 assert_eq!(map.get("stock").map(String::as_str), Some("4"));
685 assert_eq!(map.get("active").map(String::as_str), Some("false"));
686 }
687
688 #[test]
689 fn deserialize_gives_a_struct_view_and_ignores_extra_attributes() {
690 #[derive(Deserialize, PartialEq, Debug)]
691 struct Product {
692 name: String,
693 stock: i64,
694 active: bool,
695 }
696
697 let mut rec = Record::new("products");
698 rec.set("name", "Chair")
699 .set("stock", 4i64)
700 .set("active", true)
701 .set("internal_note", "ignore me");
702
703 let product: Product = rec.deserialize().unwrap();
704 assert_eq!(
705 product,
706 Product {
707 name: "Chair".into(),
708 stock: 4,
709 active: true
710 }
711 );
712 }
713
714 #[test]
715 fn an_id_is_absent_on_create_and_recorded_after() {
716 let mut rec = Record::new("products");
717 assert_eq!(rec.id(), None);
718 rec.set_id(7);
719 assert_eq!(rec.id(), Some(7));
720 assert_eq!(Record::with_id("products", 3).id(), Some(3));
721 }
722
723 #[test]
724 fn a_target_selects_the_entities_it_runs_for() {
725 let one = ListenerTarget::Entity("products".into());
726 assert!(one.matches("products"));
727 assert!(!one.matches("orders"));
728 assert!(ListenerTarget::All.matches("anything"));
729 }
730
731 #[test]
732 fn a_registration_carries_its_target() {
733 struct Noop;
734 impl ModelListener for Noop {}
735
736 let reg = ModelListenerReg::for_entity("products", Arc::new(Noop));
737 assert!(reg.matches("products"));
738 assert!(!reg.matches("orders"));
739 assert!(ModelListenerReg::all(Arc::new(Noop)).matches("orders"));
740 }
741
742 #[test]
743 fn a_snapshot_reports_what_this_write_changes() {
744 let mut previous = Record::with_id("products", 1);
745 previous.set("name", "Chair").set("stock", 4i64);
746
747 let mut rec = Record::with_id("products", 1);
748 rec.set("name", "Stool").set("stock", 4i64);
749 assert!(!rec.has_original());
750 assert!(!rec.changed("name"));
752
753 rec.set_original(previous);
754 assert!(rec.has_original());
755 assert!(rec.changed("name"));
756 assert!(!rec.changed("stock"), "an equal value is not a change");
757 assert!(!rec.changed("absent"));
758 assert_eq!(
759 rec.original("name").and_then(AttrValue::as_str),
760 Some("Chair")
761 );
762 }
763
764 #[test]
765 fn attributes_iterate_in_key_order() {
766 let mut rec = Record::new("products");
767 rec.set("zebra", "z").set("alpha", "a");
768 let keys: Vec<_> = rec.iter().map(|(k, _)| k).collect();
769 assert_eq!(keys, ["alpha", "zebra"]);
770 }
771}