Skip to main content

omni_dev/snowflake/
mod.rs

1//! Account-agnostic Snowflake query engine hosted by the daemon.
2//!
3//! Each `(account, user)` keeps a **bounded pool** of authenticated sessions
4//! (see [`session`]). A query checks one out, applies any per-query context with
5//! `USE` (skipping `USE`s already in effect), runs concurrently with other
6//! checkouts, and returns it. This gives **concurrent queries on a single
7//! authentication identity** while still honoring per-query
8//! `warehouse`/`role`/`database`/`schema`, with the number of browser auths
9//! capped at the pool size and grown lazily.
10//!
11//! This is the standalone engine, analogous to [`crate::browser`]; the daemon
12//! adapter lives in [`crate::daemon::services::snowflake`].
13
14pub mod client;
15pub mod session;
16
17use std::time::Duration;
18
19use anyhow::{anyhow, bail, Result};
20use chrono::TimeDelta;
21use serde::Deserialize;
22use serde_json::Value;
23
24use crate::utils::settings::Settings;
25use client::{Error as ClientError, Row, SnowflakeClient, SnowflakeClientConfig, SnowflakeSession};
26use session::{PoolRegistry, QueryContext, SessionInfo, SessionKey};
27
28/// Env var (with `~/.omni-dev/settings.json` fallback) for the default account.
29const ENV_ACCOUNT: &str = "SNOWFLAKE_ACCOUNT";
30/// Env var for the default user.
31const ENV_USER: &str = "SNOWFLAKE_USER";
32/// Env var for the default warehouse.
33const ENV_WAREHOUSE: &str = "SNOWFLAKE_WAREHOUSE";
34/// Env var for the default role.
35const ENV_ROLE: &str = "SNOWFLAKE_ROLE";
36/// Env var for the default database.
37const ENV_DATABASE: &str = "SNOWFLAKE_DATABASE";
38/// Env var for the default schema.
39const ENV_SCHEMA: &str = "SNOWFLAKE_SCHEMA";
40/// Env var for the per-`(account, user)` pool size (max concurrent sessions).
41const ENV_POOL_SIZE: &str = "SNOWFLAKE_POOL_SIZE";
42/// Env var for the per-request HTTP timeout (seconds).
43const ENV_HTTP_TIMEOUT: &str = "SNOWFLAKE_HTTP_TIMEOUT";
44/// Env var for the overall sign-in deadline (seconds).
45const ENV_AUTH_TIMEOUT: &str = "SNOWFLAKE_AUTH_TIMEOUT";
46/// Env var for the overall per-query deadline incl. async polling (seconds).
47const ENV_QUERY_TIMEOUT: &str = "SNOWFLAKE_QUERY_TIMEOUT";
48
49/// Default pool size when `SNOWFLAKE_POOL_SIZE` is unset: the max concurrent
50/// queries (and max browser auths) per `(account, user)`.
51const DEFAULT_POOL_SIZE: usize = 4;
52/// Default overall sign-in deadline: comfortably over the SSO callback wait so a
53/// genuine sign-in completes, but bounded so a hung auth can't hold the gate.
54const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(150);
55/// Max characters of SQL shown in the "running" preview for a busy session.
56const SQL_PREVIEW_MAX: usize = 60;
57
58/// Engine defaults, resolved from environment variables and then
59/// `~/.omni-dev/settings.json` (the Atlassian credential-resolution pattern).
60///
61/// Account/user/context are optional; a request supplies its own and falls back
62/// to these. There is **no** hardcoded account list or alias map.
63#[derive(Clone, Debug)]
64pub struct SnowflakeEngineConfig {
65    /// Default account when a request omits `--account`.
66    pub default_account: Option<String>,
67    /// Default user when a request omits `--user`.
68    pub default_user: Option<String>,
69    /// Default warehouse applied at session creation.
70    pub default_warehouse: Option<String>,
71    /// Default role applied at session creation.
72    pub default_role: Option<String>,
73    /// Default database applied at session creation.
74    pub default_database: Option<String>,
75    /// Default schema applied at session creation.
76    pub default_schema: Option<String>,
77    /// Max concurrent sessions (and browser auths) per `(account, user)`.
78    pub pool_size: usize,
79    /// Per-request HTTP timeout for REST calls.
80    pub http_timeout: Duration,
81    /// Overall deadline for one sign-in (SSO + login) so a hung auth can't hold
82    /// the shared auth gate indefinitely.
83    pub auth_timeout: Duration,
84    /// Overall deadline for one query (submit + async-result polling).
85    pub query_timeout: Duration,
86}
87
88impl Default for SnowflakeEngineConfig {
89    fn default() -> Self {
90        Self {
91            default_account: None,
92            default_user: None,
93            default_warehouse: None,
94            default_role: None,
95            default_database: None,
96            default_schema: None,
97            pool_size: DEFAULT_POOL_SIZE,
98            http_timeout: client::config::DEFAULT_HTTP_TIMEOUT,
99            auth_timeout: DEFAULT_AUTH_TIMEOUT,
100            query_timeout: client::config::DEFAULT_QUERY_TIMEOUT,
101        }
102    }
103}
104
105impl SnowflakeEngineConfig {
106    /// Resolves defaults from env vars (then settings.json). Cheap and
107    /// side-effect-free; never authenticates.
108    ///
109    /// # Errors
110    ///
111    /// Currently infallible, but returns `Result` so the daemon registry wiring
112    /// can `?` it and future validation can surface errors.
113    pub fn from_env_and_settings() -> Result<Self> {
114        let settings = Settings::load().unwrap_or_else(|_| Settings {
115            env: std::collections::HashMap::new(),
116        });
117        let pool_size = settings
118            .get_env_var(ENV_POOL_SIZE)
119            .and_then(|s| s.trim().parse::<usize>().ok())
120            .filter(|&n| n >= 1)
121            .unwrap_or(DEFAULT_POOL_SIZE);
122        let secs = |key: &str| {
123            settings
124                .get_env_var(key)
125                .and_then(|s| s.trim().parse::<u64>().ok())
126                .filter(|&n| n >= 1)
127                .map(Duration::from_secs)
128        };
129        Ok(Self {
130            default_account: settings.get_env_var(ENV_ACCOUNT),
131            default_user: settings.get_env_var(ENV_USER),
132            default_warehouse: settings.get_env_var(ENV_WAREHOUSE),
133            default_role: settings.get_env_var(ENV_ROLE),
134            default_database: settings.get_env_var(ENV_DATABASE),
135            default_schema: settings.get_env_var(ENV_SCHEMA),
136            pool_size,
137            http_timeout: secs(ENV_HTTP_TIMEOUT).unwrap_or(client::config::DEFAULT_HTTP_TIMEOUT),
138            auth_timeout: secs(ENV_AUTH_TIMEOUT).unwrap_or(DEFAULT_AUTH_TIMEOUT),
139            query_timeout: secs(ENV_QUERY_TIMEOUT).unwrap_or(client::config::DEFAULT_QUERY_TIMEOUT),
140        })
141    }
142}
143
144/// A single arbitrary-SQL query request routed to the engine.
145///
146/// `account`/`user` and the per-query context default to the engine config when
147/// omitted. Deserialized from the daemon `query` op payload.
148#[derive(Clone, Debug, Default, Deserialize)]
149#[serde(default)]
150pub struct QueryRequest {
151    /// Target account; falls back to `SNOWFLAKE_ACCOUNT`.
152    pub account: Option<String>,
153    /// Authenticating user; falls back to `SNOWFLAKE_USER`.
154    pub user: Option<String>,
155    /// Per-query `USE WAREHOUSE` override.
156    pub warehouse: Option<String>,
157    /// Per-query `USE ROLE` override.
158    pub role: Option<String>,
159    /// Per-query `USE DATABASE` override.
160    pub database: Option<String>,
161    /// Per-query `USE SCHEMA` override.
162    pub schema: Option<String>,
163    /// The SQL to execute.
164    pub sql: String,
165}
166
167impl QueryRequest {
168    /// The per-query context overrides (the `Some` fields override the session
169    /// base context).
170    fn overrides(&self) -> QueryContext {
171        QueryContext {
172            warehouse: self.warehouse.clone(),
173            role: self.role.clone(),
174            database: self.database.clone(),
175            schema: self.schema.clone(),
176        }
177    }
178}
179
180/// The account-agnostic Snowflake query engine: lazy multiplexed auth, bounded
181/// per-identity session pools, and concurrent arbitrary-SQL execution.
182pub struct SnowflakeEngine {
183    config: SnowflakeEngineConfig,
184    registry: PoolRegistry,
185}
186
187impl SnowflakeEngine {
188    /// Builds an engine. Cheap — no eager auth or I/O.
189    #[must_use]
190    pub fn new(config: SnowflakeEngineConfig) -> Self {
191        Self {
192            config,
193            registry: PoolRegistry::new(),
194        }
195    }
196
197    /// A snapshot of every active pool.
198    #[must_use]
199    pub fn sessions(&self) -> Vec<SessionInfo> {
200        self.registry.snapshot()
201    }
202
203    /// The number of active pools (`(account, user)` identities).
204    #[must_use]
205    pub fn pool_count(&self) -> usize {
206        self.registry.len()
207    }
208
209    /// Evicts the pool for `(account, user)`. Returns whether one existed.
210    pub fn disconnect(&self, account: &str, user: &str) -> bool {
211        let key = SessionKey::new(normalize_account(account), user.trim());
212        self.registry.remove(&key).is_some()
213    }
214
215    /// Evicts the pool with the given id. Returns whether one existed.
216    pub fn disconnect_by_id(&self, id: u64) -> bool {
217        self.registry.remove_by_id(id).is_some()
218    }
219
220    /// Evicts every pool. Returns how many were removed.
221    pub fn disconnect_all(&self) -> usize {
222        self.registry.take_all().len()
223    }
224
225    /// Drops every pool (and its sessions).
226    pub async fn shutdown(&self) {
227        let pools = self.registry.take_all();
228        drop(pools);
229    }
230
231    /// Runs arbitrary SQL against the `(account, user)` pool, authenticating a
232    /// session on first use, and returns a self-describing `{ columns, rows }`
233    /// payload. Concurrent calls run on separate pooled sessions (up to the pool
234    /// size).
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if no account/user can be resolved, a context flag is not
239    /// a valid identifier, authentication fails, or the query fails. On a
240    /// session-expiry error that session is discarded and the next query
241    /// re-authenticates.
242    pub async fn query(&self, req: QueryRequest) -> Result<Value> {
243        let account = normalize_account(
244            req.account
245                .as_deref()
246                .or(self.config.default_account.as_deref())
247                .ok_or_else(|| {
248                    anyhow!("no Snowflake account: pass --account or set SNOWFLAKE_ACCOUNT")
249                })?,
250        );
251        let user = req
252            .user
253            .as_deref()
254            .or(self.config.default_user.as_deref())
255            .ok_or_else(|| anyhow!("no Snowflake user: pass --user or set SNOWFLAKE_USER"))?
256            .trim()
257            .to_string();
258        validate_context(&req)?;
259
260        let key = SessionKey::new(account, user);
261        let overrides = req.overrides();
262        let pool = self.registry.get_or_create(&key, self.config.pool_size);
263
264        // Check out a session. The pool reuses an idle one when available
265        // (re-checking after the auth gate so a session freed mid-auth is reused),
266        // and only authenticates a new one — serialized to one browser at a time
267        // by the pool's shared auth gate — when none is idle and it is under
268        // capacity. The permit inside the checkout caps concurrency at pool_size.
269        let cfg = self.config.clone();
270        let create_key = key.clone();
271        let auth_timeout = self.config.auth_timeout;
272        let checkout = pool
273            .checkout(move || async move {
274                // Overall sign-in deadline so a hung auth releases the gate.
275                match tokio::time::timeout(
276                    auth_timeout,
277                    create_session_with_base(&create_key, &cfg),
278                )
279                .await
280                {
281                    Ok(result) => result,
282                    Err(_) => Err(ClientError::Auth(format!(
283                        "Snowflake sign-in timed out after {auth_timeout:?}"
284                    ))),
285                }
286            })
287            .await
288            .map_err(|e| {
289                anyhow::Error::new(e).context(format!(
290                    "failed to authenticate Snowflake session for {} / {}",
291                    key.account, key.user
292                ))
293            })?;
294
295        // Proactively renew a session whose token is about to expire, before use.
296        if checkout
297            .session()
298            .session_expiring_within(TimeDelta::seconds(120))
299            && checkout.session().renew().await.is_err()
300        {
301            pool.discard(checkout);
302            return Err(anyhow!(
303                "Snowflake session expired and was discarded — re-run the query to re-authenticate"
304            ));
305        }
306
307        // Record what this member is now running, so menus/status show it.
308        pool.start_query(checkout.id(), sql_preview(&req.sql, SQL_PREVIEW_MAX));
309
310        // Apply the requested context and run the query, transparently renewing
311        // the token and retrying once if it expires mid-flight.
312        let target = checkout.base().overlay(&overrides);
313        match run_with_renew(checkout.session(), checkout.current(), &target, &req.sql).await {
314            Ok(rows) => {
315                pool.touch();
316                pool.checkin(checkout, target);
317                Ok(client::rows_to_payload(&rows))
318            }
319            Err(e) if e.is_session_expired() => {
320                pool.discard(checkout);
321                Err(anyhow!(
322                    "Snowflake session expired and was discarded — re-run the query to re-authenticate"
323                ))
324            }
325            Err(e) => {
326                // Log the underlying cause server-side and surface it to the
327                // client (the daemon reply uses the full anyhow chain).
328                tracing::warn!("Snowflake query failed: {e}");
329                // The session's context is uncertain after a failure; check in
330                // with an empty context so the next reuse re-applies every dimension.
331                pool.checkin(checkout, QueryContext::default());
332                Err(anyhow::Error::new(e).context("Snowflake query failed"))
333            }
334        }
335    }
336}
337
338/// Authenticates a session (external-browser SSO), enables keep-alive, and
339/// captures its base (account/user default) context.
340async fn create_session_with_base(
341    key: &SessionKey,
342    config: &SnowflakeEngineConfig,
343) -> client::Result<(SnowflakeSession, QueryContext)> {
344    let mut cfg = SnowflakeClientConfig::external_browser(&key.account, &key.user);
345    cfg.warehouse = config.default_warehouse.clone();
346    cfg.role = config.default_role.clone();
347    cfg.database = config.default_database.clone();
348    cfg.schema = config.default_schema.clone();
349    cfg.http_timeout = config.http_timeout;
350    cfg.query_timeout = config.query_timeout;
351
352    let client = SnowflakeClient::new(cfg)?;
353    let session = client.create_session().await?;
354    session
355        .query("ALTER SESSION SET CLIENT_SESSION_KEEP_ALIVE = true")
356        .await?;
357    let base = capture_base_context(&session).await?;
358    Ok((session, base))
359}
360
361/// Reads the session's effective default context so per-query overrides can
362/// later be reset back to it.
363async fn capture_base_context(session: &SnowflakeSession) -> client::Result<QueryContext> {
364    let rows = session
365        .query("SELECT CURRENT_WAREHOUSE(), CURRENT_ROLE(), CURRENT_DATABASE(), CURRENT_SCHEMA()")
366        .await?;
367    let Some(row) = rows.first() else {
368        return Ok(QueryContext::default());
369    };
370    Ok(QueryContext {
371        warehouse: row.raw_at(0).map(str::to_string),
372        role: row.raw_at(1).map(str::to_string),
373        database: row.raw_at(2).map(str::to_string),
374        schema: row.raw_at(3).map(str::to_string),
375    })
376}
377
378/// Applies the context and runs the SQL, transparently renewing the session
379/// token (via the master token) and retrying once if it expired mid-flight.
380async fn run_with_renew(
381    session: &SnowflakeSession,
382    current: &QueryContext,
383    target: &QueryContext,
384    sql: &str,
385) -> client::Result<Vec<Row>> {
386    match apply_and_query(session, current, target, sql).await {
387        Err(e) if e.is_session_expired() => {
388            session.renew().await?;
389            // Re-apply the full context on the renewed session, then retry.
390            apply_and_query(session, &QueryContext::default(), target, sql).await
391        }
392        other => other,
393    }
394}
395
396/// Issues any needed `USE` statements, then runs the SQL.
397async fn apply_and_query(
398    session: &SnowflakeSession,
399    current: &QueryContext,
400    target: &QueryContext,
401    sql: &str,
402) -> client::Result<Vec<Row>> {
403    apply_context(session, current, target).await?;
404    session.query(sql).await
405}
406
407/// Issues `USE` for each context dimension whose target differs from the
408/// session's current value. Target names are either validated user overrides or
409/// Snowflake-reported base names.
410async fn apply_context(
411    session: &SnowflakeSession,
412    current: &QueryContext,
413    target: &QueryContext,
414) -> client::Result<()> {
415    for (keyword, cur, tgt) in [
416        (
417            "WAREHOUSE",
418            current.warehouse.as_deref(),
419            target.warehouse.as_deref(),
420        ),
421        ("ROLE", current.role.as_deref(), target.role.as_deref()),
422        (
423            "DATABASE",
424            current.database.as_deref(),
425            target.database.as_deref(),
426        ),
427        (
428            "SCHEMA",
429            current.schema.as_deref(),
430            target.schema.as_deref(),
431        ),
432    ] {
433        if let Some(name) = tgt {
434            if cur != Some(name) {
435                session
436                    .query(format!("USE {keyword} {name}").as_str())
437                    .await?;
438            }
439        }
440    }
441    Ok(())
442}
443
444/// Normalizes an account identifier for keying (Snowflake is case-insensitive).
445fn normalize_account(account: &str) -> String {
446    account.trim().to_ascii_uppercase()
447}
448
449/// A single-line, length-bounded preview of SQL for the "running" display
450/// (collapses whitespace/newlines; appends `…` when truncated).
451fn sql_preview(sql: &str, max: usize) -> String {
452    let collapsed = sql.split_whitespace().collect::<Vec<_>>().join(" ");
453    if collapsed.chars().count() > max {
454        let head: String = collapsed.chars().take(max).collect();
455        format!("{}…", head.trim_end())
456    } else {
457        collapsed
458    }
459}
460
461/// Validates every present context flag as a safe Snowflake identifier before it
462/// is interpolated into a `USE …` statement.
463fn validate_context(req: &QueryRequest) -> Result<()> {
464    for (name, value) in [
465        ("warehouse", req.warehouse.as_deref()),
466        ("role", req.role.as_deref()),
467        ("database", req.database.as_deref()),
468        ("schema", req.schema.as_deref()),
469    ] {
470        if let Some(value) = value {
471            validate_identifier(name, value)?;
472        }
473    }
474    Ok(())
475}
476
477/// Rejects context values that are not bare Snowflake identifiers (letters,
478/// digits, `_`, `$`, `.`), so a `--warehouse` flag cannot smuggle extra SQL into
479/// the `USE …` statement.
480fn validate_identifier(field: &str, value: &str) -> Result<()> {
481    if value.is_empty() {
482        bail!("--{field} must not be empty");
483    }
484    if !value
485        .chars()
486        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '.'))
487    {
488        bail!(
489            "--{field} '{value}' is not a valid Snowflake identifier \
490             (allowed: letters, digits, '_', '$', '.')"
491        );
492    }
493    Ok(())
494}
495
496#[cfg(test)]
497#[allow(clippy::unwrap_used, clippy::expect_used)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn default_config_has_a_nonzero_pool_size() {
503        assert!(SnowflakeEngineConfig::default().pool_size >= 1);
504    }
505
506    #[test]
507    fn normalize_account_uppercases_and_trims() {
508        assert_eq!(normalize_account("  my-org.acct  "), "MY-ORG.ACCT");
509    }
510
511    #[test]
512    fn validate_identifier_accepts_bare_identifiers() {
513        for value in ["WH", "my_wh", "DB.SCHEMA", "wh$1"] {
514            assert!(validate_identifier("warehouse", value).is_ok(), "{value}");
515        }
516    }
517
518    #[test]
519    fn validate_identifier_rejects_injection_and_empty() {
520        for value in ["", "wh; DROP TABLE t", "wh OR 1=1", "wh'", "a b"] {
521            assert!(validate_identifier("warehouse", value).is_err(), "{value}");
522        }
523    }
524
525    #[test]
526    fn validate_context_checks_each_present_flag() {
527        let mut req = QueryRequest {
528            sql: "SELECT 1".to_string(),
529            ..QueryRequest::default()
530        };
531        assert!(validate_context(&req).is_ok());
532        req.role = Some("good_role".to_string());
533        assert!(validate_context(&req).is_ok());
534        req.database = Some("bad; drop".to_string());
535        assert!(validate_context(&req).is_err());
536    }
537
538    #[tokio::test]
539    async fn query_without_account_errors_without_network() {
540        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
541        let err = engine
542            .query(QueryRequest {
543                sql: "SELECT 1".to_string(),
544                ..QueryRequest::default()
545            })
546            .await
547            .unwrap_err();
548        assert!(err.to_string().contains("account"));
549    }
550
551    #[tokio::test]
552    async fn query_without_user_errors_without_network() {
553        let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
554            default_account: Some("ACCT".to_string()),
555            ..SnowflakeEngineConfig::default()
556        });
557        let err = engine
558            .query(QueryRequest {
559                sql: "SELECT 1".to_string(),
560                ..QueryRequest::default()
561            })
562            .await
563            .unwrap_err();
564        assert!(err.to_string().contains("user"));
565    }
566
567    #[test]
568    fn disconnect_and_sessions_on_empty_engine() {
569        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
570        assert_eq!(engine.pool_count(), 0);
571        assert!(engine.sessions().is_empty());
572        assert!(!engine.disconnect("ACCT", "user"));
573        assert!(!engine.disconnect_by_id(1));
574        assert_eq!(engine.disconnect_all(), 0);
575    }
576
577    #[test]
578    fn sql_preview_collapses_whitespace_and_truncates() {
579        // Whitespace/newlines collapse to single spaces; short SQL is unchanged.
580        assert_eq!(sql_preview("SELECT   1\n  FROM t", 60), "SELECT 1 FROM t");
581        // Over-length SQL is truncated with an ellipsis.
582        let long = format!("SELECT {}", "a".repeat(100));
583        let preview = sql_preview(&long, 20);
584        assert!(preview.ends_with('…'));
585        assert!(preview.chars().count() <= 21, "{preview}");
586    }
587
588    #[test]
589    fn overrides_extracts_only_the_set_dimensions() {
590        let req = QueryRequest {
591            warehouse: Some("WH".to_string()),
592            schema: Some("S".to_string()),
593            sql: "SELECT 1".to_string(),
594            ..QueryRequest::default()
595        };
596        let overrides = req.overrides();
597        assert_eq!(overrides.warehouse.as_deref(), Some("WH"));
598        assert_eq!(overrides.schema.as_deref(), Some("S"));
599        assert!(overrides.role.is_none());
600        assert!(overrides.database.is_none());
601    }
602
603    mod orchestration {
604        use super::*;
605        use serde_json::json;
606        use wiremock::matchers::{method, path};
607        use wiremock::{Mock, MockServer, ResponseTemplate};
608
609        /// Mounts a `query-request` handler that returns `data` for every POST.
610        async fn mount_query(server: &MockServer, data: serde_json::Value) {
611            Mock::given(method("POST"))
612                .and(path("/queries/v1/query-request"))
613                .respond_with(
614                    ResponseTemplate::new(200)
615                        .set_body_json(json!({ "success": true, "data": data })),
616                )
617                .mount(server)
618                .await;
619        }
620
621        #[tokio::test]
622        async fn capture_base_context_reads_current_context() {
623            let server = MockServer::start().await;
624            mount_query(
625                &server,
626                json!({
627                    "rowtype": [
628                        { "name": "CURRENT_WAREHOUSE()", "type": "text" },
629                        { "name": "CURRENT_ROLE()", "type": "text" },
630                        { "name": "CURRENT_DATABASE()", "type": "text" },
631                        { "name": "CURRENT_SCHEMA()", "type": "text" },
632                    ],
633                    "rowset": [["WH", "R", "DB", "S"]],
634                }),
635            )
636            .await;
637            let session = client::test_session(&server.uri(), Duration::from_secs(5));
638            let base = capture_base_context(&session).await.unwrap();
639            assert_eq!(base.warehouse.as_deref(), Some("WH"));
640            assert_eq!(base.role.as_deref(), Some("R"));
641            assert_eq!(base.database.as_deref(), Some("DB"));
642            assert_eq!(base.schema.as_deref(), Some("S"));
643        }
644
645        #[tokio::test]
646        async fn capture_base_context_defaults_when_no_rows() {
647            let server = MockServer::start().await;
648            mount_query(
649                &server,
650                json!({ "rowtype": [{ "name": "X", "type": "text" }], "rowset": [] }),
651            )
652            .await;
653            let session = client::test_session(&server.uri(), Duration::from_secs(5));
654            assert_eq!(
655                capture_base_context(&session).await.unwrap(),
656                QueryContext::default()
657            );
658        }
659
660        #[tokio::test]
661        async fn apply_context_issues_use_only_for_differing_dimensions() {
662            let server = MockServer::start().await;
663            mount_query(&server, json!({ "rowtype": [], "rowset": [] })).await;
664            let session = client::test_session(&server.uri(), Duration::from_secs(5));
665
666            let current = QueryContext {
667                warehouse: Some("WH".to_string()),
668                ..QueryContext::default()
669            };
670            let target = QueryContext {
671                warehouse: Some("WH".to_string()), // same → no USE
672                role: Some("R2".to_string()),      // differs → one USE
673                ..QueryContext::default()
674            };
675            apply_context(&session, &current, &target).await.unwrap();
676
677            let reqs = server.received_requests().await.unwrap();
678            assert_eq!(reqs.len(), 1, "only the differing dimension issues a USE");
679            assert!(String::from_utf8_lossy(&reqs[0].body).contains("USE ROLE R2"));
680        }
681
682        #[tokio::test]
683        async fn run_with_renew_runs_without_renew_when_not_expired() {
684            let server = MockServer::start().await;
685            mount_query(
686                &server,
687                json!({ "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }),
688            )
689            .await;
690            let session = client::test_session(&server.uri(), Duration::from_secs(5));
691            let ctx = QueryContext::default();
692            let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
693                .await
694                .unwrap();
695            assert_eq!(rows.len(), 1);
696        }
697
698        #[tokio::test]
699        async fn run_with_renew_renews_and_retries_once_on_expiry() {
700            let server = MockServer::start().await;
701            // First query attempt: session expired (then this rule is exhausted).
702            Mock::given(method("POST"))
703                .and(path("/queries/v1/query-request"))
704                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
705                    "success": false, "code": "390112", "message": "expired", "data": {}
706                })))
707                .up_to_n_times(1)
708                .with_priority(1)
709                .mount(&server)
710                .await;
711            // Renew succeeds.
712            Mock::given(method("POST"))
713                .and(path("/session/token-request"))
714                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
715                    "success": true,
716                    "data": { "sessionToken": "fresh", "validityInSecondsST": 3600 }
717                })))
718                .mount(&server)
719                .await;
720            // The retried query succeeds.
721            Mock::given(method("POST"))
722                .and(path("/queries/v1/query-request"))
723                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
724                    "success": true,
725                    "data": { "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }
726                })))
727                .with_priority(2)
728                .mount(&server)
729                .await;
730
731            let session = client::test_session(&server.uri(), Duration::from_secs(5));
732            let ctx = QueryContext::default();
733            let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
734                .await
735                .unwrap();
736            assert_eq!(rows.len(), 1, "renewed and retried transparently");
737        }
738    }
739}