Skip to main content

link_common/models/
kalam_cell_value.rs

1//! KalamCellValue — type-safe wrapper for individual cell values
2//!
3//! Replaces raw `serde_json::Value` in query results and subscription
4//! notifications while keeping the exact same JSON wire format via
5//! `#[serde(transparent)]`.
6//!
7//! # Two row shapes, one cell type
8//!
9//! ```text
10//! Query results  → Vec<Vec<KalamCellValue>>          (positional)
11//! Subscriptions  → HashMap<String, KalamCellValue>   (named)
12//! ```
13//!
14//! # Typed accessors — one per KalamDataType
15//!
16//! Call the method that matches the column's declared `KalamDataType`:
17//!
18//! | KalamDataType | Method            | Return type        |
19//! |---------------|-------------------|--------------------|
20//! | Text          | `as_text()`       | `Option<&str>`     |
21//! | Boolean       | `as_boolean()`    | `Option<bool>`     |
22//! | SmallInt      | `as_small_int()`  | `Option<i16>`      |
23//! | Int           | `as_int()`        | `Option<i32>`      |
24//! | BigInt        | `as_big_int()`    | `Option<i64>`      |
25//! | Float         | `as_float()`      | `Option<f32>`      |
26//! | Double        | `as_double()`     | `Option<f64>`      |
27//! | Decimal       | `as_decimal()`    | `Option<f64>`      |
28//! | Timestamp     | `as_timestamp()`  | `Option<i64>` (µs since epoch) |
29//! | Date          | `as_date()`       | `Option<i32>` (days since epoch) |
30//! | DateTime      | `as_datetime()`   | `Option<i64>` (µs since epoch) |
31//! | Time          | `as_time()`       | `Option<i64>` (µs since midnight) |
32//! | Uuid          | `as_uuid()`       | `Option<&str>`     |
33//! | Json          | `as_json()`       | `Option<&JsonValue>` |
34//! | Bytes         | `as_bytes()`      | `Option<Vec<u8>>` (base64-decoded) |
35//! | Embedding     | `as_embedding()`  | `Option<Vec<f32>>` |
36//! | File          | `as_file()`       | `Option<FileRef>`  |
37//!
38//! # Wire format
39//!
40//! Serializes identically to `serde_json::Value` — no breaking changes:
41//! ```json
42//! "Alice"          // Text / Uuid
43//! 42               // Int / BigInt
44//! true             // Boolean
45//! null             // Null
46//! "1699000000000"  // Timestamp (string-encoded for i64 precision)
47//! {"id":"..."}     // File column (JSON object)
48//! ```
49
50use std::{fmt, ops::Deref};
51
52use serde::{Deserialize, Serialize};
53use serde_json::Value as JsonValue;
54
55/// A single cell value in a query result row or subscription notification.
56///
57/// Thin wrapper around [`serde_json::Value`] with `#[serde(transparent)]`
58/// for zero-cost JSON (de)serialization. Implements [`Deref`] to `JsonValue`
59/// so all existing `serde_json` accessor methods (`.as_str()`, `.is_null()`,
60/// `.as_i64()`, …) continue to work unchanged.
61///
62/// Use the typed accessor methods (e.g. [`as_big_int`][Self::as_big_int],
63/// [`as_file`][Self::as_file]) to get values in the correct Rust type
64/// matching the column's [`KalamDataType`][super::KalamDataType].
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(transparent)]
67pub struct KalamCellValue(pub JsonValue);
68
69// ── Constructors ────────────────────────────────────────────────────────────
70
71impl KalamCellValue {
72    /// Null cell value.
73    #[inline]
74    pub fn null() -> Self {
75        Self(JsonValue::Null)
76    }
77
78    /// Text (string) cell value.
79    #[inline]
80    pub fn text(s: impl Into<String>) -> Self {
81        Self(JsonValue::String(s.into()))
82    }
83
84    /// Boolean cell value.
85    #[inline]
86    pub fn boolean(b: bool) -> Self {
87        Self(JsonValue::Bool(b))
88    }
89
90    /// Integer cell value (`Int` / `BigInt` / `SmallInt`).
91    #[inline]
92    pub fn int(i: i64) -> Self {
93        Self(JsonValue::Number(i.into()))
94    }
95
96    /// Floating-point cell value (`Float` / `Double`).
97    ///
98    /// Returns `None` if the value is NaN or infinity (not representable in JSON).
99    #[inline]
100    pub fn float(f: f64) -> Option<Self> {
101        serde_json::Number::from_f64(f).map(|n| Self(JsonValue::Number(n)))
102    }
103
104    /// Cell value from a raw JSON value.
105    #[inline]
106    pub fn from_json(value: JsonValue) -> Self {
107        Self(value)
108    }
109}
110
111// ── Raw accessors ─────────────────────────────────────────────────────────
112
113impl KalamCellValue {
114    /// Borrow the inner `serde_json::Value`.
115    #[inline]
116    pub fn inner(&self) -> &JsonValue {
117        &self.0
118    }
119
120    /// Consume `self` and return the inner `serde_json::Value`.
121    #[inline]
122    pub fn into_inner(self) -> JsonValue {
123        self.0
124    }
125}
126
127// ── Typed accessors — one per KalamDataType ──────────────────────────────────
128
129impl KalamCellValue {
130    // ── Text ──────────────────────────────────────────────────────────────
131
132    /// Extract the cell as a UTF-8 string slice (`Text` columns).
133    #[inline]
134    pub fn as_text(&self) -> Option<&str> {
135        self.0.as_str()
136    }
137
138    // ── Numeric ───────────────────────────────────────────────────────────
139
140    /// Extract the cell as a 16-bit integer (`SmallInt` columns).
141    ///
142    /// Handles both JSON numbers and string-encoded integers.
143    pub fn as_small_int(&self) -> Option<i16> {
144        match &self.0 {
145            JsonValue::Number(n) => n.as_i64().map(|v| v as i16),
146            JsonValue::String(s) => s.parse().ok(),
147            _ => None,
148        }
149    }
150
151    /// Extract the cell as a 32-bit integer (`Int` columns).
152    ///
153    /// Handles both JSON numbers and string-encoded integers.
154    pub fn as_int(&self) -> Option<i32> {
155        match &self.0 {
156            JsonValue::Number(n) => n.as_i64().map(|v| v as i32),
157            JsonValue::String(s) => s.parse().ok(),
158            _ => None,
159        }
160    }
161
162    /// Extract the cell as a 64-bit integer (`BigInt` columns).
163    ///
164    /// The backend serializes `BigInt` as a JSON string to preserve precision —
165    /// this method handles both the string and numeric forms.
166    pub fn as_big_int(&self) -> Option<i64> {
167        match &self.0 {
168            JsonValue::Number(n) => n.as_i64(),
169            JsonValue::String(s) => s.parse().ok(),
170            _ => None,
171        }
172    }
173
174    /// Extract the cell as a 32-bit float (`Float` columns).
175    ///
176    /// Handles both JSON numbers and string-encoded floats.
177    pub fn as_float(&self) -> Option<f32> {
178        match &self.0 {
179            JsonValue::Number(n) => n.as_f64().map(|v| v as f32),
180            JsonValue::String(s) => s.parse().ok(),
181            _ => None,
182        }
183    }
184
185    /// Extract the cell as a 64-bit float (`Double` columns).
186    ///
187    /// Handles both JSON numbers and string-encoded floats.
188    pub fn as_double(&self) -> Option<f64> {
189        match &self.0 {
190            JsonValue::Number(n) => n.as_f64(),
191            JsonValue::String(s) => s.parse().ok(),
192            _ => None,
193        }
194    }
195
196    /// Extract the cell as a `f64` (`Decimal` columns).
197    ///
198    /// Handles both JSON numbers and string-encoded decimal values.
199    pub fn as_decimal(&self) -> Option<f64> {
200        match &self.0 {
201            JsonValue::Number(n) => n.as_f64(),
202            JsonValue::String(s) => s.parse().ok(),
203            _ => None,
204        }
205    }
206
207    // ── Boolean ───────────────────────────────────────────────────────────
208
209    /// Extract the cell as a boolean (`Boolean` columns).
210    #[inline]
211    pub fn as_boolean(&self) -> Option<bool> {
212        self.0.as_bool()
213    }
214
215    // ── Temporal ──────────────────────────────────────────────────────────
216
217    /// Extract the cell as microseconds since Unix epoch (`Timestamp` columns).
218    ///
219    /// KalamDB serializes timestamps as strings to avoid JSON integer
220    /// precision loss — this method handles both string and numeric forms.
221    pub fn as_timestamp(&self) -> Option<i64> {
222        match &self.0 {
223            JsonValue::Number(n) => n.as_i64(),
224            JsonValue::String(s) => s.parse().ok(),
225            _ => None,
226        }
227    }
228
229    /// Extract the cell as days since Unix epoch (`Date` columns).
230    pub fn as_date(&self) -> Option<i32> {
231        match &self.0 {
232            JsonValue::Number(n) => n.as_i64().map(|v| v as i32),
233            JsonValue::String(s) => s.parse().ok(),
234            _ => None,
235        }
236    }
237
238    /// Extract the cell as microseconds since Unix epoch (`DateTime` columns).
239    pub fn as_datetime(&self) -> Option<i64> {
240        match &self.0 {
241            JsonValue::Number(n) => n.as_i64(),
242            JsonValue::String(s) => s.parse().ok(),
243            _ => None,
244        }
245    }
246
247    /// Extract the cell as microseconds since midnight (`Time` columns).
248    pub fn as_time(&self) -> Option<i64> {
249        match &self.0 {
250            JsonValue::Number(n) => n.as_i64(),
251            JsonValue::String(s) => s.parse().ok(),
252            _ => None,
253        }
254    }
255
256    // ── Identity / structured ─────────────────────────────────────────────
257
258    /// Extract the cell as a UUID string (`Uuid` columns).
259    #[inline]
260    pub fn as_uuid(&self) -> Option<&str> {
261        self.0.as_str()
262    }
263
264    /// Extract the cell as a raw JSON value (`Json` columns).
265    ///
266    /// Returns `Some` only when the cell contains a JSON object or array.
267    pub fn as_json(&self) -> Option<&JsonValue> {
268        match &self.0 {
269            JsonValue::Object(_) | JsonValue::Array(_) => Some(&self.0),
270            _ => None,
271        }
272    }
273
274    /// Extract the cell as raw bytes, base64-decoded (`Bytes` columns).
275    ///
276    /// KalamDB stores binary data as base64-encoded strings.
277    pub fn as_bytes(&self) -> Option<Vec<u8>> {
278        use base64::{engine::general_purpose::STANDARD, Engine as _};
279        self.0.as_str().and_then(|s| STANDARD.decode(s).ok())
280    }
281
282    // ── Vector / ML ───────────────────────────────────────────────────────
283
284    /// Extract the cell as a float32 vector (`Embedding` columns).
285    ///
286    /// Returns `None` if any element cannot be parsed as a number.
287    pub fn as_embedding(&self) -> Option<Vec<f32>> {
288        self.0.as_array().and_then(|arr| {
289            arr.iter().map(|v| v.as_f64().map(|f| f as f32)).collect::<Option<Vec<_>>>()
290        })
291    }
292
293    // ── File ──────────────────────────────────────────────────────────────
294
295    /// Extract the cell as a [`FileRef`] (`File` columns).
296    ///
297    /// FILE columns are stored as a JSON object (or a JSON-encoded string).
298    /// This method handles both representations automatically.
299    ///
300    /// # Example
301    /// ```rust
302    /// use kalam_client::KalamCellValue;
303    ///
304    /// // From a JSON object
305    /// let cell = KalamCellValue::from(serde_json::json!({
306    ///     "id": "123", "sub": "f0001", "name": "photo.png",
307    ///     "size": 1024, "mime": "image/png", "sha256": "abc"
308    /// }));
309    /// let file_ref = cell.as_file().unwrap();
310    /// assert_eq!(file_ref.name, "photo.png");
311    /// assert!(file_ref.is_image());
312    /// ```
313    pub fn as_file(&self) -> Option<super::file_ref::FileRef> {
314        super::file_ref::FileRef::from_json_value(&self.0)
315    }
316}
317
318// ── Trait impls ─────────────────────────────────────────────────────────────
319
320impl Deref for KalamCellValue {
321    type Target = JsonValue;
322
323    #[inline]
324    fn deref(&self) -> &Self::Target {
325        &self.0
326    }
327}
328
329impl From<JsonValue> for KalamCellValue {
330    #[inline]
331    fn from(v: JsonValue) -> Self {
332        Self(v)
333    }
334}
335
336impl From<KalamCellValue> for JsonValue {
337    #[inline]
338    fn from(cell: KalamCellValue) -> Self {
339        cell.0
340    }
341}
342
343impl fmt::Display for KalamCellValue {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        match &self.0 {
346            JsonValue::Null => write!(f, "NULL"),
347            JsonValue::String(s) => write!(f, "{s}"),
348            JsonValue::Number(n) => write!(f, "{n}"),
349            JsonValue::Bool(b) => write!(f, "{b}"),
350            other => write!(f, "{other}"),
351        }
352    }
353}
354
355impl Default for KalamCellValue {
356    #[inline]
357    fn default() -> Self {
358        Self::null()
359    }
360}
361
362/// Type alias for a subscription row — named columns.
363pub type RowData = std::collections::HashMap<String, KalamCellValue>;
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn transparent_roundtrip() {
371        let cell = KalamCellValue::text("hello");
372        let json = serde_json::to_string(&cell).unwrap();
373        assert_eq!(json, r#""hello""#);
374        let back: KalamCellValue = serde_json::from_str(&json).unwrap();
375        assert_eq!(back, cell);
376    }
377
378    #[test]
379    fn null_cell() {
380        let cell = KalamCellValue::null();
381        assert!(cell.is_null());
382        assert_eq!(cell.to_string(), "NULL");
383    }
384
385    #[test]
386    fn int_cell() {
387        let cell = KalamCellValue::int(42);
388        assert_eq!(cell.as_i64(), Some(42));
389    }
390
391    #[test]
392    fn deref_access() {
393        let cell = KalamCellValue::text("world");
394        assert_eq!(cell.as_str(), Some("world"));
395    }
396
397    #[test]
398    fn from_json_value() {
399        let jv = serde_json::json!({"key": "val"});
400        let cell = KalamCellValue::from(jv.clone());
401        assert_eq!(*cell, jv);
402    }
403
404    #[test]
405    fn into_json_value() {
406        let cell = KalamCellValue::boolean(true);
407        let jv: JsonValue = cell.into();
408        assert_eq!(jv, JsonValue::Bool(true));
409    }
410}