Skip to main content

franken_snowflake_sqlapi/
response.rs

1//! The SQL API response bodies, one type per HTTP status class.
2//!
3//! Snowflake returns a *different JSON shape per status code*: a `200` carries a
4//! [`ResultSet`], a `202` a [`QueryStatus`] (poll again), and a `408`/`422` a
5//! [`QueryFailureStatus`]. See [`crate::status::ResponseClass`] for the routing.
6//!
7//! `data` cells stay `Option<String>` at the schema layer: every non-null cell
8//! is a `jsonv2` JSON **string** decoded later by [`crate::wire`] per its
9//! [`ColumnType`]; a SQL `NULL` is JSON `null` → `None`.
10
11use franken_snowflake_core::ids::{RequestId, StatementHandle};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// A `200 OK` completed result. Partition 0 arrives inline in `data`; later
16/// partitions are fetched separately (see [`PartitionInfo`]).
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct ResultSet {
20    /// Column types, row count, and partition layout.
21    pub result_set_meta_data: ResultSetMetaData,
22    /// Inline partition-0 rows: a row is a vector of nullable `jsonv2` strings.
23    pub data: Vec<Vec<Option<String>>>,
24    /// Snowflake response code (e.g. a success code like `090001`).
25    pub code: String,
26    /// The statement handle (also the query id for re-fetch / cancel).
27    pub statement_handle: StatementHandle,
28    /// Relative URL to re-`GET` for status/partitions.
29    #[serde(skip_serializing_if = "Option::is_none", default)]
30    pub statement_status_url: Option<String>,
31    /// Per-sub-statement handles when `MULTI_STATEMENT_COUNT` fans out.
32    #[serde(skip_serializing_if = "Option::is_none", default)]
33    pub statement_handles: Option<Vec<StatementHandle>>,
34    /// SQLSTATE, when present.
35    #[serde(skip_serializing_if = "Option::is_none", default)]
36    pub sql_state: Option<String>,
37    /// Human-readable message, when present.
38    #[serde(skip_serializing_if = "Option::is_none", default)]
39    pub message: Option<String>,
40    /// Echoed idempotency request id.
41    #[serde(skip_serializing_if = "Option::is_none", default)]
42    pub request_id: Option<RequestId>,
43    /// Server creation time (epoch millis), when present.
44    #[serde(skip_serializing_if = "Option::is_none", default)]
45    pub created_on: Option<i64>,
46    /// Opaque execution statistics, preserved verbatim.
47    #[serde(skip_serializing_if = "Option::is_none", default)]
48    pub stats: Option<Value>,
49}
50
51impl ResultSet {
52    /// Total rows across **all** partitions (not just the inline `data`).
53    #[must_use]
54    pub const fn total_rows(&self) -> i64 {
55        self.result_set_meta_data.num_rows
56    }
57
58    /// Number of result partitions (≥ 1; partition 0 is inline).
59    #[must_use]
60    pub fn partition_count(&self) -> usize {
61        self.result_set_meta_data.partition_info.len().max(1)
62    }
63
64    /// True when the response fanned out into multiple sub-statements.
65    #[must_use]
66    pub fn is_multi_statement(&self) -> bool {
67        self.statement_handles
68            .as_ref()
69            .is_some_and(|handles| !handles.is_empty())
70    }
71}
72
73/// Metadata describing the columns, total row count, and partition layout of a
74/// [`ResultSet`].
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct ResultSetMetaData {
78    /// Total rows across every partition.
79    pub num_rows: i64,
80    /// Result encoding; `jsonv2` for the JSON result format.
81    pub format: String,
82    /// One entry per column, in column order.
83    pub row_type: Vec<ColumnType>,
84    /// Partition sizes; index 0 corresponds to the inline `data`.
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub partition_info: Vec<PartitionInfo>,
87}
88
89/// A single column's authoritative type metadata — the source of truth for
90/// decoding (`type` + `scale` + `precision`), never row inspection.
91#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct ColumnType {
94    /// Column name.
95    pub name: String,
96    /// Snowflake logical type (`FIXED`, `REAL`, `TEXT`, `BOOLEAN`, `DATE`,
97    /// `TIME`, `TIMESTAMP_*`, `VARIANT`, `OBJECT`, `ARRAY`, `BINARY`, ...).
98    #[serde(rename = "type")]
99    pub column_type: String,
100    /// Decimal scale (digits after the point) for `FIXED`/`NUMBER`.
101    #[serde(skip_serializing_if = "Option::is_none", default)]
102    pub scale: Option<i32>,
103    /// Total precision for `FIXED`/`NUMBER`.
104    #[serde(skip_serializing_if = "Option::is_none", default)]
105    pub precision: Option<i32>,
106    /// Whether the column is nullable (distinct from the `nullable` query param).
107    pub nullable: bool,
108    /// Declared character length for `TEXT`-family columns.
109    #[serde(skip_serializing_if = "Option::is_none", default)]
110    pub length: Option<i64>,
111    /// Declared byte length.
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub byte_length: Option<i64>,
114    /// Source database, when reported.
115    #[serde(skip_serializing_if = "Option::is_none", default)]
116    pub database: Option<String>,
117    /// Source schema, when reported.
118    #[serde(skip_serializing_if = "Option::is_none", default)]
119    pub schema: Option<String>,
120    /// Source table, when reported.
121    #[serde(skip_serializing_if = "Option::is_none", default)]
122    pub table: Option<String>,
123    /// Collation specifier, when set.
124    #[serde(skip_serializing_if = "Option::is_none", default)]
125    pub collation: Option<String>,
126}
127
128/// The size of one result partition. `numRows` on the parent
129/// [`ResultSetMetaData`] is the total; these are per-partition.
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct PartitionInfo {
133    /// Rows in this partition.
134    pub row_count: i64,
135    /// Compressed (gzip) byte size. **Optional**: the live SQL API omits
136    /// `compressedSize` for inline/uncompressed partition 0 (observed against a
137    /// real account, 2026-06-25 — a `SELECT` returns `{"rowCount":N,
138    /// "uncompressedSize":B}` with no `compressedSize`). A required field here
139    /// made every live response fail to decode (`missing field compressedSize`).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub compressed_size: Option<i64>,
142    /// Uncompressed byte size. Optional for the same reason (Snowflake omits
143    /// either size field depending on partition encoding).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub uncompressed_size: Option<i64>,
146}
147
148/// A `202 Accepted` still-running status — the poll-again signal. Re-`GET` the
149/// handle (or [`QueryStatus::statement_status_url`]) until it returns `200`.
150#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct QueryStatus {
153    /// Snowflake status code.
154    pub code: String,
155    /// SQLSTATE, when present.
156    #[serde(skip_serializing_if = "Option::is_none", default)]
157    pub sql_state: Option<String>,
158    /// Human-readable message, when present.
159    #[serde(skip_serializing_if = "Option::is_none", default)]
160    pub message: Option<String>,
161    /// The statement handle to keep polling.
162    pub statement_handle: StatementHandle,
163    /// Relative URL to re-`GET`.
164    #[serde(skip_serializing_if = "Option::is_none", default)]
165    pub statement_status_url: Option<String>,
166}
167
168/// A `408` (statement timeout) or `422` (statement failed) body. The HTTP status
169/// distinguishes the two — `408` is a typed timeout, `422` a SQL
170/// compile/execution failure — so the same shape carries both.
171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct QueryFailureStatus {
174    /// Snowflake error code.
175    pub code: String,
176    /// SQLSTATE, when present.
177    #[serde(skip_serializing_if = "Option::is_none", default)]
178    pub sql_state: Option<String>,
179    /// Human-readable failure message.
180    pub message: String,
181    /// The statement handle, when one was assigned before failure.
182    #[serde(skip_serializing_if = "Option::is_none", default)]
183    pub statement_handle: Option<StatementHandle>,
184}
185
186/// The body returned by `POST /api/v2/statements/{handle}/cancel`.
187#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct StatementCancelResponse {
190    /// Snowflake status code for the cancel.
191    pub code: String,
192    /// Human-readable message, when present.
193    #[serde(skip_serializing_if = "Option::is_none", default)]
194    pub message: Option<String>,
195    /// The cancelled statement's handle, when echoed.
196    #[serde(skip_serializing_if = "Option::is_none", default)]
197    pub statement_handle: Option<StatementHandle>,
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn partition_count_is_at_least_one() {
206        let result_empty = ResultSet {
207            result_set_meta_data: ResultSetMetaData {
208                num_rows: 0,
209                format: "jsonv2".to_owned(),
210                row_type: vec![],
211                partition_info: vec![],
212            },
213            data: vec![],
214            code: "090001".to_owned(),
215            statement_handle: StatementHandle::new("h1"),
216            statement_status_url: None,
217            statement_handles: None,
218            sql_state: None,
219            message: None,
220            request_id: None,
221            created_on: None,
222            stats: None,
223        };
224        assert_eq!(result_empty.partition_count(), 1);
225
226        let mut result_multi = result_empty;
227        result_multi.result_set_meta_data.partition_info = vec![
228            PartitionInfo {
229                row_count: 5,
230                uncompressed_size: Some(100),
231                compressed_size: Some(50),
232            },
233            PartitionInfo {
234                row_count: 5,
235                uncompressed_size: Some(100),
236                compressed_size: Some(50),
237            },
238        ];
239        assert_eq!(result_multi.partition_count(), 2);
240    }
241
242    #[test]
243    fn result_set_helpers_and_flags() {
244        let mut rs = ResultSet {
245            result_set_meta_data: ResultSetMetaData {
246                num_rows: 42,
247                format: "jsonv2".to_owned(),
248                row_type: vec![ColumnType {
249                    name: "ID".to_owned(),
250                    column_type: "FIXED".to_owned(),
251                    scale: Some(0),
252                    precision: Some(38),
253                    nullable: false,
254                    length: None,
255                    byte_length: None,
256                    database: None,
257                    schema: None,
258                    table: None,
259                    collation: None,
260                }],
261                partition_info: vec![],
262            },
263            data: vec![],
264            code: "090001".to_owned(),
265            statement_handle: StatementHandle::new("h1"),
266            statement_status_url: None,
267            statement_handles: None,
268            sql_state: None,
269            message: None,
270            request_id: None,
271            created_on: None,
272            stats: None,
273        };
274
275        assert_eq!(rs.total_rows(), 42);
276        assert!(!rs.is_multi_statement());
277
278        rs.statement_handles = Some(vec![]);
279        assert!(!rs.is_multi_statement());
280
281        rs.statement_handles = Some(vec![
282            StatementHandle::new("sub-1"),
283            StatementHandle::new("sub-2"),
284        ]);
285        assert!(rs.is_multi_statement());
286    }
287
288    #[test]
289    fn partition_info_serde_matrix() -> Result<(), serde_json::Error> {
290        // Minimal: rowCount only
291        let minimal_json = r#"{"rowCount":100}"#;
292        let p1: PartitionInfo = serde_json::from_str(minimal_json)?;
293        assert_eq!(p1.row_count, 100);
294        assert_eq!(p1.compressed_size, None);
295        assert_eq!(p1.uncompressed_size, None);
296
297        // Partition 0 inline pattern: rowCount + uncompressedSize (missing compressedSize)
298        let inline_json = r#"{"rowCount":50,"uncompressedSize":2048}"#;
299        let p2: PartitionInfo = serde_json::from_str(inline_json)?;
300        assert_eq!(p2.row_count, 50);
301        assert_eq!(p2.uncompressed_size, Some(2048));
302        assert_eq!(p2.compressed_size, None);
303
304        // Both sizes present
305        let full_json = r#"{"rowCount":50,"compressedSize":512,"uncompressedSize":2048}"#;
306        let p3: PartitionInfo = serde_json::from_str(full_json)?;
307        assert_eq!(p3.row_count, 50);
308        assert_eq!(p3.compressed_size, Some(512));
309        assert_eq!(p3.uncompressed_size, Some(2048));
310
311        let reserialized = serde_json::to_string(&p3)?;
312        let roundtrip: PartitionInfo = serde_json::from_str(&reserialized)?;
313        assert_eq!(roundtrip, p3);
314        Ok(())
315    }
316
317    #[test]
318    fn query_status_serde_roundtrip() -> Result<(), serde_json::Error> {
319        let json = r#"{
320            "code": "333334",
321            "message": "Asynchronous execution in progress.",
322            "statementHandle": "01b5a2e4-0000-0123-0000-000000000001",
323            "statementStatusUrl": "/api/v2/statements/01b5a2e4-0000-0123-0000-000000000001",
324            "sqlState": "00000"
325        }"#;
326        let qs: QueryStatus = serde_json::from_str(json)?;
327        assert_eq!(qs.code, "333334");
328        assert_eq!(
329            qs.statement_handle.as_str(),
330            "01b5a2e4-0000-0123-0000-000000000001"
331        );
332        assert_eq!(
333            qs.statement_status_url.as_deref(),
334            Some("/api/v2/statements/01b5a2e4-0000-0123-0000-000000000001")
335        );
336        assert_eq!(qs.sql_state.as_deref(), Some("00000"));
337        assert_eq!(
338            qs.message.as_deref(),
339            Some("Asynchronous execution in progress.")
340        );
341
342        let reserialized = serde_json::to_string(&qs)?;
343        let roundtrip: QueryStatus = serde_json::from_str(&reserialized)?;
344        assert_eq!(roundtrip, qs);
345        Ok(())
346    }
347
348    #[test]
349    fn query_failure_status_serde_roundtrip() -> Result<(), serde_json::Error> {
350        let json = r#"{
351            "code": "002003",
352            "sqlState": "42S02",
353            "message": "SQL compilation error: Table 'DOES_NOT_EXIST' does not exist",
354            "statementHandle": "01b5a2e4-0000-0123-0000-000000000002"
355        }"#;
356        let failure: QueryFailureStatus = serde_json::from_str(json)?;
357        assert_eq!(failure.code, "002003");
358        assert_eq!(failure.sql_state.as_deref(), Some("42S02"));
359        assert_eq!(
360            failure.statement_handle.as_ref().map(|h| h.as_str()),
361            Some("01b5a2e4-0000-0123-0000-000000000002")
362        );
363
364        let reserialized = serde_json::to_string(&failure)?;
365        let roundtrip: QueryFailureStatus = serde_json::from_str(&reserialized)?;
366        assert_eq!(roundtrip, failure);
367        Ok(())
368    }
369
370    #[test]
371    fn statement_cancel_response_serde_roundtrip() -> Result<(), serde_json::Error> {
372        let json = r#"{
373            "code": "090001",
374            "message": "Statement cancelled successfully.",
375            "statementHandle": "01b5a2e4-0000-0123-0000-000000000003"
376        }"#;
377        let cancel: StatementCancelResponse = serde_json::from_str(json)?;
378        assert_eq!(cancel.code, "090001");
379        assert_eq!(
380            cancel.message.as_deref(),
381            Some("Statement cancelled successfully.")
382        );
383        assert_eq!(
384            cancel.statement_handle.as_ref().map(|h| h.as_str()),
385            Some("01b5a2e4-0000-0123-0000-000000000003")
386        );
387
388        let reserialized = serde_json::to_string(&cancel)?;
389        let roundtrip: StatementCancelResponse = serde_json::from_str(&reserialized)?;
390        assert_eq!(roundtrip, cancel);
391        Ok(())
392    }
393
394    #[test]
395    fn full_result_set_serde_roundtrip() -> Result<(), serde_json::Error> {
396        let json = r#"{
397            "resultSetMetaData": {
398                "numRows": 2,
399                "format": "jsonv2",
400                "rowType": [
401                    {
402                        "name": "ID",
403                        "type": "FIXED",
404                        "scale": 0,
405                        "precision": 38,
406                        "nullable": false,
407                        "database": "TEST_DB",
408                        "schema": "PUBLIC",
409                        "table": "USERS"
410                    },
411                    {
412                        "name": "NAME",
413                        "type": "TEXT",
414                        "nullable": true,
415                        "length": 16777216,
416                        "byteLength": 16777216,
417                        "collation": "en-ci"
418                    }
419                ],
420                "partitionInfo": [
421                    {"rowCount": 2, "uncompressedSize": 128}
422                ]
423            },
424            "data": [
425                ["1", "Alice"],
426                ["2", null]
427            ],
428            "code": "090001",
429            "statementHandle": "stmt-abc",
430            "statementStatusUrl": "/api/v2/statements/stmt-abc",
431            "statementHandles": ["stmt-abc"],
432            "sqlState": "00000",
433            "message": "Statement executed successfully.",
434            "requestId": "req-xyz",
435            "createdOn": 1700000000000,
436            "stats": {"scanBytes": 1024}
437        }"#;
438
439        let rs: ResultSet = serde_json::from_str(json)?;
440        assert_eq!(rs.total_rows(), 2);
441        assert_eq!(rs.partition_count(), 1);
442        assert!(rs.is_multi_statement());
443        assert_eq!(rs.data.len(), 2);
444        assert_eq!(
445            rs.data[0],
446            vec![Some("1".to_owned()), Some("Alice".to_owned())]
447        );
448        assert_eq!(rs.data[1], vec![Some("2".to_owned()), None]);
449        assert_eq!(rs.created_on, Some(1700000000000));
450        assert!(rs.stats.is_some());
451
452        let reserialized = serde_json::to_string(&rs)?;
453        let roundtrip: ResultSet = serde_json::from_str(&reserialized)?;
454        assert_eq!(roundtrip, rs);
455        Ok(())
456    }
457}