Skip to main content

franken_snowflake_sqlapi/
request.rs

1//! The `POST /api/v2/statements` request body and its query parameters.
2//!
3//! Snowflake JSON keys are camelCase, so the structs use
4//! `#[serde(rename_all = "camelCase")]`; absent optionals are omitted on the
5//! wire (`skip_serializing_if`) so a minimal request serializes to just
6//! `{"statement":"..."}`. Identifier fields reuse the `franken-snowflake-core`
7//! newtypes, which serialize transparently as bare strings.
8
9use std::collections::BTreeMap;
10
11use franken_snowflake_core::ids::{DatabaseName, RoleName, SchemaName, WarehouseName};
12use serde::{Deserialize, Serialize};
13
14/// The body of a `POST /api/v2/statements` submit.
15///
16/// The idempotency `requestId`, `retry`, and `async` controls are **query
17/// parameters** (see [`SubmitQueryParams`]), not body fields. `bindings` are not
18/// permitted together with multi-statement requests — that refusal is enforced
19/// by the planner, not the schema.
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct SubmitStatementRequest {
23    /// The SQL text. A single statement unless `MULTI_STATEMENT_COUNT` is set in
24    /// [`SubmitStatementRequest::parameters`].
25    pub statement: String,
26
27    /// Server-side statement timeout in seconds (`STATEMENT_TIMEOUT_IN_SECONDS`).
28    /// The enforceable cost/runtime guardrail; the client `Budget` is advisory.
29    #[serde(skip_serializing_if = "Option::is_none", default)]
30    pub timeout: Option<u32>,
31
32    /// Default database for the statement's session.
33    #[serde(skip_serializing_if = "Option::is_none", default)]
34    pub database: Option<DatabaseName>,
35
36    /// Default schema for the statement's session.
37    #[serde(skip_serializing_if = "Option::is_none", default)]
38    pub schema: Option<SchemaName>,
39
40    /// Warehouse that runs the statement.
41    #[serde(skip_serializing_if = "Option::is_none", default)]
42    pub warehouse: Option<WarehouseName>,
43
44    /// Role the statement runs as.
45    #[serde(skip_serializing_if = "Option::is_none", default)]
46    pub role: Option<RoleName>,
47
48    /// Positional typed bind values, keyed by 1-based **string** indices.
49    #[serde(skip_serializing_if = "Option::is_none", default)]
50    pub bindings: Option<BTreeMap<String, Binding>>,
51
52    /// Session parameters pinned for deterministic output (e.g. `TIMEZONE`, the
53    /// `*_OUTPUT_FORMAT` params, `USE_CACHED_RESULT`, `MULTI_STATEMENT_COUNT`).
54    /// Values are strings on the wire.
55    #[serde(skip_serializing_if = "Option::is_none", default)]
56    pub parameters: Option<BTreeMap<String, String>>,
57}
58
59impl SubmitStatementRequest {
60    /// A bare single-statement request with no session overrides.
61    #[must_use]
62    pub fn new(statement: impl Into<String>) -> Self {
63        Self {
64            statement: statement.into(),
65            timeout: None,
66            database: None,
67            schema: None,
68            warehouse: None,
69            role: None,
70            bindings: None,
71            parameters: None,
72        }
73    }
74}
75
76/// A single positional, typed bind value.
77///
78/// Snowflake keys bindings by 1-based string index and the `value` is **always**
79/// a JSON string regardless of the logical type (e.g. a number bind is
80/// `{"type":"FIXED","value":"42"}`). The type name is uppercase on the wire and
81/// kept as a `String` for lossless round-trips across Snowflake's open type set;
82/// see [`bind_type`] for the common names.
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub struct Binding {
85    /// The Snowflake binding type name (uppercase), e.g. `TEXT`, `FIXED`,
86    /// `BOOLEAN`, `TIMESTAMP_NTZ`.
87    #[serde(rename = "type")]
88    pub value_type: String,
89    /// The bound value, JSON-string-encoded.
90    pub value: String,
91}
92
93impl Binding {
94    /// Construct a binding from a type name and string-encoded value.
95    #[must_use]
96    pub fn new(value_type: impl Into<String>, value: impl Into<String>) -> Self {
97        Self {
98            value_type: value_type.into(),
99            value: value.into(),
100        }
101    }
102}
103
104/// Common Snowflake binding type names (the `type` field of a [`Binding`]).
105pub mod bind_type {
106    /// Text / VARCHAR.
107    pub const TEXT: &str = "TEXT";
108    /// Fixed-point numeric (NUMBER).
109    pub const FIXED: &str = "FIXED";
110    /// Floating point.
111    pub const REAL: &str = "REAL";
112    /// Boolean.
113    pub const BOOLEAN: &str = "BOOLEAN";
114    /// Calendar date.
115    pub const DATE: &str = "DATE";
116    /// Wall-clock time.
117    pub const TIME: &str = "TIME";
118    /// Timestamp without time zone.
119    pub const TIMESTAMP_NTZ: &str = "TIMESTAMP_NTZ";
120    /// Timestamp with local time zone.
121    pub const TIMESTAMP_LTZ: &str = "TIMESTAMP_LTZ";
122    /// Timestamp with time zone.
123    pub const TIMESTAMP_TZ: &str = "TIMESTAMP_TZ";
124    /// Binary.
125    pub const BINARY: &str = "BINARY";
126}
127
128/// The submit-time **query parameters** that ride on the `POST` URL rather than
129/// the body. They are the idempotency contract: a stable [`SubmitQueryParams::request_id`]
130/// plus `retry=true` makes a resubmit safe (the original result is returned
131/// instead of re-running). This struct is a typed carrier for the transport
132/// crate to render into a query string; it is not serialized into the body.
133#[derive(Clone, Debug, PartialEq, Eq, Default)]
134pub struct SubmitQueryParams {
135    /// Client-generated UUID; the SQL API idempotency `requestId`.
136    pub request_id: Option<String>,
137    /// `retry=true` marks a safe resubmit of a previously-sent `requestId`.
138    pub retry: bool,
139    /// `async=true` returns a handle immediately instead of waiting.
140    pub asynchronous: bool,
141    /// `nullable=false` renders SQL NULL as the string `"null"` instead of JSON
142    /// `null`. Unrelated to `rowType[].nullable`.
143    pub nullable: Option<bool>,
144}
145
146impl SubmitQueryParams {
147    /// Render the non-default parameters as `(key, value)` query pairs, in a
148    /// stable order, for the transport crate to URL-encode.
149    #[must_use]
150    pub fn to_query_pairs(&self) -> Vec<(&'static str, String)> {
151        let mut pairs = Vec::new();
152        if let Some(id) = &self.request_id {
153            pairs.push(("requestId", id.clone()));
154        }
155        if self.retry {
156            pairs.push(("retry", "true".to_owned()));
157        }
158        if self.asynchronous {
159            pairs.push(("async", "true".to_owned()));
160        }
161        if let Some(nullable) = self.nullable {
162            pairs.push(("nullable", nullable.to_string()));
163        }
164        pairs
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn submit_request_minimal_serializes_cleanly() -> Result<(), serde_json::Error> {
174        let req = SubmitStatementRequest::new("SELECT 1");
175        let json = serde_json::to_string(&req)?;
176        assert_eq!(json, r#"{"statement":"SELECT 1"}"#);
177
178        let roundtrip: SubmitStatementRequest = serde_json::from_str(&json)?;
179        assert_eq!(roundtrip, req);
180        Ok(())
181    }
182
183    #[test]
184    fn submit_request_all_fields_roundtrip() -> Result<(), serde_json::Error> {
185        let mut bindings = BTreeMap::new();
186        bindings.insert("1".to_string(), Binding::new(bind_type::FIXED, "42"));
187        bindings.insert("2".to_string(), Binding::new(bind_type::TEXT, "hello"));
188
189        let mut parameters = BTreeMap::new();
190        parameters.insert("MULTI_STATEMENT_COUNT".to_string(), "1".to_string());
191        parameters.insert("TIMEZONE".to_string(), "UTC".to_string());
192
193        let req = SubmitStatementRequest {
194            statement: "SELECT ? as id, ? as greeting".to_string(),
195            timeout: Some(30),
196            database: Some(DatabaseName::new("MY_DB")),
197            schema: Some(SchemaName::new("PUBLIC")),
198            warehouse: Some(WarehouseName::new("COMPUTE_WH")),
199            role: Some(RoleName::new("ANALYST")),
200            bindings: Some(bindings),
201            parameters: Some(parameters),
202        };
203
204        let json = serde_json::to_string(&req)?;
205        assert!(json.contains(r#""statement":"SELECT ? as id, ? as greeting""#));
206        assert!(json.contains(r#""timeout":30"#));
207        assert!(json.contains(r#""database":"MY_DB""#));
208        assert!(json.contains(r#""schema":"PUBLIC""#));
209        assert!(json.contains(r#""warehouse":"COMPUTE_WH""#));
210        assert!(json.contains(r#""role":"ANALYST""#));
211        assert!(json.contains(
212            r#""bindings":{"1":{"type":"FIXED","value":"42"},"2":{"type":"TEXT","value":"hello"}}"#
213        ));
214        assert!(json.contains(r#""parameters":{"MULTI_STATEMENT_COUNT":"1","TIMEZONE":"UTC"}"#));
215
216        let roundtrip: SubmitStatementRequest = serde_json::from_str(&json)?;
217        assert_eq!(roundtrip, req);
218        Ok(())
219    }
220
221    #[test]
222    fn binding_constructors_and_types() -> Result<(), serde_json::Error> {
223        let b = Binding::new(bind_type::BOOLEAN, "true");
224        assert_eq!(b.value_type, "BOOLEAN");
225        assert_eq!(b.value, "true");
226
227        let json = serde_json::to_string(&b)?;
228        assert_eq!(json, r#"{"type":"BOOLEAN","value":"true"}"#);
229        let roundtrip: Binding = serde_json::from_str(&json)?;
230        assert_eq!(roundtrip, b);
231
232        assert_eq!(bind_type::TEXT, "TEXT");
233        assert_eq!(bind_type::FIXED, "FIXED");
234        assert_eq!(bind_type::REAL, "REAL");
235        assert_eq!(bind_type::DATE, "DATE");
236        assert_eq!(bind_type::TIME, "TIME");
237        assert_eq!(bind_type::TIMESTAMP_NTZ, "TIMESTAMP_NTZ");
238        assert_eq!(bind_type::TIMESTAMP_LTZ, "TIMESTAMP_LTZ");
239        assert_eq!(bind_type::TIMESTAMP_TZ, "TIMESTAMP_TZ");
240        assert_eq!(bind_type::BINARY, "BINARY");
241        Ok(())
242    }
243
244    #[test]
245    fn submit_query_params_pairs() {
246        let empty = SubmitQueryParams::default();
247        assert!(empty.to_query_pairs().is_empty());
248
249        let params = SubmitQueryParams {
250            request_id: Some("uuid-123".to_string()),
251            retry: true,
252            asynchronous: true,
253            nullable: Some(false),
254        };
255        let pairs = params.to_query_pairs();
256        assert_eq!(
257            pairs,
258            vec![
259                ("requestId", "uuid-123".to_string()),
260                ("retry", "true".to_string()),
261                ("async", "true".to_string()),
262                ("nullable", "false".to_string()),
263            ]
264        );
265
266        let nullable_true = SubmitQueryParams {
267            nullable: Some(true),
268            ..Default::default()
269        };
270        assert_eq!(
271            nullable_true.to_query_pairs(),
272            vec![("nullable", "true".to_string())]
273        );
274    }
275}