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}