Skip to main content

omni_dev/snowflake/client/
session.rs

1//! A live Snowflake session: query execution and token lifecycle.
2
3use std::collections::HashMap;
4use std::io::Read as _;
5use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
6use std::time::Duration;
7
8use chrono::{DateTime, TimeDelta, Utc};
9use serde_json::{json, Value};
10
11use crate::utils::secret::Secret;
12
13use super::error::{Error, Result};
14use super::row::{Column, Row};
15use super::transport::{request_id, Transport};
16
17/// How often to poll an in-progress (async) query for its result.
18const POLL_INTERVAL: Duration = Duration::from_secs(3);
19
20/// Tokens returned by login, consumed by [`SnowflakeSession::new`].
21pub(crate) struct LoginTokens {
22    /// Session token (authorizes queries; short-lived; redacted in `Debug` output).
23    pub session_token: Secret,
24    /// Master token (authorizes renewal; longer-lived; redacted in `Debug` output).
25    pub master_token: Secret,
26    /// Session-token validity in seconds.
27    pub session_validity_secs: i64,
28    /// Master-token validity in seconds.
29    pub master_validity_secs: i64,
30}
31
32/// The mutable token state, refreshed by `renew`/`heartbeat`.
33#[derive(Clone, Debug)]
34struct Tokens {
35    session_token: Secret,
36    master_token: Secret,
37    session_expires_at: DateTime<Utc>,
38    master_expires_at: DateTime<Utc>,
39}
40
41/// The statement currently executing on a session, published for the duration of
42/// [`query`](SnowflakeSession::query) so an [`AbortHandle`] can cancel it.
43///
44/// Snowflake identifies a cancellable query by the `requestId` its submission
45/// used plus the exact `sqlText`; both — and a session token valid for the run —
46/// are captured here. The token snapshot is safe: a token is never renewed *during*
47/// a single `query` (renewal happens only between whole attempts, each of which
48/// republishes), so it always authorizes the statement it was captured with.
49#[derive(Clone, Debug)]
50struct InFlight {
51    request_id: String,
52    sql: String,
53    session_token: Secret,
54}
55
56/// A live, authenticated Snowflake session.
57///
58/// Holds the session and master tokens behind a mutex so a query (reading the
59/// session token) and a heartbeat/renew can interleave. `query` runs SQL;
60/// `renew` swaps in a fresh session token via the master token; `heartbeat`
61/// keeps the master token alive so renewal can continue indefinitely.
62pub struct SnowflakeSession {
63    transport: Arc<Transport>,
64    tokens: StdMutex<Tokens>,
65    /// The statement currently running, published while [`query`](Self::query) is
66    /// in flight so an [`AbortHandle`] can cancel it; `None` when idle. Shared
67    /// (behind `Arc`) with every handle [`abort_handle`](Self::abort_handle) hands
68    /// out, so a handle always reads the *live* in-flight statement.
69    in_flight: Arc<StdMutex<Option<InFlight>>>,
70    /// Overall deadline for one query (submit + async polling).
71    query_timeout: Duration,
72}
73
74/// A cheap, cloneable handle that cancels whatever statement a
75/// [`SnowflakeSession`] is currently running.
76///
77/// It shares the session's in-flight slot and transport (both `Arc`), so it stays
78/// valid after the session itself is checked out of a pool (moved out of the
79/// pool's slot) — which is the only way a concurrent `cancel` can reach a busy
80/// session. [`abort`](Self::abort) is a no-op (returns `Ok(false)`) when nothing
81/// is running.
82#[derive(Clone)]
83pub struct AbortHandle {
84    transport: Arc<Transport>,
85    in_flight: Arc<StdMutex<Option<InFlight>>>,
86}
87
88impl AbortHandle {
89    /// Aborts the session's in-flight statement via `queries/v1/abort-request`.
90    ///
91    /// Returns `Ok(true)` when an abort was issued for a running statement,
92    /// `Ok(false)` when nothing was running or the server reported nothing to
93    /// abort (e.g. the query already finished). The abort call uses a fresh
94    /// `requestId` and is authorized by the running statement's session token.
95    ///
96    /// # Errors
97    ///
98    /// A transport error, or [`Error::SessionExpired`] if the session token that
99    /// authorized the running statement has itself lapsed.
100    pub async fn abort(&self) -> Result<bool> {
101        let Some(target) = self.snapshot() else {
102            return Ok(false);
103        };
104        let rid = request_id();
105        let body = json!({ "sqlText": target.sql, "requestId": target.request_id });
106        match self
107            .transport
108            .post(
109                "queries/v1/abort-request",
110                &[("requestId", rid.as_str())],
111                &body,
112                Some(target.session_token.expose_secret()),
113            )
114            .await
115        {
116            Ok(_) => Ok(true),
117            // The server rejected the abort — most often because the query already
118            // finished or was never seen; there is simply nothing left to cancel.
119            Err(Error::Server { .. }) => Ok(false),
120            Err(e) => Err(e),
121        }
122    }
123
124    fn snapshot(&self) -> Option<InFlight> {
125        self.in_flight
126            .lock()
127            .unwrap_or_else(std::sync::PoisonError::into_inner)
128            .clone()
129    }
130
131    /// A handle that can never abort anything (no shared session). For tests that
132    /// need to store a handle without a live session.
133    #[cfg(test)]
134    #[must_use]
135    pub(crate) fn noop_for_test() -> Self {
136        use url::Url;
137        let base = Url::parse("http://127.0.0.1/").unwrap_or_else(|_| unreachable!());
138        Self {
139            transport: Arc::new(
140                Transport::with_base_url(base, Duration::from_secs(1))
141                    .unwrap_or_else(|_| unreachable!()),
142            ),
143            in_flight: Arc::new(StdMutex::new(None)),
144        }
145    }
146}
147
148impl SnowflakeSession {
149    /// Builds a session from a transport and freshly issued tokens.
150    pub(crate) fn new(
151        transport: Arc<Transport>,
152        tokens: LoginTokens,
153        query_timeout: Duration,
154    ) -> Self {
155        let now = Utc::now();
156        Self {
157            transport,
158            tokens: StdMutex::new(Tokens {
159                session_token: tokens.session_token,
160                master_token: tokens.master_token,
161                session_expires_at: now + TimeDelta::seconds(tokens.session_validity_secs),
162                master_expires_at: now + TimeDelta::seconds(tokens.master_validity_secs),
163            }),
164            in_flight: Arc::new(StdMutex::new(None)),
165            query_timeout,
166        }
167    }
168
169    /// A cloneable [`AbortHandle`] for this session's in-flight statement.
170    ///
171    /// The returned handle shares the session's in-flight slot, so it can cancel
172    /// whatever statement is running even after the session is checked out of a
173    /// pool (moved out of the pool's slot).
174    #[must_use]
175    pub fn abort_handle(&self) -> AbortHandle {
176        AbortHandle {
177            transport: Arc::clone(&self.transport),
178            in_flight: Arc::clone(&self.in_flight),
179        }
180    }
181
182    /// Whether the session token expires within `within` from now.
183    #[must_use]
184    pub fn session_expiring_within(&self, within: TimeDelta) -> bool {
185        Utc::now() + within >= self.lock().session_expires_at
186    }
187
188    /// When the master token currently expires (renewal must happen before this,
189    /// kept alive by [`heartbeat`](Self::heartbeat)).
190    #[must_use]
191    pub fn master_expires_at(&self) -> DateTime<Utc> {
192        self.lock().master_expires_at
193    }
194
195    /// Runs SQL and returns the result rows.
196    ///
197    /// # Errors
198    ///
199    /// [`Error::SessionExpired`] when the session token is no longer valid (renew
200    /// or re-authenticate), or a transport/server/protocol error.
201    pub async fn query(&self, sql: &str) -> Result<Vec<Row>> {
202        let token = self.lock().session_token.clone();
203        let rid = request_id();
204        let body = json!({ "sqlText": sql });
205        // Publish the statement so an `AbortHandle` can cancel it, then clear it
206        // once submission + polling returns (on both success and error).
207        self.set_in_flight(InFlight {
208            request_id: rid.clone(),
209            sql: sql.to_string(),
210            session_token: token.clone(),
211        });
212        // `post_statement` submits the query and polls the async result URL until
213        // it completes, so a query slower than the server's synchronous window
214        // (anything heavy) is not killed by a per-request timeout.
215        let result = self
216            .transport
217            .post_statement(
218                &[("requestId", rid.as_str())],
219                &body,
220                token.expose_secret(),
221                self.query_timeout,
222                POLL_INTERVAL,
223            )
224            .await;
225        self.clear_in_flight();
226        let data = result?;
227        self.rows_from_data(&data).await
228    }
229
230    /// Runs SQL that may contain **multiple** `;`-separated statements, returning
231    /// one row set per statement (in submission order).
232    ///
233    /// A single-statement submission takes the same path as [`query`](Self::query)
234    /// and yields a one-element vector. When the SQL parses to more than one
235    /// statement it is submitted with `MULTI_STATEMENT_COUNT = 0` ("any count"):
236    /// the server then returns a comma-separated list of child `resultIds` in
237    /// place of inline rows, and each child result is fetched from the
238    /// `/queries/{id}/result` endpoint.
239    ///
240    /// # Errors
241    ///
242    /// As [`query`](Self::query).
243    pub async fn query_multi(&self, sql: &str) -> Result<Vec<Vec<Row>>> {
244        // A single statement keeps the exact, cheaper single-result path — no
245        // `MULTI_STATEMENT_COUNT`, no child-result round trips.
246        if count_statements(sql) <= 1 {
247            return Ok(vec![self.query(sql).await?]);
248        }
249
250        let token = self.lock().session_token.clone();
251        let rid = request_id();
252        // 0 = "any count": the server runs however many statements are actually
253        // present, so this does not depend on `count_statements` agreeing exactly
254        // with the server's own parse.
255        let body = json!({ "sqlText": sql, "parameters": { "MULTI_STATEMENT_COUNT": 0 } });
256        // Publish the submission so an `AbortHandle` can cancel it — as `query`
257        // does — then clear it once submission + polling returns. The whole
258        // script runs under this one request id, so this is the cancellable part;
259        // the child-result fetches below are reads of finished statements.
260        self.set_in_flight(InFlight {
261            request_id: rid.clone(),
262            sql: sql.to_string(),
263            session_token: token.clone(),
264        });
265        let result = self
266            .transport
267            .post_statement(
268                &[("requestId", rid.as_str())],
269                &body,
270                token.expose_secret(),
271                self.query_timeout,
272                POLL_INTERVAL,
273            )
274            .await;
275        self.clear_in_flight();
276        let data = result?;
277
278        // A multi-statement submission returns child query ids (comma-separated)
279        // rather than inline rows; fetch each child's result set. If the server
280        // collapsed it to an inline result (no `resultIds`), return that one set.
281        let Some(ids) = data
282            .get("resultIds")
283            .and_then(Value::as_str)
284            .filter(|s| !s.trim().is_empty())
285        else {
286            return Ok(vec![self.rows_from_data(&data).await?]);
287        };
288
289        let mut results = Vec::new();
290        for id in ids.split(',').map(str::trim).filter(|s| !s.is_empty()) {
291            let child = self
292                .transport
293                .get_statement_result(id, token.expose_secret(), self.query_timeout, POLL_INTERVAL)
294                .await?;
295            results.push(self.rows_from_data(&child).await?);
296        }
297        Ok(results)
298    }
299
300    /// Parses a query-request `data` payload into rows, downloading and appending
301    /// any external result chunks (gzip-compressed JSON arrays).
302    async fn rows_from_data(&self, data: &Value) -> Result<Vec<Row>> {
303        let (columns, index, mut rows) = parse_result(data)?;
304        if let Some(chunks) = data.get("chunks").and_then(Value::as_array) {
305            let headers = parse_chunk_headers(data);
306            for chunk in chunks {
307                if let Some(url) = chunk.get("url").and_then(Value::as_str) {
308                    let bytes = self.transport.get_bytes(url, &headers).await?;
309                    rows.extend(decode_chunk_rows(&bytes, &columns, &index)?);
310                }
311            }
312        }
313        Ok(rows)
314    }
315
316    /// Renews the session token using the master token, extending the session.
317    ///
318    /// # Errors
319    ///
320    /// [`Error::SessionExpired`] when the master token has itself expired (a full
321    /// re-authentication is then required), or a transport/server error.
322    pub async fn renew(&self) -> Result<()> {
323        let (old_session, master) = {
324            let tokens = self.lock();
325            (tokens.session_token.clone(), tokens.master_token.clone())
326        };
327        let rid = request_id();
328        let body =
329            json!({ "oldSessionToken": old_session.expose_secret(), "requestType": "RENEW" });
330        let data = self
331            .transport
332            .post(
333                "session/token-request",
334                &[("requestId", rid.as_str())],
335                &body,
336                Some(master.expose_secret()),
337            )
338            .await?;
339
340        let new_session = data
341            .get("sessionToken")
342            .or_else(|| data.get("token"))
343            .and_then(Value::as_str)
344            .ok_or_else(|| Error::Protocol("token-request returned no sessionToken".into()))?
345            .to_string();
346        let st_validity = data
347            .get("validityInSecondsST")
348            .or_else(|| data.get("validityInSeconds"))
349            .and_then(Value::as_i64)
350            .unwrap_or(3600);
351
352        let mut tokens = self.lock();
353        tokens.session_token = new_session.into();
354        tokens.session_expires_at = Utc::now() + TimeDelta::seconds(st_validity);
355        if let Some(master_token) = data.get("masterToken").and_then(Value::as_str) {
356            tokens.master_token = master_token.into();
357        }
358        if let Some(mt_validity) = data.get("validityInSecondsMT").and_then(Value::as_i64) {
359            tokens.master_expires_at = Utc::now() + TimeDelta::seconds(mt_validity);
360        }
361        Ok(())
362    }
363
364    /// Sends a keep-alive heartbeat, extending the master token server-side.
365    ///
366    /// # Errors
367    ///
368    /// A transport/server error, or [`Error::SessionExpired`] if the session
369    /// token used to authorize the heartbeat has already lapsed.
370    pub async fn heartbeat(&self) -> Result<()> {
371        let token = self.lock().session_token.clone();
372        let rid = request_id();
373        self.transport
374            .post(
375                "session/heartbeat",
376                &[("requestId", rid.as_str())],
377                &json!({}),
378                Some(token.expose_secret()),
379            )
380            .await?;
381        // The server resets the master token's validity; the precise value
382        // comes back on the next renew.
383        Ok(())
384    }
385
386    /// Logs the session out (best-effort).
387    ///
388    /// # Errors
389    ///
390    /// A transport/server error.
391    pub async fn close(&self) -> Result<()> {
392        let token = self.lock().session_token.clone();
393        let rid = request_id();
394        self.transport
395            .post(
396                "session",
397                &[("delete", "true"), ("requestId", rid.as_str())],
398                &json!({}),
399                Some(token.expose_secret()),
400            )
401            .await?;
402        Ok(())
403    }
404
405    fn lock(&self) -> MutexGuard<'_, Tokens> {
406        self.tokens
407            .lock()
408            .unwrap_or_else(std::sync::PoisonError::into_inner)
409    }
410
411    fn set_in_flight(&self, in_flight: InFlight) {
412        *self
413            .in_flight
414            .lock()
415            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(in_flight);
416    }
417
418    fn clear_in_flight(&self) {
419        *self
420            .in_flight
421            .lock()
422            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
423    }
424}
425
426/// Schema (columns + uppercased name index) and inline rows from a response.
427type ParsedResult = (Arc<Vec<Column>>, Arc<HashMap<String, usize>>, Vec<Row>);
428
429/// Parses a query-request `data` payload into its schema and inline rows.
430/// External result chunks, if any, are downloaded separately by the caller.
431fn parse_result(data: &Value) -> Result<ParsedResult> {
432    if let Some(format) = data.get("queryResultFormat").and_then(Value::as_str) {
433        if !format.eq_ignore_ascii_case("json") {
434            return Err(Error::Unsupported(format!(
435                "result format '{format}' (only JSON is supported)"
436            )));
437        }
438    }
439
440    let rowtype = data
441        .get("rowtype")
442        .and_then(Value::as_array)
443        .ok_or_else(|| Error::Protocol("query response missing rowtype".into()))?;
444    let columns: Arc<Vec<Column>> = Arc::new(rowtype.iter().map(parse_column).collect());
445
446    let mut index = HashMap::new();
447    for (i, col) in columns.iter().enumerate() {
448        index.entry(col.name.to_ascii_uppercase()).or_insert(i);
449    }
450    let index = Arc::new(index);
451
452    let rows = data
453        .get("rowset")
454        .and_then(Value::as_array)
455        .map(|rows| {
456            rows.iter()
457                .map(|row| row_from_cells(row, &columns, &index))
458                .collect()
459        })
460        .unwrap_or_default();
461    Ok((columns, index, rows))
462}
463
464/// Builds a [`Row`] from a JSON array of cells.
465fn row_from_cells(
466    row: &Value,
467    columns: &Arc<Vec<Column>>,
468    index: &Arc<HashMap<String, usize>>,
469) -> Row {
470    let cells = row
471        .as_array()
472        .map(|cells| cells.iter().map(cell_to_string).collect())
473        .unwrap_or_default();
474    Row::new(cells, Arc::clone(columns), Arc::clone(index))
475}
476
477/// Extracts the per-chunk HTTP headers from a query response.
478fn parse_chunk_headers(data: &Value) -> Vec<(String, String)> {
479    data.get("chunkHeaders")
480        .and_then(Value::as_object)
481        .map(|headers| {
482            headers
483                .iter()
484                .filter_map(|(k, v)| v.as_str().map(|v| (k.clone(), v.to_string())))
485                .collect()
486        })
487        .unwrap_or_default()
488}
489
490/// Decodes a downloaded result chunk (gzip-compressed JSON array of rows).
491fn decode_chunk_rows(
492    bytes: &[u8],
493    columns: &Arc<Vec<Column>>,
494    index: &Arc<HashMap<String, usize>>,
495) -> Result<Vec<Row>> {
496    let json = gunzip_if_needed(bytes)?;
497    // Snowflake serves each chunk as bare, comma-separated row arrays
498    // (`[r1],[r2],…`) designed to be concatenated, not a self-contained JSON
499    // array — so wrap with `[` … `]` before parsing.
500    let mut framed = Vec::with_capacity(json.len() + 2);
501    framed.push(b'[');
502    framed.extend_from_slice(&json);
503    framed.push(b']');
504    let rows: Vec<Value> = serde_json::from_slice(&framed)
505        .map_err(|e| Error::Protocol(format!("invalid result chunk JSON: {e}")))?;
506    Ok(rows
507        .iter()
508        .map(|row| row_from_cells(row, columns, index))
509        .collect())
510}
511
512/// Gunzips `bytes` when they carry the gzip magic, else returns them unchanged.
513fn gunzip_if_needed(bytes: &[u8]) -> Result<Vec<u8>> {
514    if bytes.starts_with(&[0x1f, 0x8b]) {
515        let mut decoder = flate2::read::GzDecoder::new(bytes);
516        let mut out = Vec::new();
517        decoder.read_to_end(&mut out).map_err(Error::Io)?;
518        Ok(out)
519    } else {
520        Ok(bytes.to_vec())
521    }
522}
523
524/// Parses one `rowtype` entry into a [`Column`].
525fn parse_column(value: &Value) -> Column {
526    Column {
527        name: value
528            .get("name")
529            .and_then(Value::as_str)
530            .unwrap_or_default()
531            .to_string(),
532        ty: value
533            .get("type")
534            .and_then(Value::as_str)
535            .unwrap_or_default()
536            .to_ascii_lowercase(),
537        nullable: value
538            .get("nullable")
539            .and_then(Value::as_bool)
540            .unwrap_or(true),
541        length: value.get("length").and_then(Value::as_i64),
542        precision: value.get("precision").and_then(Value::as_i64),
543        scale: value.get("scale").and_then(Value::as_i64),
544    }
545}
546
547/// Normalizes a rowset cell to its raw string form (or `None` for null).
548fn cell_to_string(value: &Value) -> Option<String> {
549    match value {
550        Value::Null => None,
551        Value::String(s) => Some(s.clone()),
552        other => Some(other.to_string()),
553    }
554}
555
556/// Lexer state while scanning SQL for top-level statement separators.
557#[derive(PartialEq, Eq)]
558enum ScanState {
559    /// Ordinary SQL text.
560    Normal,
561    /// Inside a `'…'` string literal (`''` escapes a quote; `\` escapes a char).
562    Single,
563    /// Inside a `"…"` quoted identifier (`""` escapes a quote).
564    Double,
565    /// Inside a `-- …` line comment (ends at newline).
566    Line,
567    /// Inside a `/* … */` block comment.
568    Block,
569    /// Inside a `$$ … $$` dollar-quoted block (stored-proc / anonymous block body).
570    Dollar,
571}
572
573/// Counts the non-empty top-level SQL statements in `sql`, ignoring `;` that
574/// appear inside string / quoted-identifier literals, line and block comments,
575/// and `$$`-delimited blocks.
576///
577/// Used only to decide **whether** to submit with `MULTI_STATEMENT_COUNT` — the
578/// count actually sent is `0` (any count), so this is a routing hint, not a
579/// contract. It is deliberately conservative: a genuine separator between two
580/// statements is always counted, so a real multi-statement script is never
581/// mis-submitted as a single statement.
582fn count_statements(sql: &str) -> usize {
583    let bytes = sql.as_bytes();
584    let mut state = ScanState::Normal;
585    let mut count = 0usize;
586    // Whether the statement currently being scanned has any non-whitespace,
587    // non-comment content (so trailing `;` and blank/`;`-only input count as 0).
588    let mut has_content = false;
589    let mut i = 0;
590    while i < bytes.len() {
591        let c = bytes[i];
592        let next = bytes.get(i + 1).copied();
593        match state {
594            ScanState::Normal => match c {
595                b'\'' => {
596                    state = ScanState::Single;
597                    has_content = true;
598                }
599                b'"' => {
600                    state = ScanState::Double;
601                    has_content = true;
602                }
603                b'-' if next == Some(b'-') => {
604                    state = ScanState::Line;
605                    i += 1;
606                }
607                b'/' if next == Some(b'*') => {
608                    state = ScanState::Block;
609                    i += 1;
610                }
611                b'$' if next == Some(b'$') => {
612                    state = ScanState::Dollar;
613                    has_content = true;
614                    i += 1;
615                }
616                b';' => {
617                    if has_content {
618                        count += 1;
619                    }
620                    has_content = false;
621                }
622                _ => {
623                    if !c.is_ascii_whitespace() {
624                        has_content = true;
625                    }
626                }
627            },
628            ScanState::Single => match c {
629                b'\'' => {
630                    if next == Some(b'\'') {
631                        i += 1; // '' — an escaped quote; stay in the string.
632                    } else {
633                        state = ScanState::Normal;
634                    }
635                }
636                b'\\' => i += 1, // backslash escape (e.g. \'); skip the next char.
637                _ => {}
638            },
639            ScanState::Double => {
640                if c == b'"' {
641                    if next == Some(b'"') {
642                        i += 1; // "" — an escaped quote; stay in the identifier.
643                    } else {
644                        state = ScanState::Normal;
645                    }
646                }
647            }
648            ScanState::Line => {
649                if c == b'\n' {
650                    state = ScanState::Normal;
651                }
652            }
653            ScanState::Block => {
654                if c == b'*' && next == Some(b'/') {
655                    state = ScanState::Normal;
656                    i += 1;
657                }
658            }
659            ScanState::Dollar => {
660                if c == b'$' && next == Some(b'$') {
661                    state = ScanState::Normal;
662                    i += 1;
663                }
664            }
665        }
666        i += 1;
667    }
668    if has_content {
669        count += 1;
670    }
671    count
672}
673
674#[cfg(test)]
675#[allow(clippy::unwrap_used, clippy::expect_used)]
676mod tests {
677    use super::*;
678    use crate::snowflake::client::row::rows_to_payload;
679    use url::Url;
680    use wiremock::matchers::{method, path};
681    use wiremock::{Mock, MockServer, ResponseTemplate};
682
683    /// A session whose transport points at `server`, with the given session-token
684    /// validity. The master token is long-lived.
685    fn live_session(server: &MockServer, session_validity_secs: i64) -> SnowflakeSession {
686        let base = Url::parse(&server.uri()).unwrap().join("/").unwrap();
687        let transport = Arc::new(Transport::with_base_url(base, Duration::from_secs(5)).unwrap());
688        SnowflakeSession::new(
689            transport,
690            LoginTokens {
691                session_token: "sess".into(),
692                master_token: "mast".into(),
693                session_validity_secs,
694                master_validity_secs: 14_400,
695            },
696            Duration::from_secs(5),
697        )
698    }
699
700    #[test]
701    fn token_accessors_reflect_validities() {
702        // The token accessors never touch the network.
703        let base = Url::parse("https://acct.example/").unwrap();
704        let transport = Arc::new(Transport::with_base_url(base, Duration::from_secs(5)).unwrap());
705        let session = SnowflakeSession::new(
706            transport,
707            LoginTokens {
708                session_token: "s".into(),
709                master_token: "m".into(),
710                session_validity_secs: 3600,
711                master_validity_secs: 14_400,
712            },
713            Duration::from_secs(5),
714        );
715        assert!(session.master_expires_at() > Utc::now());
716        assert!(!session.session_expiring_within(TimeDelta::seconds(60)));
717        assert!(session.session_expiring_within(TimeDelta::seconds(7200)));
718    }
719
720    #[test]
721    fn tokens_debug_redacts_secrets() {
722        let tokens = Tokens {
723            session_token: "sekret-session-token".into(),
724            master_token: "sekret-master-token".into(),
725            session_expires_at: Utc::now(),
726            master_expires_at: Utc::now(),
727        };
728        // Debug must never print the token values (#1131).
729        let debug = format!("{tokens:?}");
730        assert!(
731            !debug.contains("sekret-session-token"),
732            "leaked session token: {debug}"
733        );
734        assert!(
735            !debug.contains("sekret-master-token"),
736            "leaked master token: {debug}"
737        );
738        assert!(debug.contains("session_token: <redacted>"));
739        assert!(debug.contains("master_token: <redacted>"));
740    }
741
742    #[tokio::test]
743    async fn query_returns_inline_rows() {
744        let server = MockServer::start().await;
745        Mock::given(method("POST"))
746            .and(path("/queries/v1/query-request"))
747            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
748                "success": true,
749                "data": {
750                    "queryResultFormat": "json",
751                    "rowtype": [{ "name": "N", "type": "fixed", "precision": 38, "scale": 0 }],
752                    "rowset": [["1"], ["2"]],
753                }
754            })))
755            .mount(&server)
756            .await;
757
758        let rows = live_session(&server, 3600).query("SELECT 1").await.unwrap();
759        assert_eq!(rows.len(), 2);
760    }
761
762    #[tokio::test]
763    async fn query_downloads_and_appends_external_chunks() {
764        let server = MockServer::start().await;
765        let chunk_url = format!("{}/chunk0", server.uri());
766        Mock::given(method("POST"))
767            .and(path("/queries/v1/query-request"))
768            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
769                "success": true,
770                "data": {
771                    "queryResultFormat": "json",
772                    "rowtype": [{ "name": "N", "type": "text" }],
773                    "rowset": [["a"]],
774                    "chunks": [{ "url": chunk_url }],
775                }
776            })))
777            .mount(&server)
778            .await;
779        // A real chunk is bare, comma-separated row arrays (no enclosing brackets).
780        Mock::given(method("GET"))
781            .and(path("/chunk0"))
782            .respond_with(ResponseTemplate::new(200).set_body_bytes(br#"["b"],["c"]"#.to_vec()))
783            .mount(&server)
784            .await;
785
786        let rows = live_session(&server, 3600).query("SELECT 1").await.unwrap();
787        assert_eq!(rows.len(), 3, "1 inline + 2 chunked");
788    }
789
790    #[tokio::test]
791    async fn query_surfaces_session_expired() {
792        let server = MockServer::start().await;
793        Mock::given(method("POST"))
794            .and(path("/queries/v1/query-request"))
795            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
796                "success": false, "code": "390112", "message": "expired", "data": {}
797            })))
798            .mount(&server)
799            .await;
800
801        let err = live_session(&server, 3600)
802            .query("SELECT 1")
803            .await
804            .unwrap_err();
805        assert!(matches!(err, Error::SessionExpired));
806    }
807
808    #[tokio::test]
809    async fn abort_cancels_the_in_flight_statement_by_request_id() {
810        let server = MockServer::start().await;
811        // Submit → "in progress" with a result URL that never completes in-test,
812        // so the query parks in the poll loop with its statement published.
813        Mock::given(method("POST"))
814            .and(path("/queries/v1/query-request"))
815            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
816                "success": true, "code": "333333", "data": { "getResultUrl": "/poll/1" }
817            })))
818            .mount(&server)
819            .await;
820        Mock::given(method("GET"))
821            .and(path("/poll/1"))
822            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
823                "success": true, "code": "333333", "data": {}
824            })))
825            .mount(&server)
826            .await;
827        Mock::given(method("POST"))
828            .and(path("/queries/v1/abort-request"))
829            .respond_with(
830                ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
831            )
832            .mount(&server)
833            .await;
834
835        let session = Arc::new(live_session(&server, 3600));
836        let handle = session.abort_handle();
837
838        // Idle: nothing is running, so no abort is issued.
839        assert!(
840            !handle.abort().await.unwrap(),
841            "idle session has nothing to abort"
842        );
843
844        // Start a query that parks in the poll loop, then abort it.
845        let query = {
846            let session = Arc::clone(&session);
847            tokio::spawn(async move { session.query("SELECT LONG").await })
848        };
849        // Let the query publish its in-flight statement before aborting.
850        tokio::time::sleep(Duration::from_millis(100)).await;
851        assert!(
852            handle.abort().await.unwrap(),
853            "a running statement is aborted"
854        );
855        query.abort();
856
857        // The abort carried the *query's* requestId and its exact sqlText, under a
858        // fresh requestId of its own.
859        let requests = server.received_requests().await.unwrap();
860        let query_rid = requests
861            .iter()
862            .find(|r| r.url.path() == "/queries/v1/query-request")
863            .and_then(|r| {
864                r.url
865                    .query_pairs()
866                    .find(|(k, _)| k == "requestId")
867                    .map(|(_, v)| v.into_owned())
868            })
869            .expect("the query recorded its requestId");
870        let abort = requests
871            .iter()
872            .find(|r| r.url.path() == "/queries/v1/abort-request")
873            .expect("an abort request was sent");
874        let abort_rid = abort
875            .url
876            .query_pairs()
877            .find(|(k, _)| k == "requestId")
878            .map(|(_, v)| v.into_owned())
879            .unwrap_or_default();
880        let body: Value = serde_json::from_slice(&abort.body).unwrap();
881        assert_eq!(body["sqlText"], "SELECT LONG");
882        assert_eq!(body["requestId"], query_rid, "body targets the query's id");
883        assert_ne!(
884            abort_rid, query_rid,
885            "the abort call uses its own requestId"
886        );
887    }
888
889    #[tokio::test]
890    async fn abort_reports_nothing_to_cancel_on_a_server_rejection() {
891        let server = MockServer::start().await;
892        Mock::given(method("POST"))
893            .and(path("/queries/v1/query-request"))
894            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
895                "success": true, "code": "333333", "data": { "getResultUrl": "/poll/1" }
896            })))
897            .mount(&server)
898            .await;
899        Mock::given(method("GET"))
900            .and(path("/poll/1"))
901            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
902                "success": true, "code": "333333", "data": {}
903            })))
904            .mount(&server)
905            .await;
906        // The server rejects the abort (e.g. the query already finished).
907        Mock::given(method("POST"))
908            .and(path("/queries/v1/abort-request"))
909            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
910                "success": false, "code": "000603", "message": "query not found"
911            })))
912            .mount(&server)
913            .await;
914
915        let session = Arc::new(live_session(&server, 3600));
916        let handle = session.abort_handle();
917        let query = {
918            let session = Arc::clone(&session);
919            tokio::spawn(async move { session.query("SELECT LONG").await })
920        };
921        tokio::time::sleep(Duration::from_millis(100)).await;
922        // A server rejection means there was nothing left to cancel, not an error.
923        assert!(!handle.abort().await.unwrap());
924        query.abort();
925    }
926
927    #[tokio::test]
928    async fn renew_swaps_in_a_fresh_session_token() {
929        let server = MockServer::start().await;
930        Mock::given(method("POST"))
931            .and(path("/session/token-request"))
932            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
933                "success": true,
934                "data": { "sessionToken": "new-sess", "validityInSecondsST": 3600 }
935            })))
936            .mount(&server)
937            .await;
938
939        // A nearly-expired session is no longer about to expire after a renew.
940        let session = live_session(&server, 1);
941        assert!(session.session_expiring_within(TimeDelta::seconds(120)));
942        session.renew().await.unwrap();
943        assert!(!session.session_expiring_within(TimeDelta::seconds(120)));
944    }
945
946    #[tokio::test]
947    async fn renew_errors_when_response_lacks_a_token() {
948        let server = MockServer::start().await;
949        Mock::given(method("POST"))
950            .and(path("/session/token-request"))
951            .respond_with(
952                ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
953            )
954            .mount(&server)
955            .await;
956
957        let err = live_session(&server, 3600).renew().await.unwrap_err();
958        assert!(matches!(err, Error::Protocol(_)));
959    }
960
961    #[tokio::test]
962    async fn heartbeat_and_close_post_successfully() {
963        let server = MockServer::start().await;
964        Mock::given(method("POST"))
965            .and(path("/session/heartbeat"))
966            .respond_with(
967                ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
968            )
969            .mount(&server)
970            .await;
971        Mock::given(method("POST"))
972            .and(path("/session"))
973            .respond_with(
974                ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
975            )
976            .mount(&server)
977            .await;
978
979        let session = live_session(&server, 3600);
980        session.heartbeat().await.unwrap();
981        session.close().await.unwrap();
982    }
983
984    #[test]
985    fn parse_result_builds_columns_and_inline_rows() {
986        let data = json!({
987            "queryResultFormat": "json",
988            "rowtype": [
989                { "name": "ID", "type": "fixed", "nullable": false, "precision": 38, "scale": 0 },
990                { "name": "NAME", "type": "text", "nullable": true, "length": 16_777_216 },
991            ],
992            "rowset": [["1", "alice"], ["2", null]],
993        });
994        let (_columns, _index, rows) = parse_result(&data).unwrap();
995        assert_eq!(rows.len(), 2);
996        let payload = rows_to_payload(&rows);
997        assert_eq!(payload["columns"][0]["name"], "ID");
998        assert_eq!(payload["columns"][0]["type"], "fixed(38,0)");
999        assert_eq!(payload["rows"][0]["ID"], 1);
1000        assert_eq!(payload["rows"][0]["NAME"], "alice");
1001        assert_eq!(payload["rows"][1]["NAME"], Value::Null);
1002    }
1003
1004    #[test]
1005    fn parse_result_rejects_arrow_but_allows_chunked() {
1006        let arrow = json!({ "queryResultFormat": "arrow", "rowtype": [], "rowset": [] });
1007        assert!(matches!(parse_result(&arrow), Err(Error::Unsupported(_))));
1008        // Chunked responses are no longer rejected at parse — the inline rows are
1009        // returned and chunks are downloaded separately.
1010        let chunked = json!({
1011            "queryResultFormat": "json",
1012            "rowtype": [{ "name": "C", "type": "text" }],
1013            "rowset": [["a"]],
1014            "chunks": [{ "url": "https://example/chunk0" }],
1015        });
1016        let (_c, _i, rows) = parse_result(&chunked).unwrap();
1017        assert_eq!(rows.len(), 1);
1018    }
1019
1020    #[test]
1021    fn parse_result_requires_rowtype() {
1022        assert!(matches!(
1023            parse_result(&json!({ "rowset": [] })),
1024            Err(Error::Protocol(_))
1025        ));
1026    }
1027
1028    #[test]
1029    fn decode_chunk_rows_handles_gzip_and_plain_json() {
1030        use std::io::Write as _;
1031        let (columns, index, _rows) = parse_result(&json!({
1032            "queryResultFormat": "json",
1033            "rowtype": [
1034                { "name": "ID", "type": "fixed", "precision": 38, "scale": 0 },
1035                { "name": "NAME", "type": "text" },
1036            ],
1037            "rowset": [],
1038        }))
1039        .unwrap();
1040
1041        // A real chunk is bare, comma-separated row arrays WITHOUT enclosing
1042        // brackets — the multi-row case that the framing fix must handle.
1043        let chunk_json = br#"["3","carol"],["4",null]"#;
1044
1045        // Plain JSON.
1046        let rows = decode_chunk_rows(chunk_json, &columns, &index).unwrap();
1047        assert_eq!(rows.len(), 2);
1048        assert_eq!(rows[0].to_json_object().get("NAME"), Some(&json!("carol")));
1049        assert_eq!(rows[1].to_json_object().get("NAME"), Some(&Value::Null));
1050
1051        // Gzip-compressed (as chunks arrive over the wire).
1052        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1053        encoder.write_all(chunk_json).unwrap();
1054        let gzipped = encoder.finish().unwrap();
1055        let rows = decode_chunk_rows(&gzipped, &columns, &index).unwrap();
1056        assert_eq!(rows.len(), 2);
1057        assert_eq!(rows[0].to_json_object().get("ID"), Some(&json!(3)));
1058    }
1059
1060    #[test]
1061    fn parse_chunk_headers_reads_string_pairs() {
1062        let data = json!({ "chunkHeaders": { "x-amz-server-side-encryption": "AES256" } });
1063        let headers = parse_chunk_headers(&data);
1064        assert_eq!(
1065            headers,
1066            vec![(
1067                "x-amz-server-side-encryption".to_string(),
1068                "AES256".to_string()
1069            )]
1070        );
1071    }
1072
1073    #[test]
1074    fn count_statements_counts_top_level_separators() {
1075        assert_eq!(count_statements(""), 0);
1076        assert_eq!(count_statements("   \n  "), 0);
1077        assert_eq!(count_statements(";;;"), 0, "empty statements don't count");
1078        assert_eq!(count_statements("SELECT 1"), 1);
1079        assert_eq!(count_statements("SELECT 1;"), 1, "trailing ; is not a stmt");
1080        assert_eq!(count_statements("SELECT 1; SELECT 2"), 2);
1081        assert_eq!(count_statements("SELECT 1; SELECT 2;"), 2);
1082        assert_eq!(
1083            count_statements("USE WAREHOUSE WH; USE ROLE R; SELECT 1"),
1084            3
1085        );
1086    }
1087
1088    #[test]
1089    fn count_statements_ignores_separators_in_literals_and_comments() {
1090        // `;` inside a single-quoted string is not a separator.
1091        assert_eq!(count_statements("SELECT ';'"), 1);
1092        assert_eq!(count_statements("SELECT ';'; SELECT 2"), 2);
1093        // Escaped quotes (both doubling and backslash) keep the string open.
1094        assert_eq!(count_statements("SELECT 'it''s; ok'"), 1);
1095        assert_eq!(count_statements("SELECT 'it\\'s; ok'; SELECT 2"), 2);
1096        // Quoted identifiers.
1097        assert_eq!(count_statements("SELECT 1 AS \"a;b\""), 1);
1098        // Line and block comments.
1099        assert_eq!(count_statements("SELECT 1 -- a; b\n; SELECT 2"), 2);
1100        assert_eq!(count_statements("SELECT 1 /* a; b */; SELECT 2"), 2);
1101        // Dollar-quoted proc body: internal `;` are part of one statement.
1102        assert_eq!(
1103            count_statements("CREATE PROC p() AS $$ BEGIN a; b; END $$; SELECT 1"),
1104            2
1105        );
1106    }
1107
1108    #[tokio::test]
1109    async fn query_multi_single_statement_returns_one_result_set() {
1110        let server = MockServer::start().await;
1111        Mock::given(method("POST"))
1112            .and(path("/queries/v1/query-request"))
1113            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1114                "success": true,
1115                "data": {
1116                    "queryResultFormat": "json",
1117                    "rowtype": [{ "name": "N", "type": "fixed", "precision": 38, "scale": 0 }],
1118                    "rowset": [["1"], ["2"]],
1119                }
1120            })))
1121            .mount(&server)
1122            .await;
1123
1124        let results = live_session(&server, 3600)
1125            .query_multi("SELECT 1")
1126            .await
1127            .unwrap();
1128        assert_eq!(results.len(), 1, "one statement → one result set");
1129        assert_eq!(results[0].len(), 2);
1130    }
1131
1132    #[tokio::test]
1133    async fn query_multi_follows_result_ids_for_each_statement() {
1134        let server = MockServer::start().await;
1135        // The multi-statement submission returns child ids, not inline rows.
1136        Mock::given(method("POST"))
1137            .and(path("/queries/v1/query-request"))
1138            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1139                "success": true,
1140                "data": { "resultIds": "id-a,id-b", "resultTypes": "1,1" }
1141            })))
1142            .mount(&server)
1143            .await;
1144        Mock::given(method("GET"))
1145            .and(path("/queries/id-a/result"))
1146            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1147                "success": true,
1148                "data": {
1149                    "queryResultFormat": "json",
1150                    "rowtype": [{ "name": "A", "type": "text" }],
1151                    "rowset": [["x"]],
1152                }
1153            })))
1154            .mount(&server)
1155            .await;
1156        Mock::given(method("GET"))
1157            .and(path("/queries/id-b/result"))
1158            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1159                "success": true,
1160                "data": {
1161                    "queryResultFormat": "json",
1162                    "rowtype": [{ "name": "B", "type": "fixed", "precision": 38, "scale": 0 }],
1163                    "rowset": [["1"], ["2"]],
1164                }
1165            })))
1166            .mount(&server)
1167            .await;
1168
1169        let results = live_session(&server, 3600)
1170            .query_multi("USE ROLE R; SELECT 2")
1171            .await
1172            .unwrap();
1173        assert_eq!(results.len(), 2, "two statements → two result sets");
1174        assert_eq!(results[0].len(), 1);
1175        assert_eq!(results[1].len(), 2);
1176    }
1177}