Skip to main content

franken_snowflake_sqlapi/
wire.rs

1//! The `jsonv2` wire codec: decode a result `data` cell per its column type.
2//!
3//! Every cell in a [`crate::response::ResultSet`]'s `data` is a JSON **string**
4//! (including numbers and booleans), or JSON `null`. The decode is driven by the
5//! column's [`ColumnType`] (matched case-insensitively), **never** by JSON shape.
6//! These are the load-bearing rules where connector bugs hide:
7//!
8//! | Type | Wire string | Rule |
9//! |---|---|---|
10//! | `FIXED`/`NUMBER` | `"1.0"` | keep the decimal verbatim — do **not** divide by 10^scale |
11//! | `REAL`/`FLOAT` | numeric | parse as `f64` |
12//! | `BOOLEAN` | `"true"`/`"false"` | string compare, not JSON bool |
13//! | `DATE` | `"18262"` | epoch **days** |
14//! | `TIME`/`TIMESTAMP_NTZ`/`TIMESTAMP_LTZ` | `"82919.000000000"` | fractional epoch **seconds** (not nanos) |
15//! | `TIMESTAMP_TZ` | `"<sec.frac> <offset>"` | offset = minutes encoded as `offset_minutes + 1440` |
16//! | `BINARY` | hex | hex-decode |
17//! | `VARIANT`/`OBJECT`/`ARRAY` | embedded JSON | preserve as structured JSON |
18//! | SQL `NULL` | JSON `null` | [`CellValue::Null`] |
19//!
20//! The docs are internally inconsistent on the timestamp unit (one passage says
21//! nanoseconds); this codec follows the fractional-**seconds** reading and is
22//! pinned against an empirically captured live golden in
23//! `fsnow-native-snowflake-connector-w0i.13`. [`CellValue`] is a neutral decoded
24//! value; the frame crate maps it onto a dtype later.
25
26use serde_json::Value;
27
28use crate::response::ColumnType;
29
30/// A decoded result cell. Deliberately *lossless and neutral*: numerics stay as
31/// their exact decimal strings, timestamps stay as `(seconds, nanos)` pairs, and
32/// semi-structured values stay as JSON — frame materialization (a later crate)
33/// owns the dtype projection.
34#[derive(Clone, Debug, PartialEq)]
35pub enum CellValue {
36    /// SQL `NULL`.
37    Null,
38    /// `FIXED`/`NUMBER`: the decimal exactly as written (no scale division).
39    Number(String),
40    /// `REAL`/`FLOAT`/`DOUBLE`.
41    Float(f64),
42    /// `BOOLEAN`.
43    Bool(bool),
44    /// `TEXT`/`STRING`/`VARCHAR` and any unmodeled type (decoded leniently).
45    Text(String),
46    /// `DATE`: days since the Unix epoch.
47    Date(i64),
48    /// `TIME`/`TIMESTAMP_NTZ`/`TIMESTAMP_LTZ`: fractional epoch seconds split into
49    /// whole `seconds` and `nanos`.
50    Timestamp {
51        /// Whole seconds since the Unix epoch (as encoded).
52        seconds: i64,
53        /// Fractional nanoseconds (0..=999_999_999).
54        nanos: u32,
55    },
56    /// `TIMESTAMP_TZ`: a [`CellValue::Timestamp`] plus a timezone offset in
57    /// minutes, already decoded from the wire's `offset_minutes + 1440`.
58    TimestampTz {
59        /// Whole seconds since the Unix epoch (as encoded).
60        seconds: i64,
61        /// Fractional nanoseconds (0..=999_999_999).
62        nanos: u32,
63        /// Timezone offset in minutes (e.g. `-480` for UTC-08:00).
64        offset_minutes: i32,
65    },
66    /// `BINARY`: hex-decoded bytes.
67    Binary(Vec<u8>),
68    /// `VARIANT`/`OBJECT`/`ARRAY`: the embedded JSON value.
69    Json(Value),
70}
71
72/// A `jsonv2` decode failure. Carries the column name and Snowflake type plus a
73/// static reason — **never** the raw cell value, which may be sensitive
74/// (`docs/security_model.md`).
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct WireError {
77    /// The offending column's name.
78    pub column: String,
79    /// The column's Snowflake logical type.
80    pub snowflake_type: String,
81    /// A short, value-free explanation.
82    pub reason: &'static str,
83}
84
85impl std::fmt::Display for WireError {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        write!(
88            f,
89            "jsonv2 decode error in column {:?} ({}): {}",
90            self.column, self.snowflake_type, self.reason
91        )
92    }
93}
94
95impl std::error::Error for WireError {}
96
97/// Decode one `data` cell against its [`ColumnType`]. `raw` is `None` for SQL
98/// `NULL`.
99///
100/// # Errors
101/// Returns a [`WireError`] when the cell does not match its declared type (e.g. a
102/// non-numeric `DATE`, a malformed `TIMESTAMP_TZ`, or odd-length `BINARY`).
103pub fn decode_cell(raw: Option<&str>, column: &ColumnType) -> Result<CellValue, WireError> {
104    let Some(text) = raw else {
105        return Ok(CellValue::Null);
106    };
107    let make_err = |reason: &'static str| WireError {
108        column: column.name.clone(),
109        snowflake_type: column.column_type.clone(),
110        reason,
111    };
112
113    let base_type = column
114        .column_type
115        .split('(')
116        .next()
117        .unwrap_or(&column.column_type)
118        .trim();
119
120    match base_type.to_ascii_uppercase().as_str() {
121        "FIXED" | "NUMBER" | "DECIMAL" | "NUMERIC" | "DECFLOAT" | "INT" | "INTEGER" | "BIGINT"
122        | "SMALLINT" | "TINYINT" | "BYTEINT" => Ok(CellValue::Number(text.to_owned())),
123
124        "REAL" | "FLOAT" | "FLOAT4" | "FLOAT8" | "DOUBLE" | "DOUBLE PRECISION" => text
125            .parse::<f64>()
126            .map(CellValue::Float)
127            .map_err(|_| make_err("expected a numeric REAL/FLOAT")),
128
129        "BOOLEAN" | "BOOL" => match text {
130            "true" => Ok(CellValue::Bool(true)),
131            "false" => Ok(CellValue::Bool(false)),
132            _ => Err(make_err("BOOLEAN must be the string \"true\" or \"false\"")),
133        },
134
135        "DATE" => text
136            .parse::<i64>()
137            .map(CellValue::Date)
138            .map_err(|_| make_err("DATE must be an integer epoch-day count")),
139
140        "TIME" | "TIMESTAMP_NTZ" | "TIMESTAMP_LTZ" | "DATETIME" => {
141            let (seconds, nanos) = parse_fractional_seconds(text)
142                .ok_or_else(|| make_err("expected fractional epoch seconds"))?;
143            Ok(CellValue::Timestamp { seconds, nanos })
144        }
145
146        "TIMESTAMP_TZ" => {
147            let (sec_part, offset_part) = text
148                .split_once(' ')
149                .ok_or_else(|| make_err("TIMESTAMP_TZ must be \"<seconds> <offset>\""))?;
150            let (seconds, nanos) = parse_fractional_seconds(sec_part)
151                .ok_or_else(|| make_err("expected fractional epoch seconds"))?;
152            let encoded_offset = offset_part
153                .parse::<i32>()
154                .map_err(|_| make_err("TIMESTAMP_TZ offset must be an integer"))?;
155            // The wire encodes the offset as offset_minutes + 1440 (UTC == 1440).
156            // Snowflake documents the encoded value as 720..=2160 (-12h..=+12h).
157            if !(720..=2160).contains(&encoded_offset) {
158                return Err(make_err("TIMESTAMP_TZ offset is out of range"));
159            }
160            Ok(CellValue::TimestampTz {
161                seconds,
162                nanos,
163                offset_minutes: encoded_offset - 1440,
164            })
165        }
166
167        "BINARY" | "VARBINARY" => decode_hex(text)
168            .map(CellValue::Binary)
169            .ok_or_else(|| make_err("BINARY must be an even-length hex string")),
170
171        "VARIANT" | "OBJECT" | "ARRAY" => serde_json::from_str(text)
172            .map(CellValue::Json)
173            .map_err(|_| make_err("VARIANT/OBJECT/ARRAY must hold embedded JSON")),
174
175        // TEXT/STRING/VARCHAR/CHAR and any not-yet-modeled type: keep the string.
176        _ => Ok(CellValue::Text(text.to_owned())),
177    }
178}
179
180/// Parse `"<seconds>"` or `"<seconds>.<frac>"` into `(whole_seconds, nanos)`.
181/// Fractions are taken to nanosecond precision (extra digits truncated). Returns
182/// `None` on a non-integer seconds part or non-digit fraction.
183///
184/// The result obeys `value = seconds + nanos / 1e9` with `nanos` in
185/// `[0, 1e9)`. For **negative (pre-1970) epoch values with a nonzero fraction**
186/// this needs a borrow — `"-1.5"` is `-1.5s = (-2, 500_000_000)`, and `"-0.5"`
187/// is `-0.5s = (-1, 500_000_000)`. Note the integer part of `"-0.5"` parses to
188/// `0`, so the sign is read from the string, not from the parsed integer.
189fn parse_fractional_seconds(text: &str) -> Option<(i64, u32)> {
190    let negative = text.starts_with('-');
191    let (int_str, frac_nanos) = match text.split_once('.') {
192        Some((int_str, frac)) => (int_str, frac_to_nanos(frac)?),
193        None => (text, 0),
194    };
195    let int_part = int_str.parse::<i64>().ok()?;
196    if !negative || frac_nanos == 0 {
197        // Positive, or an exact second (no fractional remainder to borrow).
198        Some((int_part, frac_nanos))
199    } else {
200        // Negative with a fractional remainder: borrow one whole second so the
201        // fraction stays non-negative. `frac_nanos` is in `(0, 1e9)` here, so
202        // `1e9 - frac_nanos` is also in `(0, 1e9)`.
203        let seconds = int_part.checked_sub(1)?;
204        Some((seconds, 1_000_000_000 - frac_nanos))
205    }
206}
207
208/// Convert a decimal fraction string (the part after `.`) to nanoseconds,
209/// padding/truncating to 9 digits. Returns `None` if empty or non-digit.
210fn frac_to_nanos(frac: &str) -> Option<u32> {
211    if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
212        return None;
213    }
214    let mut nanos = String::with_capacity(9);
215    nanos.extend(frac.chars().take(9));
216    while nanos.len() < 9 {
217        nanos.push('0');
218    }
219    nanos.parse::<u32>().ok()
220}
221
222/// Decode an even-length hex string into bytes. Returns `None` on odd length or a
223/// non-hex digit.
224fn decode_hex(text: &str) -> Option<Vec<u8>> {
225    let bytes = text.as_bytes();
226    if !bytes.len().is_multiple_of(2) {
227        return None;
228    }
229    bytes
230        .as_chunks::<2>()
231        .0
232        .iter()
233        .map(|pair| Some((hex_digit(pair[0])? << 4) | hex_digit(pair[1])?))
234        .collect()
235}
236
237/// Map one ASCII hex digit to its nibble value.
238fn hex_digit(byte: u8) -> Option<u8> {
239    match byte {
240        b'0'..=b'9' => Some(byte - b'0'),
241        b'a'..=b'f' => Some(byte - b'a' + 10),
242        b'A'..=b'F' => Some(byte - b'A' + 10),
243        _ => None,
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn col(snowflake_type: &str) -> ColumnType {
252        ColumnType {
253            name: "C".to_owned(),
254            column_type: snowflake_type.to_owned(),
255            scale: None,
256            precision: None,
257            nullable: true,
258            length: None,
259            byte_length: None,
260            database: None,
261            schema: None,
262            table: None,
263            collation: None,
264        }
265    }
266
267    #[test]
268    fn null_cell_decodes_to_null() -> Result<(), String> {
269        let value = decode_cell(None, &col("TEXT")).map_err(|e| e.to_string())?;
270        assert_eq!(value, CellValue::Null);
271        Ok(())
272    }
273
274    #[test]
275    fn number_is_kept_verbatim_without_scale_division() -> Result<(), String> {
276        // scale=2, but the wire value is NOT divided by 100.
277        let mut column = col("FIXED");
278        column.scale = Some(2);
279        let value = decode_cell(Some("1.50"), &column).map_err(|e| e.to_string())?;
280        assert_eq!(value, CellValue::Number("1.50".to_owned()));
281        assert_eq!(
282            decode_cell(
283                Some("1.2345678901234567890123456789012345678E+39"),
284                &col("DECFLOAT")
285            )
286            .map_err(|e| e.to_string())?,
287            CellValue::Number("1.2345678901234567890123456789012345678E+39".to_owned())
288        );
289        Ok(())
290    }
291
292    #[test]
293    fn boolean_is_string_not_json_bool() -> Result<(), String> {
294        assert_eq!(
295            decode_cell(Some("true"), &col("BOOLEAN")).map_err(|e| e.to_string())?,
296            CellValue::Bool(true)
297        );
298        assert_eq!(
299            decode_cell(Some("false"), &col("boolean")).map_err(|e| e.to_string())?,
300            CellValue::Bool(false)
301        );
302        // A JSON-bool-shaped or numeric value is a typed error, not a silent coerce.
303        assert!(decode_cell(Some("1"), &col("BOOLEAN")).is_err());
304        Ok(())
305    }
306
307    #[test]
308    fn date_is_epoch_days() -> Result<(), String> {
309        // 2020-01-01 is day 18262.
310        assert_eq!(
311            decode_cell(Some("18262"), &col("DATE")).map_err(|e| e.to_string())?,
312            CellValue::Date(18262)
313        );
314        assert!(decode_cell(Some("2020-01-01"), &col("DATE")).is_err());
315        Ok(())
316    }
317
318    #[test]
319    fn timestamp_is_fractional_epoch_seconds_not_nanos() -> Result<(), String> {
320        let value = decode_cell(Some("82919.000000000"), &col("TIMESTAMP_NTZ"))
321            .map_err(|e| e.to_string())?;
322        assert_eq!(
323            value,
324            CellValue::Timestamp {
325                seconds: 82919,
326                nanos: 0
327            }
328        );
329        // Sub-second precision is preserved as nanos.
330        let value = decode_cell(Some("100.5"), &col("TIME")).map_err(|e| e.to_string())?;
331        assert_eq!(
332            value,
333            CellValue::Timestamp {
334                seconds: 100,
335                nanos: 500_000_000
336            }
337        );
338        Ok(())
339    }
340
341    #[test]
342    fn timestamp_tz_decodes_offset_minus_1440() -> Result<(), String> {
343        // Snowflake SQL API handling-responses docs, consulted 2026-06-25:
344        // https://docs.snowflake.com/en/developer-guide/sql-api/handling-responses
345        // The encoded offset is 720..=2160, representing UTC-12:00..=UTC+12:00.
346        // offset encoded as offset_minutes + 1440; 960 → -480 minutes (UTC-08:00).
347        let value = decode_cell(Some("1700000000.000000000 960"), &col("TIMESTAMP_TZ"))
348            .map_err(|e| e.to_string())?;
349        assert_eq!(
350            value,
351            CellValue::TimestampTz {
352                seconds: 1_700_000_000,
353                nanos: 0,
354                offset_minutes: -480
355            }
356        );
357        assert!(decode_cell(Some("1700000000.0"), &col("TIMESTAMP_TZ")).is_err());
358        assert_eq!(
359            decode_cell(Some("1700000000.0 720"), &col("TIMESTAMP_TZ"))
360                .map_err(|e| e.to_string())?,
361            CellValue::TimestampTz {
362                seconds: 1_700_000_000,
363                nanos: 0,
364                offset_minutes: -720
365            }
366        );
367        assert_eq!(
368            decode_cell(Some("1700000000.0 2160"), &col("TIMESTAMP_TZ"))
369                .map_err(|e| e.to_string())?,
370            CellValue::TimestampTz {
371                seconds: 1_700_000_000,
372                nanos: 0,
373                offset_minutes: 720
374            }
375        );
376        assert!(decode_cell(Some("1700000000.0 719"), &col("TIMESTAMP_TZ")).is_err());
377        assert!(decode_cell(Some("1700000000.0 2161"), &col("TIMESTAMP_TZ")).is_err());
378        Ok(())
379    }
380
381    #[test]
382    fn negative_pre_1970_timestamps_decode_with_borrow() -> Result<(), String> {
383        // Regression (bead fsnow-agent-ergonomic-cli-aq2): pre-epoch fractional
384        // timestamps must satisfy value = seconds + nanos/1e9 with nanos in
385        // [0, 1e9). Before the fix, "-1.5" decoded to (-1, 5e8) = -0.5s.
386        let cases: &[(&str, i64, u32)] = &[
387            ("-1.5", -2, 500_000_000),                 // -1.5s
388            ("-0.5", -1, 500_000_000),                 // -0.5s; integer part "-0" parses to 0
389            ("-1.0", -1, 0),                           // exact: no borrow
390            ("-1", -1, 0),                             // no fraction at all
391            ("-86400.250000000", -86401, 750_000_000), // one day before epoch, .25s
392        ];
393        for (raw, seconds, nanos) in cases {
394            let value = decode_cell(Some(raw), &col("TIMESTAMP_NTZ")).map_err(|e| e.to_string())?;
395            assert_eq!(
396                value,
397                CellValue::Timestamp {
398                    seconds: *seconds,
399                    nanos: *nanos,
400                },
401                "decode of {raw:?}"
402            );
403        }
404        // The positive path is unchanged.
405        assert_eq!(
406            decode_cell(Some("1.5"), &col("TIMESTAMP_NTZ")).map_err(|e| e.to_string())?,
407            CellValue::Timestamp {
408                seconds: 1,
409                nanos: 500_000_000
410            }
411        );
412        Ok(())
413    }
414
415    #[test]
416    fn negative_timestamp_tz_decodes_with_borrow() -> Result<(), String> {
417        // 1969-12-31T23:59:59.5 at UTC-08:00 → "-0.5 960" (offset 960 = -480 + 1440).
418        let value =
419            decode_cell(Some("-0.5 960"), &col("TIMESTAMP_TZ")).map_err(|e| e.to_string())?;
420        assert_eq!(
421            value,
422            CellValue::TimestampTz {
423                seconds: -1,
424                nanos: 500_000_000,
425                offset_minutes: -480,
426            }
427        );
428        Ok(())
429    }
430
431    #[test]
432    fn binary_is_hex_decoded() -> Result<(), String> {
433        assert_eq!(
434            decode_cell(Some("deadBEEF"), &col("BINARY")).map_err(|e| e.to_string())?,
435            CellValue::Binary(vec![0xde, 0xad, 0xbe, 0xef])
436        );
437        assert!(decode_cell(Some("abc"), &col("BINARY")).is_err()); // odd length
438        assert!(decode_cell(Some("zz"), &col("BINARY")).is_err()); // non-hex
439        Ok(())
440    }
441
442    #[test]
443    fn variant_preserves_embedded_json() -> Result<(), String> {
444        let value =
445            decode_cell(Some(r#"{"k":[1,2]}"#), &col("VARIANT")).map_err(|e| e.to_string())?;
446        match value {
447            CellValue::Json(json) => assert_eq!(json["k"][1], serde_json::json!(2)),
448            other => return Err(format!("expected Json, got {other:?}")),
449        }
450        Ok(())
451    }
452
453    #[test]
454    fn unknown_type_falls_back_to_text() -> Result<(), String> {
455        assert_eq!(
456            decode_cell(Some("hello"), &col("GEOGRAPHY")).map_err(|e| e.to_string())?,
457            CellValue::Text("hello".to_owned())
458        );
459        Ok(())
460    }
461
462    #[test]
463    fn parameterized_types_decode_correctly() -> Result<(), String> {
464        assert_eq!(
465            decode_cell(Some("42"), &col("NUMBER(38,0)")).map_err(|e| e.to_string())?,
466            CellValue::Number("42".to_owned())
467        );
468        assert_eq!(
469            decode_cell(Some("3.14"), &col("DECIMAL(10, 2)")).map_err(|e| e.to_string())?,
470            CellValue::Number("3.14".to_owned())
471        );
472        assert_eq!(
473            decode_cell(Some("test"), &col("VARCHAR(255)")).map_err(|e| e.to_string())?,
474            CellValue::Text("test".to_owned())
475        );
476        Ok(())
477    }
478}