Skip to main content

omni_dev/snowflake/client/
row.rs

1//! Result rows and arbitrary-schema → JSON decoding.
2//!
3//! Snowflake's v1 query endpoint returns column metadata (`rowtype`) plus a
4//! row set whose cells are stringified (or null). This module models a column,
5//! a row, and the mapping from a raw cell to a `serde_json::Value` driven by the
6//! column's Snowflake type tag — implemented from the documented wire formats
7//! (e.g. `DATE` is days-since-epoch, timestamps are `seconds[.fraction]`).
8
9use std::collections::HashMap;
10use std::sync::Arc;
11
12use chrono::{DateTime, Days, NaiveDate, NaiveTime, Utc};
13use serde_json::{json, Map, Value};
14
15/// Metadata for one result column, from the query response `rowtype`.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct Column {
18    /// Column name as Snowflake reported it.
19    pub name: String,
20    /// Lowercase Snowflake type tag (e.g. `fixed`, `text`, `timestamp_ntz`).
21    pub ty: String,
22    /// Whether the column may be null.
23    pub nullable: bool,
24    /// Declared length (text), when reported.
25    pub length: Option<i64>,
26    /// Declared precision (number), when reported.
27    pub precision: Option<i64>,
28    /// Declared scale (number/time/timestamp), when reported.
29    pub scale: Option<i64>,
30}
31
32impl Column {
33    /// A compact, lowercase type label with parameters when reported (e.g.
34    /// `fixed(10,2)`, `text(255)`, `timestamp_ntz(9)`).
35    #[must_use]
36    pub fn type_label(&self) -> String {
37        match (self.precision, self.scale, self.length) {
38            (Some(p), Some(s), _) => format!("{}({p},{s})", self.ty),
39            (Some(p), None, _) => format!("{}({p})", self.ty),
40            (None, Some(s), _) => format!("{}({s})", self.ty),
41            (None, None, Some(l)) => format!("{}({l})", self.ty),
42            _ => self.ty.clone(),
43        }
44    }
45}
46
47/// One result row: raw stringified cells plus the shared column schema.
48#[derive(Clone, Debug)]
49pub struct Row {
50    values: Vec<Option<String>>,
51    columns: Arc<Vec<Column>>,
52    index: Arc<HashMap<String, usize>>,
53}
54
55impl Row {
56    /// Builds a row from its raw cells and the shared schema/index.
57    #[must_use]
58    pub fn new(
59        values: Vec<Option<String>>,
60        columns: Arc<Vec<Column>>,
61        index: Arc<HashMap<String, usize>>,
62    ) -> Self {
63        Self {
64            values,
65            columns,
66            index,
67        }
68    }
69
70    /// The shared column schema.
71    #[must_use]
72    pub fn columns(&self) -> &[Column] {
73        &self.columns
74    }
75
76    /// The raw cell value at `index`, if present.
77    #[must_use]
78    pub fn raw_at(&self, index: usize) -> Option<&str> {
79        self.values.get(index).and_then(|v| v.as_deref())
80    }
81
82    /// The raw cell value for a column name (case-insensitive), if present.
83    #[must_use]
84    pub fn raw(&self, name: &str) -> Option<&str> {
85        self.index
86            .get(&name.to_ascii_uppercase())
87            .and_then(|&i| self.raw_at(i))
88    }
89
90    /// Converts the row to a JSON object keyed by column name.
91    ///
92    /// Repeated column names (e.g. `SELECT 1, 1`) are disambiguated with a
93    /// `_<n>` suffix so no column is silently dropped — JSON object keys must be
94    /// unique.
95    #[must_use]
96    pub fn to_json_object(&self) -> Map<String, Value> {
97        let mut map = Map::with_capacity(self.columns.len());
98        let mut seen: HashMap<&str, u32> = HashMap::new();
99        for (i, col) in self.columns.iter().enumerate() {
100            let raw = self.values.get(i).and_then(|v| v.as_deref());
101            let value = value_to_json(raw, col);
102            let count = seen.entry(col.name.as_str()).or_insert(0);
103            *count += 1;
104            let key = if *count == 1 {
105                col.name.clone()
106            } else {
107                format!("{}_{}", col.name, *count)
108            };
109            map.insert(key, value);
110        }
111        map
112    }
113}
114
115/// Builds the self-describing `{ columns: [{name, type}], rows: [{col: val}] }`
116/// payload from a query's rows. Column metadata comes from the first row (an
117/// empty result reports `columns: []`).
118#[must_use]
119pub fn rows_to_payload(rows: &[Row]) -> Value {
120    let columns: Vec<Value> = rows.first().map_or_else(Vec::new, |row| {
121        row.columns()
122            .iter()
123            .map(|c| json!({ "name": c.name, "type": c.type_label() }))
124            .collect()
125    });
126    let out: Vec<Value> = rows
127        .iter()
128        .map(|r| Value::Object(r.to_json_object()))
129        .collect();
130    json!({ "columns": columns, "rows": out })
131}
132
133/// Builds the uniform multi-statement payload from one row set per statement.
134///
135/// The shape is `{ statements: [ {columns, rows}, … ] }`; a single-statement
136/// query is a one-element `statements` array, so every query result is uniform.
137#[must_use]
138pub fn rows_to_multi_payload(statements: &[Vec<Row>]) -> Value {
139    let statements: Vec<Value> = statements
140        .iter()
141        .map(|rows| rows_to_payload(rows))
142        .collect();
143    json!({ "statements": statements })
144}
145
146/// Maps a single raw cell to JSON according to its column type. Total: any
147/// decode failure falls back to the raw string (or `null`).
148#[must_use]
149pub fn value_to_json(raw: Option<&str>, col: &Column) -> Value {
150    let Some(s) = raw else { return Value::Null };
151    match col.ty.as_str() {
152        "fixed" if col.scale.unwrap_or(0) == 0 => s
153            .parse::<i64>()
154            .map_or_else(|_| Value::String(s.to_string()), |n| json!(n)),
155        "fixed" | "real" | "float" | "double" | "double precision" => number_or_string(s),
156        "boolean" => match s {
157            "1" => json!(true),
158            "0" => json!(false),
159            _ if s.eq_ignore_ascii_case("true") => json!(true),
160            _ if s.eq_ignore_ascii_case("false") => json!(false),
161            _ => Value::String(s.to_string()),
162        },
163        "date" => decode_date(s),
164        "time" => decode_time(s),
165        "timestamp_ntz" => decode_epoch(s).map_or_else(
166            || Value::String(s.to_string()),
167            |dt| json!(dt.naive_utc().format("%Y-%m-%dT%H:%M:%S%.f").to_string()),
168        ),
169        "timestamp_ltz" => decode_epoch(s)
170            .map_or_else(|| Value::String(s.to_string()), |dt| json!(dt.to_rfc3339())),
171        "timestamp_tz" => decode_timestamp_tz(s),
172        "variant" | "object" | "array" => {
173            serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.to_string()))
174        }
175        // text/varchar/char/string, binary (hex), geography, geometry, vector,
176        // and any unknown type: the raw wire string.
177        _ => Value::String(s.to_string()),
178    }
179}
180
181/// Parses a float string to a JSON number, falling back to the raw string for
182/// values JSON cannot represent.
183fn number_or_string(s: &str) -> Value {
184    s.parse::<f64>()
185        .ok()
186        .and_then(serde_json::Number::from_f64)
187        .map_or_else(|| Value::String(s.to_string()), Value::Number)
188}
189
190/// `DATE` is days since the Unix epoch.
191fn decode_date(s: &str) -> Value {
192    let Ok(days) = s.parse::<i64>() else {
193        return Value::String(s.to_string());
194    };
195    let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap_or_default();
196    let date = if days >= 0 {
197        epoch.checked_add_days(Days::new(days.unsigned_abs()))
198    } else {
199        epoch.checked_sub_days(Days::new(days.unsigned_abs()))
200    };
201    date.map_or_else(
202        || Value::String(s.to_string()),
203        |d| json!(d.format("%Y-%m-%d").to_string()),
204    )
205}
206
207/// `TIME` is seconds-from-midnight with an optional fractional part.
208fn decode_time(s: &str) -> Value {
209    let Some((secs, nanos)) = split_seconds(s) else {
210        return Value::String(s.to_string());
211    };
212    if secs < 0 {
213        return Value::String(s.to_string());
214    }
215    NaiveTime::from_num_seconds_from_midnight_opt(secs.unsigned_abs() as u32, nanos).map_or_else(
216        || Value::String(s.to_string()),
217        |t| json!(t.format("%H:%M:%S%.f").to_string()),
218    )
219}
220
221/// `TIMESTAMP_TZ` wire form is `"<epoch> <offset-minutes-plus-1440>"`; we keep
222/// the UTC instant (the offset is advisory and dropped here).
223fn decode_timestamp_tz(s: &str) -> Value {
224    let epoch = s.split_whitespace().next().unwrap_or(s);
225    decode_epoch(epoch).map_or_else(|| Value::String(s.to_string()), |dt| json!(dt.to_rfc3339()))
226}
227
228/// Parses an `seconds[.fraction]` epoch value into a UTC instant.
229fn decode_epoch(s: &str) -> Option<DateTime<Utc>> {
230    let (secs, nanos) = split_seconds(s)?;
231    DateTime::from_timestamp(secs, nanos)
232}
233
234/// Splits a signed `seconds[.fraction]` string into `(seconds, nanoseconds)`,
235/// flooring toward negative infinity so fractional pre-epoch values are correct.
236fn split_seconds(s: &str) -> Option<(i64, u32)> {
237    let s = s.trim();
238    let negative = s.starts_with('-');
239    let (secs_str, frac_str) = s.split_once('.').unwrap_or((s, ""));
240    let mut secs = secs_str.parse::<i64>().ok()?;
241    if frac_str.is_empty() {
242        return Some((secs, 0));
243    }
244    if !frac_str.bytes().all(|b| b.is_ascii_digit()) {
245        return None;
246    }
247    // Pad/truncate the fraction to exactly 9 digits (nanoseconds).
248    let mut digits: Vec<u8> = frac_str.bytes().take(9).collect();
249    digits.resize(9, b'0');
250    let mut nanos = std::str::from_utf8(&digits).ok()?.parse::<u32>().ok()?;
251    if negative && nanos > 0 {
252        // "-1.5s" = floor(-1.5)=-2 plus 0.5s; "-0.x" parses secs as 0.
253        secs -= 1;
254        nanos = 1_000_000_000 - nanos;
255    }
256    Some((secs, nanos))
257}
258
259#[cfg(test)]
260#[allow(clippy::unwrap_used, clippy::expect_used)]
261mod tests {
262    use super::*;
263
264    fn col(ty: &str, precision: Option<i64>, scale: Option<i64>, length: Option<i64>) -> Column {
265        Column {
266            name: "C".to_string(),
267            ty: ty.to_string(),
268            nullable: true,
269            length,
270            precision,
271            scale,
272        }
273    }
274
275    #[test]
276    fn type_label_renders_parameters() {
277        assert_eq!(
278            col("fixed", Some(10), Some(2), None).type_label(),
279            "fixed(10,2)"
280        );
281        assert_eq!(col("text", None, None, Some(255)).type_label(), "text(255)");
282        assert_eq!(
283            col("timestamp_ntz", None, None, None).type_label(),
284            "timestamp_ntz"
285        );
286    }
287
288    #[test]
289    fn null_is_json_null() {
290        assert_eq!(
291            value_to_json(None, &col("fixed", None, Some(0), None)),
292            Value::Null
293        );
294    }
295
296    #[test]
297    fn decodes_each_type() {
298        assert_eq!(
299            value_to_json(Some("42"), &col("fixed", Some(38), Some(0), None)),
300            json!(42)
301        );
302        let big = "123456789012345678901234567890";
303        assert_eq!(
304            value_to_json(Some(big), &col("fixed", Some(38), Some(0), None)),
305            json!(big),
306            "overflowing integer keeps the exact string"
307        );
308        assert_eq!(
309            value_to_json(Some("123.45"), &col("fixed", Some(10), Some(2), None)),
310            json!(123.45)
311        );
312        assert_eq!(
313            value_to_json(Some("1.5"), &col("real", None, None, None)),
314            json!(1.5)
315        );
316        assert_eq!(
317            value_to_json(Some("1"), &col("boolean", None, None, None)),
318            json!(true)
319        );
320        assert_eq!(
321            value_to_json(Some("0"), &col("boolean", None, None, None)),
322            json!(false)
323        );
324        assert_eq!(
325            value_to_json(Some("hello"), &col("text", None, None, Some(255))),
326            json!("hello")
327        );
328        assert_eq!(
329            value_to_json(Some("0"), &col("date", None, None, None)),
330            json!("1970-01-01")
331        );
332        assert_eq!(
333            value_to_json(Some("19723"), &col("date", None, None, None)),
334            json!("2024-01-01")
335        );
336        assert_eq!(
337            value_to_json(Some("3661"), &col("time", None, None, Some(0))),
338            json!("01:01:01")
339        );
340        assert_eq!(
341            value_to_json(
342                Some("1704067200"),
343                &col("timestamp_ntz", None, None, Some(0))
344            ),
345            json!("2024-01-01T00:00:00")
346        );
347        assert_eq!(
348            value_to_json(
349                Some("1704067200"),
350                &col("timestamp_ltz", None, None, Some(0))
351            ),
352            json!("2024-01-01T00:00:00+00:00")
353        );
354        assert_eq!(
355            value_to_json(
356                Some("1704067200.000 1440"),
357                &col("timestamp_tz", None, None, Some(3))
358            ),
359            json!("2024-01-01T00:00:00+00:00")
360        );
361        assert_eq!(
362            value_to_json(Some(r#"{"a":1}"#), &col("variant", None, None, None)),
363            json!({ "a": 1 })
364        );
365        assert_eq!(
366            value_to_json(Some("DEADBEEF"), &col("binary", None, None, None)),
367            json!("DEADBEEF")
368        );
369    }
370
371    #[test]
372    fn malformed_value_falls_back_to_raw() {
373        assert_eq!(
374            value_to_json(Some("maybe"), &col("boolean", None, None, None)),
375            json!("maybe")
376        );
377    }
378
379    #[test]
380    fn row_to_json_keys_by_column_name() {
381        let columns = Arc::new(vec![
382            Column {
383                name: "ID".to_string(),
384                ty: "fixed".to_string(),
385                nullable: false,
386                length: None,
387                precision: Some(38),
388                scale: Some(0),
389            },
390            Column {
391                name: "NAME".to_string(),
392                ty: "text".to_string(),
393                nullable: true,
394                length: None,
395                precision: None,
396                scale: None,
397            },
398        ]);
399        let mut index = HashMap::new();
400        index.insert("ID".to_string(), 0);
401        index.insert("NAME".to_string(), 1);
402        let row = Row::new(
403            vec![Some("1".to_string()), Some("hi".to_string())],
404            columns,
405            Arc::new(index),
406        );
407        let obj = row.to_json_object();
408        assert_eq!(obj.get("ID"), Some(&json!(1)));
409        assert_eq!(obj.get("NAME"), Some(&json!("hi")));
410        assert_eq!(obj.len(), 2);
411    }
412
413    #[test]
414    fn duplicate_column_names_are_disambiguated() {
415        let dup = || Column {
416            name: "N".to_string(),
417            ty: "fixed".to_string(),
418            nullable: false,
419            length: None,
420            precision: Some(38),
421            scale: Some(0),
422        };
423        let columns = Arc::new(vec![dup(), dup()]);
424        let mut index = HashMap::new();
425        index.insert("N".to_string(), 0);
426        let row = Row::new(
427            vec![Some("1".to_string()), Some("2".to_string())],
428            columns,
429            Arc::new(index),
430        );
431        let obj = row.to_json_object();
432        assert_eq!(obj.get("N"), Some(&json!(1)));
433        assert_eq!(obj.get("N_2"), Some(&json!(2)), "second N is not dropped");
434        assert_eq!(obj.len(), 2);
435    }
436
437    #[test]
438    fn rows_to_multi_payload_wraps_each_statement() {
439        let columns = Arc::new(vec![Column {
440            name: "N".to_string(),
441            ty: "fixed".to_string(),
442            nullable: false,
443            length: None,
444            precision: Some(38),
445            scale: Some(0),
446        }]);
447        let index = Arc::new(HashMap::from([("N".to_string(), 0)]));
448        let row = Row::new(vec![Some("1".to_string())], columns, index);
449
450        // Even a single statement is a one-element `statements` array.
451        let one = rows_to_multi_payload(&[vec![row.clone()]]);
452        assert_eq!(one["statements"].as_array().unwrap().len(), 1);
453        assert_eq!(one["statements"][0]["rows"][0]["N"], json!(1));
454
455        // Two statements → two entries, with an empty result set preserved.
456        let two = rows_to_multi_payload(&[vec![row], vec![]]);
457        let stmts = two["statements"].as_array().unwrap();
458        assert_eq!(stmts.len(), 2);
459        assert_eq!(stmts[1], json!({ "columns": [], "rows": [] }));
460    }
461}