Skip to main content

faucet_source_snowflake/
stream.rs

1//! Snowflake SQL REST API source.
2//!
3//! Executes the configured SQL statement against
4//! [`POST /api/v2/statements`](https://docs.snowflake.com/en/developer-guide/sql-api/submitting-requests)
5//! and streams the rows back as JSON. Large result sets are split server-side
6//! into partitions; the source fetches each subsequent partition via
7//! `GET /api/v2/statements/{handle}?partition={n}` and re-frames the rows
8//! into pages of [`SnowflakeSourceConfig::batch_size`].
9
10use 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/// Snowflake API response envelope. Only the fields we need are pulled out;
26/// everything else (`createdOn`, `numRows`, `databaseProvider`, ...) is
27/// ignored.
28#[derive(Debug, Deserialize)]
29struct StatementResponse {
30    /// Snowflake SQL state code. `"090001"` means "statement executed
31    /// successfully"; anything else is an error.
32    #[serde(default)]
33    code: Option<String>,
34    /// Human-readable message paired with `code`.
35    #[serde(default)]
36    message: Option<String>,
37    /// Opaque handle for fetching additional partitions and re-polling
38    /// asynchronous results.
39    #[serde(rename = "statementHandle", default)]
40    statement_handle: Option<String>,
41    /// Result schema and partition manifest. Only present on the first
42    /// (synchronous) response or on the resync GET after async submission;
43    /// follow-up partition fetches omit it.
44    #[serde(rename = "resultSetMetaData", default)]
45    result_set_metadata: Option<ResultSetMetadata>,
46    /// One element per row, each a JSON array of stringified cells.
47    #[serde(default)]
48    data: Option<Vec<Vec<Value>>>,
49}
50
51#[derive(Debug, Deserialize)]
52struct ResultSetMetadata {
53    /// Per-column metadata. Required to map cell arrays back to JSON keys.
54    #[serde(rename = "rowType", default)]
55    row_type: Vec<ColumnMeta>,
56    /// One entry per partition. The first entry covers the rows in the
57    /// initial response; subsequent entries must be fetched individually.
58    #[serde(rename = "partitionInfo", default)]
59    partition_info: Vec<PartitionInfo>,
60}
61
62#[derive(Debug, Deserialize)]
63#[allow(dead_code)] // `rowCount` is informational; we drain partitions until empty.
64struct PartitionInfo {
65    #[serde(rename = "rowCount", default)]
66    row_count: u64,
67}
68
69/// A source that streams the rows of a Snowflake SQL statement via the SQL
70/// REST API.
71pub struct SnowflakeSource {
72    config: SnowflakeSourceConfig,
73    client: Client,
74    /// Optional explicit endpoint override (full base URL up to but not
75    /// including `/api/v2/statements`). When `None`, derived from
76    /// `config.account`. Intended for wiremock tests and private-link
77    /// deployments.
78    endpoint_base: Option<String>,
79    /// Optional shared auth provider. When set, takes precedence over inline
80    /// auth; the provider yields a `Bearer` or `Token` credential mapped onto
81    /// [`SnowflakeAuth::OAuth`]. Set via [`Self::with_auth_provider`].
82    auth_provider: Option<SharedAuthProvider>,
83}
84
85impl SnowflakeSource {
86    /// Create a new Snowflake source. Initialises the underlying HTTP client and
87    /// does no I/O; it fails only on an invalid config (an out-of-range
88    /// `batch_size`).
89    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    /// Attach a shared [`AuthProvider`](faucet_core::AuthProvider). When set,
100    /// the provider supplies the credential for every request (taking
101    /// precedence over inline auth), so several sources can share one OAuth
102    /// token with single-flight refresh. Used by the CLI to resolve
103    /// `auth: { ref }`, and by library callers who inject a provider directly.
104    ///
105    /// The provider must yield a `Bearer` or `Token` credential, which maps
106    /// onto [`SnowflakeAuth::OAuth`]. Key-pair JWT cannot be supplied via a
107    /// provider (JWT is minted locally from the RSA key).
108    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
109        self.auth_provider = Some(provider);
110        self
111    }
112
113    /// Override the base URL used to reach the SQL REST API.
114    ///
115    /// Pass `http://127.0.0.1:1234` to point the source at a mock server —
116    /// the source will issue requests to `{base}/api/v2/statements` and
117    /// `{base}/api/v2/statements/{handle}?partition=N`. Useful for tests
118    /// and proxy/private-link setups.
119    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    /// Resolve the effective [`SnowflakeAuth`] for this request.
145    ///
146    /// Resolution order:
147    /// 1. If a shared provider is attached, call it and map the credential.
148    /// 2. Otherwise, use the inline auth from the config.
149    /// 3. If the config holds an unresolved `Reference` with no provider,
150    ///    return [`FaucetError::Auth`].
151    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    /// Build the JSON body for `POST /api/v2/statements`.
165    ///
166    /// Bindings are sent as 1-based positional values under the documented
167    /// `bindings: {"1": {"type": "<TYPE>", "value": "..."}}` shape. The value
168    /// is always a string; the `type` is inferred from the JSON value
169    /// (`FIXED` for integers, `REAL` for floats, `BOOLEAN` for bools, `TEXT`
170    /// for strings/arrays/objects) so a numeric/boolean bind compares against
171    /// a typed column rather than being forced to TEXT (#78/#34). A JSON
172    /// `null` is bound as an explicit `{"type":"TEXT","value":null}` so it
173    /// keeps its positional slot rather than shifting later parameters.
174    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                // Bindings are 1-based positional (`?` markers). A NULL must be
191                // bound as an explicit `{"type":"TEXT","value":null}` rather
192                // than skipped — skipping leaves a gap that shifts every later
193                // parameter onto the wrong marker and corrupts the query
194                // (#78/#18).
195                //
196                // The Snowflake type is inferred from the JSON value so a
197                // numeric or boolean bind compares correctly against a
198                // NUMBER/BOOLEAN column instead of being forced to TEXT
199                // (#78/#34). The value is always sent as a string (or null).
200                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    /// Resolve the final SQL statement and ordered bind values for a given
223    /// parent-record context.
224    ///
225    /// `{key}` tokens whose key matches `context` are replaced with `?`
226    /// markers (Snowflake's positional binding form) and their values are
227    /// appended to the returned `Vec` after any [`config.params`](SnowflakeSourceConfig::params).
228    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    /// Issue the initial `POST /api/v2/statements` request for the configured
242    /// query, with `{key}` context tokens resolved to positional bindings.
243    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    /// Issue `POST /api/v2/statements` for an arbitrary SQL statement (the
252    /// configured query, or an internal catalog query for
253    /// [`Source::discover`](faucet_core::Source::discover)), following the
254    /// async-202 poll until the statement completes.
255    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            // Statement still running on the server. Poll until it completes.
296            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    /// Poll the same handle as a partition-less GET until the response is
309    /// 200 + `code: "090001"`. Used after a 202 from the initial POST.
310    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                // `poll_timeout == 0` disables the cap (poll forever).
333                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    /// Fetch one additional partition's worth of rows.
357    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
397/// Catalog query behind [`Source::discover`](faucet_core::Source::discover):
398/// one row per column of every base table in the configured database
399/// (`INFORMATION_SCHEMA` itself excluded), with the table's `row_count`
400/// estimate joined in from `information_schema.tables`. Catalog metadata
401/// only — never a data scan.
402const 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
411/// One flattened `information_schema.columns` row used by `discover`:
412/// (schema, table, column, data_type, is_nullable, estimated_rows).
413type CatalogRow = (String, String, String, String, bool, Option<i64>);
414
415/// In-progress per-table accumulator used while grouping catalog rows:
416/// (schema, table, estimated_rows, columns).
417type TableAcc = (String, String, Option<i64>, Vec<(String, Value)>);
418
419/// Decode the catalog result's raw cell arrays into [`CatalogRow`]s using the
420/// statement's own `rowType` metadata (so cells are decoded positionally, with
421/// the existing type-aware [`row_to_json`] machinery — no assumption about the
422/// server's identifier casing). Pure — unit-testable without a live server.
423fn 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            // `row_count` is a scale-0 FIXED column → decoded as a JSON
452            // number; the string branch is defensive.
453            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
470/// Group flattened catalog rows (ordered by schema, table, ordinal position)
471/// into one [`faucet_core::DatasetDescriptor`] per table. Pure. `quote` is the
472/// dialect's identifier quoter — Snowflake uses ANSI double quotes with
473/// interior quotes doubled ([`faucet_core::util::quote_ident`]).
474fn 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
519/// Validate `code: "090001"` (statement executed successfully). Any other
520/// code surfaces as `FaucetError::Source` carrying Snowflake's message.
521fn 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    /// Enumerate every base table in the configured database (from
557    /// `information_schema.columns` / `information_schema.tables`, excluding
558    /// `INFORMATION_SCHEMA` itself), with column types mapped to JSON-Schema
559    /// fragments and a row estimate from `information_schema.tables.row_count`.
560    /// Runs one catalog statement through the source's ordinary SQL REST API
561    /// path (async-202 poll and partition paging included) — catalog metadata
562    /// only, no data scan.
563    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            // No metadata at all — an empty catalog (no base tables visible).
578            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    /// Preflight probe for `faucet doctor`. Overrides the default (which pulls a
614    /// page via `stream_pages` and would **execute the configured query**).
615    /// Instead submits a cheap `SELECT 1` so doctor validates auth, warehouse,
616    /// and connectivity without running the user's real statement.
617    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    /// Stream rows partition-by-partition without buffering the full result
742    /// set. Rows are accumulated into a per-page buffer of
743    /// [`SnowflakeSourceConfig::batch_size`] entries and yielded as soon as
744    /// the buffer is full.
745    ///
746    /// The trait-level `batch_size` argument is ignored in favour of the
747    /// config field — the config is the user-facing knob the README
748    /// documents, and routing the pipeline-supplied hint through it would
749    /// silently override an explicit config value.
750    ///
751    /// `batch_size = 0` drains every partition and emits the full result
752    /// set as a single page. The source has no incremental-replication mode
753    /// today, so every emitted page carries `bookmark: None`.
754    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                // Either the query returned no schema or an empty result set
771                // with no metadata. Either way nothing to emit.
772                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                // Sum the row counts across partitions for a tight pre-allocation.
784                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            // First partition: rows come back in the POST response itself.
797            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            // Remaining partitions: one GET each.
807            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        // #78/#34: types are inferred from the JSON value (value still a
930        // string), so numeric/boolean binds compare against typed columns.
931        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        // The catch-all arm (`other => ("TEXT", ...)`) stringifies array/object
952        // JSON values into a TEXT bind so they keep their positional slot.
953        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        // The generated JSON Schema should describe the config's properties.
975        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        // Regression for #78/#18: a NULL must occupy its positional slot as an
985        // explicit null-valued binding, not be skipped (which would shift "42"
986        // onto marker 1 and leave marker 2 unbound).
987        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    // ── discover: pure catalog decoding + grouping ───────────────────────────
1020
1021    use faucet_core::util::quote_ident;
1022    use serde_json::json;
1023
1024    /// The six-column `rowType` metadata the catalog query produces.
1025    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        // A null in an identifier cell is a decode error, not a silent skip.
1110        let data = vec![catalog_cell("PUBLIC", "T", "ID", "NUMBER", "NO", json!(1))];
1111        let mut bad = data.clone();
1112        bad[0][1] = Value::Null; // TABLE_NAME
1113        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        // NUMBER maps to "number" — Snowflake's NUMBER(38,0) is integer-ish,
1162        // but "number" is the acceptable conservative mapping.
1163        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}