1use crate::config::SnowflakeSourceConfig;
11use crate::convert::{ColumnMeta, row_to_json};
12use async_trait::async_trait;
13use faucet_common_snowflake::{
14 SnowflakeAuth, authorization_header, credential_to_auth, snowflake_token_type,
15};
16use faucet_core::util::substitute_context_bind_params;
17use faucet_core::{AuthSpec, FaucetError, SharedAuthProvider, Stream, StreamPage};
18use reqwest::Client;
19use serde::Deserialize;
20use serde_json::{Map, Value, json};
21use std::collections::HashMap;
22use std::pin::Pin;
23use std::time::Duration;
24
25#[derive(Debug, Deserialize)]
29struct StatementResponse {
30 #[serde(default)]
33 code: Option<String>,
34 #[serde(default)]
36 message: Option<String>,
37 #[serde(rename = "statementHandle", default)]
40 statement_handle: Option<String>,
41 #[serde(rename = "resultSetMetaData", default)]
45 result_set_metadata: Option<ResultSetMetadata>,
46 #[serde(default)]
48 data: Option<Vec<Vec<Value>>>,
49}
50
51#[derive(Debug, Deserialize)]
52struct ResultSetMetadata {
53 #[serde(rename = "rowType", default)]
55 row_type: Vec<ColumnMeta>,
56 #[serde(rename = "partitionInfo", default)]
59 partition_info: Vec<PartitionInfo>,
60}
61
62#[derive(Debug, Deserialize)]
63#[allow(dead_code)] struct PartitionInfo {
65 #[serde(rename = "rowCount", default)]
66 row_count: u64,
67}
68
69pub struct SnowflakeSource {
72 config: SnowflakeSourceConfig,
73 client: Client,
74 endpoint_base: Option<String>,
79 auth_provider: Option<SharedAuthProvider>,
83}
84
85impl SnowflakeSource {
86 pub fn new(config: SnowflakeSourceConfig) -> Result<Self, FaucetError> {
90 faucet_core::validate_batch_size(config.batch_size)?;
91 Ok(Self {
92 config,
93 client: Client::new(),
94 endpoint_base: None,
95 auth_provider: None,
96 })
97 }
98
99 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
109 self.auth_provider = Some(provider);
110 self
111 }
112
113 pub fn with_endpoint_base(mut self, base: impl Into<String>) -> Self {
120 self.endpoint_base = Some(base.into());
121 self
122 }
123
124 fn base_url(&self) -> String {
125 match &self.endpoint_base {
126 Some(b) => b.trim_end_matches('/').to_owned(),
127 None => format!("https://{}.snowflakecomputing.com", self.config.account),
128 }
129 }
130
131 fn statements_url(&self) -> String {
132 format!("{}/api/v2/statements", self.base_url())
133 }
134
135 fn partition_url(&self, handle: &str, partition: usize) -> String {
136 format!(
137 "{}/api/v2/statements/{}?partition={}",
138 self.base_url(),
139 handle,
140 partition
141 )
142 }
143
144 async fn resolve_auth(&self) -> Result<SnowflakeAuth, FaucetError> {
152 if let Some(p) = &self.auth_provider {
153 return credential_to_auth(p.credential().await?);
154 }
155 match &self.config.auth {
156 AuthSpec::Inline(a) => Ok(a.clone()),
157 AuthSpec::Reference(r) => Err(FaucetError::Auth(format!(
158 "auth references provider '{}' but no provider was supplied",
159 r.name
160 ))),
161 }
162 }
163
164 fn build_request_body(&self, bindings: &[Value]) -> Value {
175 let mut body = json!({
176 "statement": self.config.query,
177 "timeout": self.config.statement_timeout.as_secs(),
178 "database": self.config.database,
179 "schema": self.config.schema,
180 "warehouse": self.config.warehouse,
181 });
182
183 if let Some(role) = &self.config.role {
184 body["role"] = json!(role);
185 }
186
187 if !bindings.is_empty() {
188 let mut map = Map::with_capacity(bindings.len());
189 for (i, v) in bindings.iter().enumerate() {
190 let (ty, value) = match v {
201 Value::Null => ("TEXT", Value::Null),
202 Value::Bool(b) => ("BOOLEAN", Value::String(b.to_string())),
203 Value::Number(n) => {
204 let ty = if n.is_i64() || n.is_u64() {
205 "FIXED"
206 } else {
207 "REAL"
208 };
209 (ty, Value::String(n.to_string()))
210 }
211 Value::String(s) => ("TEXT", Value::String(s.clone())),
212 other => ("TEXT", Value::String(other.to_string())),
213 };
214 map.insert((i + 1).to_string(), json!({"type": ty, "value": value}));
215 }
216 body["bindings"] = Value::Object(map);
217 }
218
219 body
220 }
221
222 fn resolve_query(&self, context: &HashMap<String, Value>) -> (String, Vec<Value>) {
229 let mut bindings = self.config.params.clone();
230 let (rewritten, context_values) = if context.is_empty() {
231 (self.config.query.clone(), Vec::new())
232 } else {
233 substitute_context_bind_params(&self.config.query, context, bindings.len() + 1, |_| {
234 "?".to_string()
235 })
236 };
237 bindings.extend(context_values);
238 (rewritten, bindings)
239 }
240
241 async fn submit_statement(
244 &self,
245 context: &HashMap<String, Value>,
246 ) -> Result<StatementResponse, FaucetError> {
247 let (query, bindings) = self.resolve_query(context);
248 self.submit_sql(query, &bindings).await
249 }
250
251 async fn submit_sql(
256 &self,
257 statement: String,
258 bindings: &[Value],
259 ) -> Result<StatementResponse, FaucetError> {
260 let mut body = self.build_request_body(bindings);
261 body["statement"] = Value::String(statement);
262
263 let url = self.statements_url();
264 let effective = self.resolve_auth().await?;
265 let auth = authorization_header(&effective, &self.config.account)?;
266 let token_type = snowflake_token_type(&effective);
267
268 let resp = self
269 .client
270 .post(&url)
271 .header("Authorization", &auth)
272 .header("Content-Type", "application/json")
273 .header("Accept", "application/json")
274 .header("X-Snowflake-Authorization-Token-Type", token_type)
275 .json(&body)
276 .send()
277 .await
278 .map_err(|e| FaucetError::Source(format!("Snowflake request failed: {e}")))?;
279
280 let status = resp.status();
281 let async_pending = status.as_u16() == 202;
282 if !status.is_success() && !async_pending {
283 let text = resp.text().await.unwrap_or_default();
284 return Err(FaucetError::Source(format!(
285 "Snowflake SQL API returned HTTP {status}: {text}"
286 )));
287 }
288
289 let parsed: StatementResponse = resp
290 .json()
291 .await
292 .map_err(|e| FaucetError::Source(format!("failed to parse Snowflake response: {e}")))?;
293
294 if async_pending {
295 let handle = parsed.statement_handle.clone().ok_or_else(|| {
297 FaucetError::Source(
298 "Snowflake returned 202 without a statementHandle to poll".into(),
299 )
300 })?;
301 self.poll_until_ready(&handle, &auth, token_type).await
302 } else {
303 check_code(&parsed)?;
304 Ok(parsed)
305 }
306 }
307
308 async fn poll_until_ready(
311 &self,
312 handle: &str,
313 auth: &str,
314 token_type: &'static str,
315 ) -> Result<StatementResponse, FaucetError> {
316 let url = format!("{}/api/v2/statements/{}", self.base_url(), handle);
317 let poll_timeout = self.config.poll_timeout;
318 let started = std::time::Instant::now();
319 loop {
320 let resp = self
321 .client
322 .get(&url)
323 .header("Authorization", auth)
324 .header("Accept", "application/json")
325 .header("X-Snowflake-Authorization-Token-Type", token_type)
326 .send()
327 .await
328 .map_err(|e| FaucetError::Source(format!("Snowflake poll request failed: {e}")))?;
329
330 let status = resp.status();
331 if status.as_u16() == 202 {
332 if !poll_timeout.is_zero() && started.elapsed() >= poll_timeout {
334 return Err(FaucetError::Source(format!(
335 "Snowflake statement '{handle}' did not finish within poll_timeout ({}s); still HTTP 202",
336 poll_timeout.as_secs()
337 )));
338 }
339 tokio::time::sleep(Duration::from_millis(500)).await;
340 continue;
341 }
342 if !status.is_success() {
343 let text = resp.text().await.unwrap_or_default();
344 return Err(FaucetError::Source(format!(
345 "Snowflake poll returned HTTP {status}: {text}"
346 )));
347 }
348 let parsed: StatementResponse = resp.json().await.map_err(|e| {
349 FaucetError::Source(format!("failed to parse Snowflake poll response: {e}"))
350 })?;
351 check_code(&parsed)?;
352 return Ok(parsed);
353 }
354 }
355
356 async fn fetch_partition(
358 &self,
359 handle: &str,
360 partition: usize,
361 auth: &str,
362 token_type: &'static str,
363 ) -> Result<Vec<Vec<Value>>, FaucetError> {
364 let url = self.partition_url(handle, partition);
365 let resp = self
366 .client
367 .get(&url)
368 .header("Authorization", auth)
369 .header("Accept", "application/json")
370 .header("X-Snowflake-Authorization-Token-Type", token_type)
371 .send()
372 .await
373 .map_err(|e| {
374 FaucetError::Source(format!(
375 "Snowflake partition fetch failed (partition {partition}): {e}"
376 ))
377 })?;
378
379 let status = resp.status();
380 if !status.is_success() {
381 let text = resp.text().await.unwrap_or_default();
382 return Err(FaucetError::Source(format!(
383 "Snowflake partition fetch returned HTTP {status} (partition {partition}): {text}"
384 )));
385 }
386
387 let parsed: StatementResponse = resp.json().await.map_err(|e| {
388 FaucetError::Source(format!(
389 "failed to parse Snowflake partition response (partition {partition}): {e}"
390 ))
391 })?;
392 check_code(&parsed)?;
393 Ok(parsed.data.unwrap_or_default())
394 }
395}
396
397const CATALOG_SQL: &str = "SELECT c.table_schema, c.table_name, c.column_name, c.data_type, \
403 c.is_nullable, t.row_count \
404 FROM information_schema.columns c \
405 JOIN information_schema.tables t \
406 ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
407 WHERE t.table_type = 'BASE TABLE' \
408 AND c.table_schema <> 'INFORMATION_SCHEMA' \
409 ORDER BY c.table_schema, c.table_name, c.ordinal_position";
410
411type CatalogRow = (String, String, String, String, bool, Option<i64>);
414
415type TableAcc = (String, String, Option<i64>, Vec<(String, Value)>);
418
419fn catalog_rows(
424 data: &[Vec<Value>],
425 columns: &[ColumnMeta],
426) -> Result<Vec<CatalogRow>, FaucetError> {
427 if columns.len() < 6 {
428 return Err(FaucetError::Source(format!(
429 "snowflake: catalog discovery failed: expected 6 result columns, got {}",
430 columns.len()
431 )));
432 }
433 data.iter()
434 .map(|raw| {
435 let rec = row_to_json(raw, columns);
436 let text = |i: usize| -> Result<String, FaucetError> {
437 rec[columns[i].name.as_str()]
438 .as_str()
439 .map(str::to_owned)
440 .ok_or_else(|| {
441 FaucetError::Source(format!(
442 "snowflake: catalog decode failed ({}): expected a string",
443 columns[i].name
444 ))
445 })
446 };
447 let is_nullable = rec[columns[4].name.as_str()]
448 .as_str()
449 .map(|v| v.eq_ignore_ascii_case("yes"))
450 .unwrap_or(true);
451 let estimated_rows = match &rec[columns[5].name.as_str()] {
454 Value::Number(n) => n.as_i64(),
455 Value::String(s) => s.trim().parse::<i64>().ok(),
456 _ => None,
457 };
458 Ok((
459 text(0)?,
460 text(1)?,
461 text(2)?,
462 text(3)?,
463 is_nullable,
464 estimated_rows,
465 ))
466 })
467 .collect()
468}
469
470fn descriptors_from_catalog(
475 rows: Vec<CatalogRow>,
476 quote: fn(&str) -> String,
477) -> Vec<faucet_core::DatasetDescriptor> {
478 let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
479 let mut current: Option<TableAcc> = None;
480
481 let flush = |cur: Option<TableAcc>, out: &mut Vec<faucet_core::DatasetDescriptor>| {
482 if let Some((schema, table, est, cols)) = cur {
483 let query = format!("SELECT * FROM {}.{}", quote(&schema), quote(&table));
484 let mut d = faucet_core::DatasetDescriptor::new(
485 format!("{schema}.{table}"),
486 "table",
487 json!({ "query": query }),
488 )
489 .with_schema(faucet_core::columns_to_schema(cols));
490 if let Some(n) = est
491 && n >= 0
492 {
493 d = d.with_estimated_rows(n as u64);
494 }
495 out.push(d);
496 }
497 };
498
499 for (schema, table, column, data_type, is_nullable, est) in rows {
500 let same = current
501 .as_ref()
502 .is_some_and(|(s, t, _, _)| *s == schema && *t == table);
503 if !same {
504 flush(current.take(), &mut out);
505 current = Some((schema, table, est, Vec::new()));
506 }
507 let mut fragment = faucet_core::sql_type_to_json_schema(&data_type);
508 if is_nullable {
509 fragment = faucet_core::nullable_type(fragment);
510 }
511 if let Some((_, _, _, cols)) = current.as_mut() {
512 cols.push((column, fragment));
513 }
514 }
515 flush(current, &mut out);
516 out
517}
518
519fn check_code(resp: &StatementResponse) -> Result<(), FaucetError> {
522 if let Some(code) = &resp.code
523 && code != "090001"
524 {
525 return Err(FaucetError::Source(format!(
526 "Snowflake error {}: {}",
527 code,
528 resp.message.clone().unwrap_or_default()
529 )));
530 }
531 Ok(())
532}
533
534#[async_trait]
535impl faucet_core::Source for SnowflakeSource {
536 fn connector_name(&self) -> &'static str {
537 "snowflake"
538 }
539
540 fn config_schema(&self) -> Value {
541 serde_json::to_value(faucet_core::schema_for!(SnowflakeSourceConfig))
542 .expect("schema serialization")
543 }
544
545 fn dataset_uri(&self) -> String {
546 format!(
547 "snowflake://{}/{}/{}?query={}",
548 self.config.account, self.config.database, self.config.schema, self.config.query
549 )
550 }
551
552 fn supports_discover(&self) -> bool {
553 true
554 }
555
556 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
564 let wrap = |e: FaucetError| -> FaucetError {
565 FaucetError::Source(format!("snowflake: catalog discovery failed: {e}"))
566 };
567 let initial = self
568 .submit_sql(CATALOG_SQL.to_string(), &[])
569 .await
570 .map_err(wrap)?;
571 let columns = initial
572 .result_set_metadata
573 .as_ref()
574 .map(|m| m.row_type.clone())
575 .unwrap_or_default();
576 if columns.is_empty() {
577 return Ok(Vec::new());
579 }
580
581 let partition_count = initial
582 .result_set_metadata
583 .as_ref()
584 .map(|m| m.partition_info.len())
585 .unwrap_or(0);
586 let mut raw = initial.data.unwrap_or_default();
587 if partition_count > 1 {
588 let handle = initial.statement_handle.ok_or_else(|| {
589 FaucetError::Source(
590 "snowflake: catalog discovery failed: >1 partition without a statementHandle"
591 .into(),
592 )
593 })?;
594 let effective = self.resolve_auth().await.map_err(wrap)?;
595 let auth = authorization_header(&effective, &self.config.account).map_err(wrap)?;
596 let token_type = snowflake_token_type(&effective);
597 for i in 1..partition_count {
598 raw.extend(
599 self.fetch_partition(&handle, i, &auth, token_type)
600 .await
601 .map_err(wrap)?,
602 );
603 }
604 }
605
606 let rows = catalog_rows(&raw, &columns)?;
607 Ok(descriptors_from_catalog(
608 rows,
609 faucet_core::util::quote_ident,
610 ))
611 }
612
613 async fn check(
618 &self,
619 ctx: &faucet_core::check::CheckContext,
620 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
621 use faucet_core::check::{CheckReport, Probe};
622 let start = std::time::Instant::now();
623 let probe = async {
624 let mut body = self.build_request_body(&[]);
625 body["statement"] = Value::String("SELECT 1".to_string());
626 let url = self.statements_url();
627 let effective = self.resolve_auth().await.map_err(|e| {
628 Probe::fail_hint(
629 "auth",
630 start.elapsed(),
631 e.to_string(),
632 "verify the Snowflake credentials / shared auth provider",
633 )
634 })?;
635 let auth = authorization_header(&effective, &self.config.account)
636 .map_err(|e| Probe::fail("auth", start.elapsed(), e.to_string()))?;
637 let token_type = snowflake_token_type(&effective);
638 let resp = self
639 .client
640 .post(&url)
641 .header("Authorization", &auth)
642 .header("Content-Type", "application/json")
643 .header("Accept", "application/json")
644 .header("X-Snowflake-Authorization-Token-Type", token_type)
645 .json(&body)
646 .send()
647 .await
648 .map_err(|e| {
649 Probe::fail_hint(
650 "query",
651 start.elapsed(),
652 format!("Snowflake request failed: {e}"),
653 "verify the account endpoint is reachable",
654 )
655 })?;
656 let status = resp.status();
657 if status.is_success() || status.as_u16() == 202 {
658 Ok::<Probe, Probe>(Probe::pass("query", start.elapsed()))
659 } else {
660 let text = resp.text().await.unwrap_or_default();
661 Err(Probe::fail_hint(
662 "query",
663 start.elapsed(),
664 format!("Snowflake SQL API returned HTTP {status}: {text}"),
665 "verify credentials, warehouse, database/schema, and role",
666 ))
667 }
668 };
669 let probe = match tokio::time::timeout(ctx.timeout, probe).await {
670 Ok(Ok(p)) | Ok(Err(p)) => p,
671 Err(_elapsed) => Probe::fail_hint(
672 "query",
673 start.elapsed(),
674 "Snowflake probe timed out",
675 "Snowflake did not respond within the check timeout",
676 ),
677 };
678 Ok(CheckReport::single(probe))
679 }
680
681 async fn fetch_with_context(
682 &self,
683 context: &HashMap<String, Value>,
684 ) -> Result<Vec<Value>, FaucetError> {
685 let initial = self.submit_statement(context).await?;
686 let columns = initial
687 .result_set_metadata
688 .as_ref()
689 .map(|m| m.row_type.clone())
690 .unwrap_or_default();
691
692 if columns.is_empty() {
693 tracing::info!(
694 rows = 0,
695 query = %self.config.query,
696 "Snowflake source fetch returned no schema (likely no rows)",
697 );
698 return Ok(Vec::new());
699 }
700
701 let mut rows: Vec<Value> = initial
702 .data
703 .unwrap_or_default()
704 .iter()
705 .map(|r| row_to_json(r, &columns))
706 .collect();
707
708 let partition_count = initial
709 .result_set_metadata
710 .as_ref()
711 .map(|m| m.partition_info.len())
712 .unwrap_or(0);
713
714 if partition_count > 1 {
715 let handle = initial.statement_handle.ok_or_else(|| {
716 FaucetError::Source(
717 "Snowflake reported >1 partition without a statementHandle to fetch them"
718 .into(),
719 )
720 })?;
721 let effective = self.resolve_auth().await?;
722 let auth = authorization_header(&effective, &self.config.account)?;
723 let token_type = snowflake_token_type(&effective);
724
725 for i in 1..partition_count {
726 let raw = self.fetch_partition(&handle, i, &auth, token_type).await?;
727 for r in raw {
728 rows.push(row_to_json(&r, &columns));
729 }
730 }
731 }
732
733 tracing::info!(
734 rows = rows.len(),
735 query = %self.config.query,
736 "Snowflake source fetch complete",
737 );
738 Ok(rows)
739 }
740
741 fn stream_pages<'a>(
755 &'a self,
756 context: &'a HashMap<String, Value>,
757 _batch_size: usize,
758 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
759 let batch_size = self.config.batch_size;
760
761 Box::pin(async_stream::try_stream! {
762 let initial = self.submit_statement(context).await?;
763 let columns = initial
764 .result_set_metadata
765 .as_ref()
766 .map(|m| m.row_type.clone())
767 .unwrap_or_default();
768
769 if columns.is_empty() {
770 return;
773 }
774
775 let partition_count = initial
776 .result_set_metadata
777 .as_ref()
778 .map(|m| m.partition_info.len())
779 .unwrap_or(1);
780
781 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
782 let initial_capacity = if batch_size == 0 {
783 initial
785 .result_set_metadata
786 .as_ref()
787 .map(|m| m.partition_info.iter().map(|p| p.row_count as usize).sum())
788 .unwrap_or(1024)
789 } else {
790 batch_size
791 };
792
793 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
794 let mut total = 0usize;
795
796 for raw in initial.data.unwrap_or_default() {
798 buffer.push(row_to_json(&raw, &columns));
799 if buffer.len() >= chunk {
800 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
801 total += page.len();
802 yield StreamPage { records: page, bookmark: None };
803 }
804 }
805
806 if partition_count > 1 {
808 let handle = initial.statement_handle.ok_or_else(|| {
809 FaucetError::Source(
810 "Snowflake reported >1 partition without a statementHandle".into(),
811 )
812 })?;
813 let effective = self.resolve_auth().await?;
814 let auth = authorization_header(&effective, &self.config.account)?;
815 let token_type = snowflake_token_type(&effective);
816
817 for i in 1..partition_count {
818 let raw = self.fetch_partition(&handle, i, &auth, token_type).await?;
819 for r in raw {
820 buffer.push(row_to_json(&r, &columns));
821 if buffer.len() >= chunk {
822 let page = std::mem::replace(
823 &mut buffer,
824 Vec::with_capacity(initial_capacity),
825 );
826 total += page.len();
827 yield StreamPage { records: page, bookmark: None };
828 }
829 }
830 }
831 }
832
833 if !buffer.is_empty() {
834 total += buffer.len();
835 yield StreamPage { records: buffer, bookmark: None };
836 }
837
838 tracing::info!(
839 rows = total,
840 batch_size,
841 query = %self.config.query,
842 "Snowflake source stream complete",
843 );
844 })
845 }
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851 use crate::config::SnowflakeAuth;
852
853 fn cfg() -> SnowflakeSourceConfig {
854 SnowflakeSourceConfig::new(
855 "xy12345.us-east-1",
856 "WH",
857 "DB",
858 "PUBLIC",
859 SnowflakeAuth::OAuth { token: "t".into() },
860 "SELECT 1",
861 )
862 }
863
864 #[test]
865 fn new_rejects_out_of_range_batch_size() {
866 let mut config = cfg();
867 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
868 match SnowflakeSource::new(config) {
869 Err(FaucetError::Config(m)) => assert!(m.contains("batch_size"), "got: {m}"),
870 _ => panic!("expected a batch_size Config error"),
871 }
872 }
873
874 #[test]
875 fn statements_url_uses_account_when_no_override() {
876 let src = SnowflakeSource::new(cfg()).unwrap();
877 assert_eq!(
878 src.statements_url(),
879 "https://xy12345.us-east-1.snowflakecomputing.com/api/v2/statements"
880 );
881 }
882
883 #[test]
884 fn statements_url_uses_endpoint_override() {
885 let src = SnowflakeSource::new(cfg())
886 .unwrap()
887 .with_endpoint_base("http://127.0.0.1:9999");
888 assert_eq!(
889 src.statements_url(),
890 "http://127.0.0.1:9999/api/v2/statements"
891 );
892 }
893
894 #[test]
895 fn partition_url_includes_handle_and_index() {
896 let src = SnowflakeSource::new(cfg())
897 .unwrap()
898 .with_endpoint_base("http://srv");
899 assert_eq!(
900 src.partition_url("abc-123", 2),
901 "http://srv/api/v2/statements/abc-123?partition=2"
902 );
903 }
904
905 #[test]
906 fn build_request_body_minimal() {
907 let src = SnowflakeSource::new(cfg()).unwrap();
908 let body = src.build_request_body(&[]);
909 assert_eq!(body["statement"], "SELECT 1");
910 assert_eq!(body["timeout"], 60);
911 assert_eq!(body["database"], "DB");
912 assert_eq!(body["schema"], "PUBLIC");
913 assert_eq!(body["warehouse"], "WH");
914 assert!(body.get("bindings").is_none());
915 assert!(body.get("role").is_none());
916 }
917
918 #[test]
919 fn build_request_body_includes_role_when_set() {
920 let mut c = cfg();
921 c.role = Some("ANALYST".into());
922 let src = SnowflakeSource::new(c).unwrap();
923 let body = src.build_request_body(&[]);
924 assert_eq!(body["role"], "ANALYST");
925 }
926
927 #[test]
928 fn build_request_body_infers_binding_types() {
929 let src = SnowflakeSource::new(cfg()).unwrap();
932 let body = src.build_request_body(&[
933 Value::String("alice".into()),
934 json!(42),
935 json!(true),
936 json!(3.5),
937 ]);
938 let b = &body["bindings"];
939 assert_eq!(b["1"]["type"], "TEXT");
940 assert_eq!(b["1"]["value"], "alice");
941 assert_eq!(b["2"]["type"], "FIXED");
942 assert_eq!(b["2"]["value"], "42");
943 assert_eq!(b["3"]["type"], "BOOLEAN");
944 assert_eq!(b["3"]["value"], "true");
945 assert_eq!(b["4"]["type"], "REAL");
946 assert_eq!(b["4"]["value"], "3.5");
947 }
948
949 #[test]
950 fn build_request_body_array_and_object_bindings_fall_back_to_text() {
951 let src = SnowflakeSource::new(cfg()).unwrap();
954 let body = src.build_request_body(&[json!([1, 2, 3]), json!({"k": "v"})]);
955 let b = &body["bindings"];
956 assert_eq!(b["1"]["type"], "TEXT");
957 assert_eq!(b["1"]["value"], "[1,2,3]");
958 assert_eq!(b["2"]["type"], "TEXT");
959 assert_eq!(b["2"]["value"], r#"{"k":"v"}"#);
960 }
961
962 #[test]
963 fn connector_name_is_snowflake() {
964 use faucet_core::Source;
965 let src = SnowflakeSource::new(cfg()).unwrap();
966 assert_eq!(src.connector_name(), "snowflake");
967 }
968
969 #[test]
970 fn config_schema_reports_required_fields() {
971 use faucet_core::Source;
972 let src = SnowflakeSource::new(cfg()).unwrap();
973 let schema = src.config_schema();
974 assert!(schema["properties"]["account"].is_object());
976 assert!(schema["properties"]["query"].is_object());
977 let required = schema["required"].as_array().expect("required array");
978 assert!(required.iter().any(|v| v == "account"));
979 assert!(required.iter().any(|v| v == "query"));
980 }
981
982 #[test]
983 fn build_request_body_null_binding_preserves_positional_alignment() {
984 let src = SnowflakeSource::new(cfg()).unwrap();
988 let body = src.build_request_body(&[Value::Null, json!(42)]);
989 let b = &body["bindings"];
990 assert_eq!(b["1"]["type"], "TEXT");
991 assert_eq!(
992 b["1"]["value"],
993 Value::Null,
994 "position 1 must be a NULL binding"
995 );
996 assert_eq!(b["2"]["value"], "42", "position 2 must still be 42");
997 }
998
999 #[test]
1000 fn resolve_query_with_no_context_returns_input() {
1001 let src = SnowflakeSource::new(cfg().with_params(vec![json!(7)])).unwrap();
1002 let (q, binds) = src.resolve_query(&HashMap::new());
1003 assert_eq!(q, "SELECT 1");
1004 assert_eq!(binds, vec![json!(7)]);
1005 }
1006
1007 #[test]
1008 fn resolve_query_substitutes_context_with_question_mark_markers() {
1009 let mut c = cfg();
1010 c.query = "SELECT * FROM t WHERE id = {parent.id}".into();
1011 let src = SnowflakeSource::new(c).unwrap();
1012 let mut ctx = HashMap::new();
1013 ctx.insert("parent.id".to_string(), json!(7));
1014 let (q, binds) = src.resolve_query(&ctx);
1015 assert_eq!(q, "SELECT * FROM t WHERE id = ?");
1016 assert_eq!(binds, vec![json!(7)]);
1017 }
1018
1019 use faucet_core::util::quote_ident;
1022 use serde_json::json;
1023
1024 fn catalog_columns() -> Vec<ColumnMeta> {
1026 [
1027 ("TABLE_SCHEMA", "text"),
1028 ("TABLE_NAME", "text"),
1029 ("COLUMN_NAME", "text"),
1030 ("DATA_TYPE", "text"),
1031 ("IS_NULLABLE", "text"),
1032 ("ROW_COUNT", "fixed"),
1033 ]
1034 .into_iter()
1035 .map(|(name, ty)| ColumnMeta {
1036 name: name.into(),
1037 ty: ty.into(),
1038 scale: 0,
1039 })
1040 .collect()
1041 }
1042
1043 fn catalog_cell(
1044 schema: &str,
1045 table: &str,
1046 column: &str,
1047 ty: &str,
1048 nullable: &str,
1049 rows: Value,
1050 ) -> Vec<Value> {
1051 vec![
1052 json!(schema),
1053 json!(table),
1054 json!(column),
1055 json!(ty),
1056 json!(nullable),
1057 rows,
1058 ]
1059 }
1060
1061 #[test]
1062 fn catalog_rows_decodes_cells_positionally() {
1063 let data = vec![
1064 catalog_cell("PUBLIC", "ORDERS", "ID", "NUMBER", "NO", json!("120")),
1065 catalog_cell("PUBLIC", "ORDERS", "NOTE", "TEXT", "YES", json!("120")),
1066 ];
1067 let rows = catalog_rows(&data, &catalog_columns()).unwrap();
1068 assert_eq!(
1069 rows[0],
1070 (
1071 "PUBLIC".into(),
1072 "ORDERS".into(),
1073 "ID".into(),
1074 "NUMBER".into(),
1075 false,
1076 Some(120)
1077 )
1078 );
1079 assert!(rows[1].4, "IS_NULLABLE = YES");
1080 }
1081
1082 #[test]
1083 fn catalog_rows_null_row_count_means_no_estimate() {
1084 let data = vec![catalog_cell(
1085 "PUBLIC",
1086 "T",
1087 "ID",
1088 "NUMBER",
1089 "NO",
1090 Value::Null,
1091 )];
1092 let rows = catalog_rows(&data, &catalog_columns()).unwrap();
1093 assert_eq!(rows[0].5, None);
1094 }
1095
1096 #[test]
1097 fn catalog_rows_rejects_short_metadata() {
1098 let cols = &catalog_columns()[..3];
1099 match catalog_rows(&[], cols) {
1100 Err(FaucetError::Source(m)) => {
1101 assert!(m.contains("catalog discovery failed"), "got: {m}")
1102 }
1103 other => panic!("expected Source error, got: {other:?}"),
1104 }
1105 }
1106
1107 #[test]
1108 fn catalog_rows_rejects_non_string_identifier() {
1109 let data = vec![catalog_cell("PUBLIC", "T", "ID", "NUMBER", "NO", json!(1))];
1111 let mut bad = data.clone();
1112 bad[0][1] = Value::Null; match catalog_rows(&bad, &catalog_columns()) {
1114 Err(FaucetError::Source(m)) => {
1115 assert!(m.contains("catalog decode failed"), "got: {m}")
1116 }
1117 other => panic!("expected Source error, got: {other:?}"),
1118 }
1119 }
1120
1121 #[test]
1122 fn descriptors_group_catalog_rows_per_table() {
1123 let rows = vec![
1124 (
1125 "PUBLIC".to_string(),
1126 "ORDERS".to_string(),
1127 "ID".to_string(),
1128 "NUMBER".to_string(),
1129 false,
1130 Some(120i64),
1131 ),
1132 (
1133 "PUBLIC".to_string(),
1134 "ORDERS".to_string(),
1135 "NOTE".to_string(),
1136 "TEXT".to_string(),
1137 true,
1138 Some(120i64),
1139 ),
1140 (
1141 "SALES".to_string(),
1142 "ORDERS".to_string(),
1143 "DATA".to_string(),
1144 "VARIANT".to_string(),
1145 false,
1146 None,
1147 ),
1148 ];
1149 let ds = descriptors_from_catalog(rows, quote_ident);
1150 assert_eq!(ds.len(), 2, "same table name in two schemas = two datasets");
1151
1152 assert_eq!(ds[0].name, "PUBLIC.ORDERS");
1153 assert_eq!(ds[0].kind, "table");
1154 assert_eq!(ds[0].estimated_rows, Some(120));
1155 assert_eq!(
1156 ds[0].config_patch["query"],
1157 r#"SELECT * FROM "PUBLIC"."ORDERS""#
1158 );
1159 let schema = ds[0].schema.as_ref().unwrap();
1160 assert_eq!(schema["type"], "object");
1161 assert_eq!(schema["properties"]["ID"]["type"], "number");
1164 assert_eq!(
1165 schema["properties"]["NOTE"]["type"],
1166 json!(["string", "null"])
1167 );
1168
1169 assert_eq!(ds[1].name, "SALES.ORDERS");
1170 assert_eq!(ds[1].estimated_rows, None);
1171 assert_eq!(
1172 ds[1].schema.as_ref().unwrap()["properties"]["DATA"]["type"],
1173 "object",
1174 "VARIANT maps to object"
1175 );
1176 }
1177
1178 #[test]
1179 fn snowflake_catalog_types_map_to_json_types() {
1180 for (sf, want) in [
1181 ("TEXT", "string"),
1182 ("NUMBER", "number"),
1183 ("FLOAT", "number"),
1184 ("BOOLEAN", "boolean"),
1185 ("VARIANT", "object"),
1186 ("OBJECT", "object"),
1187 ("ARRAY", "array"),
1188 ("TIMESTAMP_NTZ", "string"),
1189 ("DATE", "string"),
1190 ("BINARY", "string"),
1191 ] {
1192 assert_eq!(
1193 faucet_core::sql_type_to_json_schema(sf),
1194 json!({ "type": want }),
1195 "for Snowflake type {sf:?}"
1196 );
1197 }
1198 }
1199
1200 #[test]
1201 fn descriptors_quote_hostile_identifiers() {
1202 let rows = vec![(
1203 "PUBLIC".to_string(),
1204 "weird\"; DROP".to_string(),
1205 "ID".to_string(),
1206 "NUMBER".to_string(),
1207 false,
1208 None,
1209 )];
1210 let ds = descriptors_from_catalog(rows, quote_ident);
1211 let q = ds[0].config_patch["query"].as_str().unwrap();
1212 assert!(q.contains(r#""weird""; DROP""#), "quoted identifier: {q}");
1213 }
1214
1215 #[test]
1216 fn descriptors_empty_catalog_is_empty() {
1217 assert!(descriptors_from_catalog(Vec::new(), quote_ident).is_empty());
1218 }
1219
1220 #[test]
1221 fn source_advertises_discover() {
1222 use faucet_core::Source;
1223 let src = SnowflakeSource::new(cfg()).unwrap();
1224 assert!(src.supports_discover());
1225 }
1226
1227 #[test]
1228 fn dataset_uri_includes_account_db_schema_and_query() {
1229 use faucet_core::Source;
1230 let src = SnowflakeSource::new(cfg()).unwrap();
1231 assert_eq!(
1232 src.dataset_uri(),
1233 "snowflake://xy12345.us-east-1/DB/PUBLIC?query=SELECT 1"
1234 );
1235 }
1236}