1pub 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
28const ENV_ACCOUNT: &str = "SNOWFLAKE_ACCOUNT";
30const ENV_USER: &str = "SNOWFLAKE_USER";
32const ENV_WAREHOUSE: &str = "SNOWFLAKE_WAREHOUSE";
34const ENV_ROLE: &str = "SNOWFLAKE_ROLE";
36const ENV_DATABASE: &str = "SNOWFLAKE_DATABASE";
38const ENV_SCHEMA: &str = "SNOWFLAKE_SCHEMA";
40const ENV_POOL_SIZE: &str = "SNOWFLAKE_POOL_SIZE";
42const ENV_HTTP_TIMEOUT: &str = "SNOWFLAKE_HTTP_TIMEOUT";
44const ENV_AUTH_TIMEOUT: &str = "SNOWFLAKE_AUTH_TIMEOUT";
46const ENV_QUERY_TIMEOUT: &str = "SNOWFLAKE_QUERY_TIMEOUT";
48
49const DEFAULT_POOL_SIZE: usize = 4;
52const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(150);
55const SQL_PREVIEW_MAX: usize = 60;
57
58#[derive(Clone, Debug)]
64pub struct SnowflakeEngineConfig {
65 pub default_account: Option<String>,
67 pub default_user: Option<String>,
69 pub default_warehouse: Option<String>,
71 pub default_role: Option<String>,
73 pub default_database: Option<String>,
75 pub default_schema: Option<String>,
77 pub pool_size: usize,
79 pub http_timeout: Duration,
81 pub auth_timeout: Duration,
84 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 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#[derive(Clone, Debug, Default, Deserialize)]
149#[serde(default)]
150pub struct QueryRequest {
151 pub account: Option<String>,
153 pub user: Option<String>,
155 pub warehouse: Option<String>,
157 pub role: Option<String>,
159 pub database: Option<String>,
161 pub schema: Option<String>,
163 pub sql: String,
165}
166
167impl QueryRequest {
168 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
180pub struct SnowflakeEngine {
183 config: SnowflakeEngineConfig,
184 registry: PoolRegistry,
185}
186
187impl SnowflakeEngine {
188 #[must_use]
190 pub fn new(config: SnowflakeEngineConfig) -> Self {
191 Self {
192 config,
193 registry: PoolRegistry::new(),
194 }
195 }
196
197 #[must_use]
199 pub fn sessions(&self) -> Vec<SessionInfo> {
200 self.registry.snapshot()
201 }
202
203 #[must_use]
205 pub fn pool_count(&self) -> usize {
206 self.registry.len()
207 }
208
209 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 pub fn disconnect_by_id(&self, id: u64) -> bool {
217 self.registry.remove_by_id(id).is_some()
218 }
219
220 pub fn disconnect_all(&self) -> usize {
222 self.registry.take_all().len()
223 }
224
225 pub async fn shutdown(&self) {
227 let pools = self.registry.take_all();
228 drop(pools);
229 }
230
231 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 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 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 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 pool.start_query(checkout.id(), sql_preview(&req.sql, SQL_PREVIEW_MAX));
309
310 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 tracing::warn!("Snowflake query failed: {e}");
329 pool.checkin(checkout, QueryContext::default());
332 Err(anyhow::Error::new(e).context("Snowflake query failed"))
333 }
334 }
335 }
336}
337
338async 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
361async 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
378async 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 apply_and_query(session, &QueryContext::default(), target, sql).await
391 }
392 other => other,
393 }
394}
395
396async 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
407async 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
444fn normalize_account(account: &str) -> String {
446 account.trim().to_ascii_uppercase()
447}
448
449fn 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
461fn 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
477fn 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 assert_eq!(sql_preview("SELECT 1\n FROM t", 60), "SELECT 1 FROM t");
581 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 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()), role: Some("R2".to_string()), ..QueryContext::default()
674 };
675 apply_context(&session, ¤t, &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 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 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 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}