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    match column.column_type.to_ascii_uppercase().as_str() {
114        "FIXED" | "NUMBER" | "DECIMAL" | "NUMERIC" | "DECFLOAT" | "INT" | "INTEGER" | "BIGINT"
115        | "SMALLINT" | "TINYINT" | "BYTEINT" => Ok(CellValue::Number(text.to_owned())),
116
117        "REAL" | "FLOAT" | "FLOAT4" | "FLOAT8" | "DOUBLE" | "DOUBLE PRECISION" => text
118            .parse::<f64>()
119            .map(CellValue::Float)
120            .map_err(|_| make_err("expected a numeric REAL/FLOAT")),
121
122        "BOOLEAN" | "BOOL" => match text {
123            "true" => Ok(CellValue::Bool(true)),
124            "false" => Ok(CellValue::Bool(false)),
125            _ => Err(make_err("BOOLEAN must be the string \"true\" or \"false\"")),
126        },
127
128        "DATE" => text
129            .parse::<i64>()
130            .map(CellValue::Date)
131            .map_err(|_| make_err("DATE must be an integer epoch-day count")),
132
133        "TIME" | "TIMESTAMP_NTZ" | "TIMESTAMP_LTZ" | "DATETIME" => {
134            let (seconds, nanos) = parse_fractional_seconds(text)
135                .ok_or_else(|| make_err("expected fractional epoch seconds"))?;
136            Ok(CellValue::Timestamp { seconds, nanos })
137        }
138
139        "TIMESTAMP_TZ" => {
140            let (sec_part, offset_part) = text
141                .split_once(' ')
142                .ok_or_else(|| make_err("TIMESTAMP_TZ must be \"<seconds> <offset>\""))?;
143            let (seconds, nanos) = parse_fractional_seconds(sec_part)
144                .ok_or_else(|| make_err("expected fractional epoch seconds"))?;
145            let encoded_offset = offset_part
146                .parse::<i32>()
147                .map_err(|_| make_err("TIMESTAMP_TZ offset must be an integer"))?;
148            // The wire encodes the offset as offset_minutes + 1440 (UTC == 1440).
149            // Snowflake documents the encoded value as 720..=2160 (-12h..=+12h).
150            if !(720..=2160).contains(&encoded_offset) {
151                return Err(make_err("TIMESTAMP_TZ offset is out of range"));
152            }
153            Ok(CellValue::TimestampTz {
154                seconds,
155                nanos,
156                offset_minutes: encoded_offset - 1440,
157            })
158        }
159
160        "BINARY" | "VARBINARY" => decode_hex(text)
161            .map(CellValue::Binary)
162            .ok_or_else(|| make_err("BINARY must be an even-length hex string")),
163
164        "VARIANT" | "OBJECT" | "ARRAY" => serde_json::from_str(text)
165            .map(CellValue::Json)
166            .map_err(|_| make_err("VARIANT/OBJECT/ARRAY must hold embedded JSON")),
167
168        // TEXT/STRING/VARCHAR/CHAR and any not-yet-modeled type: keep the string.
169        _ => Ok(CellValue::Text(text.to_owned())),
170    }
171}
172
173/// Parse `"<seconds>"` or `"<seconds>.<frac>"` into `(whole_seconds, nanos)`.
174/// Fractions are taken to nanosecond precision (extra digits truncated). Returns
175/// `None` on a non-integer seconds part or non-digit fraction.
176///
177/// The result obeys `value = seconds + nanos / 1e9` with `nanos` in
178/// `[0, 1e9)`. For **negative (pre-1970) epoch values with a nonzero fraction**
179/// this needs a borrow — `"-1.5"` is `-1.5s = (-2, 500_000_000)`, and `"-0.5"`
180/// is `-0.5s = (-1, 500_000_000)`. Note the integer part of `"-0.5"` parses to
181/// `0`, so the sign is read from the string, not from the parsed integer.
182fn parse_fractional_seconds(text: &str) -> Option<(i64, u32)> {
183    let negative = text.starts_with('-');
184    let (int_str, frac_nanos) = match text.split_once('.') {
185        Some((int_str, frac)) => (int_str, frac_to_nanos(frac)?),
186        None => (text, 0),
187    };
188    let int_part = int_str.parse::<i64>().ok()?;
189    if !negative || frac_nanos == 0 {
190        // Positive, or an exact second (no fractional remainder to borrow).
191        Some((int_part, frac_nanos))
192    } else {
193        // Negative with a fractional remainder: borrow one whole second so the
194        // fraction stays non-negative. `frac_nanos` is in `(0, 1e9)` here, so
195        // `1e9 - frac_nanos` is also in `(0, 1e9)`.
196        let seconds = int_part.checked_sub(1)?;
197        Some((seconds, 1_000_000_000 - frac_nanos))
198    }
199}
200
201/// Convert a decimal fraction string (the part after `.`) to nanoseconds,
202/// padding/truncating to 9 digits. Returns `None` if empty or non-digit.
203fn frac_to_nanos(frac: &str) -> Option<u32> {
204    if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
205        return None;
206    }
207    let mut nanos = String::with_capacity(9);
208    nanos.extend(frac.chars().take(9));
209    while nanos.len() < 9 {
210        nanos.push('0');
211    }
212    nanos.parse::<u32>().ok()
213}
214
215/// Decode an even-length hex string into bytes. Returns `None` on odd length or a
216/// non-hex digit.
217fn decode_hex(text: &str) -> Option<Vec<u8>> {
218    let bytes = text.as_bytes();
219    if !bytes.len().is_multiple_of(2) {
220        return None;
221    }
222    bytes
223        .as_chunks::<2>()
224        .0
225        .iter()
226        .map(|pair| Some((hex_digit(pair[0])? << 4) | hex_digit(pair[1])?))
227        .collect()
228}
229
230/// Map one ASCII hex digit to its nibble value.
231fn hex_digit(byte: u8) -> Option<u8> {
232    match byte {
233        b'0'..=b'9' => Some(byte - b'0'),
234        b'a'..=b'f' => Some(byte - b'a' + 10),
235        b'A'..=b'F' => Some(byte - b'A' + 10),
236        _ => None,
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn col(snowflake_type: &str) -> ColumnType {
245        ColumnType {
246            name: "C".to_owned(),
247            column_type: snowflake_type.to_owned(),
248            scale: None,
249            precision: None,
250            nullable: true,
251            length: None,
252            byte_length: None,
253            database: None,
254            schema: None,
255            table: None,
256            collation: None,
257        }
258    }
259
260    #[test]
261    fn null_cell_decodes_to_null() -> Result<(), String> {
262        let value = decode_cell(None, &col("TEXT")).map_err(|e| e.to_string())?;
263        assert_eq!(value, CellValue::Null);
264        Ok(())
265    }
266
267    #[test]
268    fn number_is_kept_verbatim_without_scale_division() -> Result<(), String> {
269        // scale=2, but the wire value is NOT divided by 100.
270        let mut column = col("FIXED");
271        column.scale = Some(2);
272        let value = decode_cell(Some("1.50"), &column).map_err(|e| e.to_string())?;
273        assert_eq!(value, CellValue::Number("1.50".to_owned()));
274        assert_eq!(
275            decode_cell(
276                Some("1.2345678901234567890123456789012345678E+39"),
277                &col("DECFLOAT")
278            )
279            .map_err(|e| e.to_string())?,
280            CellValue::Number("1.2345678901234567890123456789012345678E+39".to_owned())
281        );
282        Ok(())
283    }
284
285    #[test]
286    fn boolean_is_string_not_json_bool() -> Result<(), String> {
287        assert_eq!(
288            decode_cell(Some("true"), &col("BOOLEAN")).map_err(|e| e.to_string())?,
289            CellValue::Bool(true)
290        );
291        assert_eq!(
292            decode_cell(Some("false"), &col("boolean")).map_err(|e| e.to_string())?,
293            CellValue::Bool(false)
294        );
295        // A JSON-bool-shaped or numeric value is a typed error, not a silent coerce.
296        assert!(decode_cell(Some("1"), &col("BOOLEAN")).is_err());
297        Ok(())
298    }
299
300    #[test]
301    fn date_is_epoch_days() -> Result<(), String> {
302        // 2020-01-01 is day 18262.
303        assert_eq!(
304            decode_cell(Some("18262"), &col("DATE")).map_err(|e| e.to_string())?,
305            CellValue::Date(18262)
306        );
307        assert!(decode_cell(Some("2020-01-01"), &col("DATE")).is_err());
308        Ok(())
309    }
310
311    #[test]
312    fn timestamp_is_fractional_epoch_seconds_not_nanos() -> Result<(), String> {
313        let value = decode_cell(Some("82919.000000000"), &col("TIMESTAMP_NTZ"))
314            .map_err(|e| e.to_string())?;
315        assert_eq!(
316            value,
317            CellValue::Timestamp {
318                seconds: 82919,
319                nanos: 0
320            }
321        );
322        // Sub-second precision is preserved as nanos.
323        let value = decode_cell(Some("100.5"), &col("TIME")).map_err(|e| e.to_string())?;
324        assert_eq!(
325            value,
326            CellValue::Timestamp {
327                seconds: 100,
328                nanos: 500_000_000
329            }
330        );
331        Ok(())
332    }
333
334    #[test]
335    fn timestamp_tz_decodes_offset_minus_1440() -> Result<(), String> {
336        // Snowflake SQL API handling-responses docs, consulted 2026-06-25:
337        // https://docs.snowflake.com/en/developer-guide/sql-api/handling-responses
338        // The encoded offset is 720..=2160, representing UTC-12:00..=UTC+12:00.
339        // offset encoded as offset_minutes + 1440; 960 → -480 minutes (UTC-08:00).
340        let value = decode_cell(Some("1700000000.000000000 960"), &col("TIMESTAMP_TZ"))
341            .map_err(|e| e.to_string())?;
342        assert_eq!(
343            value,
344            CellValue::TimestampTz {
345                seconds: 1_700_000_000,
346                nanos: 0,
347                offset_minutes: -480
348            }
349        );
350        assert!(decode_cell(Some("1700000000.0"), &col("TIMESTAMP_TZ")).is_err());
351        assert_eq!(
352            decode_cell(Some("1700000000.0 720"), &col("TIMESTAMP_TZ"))
353                .map_err(|e| e.to_string())?,
354            CellValue::TimestampTz {
355                seconds: 1_700_000_000,
356                nanos: 0,
357                offset_minutes: -720
358            }
359        );
360        assert_eq!(
361            decode_cell(Some("1700000000.0 2160"), &col("TIMESTAMP_TZ"))
362                .map_err(|e| e.to_string())?,
363            CellValue::TimestampTz {
364                seconds: 1_700_000_000,
365                nanos: 0,
366                offset_minutes: 720
367            }
368        );
369        assert!(decode_cell(Some("1700000000.0 719"), &col("TIMESTAMP_TZ")).is_err());
370        assert!(decode_cell(Some("1700000000.0 2161"), &col("TIMESTAMP_TZ")).is_err());
371        Ok(())
372    }
373
374    #[test]
375    fn negative_pre_1970_timestamps_decode_with_borrow() -> Result<(), String> {
376        // Regression (bead fsnow-agent-ergonomic-cli-aq2): pre-epoch fractional
377        // timestamps must satisfy value = seconds + nanos/1e9 with nanos in
378        // [0, 1e9). Before the fix, "-1.5" decoded to (-1, 5e8) = -0.5s.
379        let cases: &[(&str, i64, u32)] = &[
380            ("-1.5", -2, 500_000_000),                 // -1.5s
381            ("-0.5", -1, 500_000_000),                 // -0.5s; integer part "-0" parses to 0
382            ("-1.0", -1, 0),                           // exact: no borrow
383            ("-1", -1, 0),                             // no fraction at all
384            ("-86400.250000000", -86401, 750_000_000), // one day before epoch, .25s
385        ];
386        for (raw, seconds, nanos) in cases {
387            let value = decode_cell(Some(raw), &col("TIMESTAMP_NTZ")).map_err(|e| e.to_string())?;
388            assert_eq!(
389                value,
390                CellValue::Timestamp {
391                    seconds: *seconds,
392                    nanos: *nanos,
393                },
394                "decode of {raw:?}"
395            );
396        }
397        // The positive path is unchanged.
398        assert_eq!(
399            decode_cell(Some("1.5"), &col("TIMESTAMP_NTZ")).map_err(|e| e.to_string())?,
400            CellValue::Timestamp {
401                seconds: 1,
402                nanos: 500_000_000
403            }
404        );
405        Ok(())
406    }
407
408    #[test]
409    fn negative_timestamp_tz_decodes_with_borrow() -> Result<(), String> {
410        // 1969-12-31T23:59:59.5 at UTC-08:00 → "-0.5 960" (offset 960 = -480 + 1440).
411        let value =
412            decode_cell(Some("-0.5 960"), &col("TIMESTAMP_TZ")).map_err(|e| e.to_string())?;
413        assert_eq!(
414            value,
415            CellValue::TimestampTz {
416                seconds: -1,
417                nanos: 500_000_000,
418                offset_minutes: -480,
419            }
420        );
421        Ok(())
422    }
423
424    #[test]
425    fn binary_is_hex_decoded() -> Result<(), String> {
426        assert_eq!(
427            decode_cell(Some("deadBEEF"), &col("BINARY")).map_err(|e| e.to_string())?,
428            CellValue::Binary(vec![0xde, 0xad, 0xbe, 0xef])
429        );
430        assert!(decode_cell(Some("abc"), &col("BINARY")).is_err()); // odd length
431        assert!(decode_cell(Some("zz"), &col("BINARY")).is_err()); // non-hex
432        Ok(())
433    }
434
435    #[test]
436    fn variant_preserves_embedded_json() -> Result<(), String> {
437        let value =
438            decode_cell(Some(r#"{"k":[1,2]}"#), &col("VARIANT")).map_err(|e| e.to_string())?;
439        match value {
440            CellValue::Json(json) => assert_eq!(json["k"][1], serde_json::json!(2)),
441            other => return Err(format!("expected Json, got {other:?}")),
442        }
443        Ok(())
444    }
445
446    #[test]
447    fn unknown_type_falls_back_to_text() -> Result<(), String> {
448        assert_eq!(
449            decode_cell(Some("hello"), &col("GEOGRAPHY")).map_err(|e| e.to_string())?,
450            CellValue::Text("hello".to_owned())
451        );
452        Ok(())
453    }
454}