Skip to main content

laterite_core/
record.rs

1//! The record layer: a dynamic attribute bag and the lifecycle listeners that
2//! shape a write.
3//!
4//! A [`Record`] is a map of attribute names to typed values, keyed by an entity
5//! name rather than modelled as a compile-time struct. That is what lets one
6//! pipeline serve both the fixed tables a module declares and content types
7//! defined at runtime, and what lets a listener registered by one module reach
8//! another module's entity.
9//!
10//! A record is a payload, not a live object: it has no `save`, no identity map
11//! and no lazy loading. Code that wants a struct view converts at the edge with
12//! [`Record::deserialize`].
13
14use 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/// One attribute's value.
27///
28/// Non-exhaustive: variants are added as the storage layer learns to bind them,
29/// so match with a `_` arm.
30#[derive(Debug, Clone, PartialEq)]
31#[non_exhaustive]
32pub enum AttrValue {
33    /// No value. Stored as SQL `NULL`, distinct from an empty string.
34    Null,
35    Text(String),
36    Int(i64),
37    Float(f64),
38    Bool(bool),
39    /// An instant, always UTC. Rendered as RFC 3339 with microsecond precision,
40    /// the one timestamp format the framework stores.
41    DateTime(DateTime<Utc>),
42    Json(Value),
43}
44
45impl AttrValue {
46    /// Whether this is [`AttrValue::Null`].
47    pub fn is_null(&self) -> bool {
48        matches!(self, AttrValue::Null)
49    }
50
51    /// The storage text for this value, or `None` for [`AttrValue::Null`].
52    ///
53    /// This is the lossy bridge to the all-text write path; typed binding
54    /// replaces it where the storage layer can bind a value natively.
55    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    /// The text, when this is [`AttrValue::Text`].
68    pub fn as_str(&self) -> Option<&str> {
69        match self {
70            AttrValue::Text(s) => Some(s),
71            _ => None,
72        }
73    }
74
75    /// The integer, when this is [`AttrValue::Int`].
76    pub fn as_int(&self) -> Option<i64> {
77        match self {
78            AttrValue::Int(i) => Some(*i),
79            _ => None,
80        }
81    }
82
83    /// The float, when this is [`AttrValue::Float`] (or an [`AttrValue::Int`],
84    /// widened).
85    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    /// The boolean, when this is [`AttrValue::Bool`].
94    pub fn as_bool(&self) -> Option<bool> {
95        match self {
96            AttrValue::Bool(b) => Some(*b),
97            _ => None,
98        }
99    }
100
101    /// The instant, when this is [`AttrValue::DateTime`].
102    pub fn as_datetime(&self) -> Option<DateTime<Utc>> {
103        match self {
104            AttrValue::DateTime(dt) => Some(*dt),
105            _ => None,
106        }
107    }
108
109    /// The JSON, when this is [`AttrValue::Json`].
110    pub fn as_json(&self) -> Option<&Value> {
111        match self {
112            AttrValue::Json(v) => Some(v),
113            _ => None,
114        }
115    }
116
117    /// This value as JSON, for [`Record::deserialize`]. Every variant maps to a
118    /// natural JSON type; a datetime becomes its RFC 3339 string.
119    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
176/// `None` becomes [`AttrValue::Null`], so an optional column reads naturally.
177impl<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/// Which stage of a save a listener is seeing.
184///
185/// Deletion is deliberately **not** a variant here. It has its own pair of
186/// listener methods ([`ModelListener::before_delete`] and `after_delete`),
187/// because a listener written for saves must not silently start receiving
188/// deletes: [`crate::listeners::Timestamps`], for one, stamps `updated_at` on
189/// every save, which is nonsense for a row being removed. Separate methods mean
190/// a listener opts into deletion rather than inheriting it.
191///
192/// Non-exhaustive so a further save stage stays additive.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194#[non_exhaustive]
195pub enum Op {
196    Create,
197    Update,
198}
199
200/// A dynamic record: an entity name, an optional id, and its attributes.
201#[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    /// An empty record for `entity`, with no id (the shape of a create).
211    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    /// An empty record for an existing row (the shape of an update).
221    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    /// The entity this record belongs to: a fixed table or a runtime content type.
231    pub fn entity(&self) -> &str {
232        &self.entity
233    }
234
235    /// The primary key, `None` until a create returns one.
236    pub fn id(&self) -> Option<i64> {
237        self.id
238    }
239
240    /// Records the id a create returned.
241    pub fn set_id(&mut self, id: i64) {
242        self.id = Some(id);
243    }
244
245    /// Sets an attribute, replacing any previous value. Chainable.
246    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    /// An attribute, or `None` when the key is absent. An attribute that is
252    /// present and null returns `Some(AttrValue::Null)`, which is a different
253    /// thing: absent means "not part of this write", null means "write NULL".
254    pub fn get(&self, key: &str) -> Option<&AttrValue> {
255        self.attrs.get(key)
256    }
257
258    /// Removes an attribute, returning it.
259    pub fn remove(&mut self, key: &str) -> Option<AttrValue> {
260        self.attrs.remove(key)
261    }
262
263    /// Whether the key is present, null or not.
264    pub fn contains(&self, key: &str) -> bool {
265        self.attrs.contains_key(key)
266    }
267
268    /// The attributes, in key order.
269    pub fn iter(&self) -> impl Iterator<Item = (&str, &AttrValue)> {
270        self.attrs.iter().map(|(k, v)| (k.as_str(), v))
271    }
272
273    /// How many attributes are set.
274    pub fn len(&self) -> usize {
275        self.attrs.len()
276    }
277
278    /// Whether no attribute is set.
279    pub fn is_empty(&self) -> bool {
280        self.attrs.is_empty()
281    }
282
283    /// The text at `key`, when it holds [`AttrValue::Text`].
284    pub fn text(&self, key: &str) -> Option<&str> {
285        self.get(key).and_then(AttrValue::as_str)
286    }
287
288    /// The integer at `key`.
289    pub fn int(&self, key: &str) -> Option<i64> {
290        self.get(key).and_then(AttrValue::as_int)
291    }
292
293    /// The float at `key`.
294    pub fn float(&self, key: &str) -> Option<f64> {
295        self.get(key).and_then(AttrValue::as_float)
296    }
297
298    /// The boolean at `key`.
299    pub fn bool(&self, key: &str) -> Option<bool> {
300        self.get(key).and_then(AttrValue::as_bool)
301    }
302
303    /// The instant at `key`.
304    pub fn datetime(&self, key: &str) -> Option<DateTime<Utc>> {
305        self.get(key).and_then(AttrValue::as_datetime)
306    }
307
308    /// The JSON at `key`.
309    pub fn json(&self, key: &str) -> Option<&Value> {
310        self.get(key).and_then(AttrValue::as_json)
311    }
312
313    /// Records the row as it stood before this write, read inside the same
314    /// transaction. The pipeline sets it on an update when the persister can
315    /// supply one.
316    pub fn set_original(&mut self, previous: Record) {
317        self.original = Some(previous.attrs);
318    }
319
320    /// Whether a pre-write snapshot is available. A create never has one, and
321    /// neither does an update whose persister does not supply one.
322    pub fn has_original(&self) -> bool {
323        self.original.is_some()
324    }
325
326    /// The pre-write value at `key`.
327    pub fn original(&self, key: &str) -> Option<&AttrValue> {
328        self.original.as_ref().and_then(|prev| prev.get(key))
329    }
330
331    /// Whether this write changes `key`. Always false without a snapshot, so a
332    /// change-based listener should check [`Record::has_original`] first rather
333    /// than read "unchanged" into its absence.
334    ///
335    /// The comparison is text-level today: a snapshot is read back as text, so a
336    /// listener that re-types an unchanged value (a date parsed into a
337    /// [`AttrValue::DateTime`]) reads as changed. Fine for stamping, not yet
338    /// sufficient for a per-attribute diff.
339    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    /// Reads the attributes into a struct, for code that would rather work with
347    /// types than a bag. Attributes the target does not name are ignored.
348    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    /// Builds a record from an all-text submission, the shape the form write
358    /// path uses today. Every value arrives as [`AttrValue::Text`], because a
359    /// string carries no type; typed values come from field types and listeners.
360    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    /// The record as an all-text map, for a storage layer that binds strings.
369    /// A null attribute becomes an empty string, matching that path's existing
370    /// "empty means none" contract.
371    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/// Who is performing a write. Every save states one, so a listener always has a
380/// total answer and a missing actor cannot be confused with a non-human write.
381///
382/// Non-exhaustive: an unauthenticated public submission joins when the public web
383/// surface gains a write path.
384#[derive(Debug, Clone, PartialEq, Eq)]
385#[non_exhaustive]
386pub enum Actor {
387    /// A signed-in operator.
388    User { id: i64, username: String },
389    /// The process itself, named for the trail: `seed`, `job:purge-sessions`,
390    /// `lat admin create`. Not a stand-in user row.
391    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    /// The user to attribute a row to, `None` when no person is behind the write.
409    pub fn user_id(&self) -> Option<i64> {
410        match self {
411            Actor::User { id, .. } => Some(*id),
412            _ => None,
413        }
414    }
415
416    /// The text a trail snapshots: the username, or the process name.
417    pub fn label(&self) -> &str {
418        match self {
419            Actor::User { username, .. } => username,
420            Actor::System { process } => process,
421        }
422    }
423}
424
425/// The open transaction a save runs in, shared by the listeners and the
426/// persister so a read sees the in-flight write.
427///
428/// Holding a transaction across listener code keeps the write consistent, at the
429/// cost of keeping it open: a listener should do its own work and return, not
430/// call out to a slow service.
431///
432/// Fields are private and read through accessors, so this can gain context after
433/// 1.0. `new` cannot: extra context arrives as a `with_*` builder instead.
434pub 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    /// Which database this is, for the portable query helpers.
450    pub fn backend(&self) -> DbBackend {
451        self.backend
452    }
453
454    /// The transaction's connection. Every read and write in a save goes here.
455    pub fn conn(&mut self) -> &mut sqlx::AnyConnection {
456        self.conn
457    }
458
459    /// Who is performing this write.
460    pub fn actor(&self) -> &Actor {
461        &self.actor
462    }
463}
464
465/// A committed write, for [`ModelListener::after_save`]. Carries the pool rather
466/// than the transaction, because the transaction is gone by then.
467///
468/// Fields are private for the same reason as [`SaveCx`].
469pub 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    /// The pool. `Db` is `Clone`, so a listener that spawns work clones it.
480    pub fn db(&self) -> &Db {
481        self.db
482    }
483
484    pub fn backend(&self) -> DbBackend {
485        self.db.backend
486    }
487
488    /// Who performed the write.
489    pub fn actor(&self) -> &Actor {
490        self.actor
491    }
492}
493
494/// Which entities a listener runs for.
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum ListenerTarget {
497    /// One entity, by name.
498    Entity(String),
499    /// Every entity, for a concern that genuinely applies to all of them, such
500    /// as auditing.
501    ///
502    /// Not for a listener that **adds** attributes: the built-in persister writes
503    /// whatever the record holds, so an attribute added for every entity reaches
504    /// tables that have no such column and fails their writes. Stamping a column
505    /// (timestamps, `updated_by`) targets the entities that have it.
506    All,
507}
508
509impl ListenerTarget {
510    /// Whether this target covers `entity`.
511    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/// Shapes a write as it happens.
520///
521/// [`before_save`](ModelListener::before_save) runs outside the storage layer's
522/// transaction and may change attributes or refuse the write.
523/// [`after_save`](ModelListener::after_save) runs once the write succeeded and
524/// is **not** atomic with it: work that must commit or roll back with the row
525/// belongs in the storage layer, not here.
526#[async_trait]
527pub trait ModelListener: Send + Sync + 'static {
528    /// Runs before the write, inside the transaction, so a read here sees the
529    /// in-flight state. Returning an [`ErrorBag`] refuses the write and rolls it
530    /// back, and those messages reach the operator against their fields.
531    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    /// Runs after the transaction commits, for side effects. Not atomic with the
542    /// write: it cannot roll one back, and its own failure leaves the row saved.
543    async fn after_save(&self, cx: &SavedCx<'_>, rec: &Record, op: Op) {
544        let _ = (cx, rec, op);
545    }
546
547    /// Runs before the row is removed, inside the transaction, holding the row
548    /// as it currently stands. Returning a message refuses the delete and rolls
549    /// it back, and the operator sees that message.
550    ///
551    /// This is where a listener guards its own references: a module that points
552    /// at this record refuses here rather than leaving a dangling row behind.
553    /// The record is read-only, since there is nothing to change about a row
554    /// being removed.
555    async fn before_delete(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<(), Text> {
556        let _ = (cx, rec);
557        Ok(())
558    }
559
560    /// Runs after the delete commits, for side effects such as removing files
561    /// the row owned. Not atomic with it, exactly as `after_save` is not: the
562    /// row is already gone, so failing here cannot bring it back.
563    async fn after_delete(&self, cx: &SavedCx<'_>, rec: &Record) {
564        let _ = (cx, rec);
565    }
566}
567
568/// A registered listener and the entities it runs for.
569pub struct ModelListenerReg {
570    pub target: ListenerTarget,
571    pub listener: Arc<dyn ModelListener>,
572}
573
574impl ModelListenerReg {
575    /// Registers a listener for one entity.
576    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    /// Registers a listener for every entity.
584    pub fn all(listener: Arc<dyn ModelListener>) -> Self {
585        Self {
586            target: ListenerTarget::All,
587            listener,
588        }
589    }
590
591    /// Whether this registration runs for `entity`.
592    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        // A typed read of the wrong kind is None, not a panic or a coercion.
627        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        // Null carries no storage text; absent is not in the map at all.
647        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        // A string carries no type, so everything arrives as text.
667        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        // Without a snapshot nothing reads as changed, so a listener must ask.
751        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}