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}