Skip to main content

zer_core/
record.rs

1use std::borrow::Cow;
2
3use ahash::AHashMap;
4
5pub type RecordId  = u64;
6pub type FieldName = String;
7
8/// Typed value stored in a record field.
9#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
10pub enum FieldValue {
11    Text(String),
12    Int(i64),
13    UInt(u64),
14    Float(f64),
15    Bool(bool),
16    Bytes(Vec<u8>),
17    Null,
18}
19
20impl From<String> for FieldValue {
21    fn from(s: String) -> Self { FieldValue::Text(s) }
22}
23impl From<&str> for FieldValue {
24    fn from(s: &str) -> Self { FieldValue::Text(s.to_owned()) }
25}
26impl From<i64> for FieldValue {
27    fn from(i: i64) -> Self { FieldValue::Int(i) }
28}
29impl From<i32> for FieldValue {
30    fn from(i: i32) -> Self { FieldValue::Int(i as i64) }
31}
32impl From<u64> for FieldValue {
33    fn from(u: u64) -> Self { FieldValue::UInt(u) }
34}
35impl From<Vec<u8>> for FieldValue {
36    fn from(b: Vec<u8>) -> Self { FieldValue::Bytes(b) }
37}
38impl From<u32> for FieldValue {
39    fn from(u: u32) -> Self { FieldValue::UInt(u as u64) }
40}
41impl From<f64> for FieldValue {
42    fn from(f: f64) -> Self { FieldValue::Float(f) }
43}
44impl From<f32> for FieldValue {
45    fn from(f: f32) -> Self { FieldValue::Float(f as f64) }
46}
47impl From<bool> for FieldValue {
48    fn from(b: bool) -> Self { FieldValue::Bool(b) }
49}
50impl<T: Into<FieldValue>> From<Option<T>> for FieldValue {
51    fn from(opt: Option<T>) -> Self {
52        match opt {
53            Some(v) => v.into(),
54            None    => FieldValue::Null,
55        }
56    }
57}
58
59/// A single data record with a unique ID and a map of field values.
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
61pub struct Record {
62    pub id:     RecordId,
63    pub fields: AHashMap<FieldName, FieldValue>,
64    pub source: Option<String>,
65}
66
67impl Record {
68    pub fn new(id: RecordId) -> Self {
69        Self { id, fields: AHashMap::new(), source: None }
70    }
71
72    pub fn with_source(mut self, source: impl Into<String>) -> Self {
73        self.source = Some(source.into());
74        self
75    }
76
77    pub fn insert(mut self, name: impl Into<String>, value: impl Into<FieldValue>) -> Self {
78        self.fields.insert(name.into(), value.into());
79        self
80    }
81
82    pub fn get(&self, name: &str) -> Option<&FieldValue> {
83        self.fields.get(name)
84    }
85
86    pub fn text(&self, name: &str) -> Option<&str> {
87        match self.fields.get(name) {
88            Some(FieldValue::Text(s)) => Some(s.as_str()),
89            _ => None,
90        }
91    }
92
93    /// Returns the field value as a string, coercing non-text scalars to their string representation.
94    pub fn field_as_str(&self, name: &str) -> Option<Cow<'_, str>> {
95        match self.fields.get(name)? {
96            FieldValue::Text(s)  => Some(Cow::Borrowed(s.as_str())),
97            FieldValue::Int(i)   => Some(Cow::Owned(i.to_string())),
98            FieldValue::UInt(u)  => Some(Cow::Owned(u.to_string())),
99            FieldValue::Float(f) => Some(Cow::Owned(f.to_string())),
100            FieldValue::Bool(b)  => Some(Cow::Owned(b.to_string())),
101            FieldValue::Bytes(_) => None,
102            FieldValue::Null     => None,
103        }
104    }
105
106    /// Extract a typed value from a named field using the [`FromFieldValue`] trait.
107    ///
108    /// ```rust
109    /// use zer_core::record::{Record, FieldValue};
110    /// let r = Record::new(1).insert("lat", 52.37f64);
111    /// let lat: Option<f64> = r.field_as::<f64>("lat");
112    /// assert_eq!(lat, Some(52.37));
113    /// ```
114    pub fn field_as<T: FromFieldValue>(&self, name: &str) -> Option<T> {
115        self.fields.get(name).and_then(T::from_field_value)
116    }
117}
118
119/// Typed extraction from a [`FieldValue`].
120pub trait FromFieldValue: Sized {
121    fn from_field_value(v: &FieldValue) -> Option<Self>;
122}
123
124impl FromFieldValue for f64 {
125    fn from_field_value(v: &FieldValue) -> Option<Self> {
126        match v {
127            FieldValue::Float(f) => Some(*f),
128            FieldValue::Int(i)   => Some(*i as f64),
129            FieldValue::UInt(u)  => Some(*u as f64),
130            // Text fallback: typed data avoids the parse; string data still works.
131            FieldValue::Text(s)  => s.parse::<f64>().ok(),
132            _                    => None,
133        }
134    }
135}
136
137impl FromFieldValue for f32 {
138    fn from_field_value(v: &FieldValue) -> Option<Self> {
139        match v {
140            FieldValue::Float(f) => Some(*f as f32),
141            FieldValue::Int(i)   => Some(*i as f32),
142            FieldValue::UInt(u)  => Some(*u as f32),
143            FieldValue::Text(s)  => s.parse::<f32>().ok(),
144            _                    => None,
145        }
146    }
147}
148
149impl FromFieldValue for i64 {
150    fn from_field_value(v: &FieldValue) -> Option<Self> {
151        match v {
152            FieldValue::Int(i)  => Some(*i),
153            FieldValue::UInt(u) => i64::try_from(*u).ok(),
154            FieldValue::Text(s) => s.parse::<i64>().ok(),
155            _                   => None,
156        }
157    }
158}
159
160impl FromFieldValue for i32 {
161    fn from_field_value(v: &FieldValue) -> Option<Self> {
162        match v {
163            FieldValue::Int(i)  => i32::try_from(*i).ok(),
164            FieldValue::UInt(u) => i32::try_from(*u).ok(),
165            FieldValue::Text(s) => s.parse::<i32>().ok(),
166            _                   => None,
167        }
168    }
169}
170
171impl FromFieldValue for u64 {
172    fn from_field_value(v: &FieldValue) -> Option<Self> {
173        match v {
174            FieldValue::UInt(u) => Some(*u),
175            FieldValue::Int(i)  => u64::try_from(*i).ok(),
176            FieldValue::Text(s) => s.parse::<u64>().ok(),
177            _                   => None,
178        }
179    }
180}
181
182impl FromFieldValue for u32 {
183    fn from_field_value(v: &FieldValue) -> Option<Self> {
184        match v {
185            FieldValue::UInt(u) => u32::try_from(*u).ok(),
186            FieldValue::Int(i)  => u32::try_from(*i).ok(),
187            FieldValue::Text(s) => s.parse::<u32>().ok(),
188            _                   => None,
189        }
190    }
191}
192
193impl FromFieldValue for bool {
194    fn from_field_value(v: &FieldValue) -> Option<Self> {
195        match v {
196            FieldValue::Bool(b) => Some(*b),
197            _                   => None,
198        }
199    }
200}
201
202impl FromFieldValue for String {
203    fn from_field_value(v: &FieldValue) -> Option<Self> {
204        match v {
205            FieldValue::Text(s)  => Some(s.clone()),
206            FieldValue::Int(i)   => Some(i.to_string()),
207            FieldValue::UInt(u)  => Some(u.to_string()),
208            FieldValue::Float(f) => Some(f.to_string()),
209            FieldValue::Bool(b)  => Some(b.to_string()),
210            FieldValue::Bytes(_) | FieldValue::Null => None,
211        }
212    }
213}
214
215impl FromFieldValue for Vec<u8> {
216    fn from_field_value(v: &FieldValue) -> Option<Self> {
217        match v {
218            FieldValue::Bytes(b) => Some(b.clone()),
219            FieldValue::Text(s)  => Some(s.as_bytes().to_vec()),
220            _                    => None,
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn field_value_equality() {
231        assert_eq!(FieldValue::Text("hello".into()), FieldValue::Text("hello".into()));
232        assert_ne!(FieldValue::Int(1), FieldValue::Int(2));
233        assert_eq!(FieldValue::Null, FieldValue::Null);
234    }
235
236    #[test]
237    fn record_builder_chain() {
238        let r = Record::new(42)
239            .with_source("kvk")
240            .insert("name", "Alice")
241            .insert("age", 30i64);
242
243        assert_eq!(r.id, 42);
244        assert_eq!(r.source.as_deref(), Some("kvk"));
245        assert_eq!(r.text("name"), Some("Alice"));
246        assert_eq!(r.get("age"), Some(&FieldValue::Int(30)));
247        assert_eq!(r.get("missing"), None);
248    }
249
250    #[test]
251    fn field_as_str_coerces_scalars() {
252        let r = Record::new(1)
253            .insert("phone",  5551234567i64)
254            .insert("lat",    52.345f64)
255            .insert("active", true)
256            .insert("name",   "Alice")
257            .insert("empty",  FieldValue::Null);
258
259        assert_eq!(r.field_as_str("phone").as_deref(),  Some("5551234567"));
260        assert_eq!(r.field_as_str("lat").as_deref(),    Some("52.345"));
261        assert_eq!(r.field_as_str("active").as_deref(), Some("true"));
262        assert_eq!(r.field_as_str("name").as_deref(),   Some("Alice"));
263        assert_eq!(r.field_as_str("empty"),             None);
264        assert_eq!(r.field_as_str("missing"),           None);
265    }
266
267    #[test]
268    fn from_impls_roundtrip() {
269        assert_eq!(FieldValue::from("hello"),     FieldValue::Text("hello".into()));
270        assert_eq!(FieldValue::from(42i64),       FieldValue::Int(42));
271        assert_eq!(FieldValue::from(3.14f64),     FieldValue::Float(3.14));
272        assert_eq!(FieldValue::from(true),        FieldValue::Bool(true));
273        assert_eq!(FieldValue::from(Some("hi")),  FieldValue::Text("hi".into()));
274        assert_eq!(FieldValue::from(None::<&str>), FieldValue::Null);
275        // u64 now produces UInt, not Int
276        assert_eq!(FieldValue::from(u64::MAX),    FieldValue::UInt(u64::MAX));
277        // bytes roundtrip
278        assert_eq!(FieldValue::from(vec![1u8, 2, 3]), FieldValue::Bytes(vec![1, 2, 3]));
279    }
280
281    #[test]
282    fn field_as_str_new_variants() {
283        let r = Record::new(1)
284            .insert("count", 42u64)
285            .insert("data",  FieldValue::Bytes(vec![0xff]));
286        assert_eq!(r.field_as_str("count").as_deref(), Some("42"));
287        assert_eq!(r.field_as_str("data"),             None);
288    }
289
290    #[test]
291    fn field_as_typed() {
292        let r = Record::new(1)
293            .insert("lat",    52.37f64)
294            .insert("count",  10u64)
295            .insert("age",    30i64)
296            .insert("active", true)
297            .insert("name",   "Alice")
298            .insert("blob",   FieldValue::Bytes(vec![1, 2, 3]));
299
300        assert_eq!(r.field_as::<f64>("lat"),    Some(52.37));
301        assert_eq!(r.field_as::<f32>("lat"),    Some(52.37f32));
302        assert_eq!(r.field_as::<u64>("count"),  Some(10u64));
303        assert_eq!(r.field_as::<i64>("count"),  Some(10i64));
304        assert_eq!(r.field_as::<i64>("age"),    Some(30i64));
305        assert_eq!(r.field_as::<bool>("active"), Some(true));
306        assert_eq!(r.field_as::<String>("name"), Some("Alice".to_string()));
307        assert_eq!(r.field_as::<Vec<u8>>("blob"), Some(vec![1u8, 2, 3]));
308        assert_eq!(r.field_as::<f64>("missing"), None);
309    }
310
311    #[test]
312    fn field_as_cross_variant_coercions() {
313        let r = Record::new(1)
314            .insert("int_val",  100i64)
315            .insert("uint_val", 200u64);
316
317        // Int → f64
318        assert_eq!(r.field_as::<f64>("int_val"), Some(100.0));
319        // UInt → f64
320        assert_eq!(r.field_as::<f64>("uint_val"), Some(200.0));
321        // UInt → i64 (in range)
322        assert_eq!(r.field_as::<i64>("uint_val"), Some(200i64));
323        // Int → u64 (non-negative)
324        assert_eq!(r.field_as::<u64>("int_val"), Some(100u64));
325
326        // negative Int → u64 fails
327        let r2 = Record::new(2).insert("neg", -1i64);
328        assert_eq!(r2.field_as::<u64>("neg"), None);
329    }
330}