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}