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;
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 write a listener is seeing.
184///
185/// Non-exhaustive: deletion joins when the delete path lands.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187#[non_exhaustive]
188pub enum Op {
189    Create,
190    Update,
191}
192
193/// A dynamic record: an entity name, an optional id, and its attributes.
194#[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    /// An empty record for `entity`, with no id (the shape of a create).
204    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    /// An empty record for an existing row (the shape of an update).
214    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    /// The entity this record belongs to: a fixed table or a runtime content type.
224    pub fn entity(&self) -> &str {
225        &self.entity
226    }
227
228    /// The primary key, `None` until a create returns one.
229    pub fn id(&self) -> Option<i64> {
230        self.id
231    }
232
233    /// Records the id a create returned.
234    pub fn set_id(&mut self, id: i64) {
235        self.id = Some(id);
236    }
237
238    /// Sets an attribute, replacing any previous value. Chainable.
239    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    /// An attribute, or `None` when the key is absent. An attribute that is
245    /// present and null returns `Some(AttrValue::Null)`, which is a different
246    /// thing: absent means "not part of this write", null means "write NULL".
247    pub fn get(&self, key: &str) -> Option<&AttrValue> {
248        self.attrs.get(key)
249    }
250
251    /// Removes an attribute, returning it.
252    pub fn remove(&mut self, key: &str) -> Option<AttrValue> {
253        self.attrs.remove(key)
254    }
255
256    /// Whether the key is present, null or not.
257    pub fn contains(&self, key: &str) -> bool {
258        self.attrs.contains_key(key)
259    }
260
261    /// The attributes, in key order.
262    pub fn iter(&self) -> impl Iterator<Item = (&str, &AttrValue)> {
263        self.attrs.iter().map(|(k, v)| (k.as_str(), v))
264    }
265
266    /// How many attributes are set.
267    pub fn len(&self) -> usize {
268        self.attrs.len()
269    }
270
271    /// Whether no attribute is set.
272    pub fn is_empty(&self) -> bool {
273        self.attrs.is_empty()
274    }
275
276    /// The text at `key`, when it holds [`AttrValue::Text`].
277    pub fn text(&self, key: &str) -> Option<&str> {
278        self.get(key).and_then(AttrValue::as_str)
279    }
280
281    /// The integer at `key`.
282    pub fn int(&self, key: &str) -> Option<i64> {
283        self.get(key).and_then(AttrValue::as_int)
284    }
285
286    /// The float at `key`.
287    pub fn float(&self, key: &str) -> Option<f64> {
288        self.get(key).and_then(AttrValue::as_float)
289    }
290
291    /// The boolean at `key`.
292    pub fn bool(&self, key: &str) -> Option<bool> {
293        self.get(key).and_then(AttrValue::as_bool)
294    }
295
296    /// The instant at `key`.
297    pub fn datetime(&self, key: &str) -> Option<DateTime<Utc>> {
298        self.get(key).and_then(AttrValue::as_datetime)
299    }
300
301    /// The JSON at `key`.
302    pub fn json(&self, key: &str) -> Option<&Value> {
303        self.get(key).and_then(AttrValue::as_json)
304    }
305
306    /// Records the row as it stood before this write, read inside the same
307    /// transaction. The pipeline sets it on an update when the persister can
308    /// supply one.
309    pub fn set_original(&mut self, previous: Record) {
310        self.original = Some(previous.attrs);
311    }
312
313    /// Whether a pre-write snapshot is available. A create never has one, and
314    /// neither does an update whose persister does not supply one.
315    pub fn has_original(&self) -> bool {
316        self.original.is_some()
317    }
318
319    /// The pre-write value at `key`.
320    pub fn original(&self, key: &str) -> Option<&AttrValue> {
321        self.original.as_ref().and_then(|prev| prev.get(key))
322    }
323
324    /// Whether this write changes `key`. Always false without a snapshot, so a
325    /// change-based listener should check [`Record::has_original`] first rather
326    /// than read "unchanged" into its absence.
327    ///
328    /// The comparison is text-level today: a snapshot is read back as text, so a
329    /// listener that re-types an unchanged value (a date parsed into a
330    /// [`AttrValue::DateTime`]) reads as changed. Fine for stamping, not yet
331    /// sufficient for a per-attribute diff.
332    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    /// Reads the attributes into a struct, for code that would rather work with
340    /// types than a bag. Attributes the target does not name are ignored.
341    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    /// Builds a record from an all-text submission, the shape the form write
351    /// path uses today. Every value arrives as [`AttrValue::Text`], because a
352    /// string carries no type; typed values come from field types and listeners.
353    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    /// The record as an all-text map, for a storage layer that binds strings.
362    /// A null attribute becomes an empty string, matching that path's existing
363    /// "empty means none" contract.
364    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/// Who is performing a write. Every save states one, so a listener always has a
373/// total answer and a missing actor cannot be confused with a non-human write.
374///
375/// Non-exhaustive: an unauthenticated public submission joins when the public web
376/// surface gains a write path.
377#[derive(Debug, Clone, PartialEq, Eq)]
378#[non_exhaustive]
379pub enum Actor {
380    /// A signed-in operator.
381    User { id: i64, username: String },
382    /// The process itself, named for the trail: `seed`, `job:purge-sessions`,
383    /// `lat admin create`. Not a stand-in user row.
384    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    /// The user to attribute a row to, `None` when no person is behind the write.
402    pub fn user_id(&self) -> Option<i64> {
403        match self {
404            Actor::User { id, .. } => Some(*id),
405            _ => None,
406        }
407    }
408
409    /// The text a trail snapshots: the username, or the process name.
410    pub fn label(&self) -> &str {
411        match self {
412            Actor::User { username, .. } => username,
413            Actor::System { process } => process,
414        }
415    }
416}
417
418/// The open transaction a save runs in, shared by the listeners and the
419/// persister so a read sees the in-flight write.
420///
421/// Holding a transaction across listener code keeps the write consistent, at the
422/// cost of keeping it open: a listener should do its own work and return, not
423/// call out to a slow service.
424///
425/// Fields are private and read through accessors, so this can gain context after
426/// 1.0. `new` cannot: extra context arrives as a `with_*` builder instead.
427pub 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    /// Which database this is, for the portable query helpers.
443    pub fn backend(&self) -> DbBackend {
444        self.backend
445    }
446
447    /// The transaction's connection. Every read and write in a save goes here.
448    pub fn conn(&mut self) -> &mut sqlx::AnyConnection {
449        self.conn
450    }
451
452    /// Who is performing this write.
453    pub fn actor(&self) -> &Actor {
454        &self.actor
455    }
456}
457
458/// A committed write, for [`ModelListener::after_save`]. Carries the pool rather
459/// than the transaction, because the transaction is gone by then.
460///
461/// Fields are private for the same reason as [`SaveCx`].
462pub 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    /// The pool. `Db` is `Clone`, so a listener that spawns work clones it.
473    pub fn db(&self) -> &Db {
474        self.db
475    }
476
477    pub fn backend(&self) -> DbBackend {
478        self.db.backend
479    }
480
481    /// Who performed the write.
482    pub fn actor(&self) -> &Actor {
483        self.actor
484    }
485}
486
487/// Which entities a listener runs for.
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub enum ListenerTarget {
490    /// One entity, by name.
491    Entity(String),
492    /// Every entity, for a concern that genuinely applies to all of them, such
493    /// as auditing.
494    ///
495    /// Not for a listener that **adds** attributes: the built-in persister writes
496    /// whatever the record holds, so an attribute added for every entity reaches
497    /// tables that have no such column and fails their writes. Stamping a column
498    /// (timestamps, `updated_by`) targets the entities that have it.
499    All,
500}
501
502impl ListenerTarget {
503    /// Whether this target covers `entity`.
504    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/// Shapes a write as it happens.
513///
514/// [`before_save`](ModelListener::before_save) runs outside the storage layer's
515/// transaction and may change attributes or refuse the write.
516/// [`after_save`](ModelListener::after_save) runs once the write succeeded and
517/// is **not** atomic with it: work that must commit or roll back with the row
518/// belongs in the storage layer, not here.
519#[async_trait]
520pub trait ModelListener: Send + Sync + 'static {
521    /// Runs before the write, inside the transaction, so a read here sees the
522    /// in-flight state. Returning an [`ErrorBag`] refuses the write and rolls it
523    /// back, and those messages reach the operator against their fields.
524    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    /// Runs after the transaction commits, for side effects. Not atomic with the
535    /// write: it cannot roll one back, and its own failure leaves the row saved.
536    async fn after_save(&self, cx: &SavedCx<'_>, rec: &Record, op: Op) {
537        let _ = (cx, rec, op);
538    }
539}
540
541/// A registered listener and the entities it runs for.
542pub struct ModelListenerReg {
543    pub target: ListenerTarget,
544    pub listener: Arc<dyn ModelListener>,
545}
546
547impl ModelListenerReg {
548    /// Registers a listener for one entity.
549    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    /// Registers a listener for every entity.
557    pub fn all(listener: Arc<dyn ModelListener>) -> Self {
558        Self {
559            target: ListenerTarget::All,
560            listener,
561        }
562    }
563
564    /// Whether this registration runs for `entity`.
565    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        // A typed read of the wrong kind is None, not a panic or a coercion.
600        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        // Null carries no storage text; absent is not in the map at all.
620        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        // A string carries no type, so everything arrives as text.
640        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        // Without a snapshot nothing reads as changed, so a listener must ask.
724        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}