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`, scale 0        | `Number` (i64/u64 if it fits, else `String` — full precision) |
13//! | `fixed`, scale > 0      | `String` (exact decimal — full precision preserved) |
14//! | `real`                  | `Number` (f64)                           |
15//! | `boolean`               | `Bool`                                   |
16//! | `text` / `binary`       | `String`                                 |
17//! | `date` / `time` / `timestamp_*` | `String` (ISO/Unix-seconds as-is) |
18//! | `variant` / `object` / `array` | parsed JSON value (falls back to `String`) |
19//! | anything else           | `String` (raw cell)                      |
20//!
21//! `null` cells map to `Value::Null` regardless of declared type.
22
23use serde::Deserialize;
24use serde_json::{Map, Value};
25
26/// One entry in `resultSetMetaData.rowType`. Only the fields we actually use
27/// are deserialised; everything else is ignored.
28#[derive(Debug, Clone, Deserialize)]
29pub struct ColumnMeta {
30    /// Column name as Snowflake reports it. Typically uppercase for
31    /// unquoted identifiers; we keep the casing untouched.
32    pub name: String,
33    /// Low-level Snowflake type — lowercased identifiers like `"fixed"`,
34    /// `"text"`, `"boolean"`, `"variant"`, `"timestamp_ntz"`. See the
35    /// Snowflake [JSON v2 result format
36    /// docs](https://docs.snowflake.com/en/developer-guide/sql-api/handling-responses).
37    #[serde(rename = "type")]
38    pub ty: String,
39    /// Scale of a `fixed` (`NUMBER`/`DECIMAL`/`NUMERIC`) column — the number of
40    /// digits after the decimal point. Snowflake reports `0` for integer-typed
41    /// fixed columns and `> 0` for fractional ones. Absent for non-fixed types
42    /// (defaults to `0`). A non-zero scale means the cell carries a fractional
43    /// decimal whose exact value is preserved losslessly as a JSON string,
44    /// matching how the BigQuery source treats `NUMERIC`/`BIGNUMERIC`.
45    #[serde(default)]
46    pub scale: i64,
47}
48
49/// Build a JSON object out of one Snowflake row (an array of string cells).
50///
51/// `row` is expected to have the same length as `columns`. If the row is
52/// shorter, missing trailing cells are treated as `null`; if it is longer,
53/// the extras are silently ignored.
54pub fn row_to_json(row: &[Value], columns: &[ColumnMeta]) -> Value {
55    let mut obj = Map::with_capacity(columns.len());
56    for (i, col) in columns.iter().enumerate() {
57        let cell = row.get(i);
58        obj.insert(col.name.clone(), cell_to_json(cell, &col.ty, col.scale));
59    }
60    Value::Object(obj)
61}
62
63/// Convert a single Snowflake cell to a typed `serde_json::Value`.
64///
65/// `scale` is the column's reported decimal scale (only meaningful for `fixed`
66/// columns; `0` otherwise).
67fn cell_to_json(cell: Option<&Value>, ty: &str, scale: i64) -> Value {
68    let Some(cell) = cell else {
69        return Value::Null;
70    };
71    // SQL REST API ships `null` as a JSON null in the array.
72    if cell.is_null() {
73        return Value::Null;
74    }
75    // Pre-typed values (already a JSON number / bool / object) are passed
76    // through verbatim. This is defensive — Snowflake's documented JSON v2
77    // format always wraps cells as strings, but tolerating already-typed
78    // values keeps the converter robust against future format tweaks.
79    let s = match cell {
80        Value::String(s) => s.as_str(),
81        other => return other.clone(),
82    };
83
84    match ty.to_ascii_lowercase().as_str() {
85        "fixed" => parse_number(s, scale),
86        "real" => parse_real(s),
87        "boolean" => parse_bool(s),
88        "variant" | "object" | "array" => {
89            serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.to_owned()))
90        }
91        _ => Value::String(s.to_owned()),
92    }
93}
94
95/// Parse a `FIXED` (`NUMBER`/`DECIMAL`/`NUMERIC`) column value losslessly.
96///
97/// Snowflake reports a `scale` for fixed-point columns:
98///
99/// - **scale > 0** (any `NUMBER(p,s)`/`DECIMAL`/`NUMERIC` with `s > 0`, i.e.
100///   every monetary/decimal column): the cell is a fractional decimal whose
101///   exact value cannot in general be represented by an `f64`
102///   (`serde_json` is built here *without* `arbitrary_precision`, so a JSON
103///   number is always an `f64`). To honor the connector's documented
104///   full-precision contract we keep the **exact decimal text as a JSON
105///   string**, matching how the BigQuery source preserves `NUMERIC`/
106///   `BIGNUMERIC`. We only fall back to numeric parsing when the value is not
107///   a well-formed finite decimal (defensive — never observed from Snowflake).
108/// - **scale 0** (integer-typed `NUMBER(p,0)`): parsed as a JSON integer when
109///   it fits `i64`/`u64`; a value beyond `u64` (e.g. `NUMBER(38,0)`) is kept
110///   as a lossless string rather than dropping precision through `f64`.
111///
112/// This is also robust when the scale metadata is missing/unreliable: a `fixed`
113/// cell whose text is a fractional decimal is preserved as a string regardless
114/// of the reported scale.
115fn parse_number(s: &str, scale: i64) -> Value {
116    let trimmed = s.trim();
117
118    if scale > 0 || is_fractional_decimal(trimmed) {
119        // Fractional fixed value — preserve the exact decimal text losslessly
120        // as a string when it is a well-formed finite decimal. A non-decimal /
121        // non-finite token (shouldn't occur for `fixed`) falls back to numeric
122        // parsing for round-trip safety.
123        if is_finite_decimal(trimmed) {
124            return Value::String(s.to_owned());
125        }
126        return parse_real(s);
127    }
128
129    if let Ok(i) = s.parse::<i64>() {
130        return Value::Number(i.into());
131    }
132    if let Ok(u) = s.parse::<u64>() {
133        return Value::Number(u.into());
134    }
135    // Past u64: only an integer literal stays a (lossless) string; a decimal or
136    // scientific value is genuinely floating-point, so let `parse_real` handle it.
137    let digits = trimmed.strip_prefix(['+', '-']).unwrap_or(trimmed);
138    if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
139        return Value::String(s.to_owned());
140    }
141    parse_real(s)
142}
143
144/// True when `s` is a finite decimal literal containing a fractional component
145/// (a `.` with digits, or a scientific exponent) — i.e. not a plain integer.
146fn is_fractional_decimal(s: &str) -> bool {
147    is_finite_decimal(s) && (s.contains('.') || s.contains(['e', 'E']))
148}
149
150/// True when `s` is a well-formed finite decimal literal: an optional sign,
151/// decimal digits with at most one decimal point, and an optional scientific
152/// exponent. Rejects `NaN`/`Infinity` and any non-numeric token, so they fall
153/// through to `parse_real`'s round-trip-safe handling.
154fn is_finite_decimal(s: &str) -> bool {
155    let body = s.strip_prefix(['+', '-']).unwrap_or(s);
156    if body.is_empty() {
157        return false;
158    }
159
160    // Split off an optional scientific exponent (`e`/`E` + signed integer).
161    let (mantissa, exponent) = match body.split_once(['e', 'E']) {
162        Some((m, e)) => (m, Some(e)),
163        None => (body, None),
164    };
165
166    // Mantissa: digits with at most one decimal point, and at least one digit.
167    let mut seen_dot = false;
168    let mut seen_digit = false;
169    for b in mantissa.bytes() {
170        match b {
171            b'0'..=b'9' => seen_digit = true,
172            b'.' if !seen_dot => seen_dot = true,
173            _ => return false,
174        }
175    }
176    if !seen_digit {
177        return false;
178    }
179
180    // Exponent (if present): optional sign + at least one digit.
181    if let Some(exp) = exponent {
182        let exp_digits = exp.strip_prefix(['+', '-']).unwrap_or(exp);
183        if exp_digits.is_empty() || !exp_digits.bytes().all(|b| b.is_ascii_digit()) {
184            return false;
185        }
186    }
187
188    true
189}
190
191/// Parse a floating-point column. Non-finite values (`Infinity`, `NaN`) are
192/// not representable in JSON and fall back to a string for round-trip safety.
193fn parse_real(s: &str) -> Value {
194    match s.parse::<f64>() {
195        Ok(f) => serde_json::Number::from_f64(f)
196            .map(Value::Number)
197            .unwrap_or_else(|| Value::String(s.to_owned())),
198        Err(_) => Value::String(s.to_owned()),
199    }
200}
201
202/// Parse a boolean column. Snowflake ships booleans as `"true"`/`"false"`
203/// (lowercase) in the JSON v2 format, but we accept the canonical Snowflake
204/// SQL forms (`"TRUE"`/`"FALSE"`, `"1"`/`"0"`) too.
205fn parse_bool(s: &str) -> Value {
206    match s {
207        "true" | "TRUE" | "1" => Value::Bool(true),
208        "false" | "FALSE" | "0" => Value::Bool(false),
209        other => Value::String(other.to_owned()),
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde_json::json;
217
218    fn col(name: &str, ty: &str) -> ColumnMeta {
219        ColumnMeta {
220            name: name.into(),
221            ty: ty.into(),
222            scale: 0,
223        }
224    }
225
226    /// A `fixed` column with an explicit (non-zero) decimal scale.
227    fn col_scaled(name: &str, scale: i64) -> ColumnMeta {
228        ColumnMeta {
229            name: name.into(),
230            ty: "fixed".into(),
231            scale,
232        }
233    }
234
235    #[test]
236    fn fixed_parses_as_integer() {
237        let row = [json!("42")];
238        let cols = [col("ID", "fixed")];
239        assert_eq!(row_to_json(&row, &cols), json!({"ID": 42}));
240    }
241
242    #[test]
243    fn fixed_fractional_preserves_exact_decimal_as_string() {
244        // A `fixed`/NUMBER column with non-zero scale ships a fractional
245        // decimal. Decoding it as f64 (the previous behavior) silently lost
246        // precision; the exact decimal text is preserved as a string instead,
247        // matching the BigQuery source's NUMERIC handling.
248        let row = [json!("2.5")];
249        let cols = [col_scaled("RATIO", 1)];
250        assert_eq!(row_to_json(&row, &cols), json!({"RATIO": "2.5"}));
251    }
252
253    #[test]
254    fn fixed_high_precision_decimal_preserves_all_digits() {
255        // 29 significant digits + 9 fractional — far beyond f64's ~15–17
256        // significant digits. Round-tripping through f64 would corrupt this.
257        let row = [json!("12345678901234567890.123456789")];
258        let cols = [col_scaled("AMOUNT", 9)];
259        let out = row_to_json(&row, &cols);
260        assert_eq!(out, json!({"AMOUNT": "12345678901234567890.123456789"}));
261        // Assert the serialized JSON is byte-exact — no digit dropped, no
262        // float rounding, value emitted as a JSON string.
263        assert_eq!(
264            serde_json::to_string(&out).unwrap(),
265            r#"{"AMOUNT":"12345678901234567890.123456789"}"#
266        );
267    }
268
269    #[test]
270    fn fixed_monetary_amount_preserves_exact_value() {
271        let row = [json!("1234.56")];
272        let cols = [col_scaled("PRICE", 2)];
273        let out = row_to_json(&row, &cols);
274        assert_eq!(out, json!({"PRICE": "1234.56"}));
275        assert_eq!(
276            serde_json::to_string(&out).unwrap(),
277            r#"{"PRICE":"1234.56"}"#
278        );
279    }
280
281    #[test]
282    fn fixed_negative_fractional_preserves_exact_value() {
283        let row = [json!("-0.0000000001")];
284        let cols = [col_scaled("DELTA", 10)];
285        assert_eq!(row_to_json(&row, &cols), json!({"DELTA": "-0.0000000001"}));
286    }
287
288    #[test]
289    fn fixed_scale_zero_integer_stays_json_integer() {
290        // Scale-0 fixed values that fit i64 remain JSON integers.
291        let row = [json!("100"), json!("-7")];
292        let cols = [col_scaled("A", 0), col_scaled("B", 0)];
293        assert_eq!(row_to_json(&row, &cols), json!({"A": 100, "B": -7}));
294        let out = row_to_json(&row, &cols);
295        assert_eq!(serde_json::to_string(&out).unwrap(), r#"{"A":100,"B":-7}"#);
296    }
297
298    #[test]
299    fn fixed_fractional_detected_without_scale_metadata() {
300        // Defensive: even if scale metadata is missing/zero, a `fixed` cell
301        // whose text is fractional is preserved losslessly as a string rather
302        // than dropping precision through f64.
303        let row = [json!("3.141592653589793238462643383279")];
304        let cols = [col("PI", "fixed")]; // scale defaults to 0
305        assert_eq!(
306            row_to_json(&row, &cols),
307            json!({"PI": "3.141592653589793238462643383279"})
308        );
309    }
310
311    #[test]
312    fn fixed_scientific_notation_preserved_as_string() {
313        // A scaled fixed value rendered in scientific notation is still a
314        // fractional decimal — keep its exact text.
315        let row = [json!("1.5e10")];
316        let cols = [col_scaled("X", 4)];
317        assert_eq!(row_to_json(&row, &cols), json!({"X": "1.5e10"}));
318    }
319
320    #[test]
321    fn fixed_scale_present_in_column_metadata_deserializes() {
322        // The `scale` field is read from Snowflake's rowType metadata.
323        let meta: ColumnMeta =
324            serde_json::from_value(json!({"name": "AMT", "type": "fixed", "scale": 2})).unwrap();
325        assert_eq!(meta.scale, 2);
326        // Absent scale defaults to 0.
327        let meta0: ColumnMeta =
328            serde_json::from_value(json!({"name": "ID", "type": "fixed"})).unwrap();
329        assert_eq!(meta0.scale, 0);
330    }
331
332    #[test]
333    fn fixed_non_decimal_token_falls_back_to_real() {
334        // Defensive guard: a `fixed` column reporting scale>0 but carrying a
335        // non-finite/garbage token must not be emitted as a misleading numeric
336        // string. It falls back to parse_real (string for non-finite).
337        let row = [json!("NaN")];
338        let cols = [col_scaled("X", 2)];
339        assert_eq!(row_to_json(&row, &cols), json!({"X": "NaN"}));
340    }
341
342    #[test]
343    fn fixed_past_i64_within_u64_stays_numeric() {
344        // A FIXED/NUMBER(20,0) value above i64::MAX but within u64 range is
345        // still losslessly representable as a JSON integer — keep it numeric
346        // instead of dropping precision through f64.
347        let row = [json!("18446744073709551615")]; // u64::MAX
348        let cols = [col("ID", "fixed")];
349        assert_eq!(
350            row_to_json(&row, &cols),
351            json!({"ID": 18446744073709551615u64})
352        );
353    }
354
355    #[test]
356    fn fixed_beyond_u64_kept_as_string_lossless() {
357        // A NUMBER(38,0) value beyond u64 can't be a JSON integer; keep it as a
358        // string (lossless) rather than a lossy f64.
359        let row = [json!("123456789012345678901234567890")];
360        let cols = [col("ID", "fixed")];
361        assert_eq!(
362            row_to_json(&row, &cols),
363            json!({"ID": "123456789012345678901234567890"})
364        );
365    }
366
367    #[test]
368    fn real_parses_as_float() {
369        let row = [json!("0.5")];
370        let cols = [col("X", "real")];
371        assert_eq!(row_to_json(&row, &cols), json!({"X": 0.5}));
372    }
373
374    #[test]
375    fn boolean_parses_lowercase() {
376        let row = [json!("true"), json!("false")];
377        let cols = [col("A", "boolean"), col("B", "boolean")];
378        assert_eq!(row_to_json(&row, &cols), json!({"A": true, "B": false}));
379    }
380
381    #[test]
382    fn variant_parses_inner_json() {
383        let row = [json!(r#"{"k":1}"#)];
384        let cols = [col("DATA", "variant")];
385        assert_eq!(row_to_json(&row, &cols), json!({"DATA": {"k": 1}}));
386    }
387
388    #[test]
389    fn variant_falls_back_to_string_on_invalid_json() {
390        let row = [json!("not-json")];
391        let cols = [col("DATA", "variant")];
392        assert_eq!(row_to_json(&row, &cols), json!({"DATA": "not-json"}));
393    }
394
395    #[test]
396    fn text_passes_through() {
397        let row = [json!("hello")];
398        let cols = [col("NAME", "text")];
399        assert_eq!(row_to_json(&row, &cols), json!({"NAME": "hello"}));
400    }
401
402    #[test]
403    fn null_cell_maps_to_null() {
404        let row = [json!(null)];
405        let cols = [col("X", "fixed")];
406        assert_eq!(row_to_json(&row, &cols), json!({"X": null}));
407    }
408
409    #[test]
410    fn missing_trailing_cell_maps_to_null() {
411        let row: [Value; 0] = [];
412        let cols = [col("X", "text")];
413        assert_eq!(row_to_json(&row, &cols), json!({"X": null}));
414    }
415
416    #[test]
417    fn timestamp_passes_through_as_string() {
418        let row = [json!("1700000000.000000000")];
419        let cols = [col("TS", "timestamp_ntz")];
420        assert_eq!(
421            row_to_json(&row, &cols),
422            json!({"TS": "1700000000.000000000"})
423        );
424    }
425
426    #[test]
427    fn unknown_type_passes_through_as_string() {
428        let row = [json!("0xDEADBEEF")];
429        let cols = [col("BLOB", "binary")];
430        assert_eq!(row_to_json(&row, &cols), json!({"BLOB": "0xDEADBEEF"}));
431    }
432
433    #[test]
434    fn non_finite_real_falls_back_to_string() {
435        let row = [json!("NaN")];
436        let cols = [col("X", "real")];
437        // NaN is not representable in JSON; round-trip-safe fallback.
438        assert_eq!(row_to_json(&row, &cols), json!({"X": "NaN"}));
439    }
440}