Skip to main content

faucet_source_snowflake/
convert.rs

1//! Type-aware conversion from Snowflake SQL REST API rows to JSON objects.
2//!
3//! The Snowflake SQL REST API returns every cell as a string, regardless of
4//! its column type, and ships a schema (`resultSetMetaData.rowType`) alongside
5//! the rows. This module pairs each cell with its column metadata and
6//! produces a `serde_json::Value::Object` per row.
7//!
8//! Type coverage (Snowflake's `type` field is lowercase in the JSON v2 format):
9//!
10//! | Snowflake type          | JSON output                              |
11//! |-------------------------|------------------------------------------|
12//! | `fixed`                 | `Number` (i64 if it fits, else f64)      |
13//! | `real`                  | `Number` (f64)                           |
14//! | `boolean`               | `Bool`                                   |
15//! | `text` / `binary`       | `String`                                 |
16//! | `date` / `time` / `timestamp_*` | `String` (ISO/Unix-seconds as-is) |
17//! | `variant` / `object` / `array` | parsed JSON value (falls back to `String`) |
18//! | anything else           | `String` (raw cell)                      |
19//!
20//! `null` cells map to `Value::Null` regardless of declared type.
21
22use serde::Deserialize;
23use serde_json::{Map, Value};
24
25/// One entry in `resultSetMetaData.rowType`. Only the fields we actually use
26/// are deserialised; everything else is ignored.
27#[derive(Debug, Clone, Deserialize)]
28pub struct ColumnMeta {
29    /// Column name as Snowflake reports it. Typically uppercase for
30    /// unquoted identifiers; we keep the casing untouched.
31    pub name: String,
32    /// Low-level Snowflake type — lowercased identifiers like `"fixed"`,
33    /// `"text"`, `"boolean"`, `"variant"`, `"timestamp_ntz"`. See the
34    /// Snowflake [JSON v2 result format
35    /// docs](https://docs.snowflake.com/en/developer-guide/sql-api/handling-responses).
36    #[serde(rename = "type")]
37    pub ty: String,
38}
39
40/// Build a JSON object out of one Snowflake row (an array of string cells).
41///
42/// `row` is expected to have the same length as `columns`. If the row is
43/// shorter, missing trailing cells are treated as `null`; if it is longer,
44/// the extras are silently ignored.
45pub fn row_to_json(row: &[Value], columns: &[ColumnMeta]) -> Value {
46    let mut obj = Map::with_capacity(columns.len());
47    for (i, col) in columns.iter().enumerate() {
48        let cell = row.get(i);
49        obj.insert(col.name.clone(), cell_to_json(cell, &col.ty));
50    }
51    Value::Object(obj)
52}
53
54/// Convert a single Snowflake cell to a typed `serde_json::Value`.
55fn cell_to_json(cell: Option<&Value>, ty: &str) -> Value {
56    let Some(cell) = cell else {
57        return Value::Null;
58    };
59    // SQL REST API ships `null` as a JSON null in the array.
60    if cell.is_null() {
61        return Value::Null;
62    }
63    // Pre-typed values (already a JSON number / bool / object) are passed
64    // through verbatim. This is defensive — Snowflake's documented JSON v2
65    // format always wraps cells as strings, but tolerating already-typed
66    // values keeps the converter robust against future format tweaks.
67    let s = match cell {
68        Value::String(s) => s.as_str(),
69        other => return other.clone(),
70    };
71
72    match ty.to_ascii_lowercase().as_str() {
73        "fixed" => parse_number(s),
74        "real" => parse_real(s),
75        "boolean" => parse_bool(s),
76        "variant" | "object" | "array" => {
77            serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.to_owned()))
78        }
79        _ => Value::String(s.to_owned()),
80    }
81}
82
83/// Parse an integer-valued column (`FIXED`/`NUMBER` with scale 0 ships an
84/// integer literal here; non-zero-scale columns ship a decimal string and
85/// fall back to `f64`).
86///
87/// A scale-0 value beyond `i64`/`u64` (e.g. `NUMBER(38,0)`) would lose
88/// precision as `f64`, so it is kept as a string (lossless), matching how
89/// BigQuery NUMERIC is handled. Decimal/scientific literals (non-zero scale)
90/// still fall back to `f64`.
91fn parse_number(s: &str) -> Value {
92    if let Ok(i) = s.parse::<i64>() {
93        return Value::Number(i.into());
94    }
95    if let Ok(u) = s.parse::<u64>() {
96        return Value::Number(u.into());
97    }
98    // Past u64: only an integer literal stays a (lossless) string; a decimal or
99    // scientific value is genuinely floating-point, so let `parse_real` handle it.
100    let digits = s.trim().strip_prefix(['+', '-']).unwrap_or(s.trim());
101    if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
102        return Value::String(s.to_owned());
103    }
104    parse_real(s)
105}
106
107/// Parse a floating-point column. Non-finite values (`Infinity`, `NaN`) are
108/// not representable in JSON and fall back to a string for round-trip safety.
109fn parse_real(s: &str) -> Value {
110    match s.parse::<f64>() {
111        Ok(f) => serde_json::Number::from_f64(f)
112            .map(Value::Number)
113            .unwrap_or_else(|| Value::String(s.to_owned())),
114        Err(_) => Value::String(s.to_owned()),
115    }
116}
117
118/// Parse a boolean column. Snowflake ships booleans as `"true"`/`"false"`
119/// (lowercase) in the JSON v2 format, but we accept the canonical Snowflake
120/// SQL forms (`"TRUE"`/`"FALSE"`, `"1"`/`"0"`) too.
121fn parse_bool(s: &str) -> Value {
122    match s {
123        "true" | "TRUE" | "1" => Value::Bool(true),
124        "false" | "FALSE" | "0" => Value::Bool(false),
125        other => Value::String(other.to_owned()),
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use serde_json::json;
133
134    fn col(name: &str, ty: &str) -> ColumnMeta {
135        ColumnMeta {
136            name: name.into(),
137            ty: ty.into(),
138        }
139    }
140
141    #[test]
142    fn fixed_parses_as_integer() {
143        let row = [json!("42")];
144        let cols = [col("ID", "fixed")];
145        assert_eq!(row_to_json(&row, &cols), json!({"ID": 42}));
146    }
147
148    #[test]
149    fn fixed_decimal_falls_back_to_float() {
150        let row = [json!("2.5")];
151        let cols = [col("RATIO", "fixed")];
152        assert_eq!(row_to_json(&row, &cols), json!({"RATIO": 2.5}));
153    }
154
155    #[test]
156    fn fixed_past_i64_within_u64_stays_numeric() {
157        // A FIXED/NUMBER(20,0) value above i64::MAX but within u64 range is
158        // still losslessly representable as a JSON integer — keep it numeric
159        // instead of dropping precision through f64.
160        let row = [json!("18446744073709551615")]; // u64::MAX
161        let cols = [col("ID", "fixed")];
162        assert_eq!(
163            row_to_json(&row, &cols),
164            json!({"ID": 18446744073709551615u64})
165        );
166    }
167
168    #[test]
169    fn fixed_beyond_u64_kept_as_string_lossless() {
170        // A NUMBER(38,0) value beyond u64 can't be a JSON integer; keep it as a
171        // string (lossless) rather than a lossy f64.
172        let row = [json!("123456789012345678901234567890")];
173        let cols = [col("ID", "fixed")];
174        assert_eq!(
175            row_to_json(&row, &cols),
176            json!({"ID": "123456789012345678901234567890"})
177        );
178    }
179
180    #[test]
181    fn real_parses_as_float() {
182        let row = [json!("0.5")];
183        let cols = [col("X", "real")];
184        assert_eq!(row_to_json(&row, &cols), json!({"X": 0.5}));
185    }
186
187    #[test]
188    fn boolean_parses_lowercase() {
189        let row = [json!("true"), json!("false")];
190        let cols = [col("A", "boolean"), col("B", "boolean")];
191        assert_eq!(row_to_json(&row, &cols), json!({"A": true, "B": false}));
192    }
193
194    #[test]
195    fn variant_parses_inner_json() {
196        let row = [json!(r#"{"k":1}"#)];
197        let cols = [col("DATA", "variant")];
198        assert_eq!(row_to_json(&row, &cols), json!({"DATA": {"k": 1}}));
199    }
200
201    #[test]
202    fn variant_falls_back_to_string_on_invalid_json() {
203        let row = [json!("not-json")];
204        let cols = [col("DATA", "variant")];
205        assert_eq!(row_to_json(&row, &cols), json!({"DATA": "not-json"}));
206    }
207
208    #[test]
209    fn text_passes_through() {
210        let row = [json!("hello")];
211        let cols = [col("NAME", "text")];
212        assert_eq!(row_to_json(&row, &cols), json!({"NAME": "hello"}));
213    }
214
215    #[test]
216    fn null_cell_maps_to_null() {
217        let row = [json!(null)];
218        let cols = [col("X", "fixed")];
219        assert_eq!(row_to_json(&row, &cols), json!({"X": null}));
220    }
221
222    #[test]
223    fn missing_trailing_cell_maps_to_null() {
224        let row: [Value; 0] = [];
225        let cols = [col("X", "text")];
226        assert_eq!(row_to_json(&row, &cols), json!({"X": null}));
227    }
228
229    #[test]
230    fn timestamp_passes_through_as_string() {
231        let row = [json!("1700000000.000000000")];
232        let cols = [col("TS", "timestamp_ntz")];
233        assert_eq!(
234            row_to_json(&row, &cols),
235            json!({"TS": "1700000000.000000000"})
236        );
237    }
238
239    #[test]
240    fn unknown_type_passes_through_as_string() {
241        let row = [json!("0xDEADBEEF")];
242        let cols = [col("BLOB", "binary")];
243        assert_eq!(row_to_json(&row, &cols), json!({"BLOB": "0xDEADBEEF"}));
244    }
245
246    #[test]
247    fn non_finite_real_falls_back_to_string() {
248        let row = [json!("NaN")];
249        let cols = [col("X", "real")];
250        // NaN is not representable in JSON; round-trip-safe fallback.
251        assert_eq!(row_to_json(&row, &cols), json!({"X": "NaN"}));
252    }
253}