Skip to main content

faucet_sink_snowflake/
sink.rs

1//! Snowflake SQL REST API sink.
2
3use crate::config::SnowflakeSinkConfig;
4use crate::idempotent;
5use async_trait::async_trait;
6use faucet_common_snowflake::{
7    SnowflakeAuth, authorization_header, credential_to_auth, snowflake_token_type,
8};
9use faucet_core::util::quote_ident;
10use faucet_core::{AuthSpec, FaucetError, SharedAuthProvider};
11use reqwest::Client;
12use serde::Deserialize;
13use serde_json::{Value, json};
14use tokio::sync::OnceCell;
15
16/// A sink that writes JSON records to a Snowflake table using the
17/// SQL REST API.
18pub struct SnowflakeSink {
19    config: SnowflakeSinkConfig,
20    client: Client,
21    /// Optional explicit endpoint override. When `None`, the URL is derived
22    /// from `config.account`. Used by tests to point the sink at a mock
23    /// server, and useful for proxies / private-link deployments.
24    endpoint: Option<String>,
25    /// Optional shared auth provider. When set, takes precedence over inline
26    /// auth; the provider yields a `Bearer` or `Token` credential mapped onto
27    /// [`SnowflakeAuth::OAuth`]. Set via [`Self::with_auth_provider`].
28    auth_provider: Option<SharedAuthProvider>,
29    /// One-shot guard so the exactly-once watermark table's
30    /// `CREATE TABLE IF NOT EXISTS` DDL runs at most once per sink instance
31    /// (Snowflake DDL auto-commits, so it must be its own request, outside
32    /// the data transaction). A failed attempt leaves the cell empty and is
33    /// retried on the next call.
34    commit_table_ready: OnceCell<()>,
35    /// Lazily-resolved external-stage upload store for the Arrow columnar
36    /// bulk-load path (#381). Built once from `config.bulk_load` on the first
37    /// columnar write and reused for every subsequent staged file.
38    #[cfg(feature = "arrow")]
39    bulk_store: OnceCell<crate::bulk::BulkStore>,
40}
41
42#[derive(Deserialize)]
43struct SnowflakeResponse {
44    message: Option<String>,
45    #[serde(default)]
46    code: Option<String>,
47    /// Present on an HTTP 202 (asynchronous execution) response — the
48    /// opaque handle used to poll the statement to completion.
49    #[serde(rename = "statementHandle", default)]
50    statement_handle: Option<String>,
51    /// Result rows for a completed query (`[["cell", …], …]`); the SQL REST
52    /// API renders every cell as a JSON string (or `null`). Only consumed by
53    /// [`SnowflakeSink::last_committed_token`].
54    #[serde(default)]
55    data: Option<Vec<Vec<Value>>>,
56}
57
58/// Map a parsed statement response onto a success/error result. Code
59/// `090001` is "Statement executed successfully"; any other non-null code
60/// is a Snowflake-side error.
61fn check_statement_code(sf_resp: &SnowflakeResponse) -> Result<(), FaucetError> {
62    if let Some(code) = &sf_resp.code
63        && code != "090001"
64    {
65        return Err(FaucetError::Sink(format!(
66            "Snowflake error {}: {}",
67            code,
68            sf_resp.message.clone().unwrap_or_default()
69        )));
70    }
71    Ok(())
72}
73
74impl SnowflakeSink {
75    /// Create a new Snowflake sink.
76    ///
77    /// Returns [`FaucetError::Config`] if `batch_size` exceeds
78    /// `MAX_BATCH_SIZE` (#78/#44).
79    pub fn new(config: SnowflakeSinkConfig) -> Result<Self, FaucetError> {
80        faucet_core::validate_batch_size(config.batch_size)?;
81        // `bulk_load` (Arrow columnar COPY) only functions with the `arrow`
82        // feature compiled in — reject a config that requests it otherwise so
83        // the failure is loud at load time, not a silent row-path fallback.
84        #[cfg(not(feature = "arrow"))]
85        if config.bulk_load.is_some() {
86            return Err(FaucetError::Config(
87                "snowflake `bulk_load` requires a binary built with the `arrow` feature \
88                 (e.g. `cargo install faucet-cli --features arrow`)"
89                    .into(),
90            ));
91        }
92        Ok(Self {
93            config,
94            client: Client::new(),
95            endpoint: None,
96            auth_provider: None,
97            commit_table_ready: OnceCell::new(),
98            #[cfg(feature = "arrow")]
99            bulk_store: OnceCell::new(),
100        })
101    }
102
103    /// Attach a shared [`AuthProvider`](faucet_core::AuthProvider). When set,
104    /// the provider supplies the credential for every request (taking
105    /// precedence over inline auth), so several sinks can share one OAuth
106    /// token with single-flight refresh. Used by the CLI to resolve
107    /// `auth: { ref }`, and by library callers who inject a provider directly.
108    ///
109    /// The provider must yield a `Bearer` or `Token` credential, which maps
110    /// onto [`SnowflakeAuth::OAuth`]. Key-pair JWT cannot be supplied via a
111    /// provider (JWT is minted locally from the RSA key).
112    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
113        self.auth_provider = Some(provider);
114        self
115    }
116
117    /// Override the API endpoint URL (full URL including
118    /// `/api/v2/statements`). When set, this URL is used verbatim instead
119    /// of the account-derived `https://{account}.snowflakecomputing.com/...`
120    /// URL. Intended for tests (wiremock) and proxy / private-link setups.
121    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
122        self.endpoint = Some(endpoint.into());
123        self
124    }
125
126    /// Build the SQL REST API endpoint URL.
127    fn api_url(&self) -> String {
128        if let Some(endpoint) = &self.endpoint {
129            return endpoint.clone();
130        }
131        format!(
132            "https://{}.snowflakecomputing.com/api/v2/statements",
133            self.config.account
134        )
135    }
136
137    /// Resolve the effective [`SnowflakeAuth`] for this request.
138    ///
139    /// Resolution order:
140    /// 1. If a shared provider is attached, call it and map the credential.
141    /// 2. Otherwise, use the inline auth from the config.
142    /// 3. If the config holds an unresolved `Reference` with no provider,
143    ///    return [`FaucetError::Auth`].
144    async fn resolve_auth(&self) -> Result<SnowflakeAuth, FaucetError> {
145        if let Some(p) = &self.auth_provider {
146            return credential_to_auth(p.credential().await?);
147        }
148        match &self.config.auth {
149            AuthSpec::Inline(a) => Ok(a.clone()),
150            AuthSpec::Reference(r) => Err(FaucetError::Auth(format!(
151                "auth references provider '{}' but no provider was supplied",
152                r.name
153            ))),
154        }
155    }
156
157    /// Get the authorization header value.
158    async fn auth_header(&self) -> Result<(String, &'static str), FaucetError> {
159        let effective = self.resolve_auth().await?;
160        let header = authorization_header(&effective, &self.config.account)?;
161        let token_type = snowflake_token_type(&effective);
162        Ok((header, token_type))
163    }
164
165    /// Execute a SQL statement via the REST API, optionally with positional
166    /// bindings (`{"1": {"type": "TEXT", "value": ...}}`). Convenience
167    /// wrapper over [`Self::execute_statement`] for callers that don't need
168    /// the parsed response.
169    async fn execute_sql(&self, sql: &str, bindings: Option<Value>) -> Result<(), FaucetError> {
170        self.execute_statement(sql, bindings, None)
171            .await
172            .map(|_| ())
173    }
174
175    /// Execute a SQL statement via the REST API and return the parsed final
176    /// response (after polling to completion if Snowflake answered 202).
177    ///
178    /// `bindings` are positional (`{"1": {"type": "TEXT", "value": ...}}`);
179    /// `parameters` is the optional session-parameters object merged into the
180    /// request body (used by the exactly-once path to set
181    /// `MULTI_STATEMENT_COUNT` for a multi-statement transaction).
182    async fn execute_statement(
183        &self,
184        sql: &str,
185        bindings: Option<Value>,
186        parameters: Option<Value>,
187    ) -> Result<SnowflakeResponse, FaucetError> {
188        let url = self.api_url();
189        let (auth, token_type) = self.auth_header().await?;
190
191        let mut body = json!({
192            "statement": sql,
193            "timeout": 60,
194            "database": self.config.database,
195            "schema": self.config.schema,
196            "warehouse": self.config.warehouse,
197        });
198        if let Some(bindings) = bindings {
199            body["bindings"] = bindings;
200        }
201        if let Some(parameters) = parameters {
202            body["parameters"] = parameters;
203        }
204
205        let resp = self
206            .client
207            .post(&url)
208            .header("Authorization", &auth)
209            .header("Content-Type", "application/json")
210            .header("Accept", "application/json")
211            .header("X-Snowflake-Authorization-Token-Type", token_type)
212            .json(&body)
213            .send()
214            .await
215            .map_err(|e| FaucetError::Sink(format!("Snowflake request failed: {e}")))?;
216
217        let status = resp.status();
218        if !status.is_success() {
219            let body_text = resp.text().await.unwrap_or_default();
220            return Err(FaucetError::Sink(format!(
221                "Snowflake SQL API returned HTTP {status}: {body_text}"
222            )));
223        }
224
225        // HTTP 202 means Snowflake *accepted* the statement but has not yet
226        // executed it. Treating that as success would report rows as written
227        // before they are actually committed. Poll the returned handle until
228        // the statement completes (#78/#17).
229        let is_async = status.as_u16() == 202;
230
231        let sf_resp: SnowflakeResponse = resp
232            .json()
233            .await
234            .map_err(|e| FaucetError::Sink(format!("failed to parse Snowflake response: {e}")))?;
235
236        if is_async {
237            let handle = sf_resp.statement_handle.ok_or_else(|| {
238                FaucetError::Sink(
239                    "Snowflake returned HTTP 202 without a statementHandle to poll".into(),
240                )
241            })?;
242            return self.poll_until_complete(&handle).await;
243        }
244
245        check_statement_code(&sf_resp)?;
246        Ok(sf_resp)
247    }
248
249    /// Poll `GET /api/v2/statements/{handle}` until the statement finishes
250    /// executing (HTTP 200 + code `090001`), bounded by `poll_timeout`.
251    /// Returns the final parsed response (which carries the result `data`
252    /// for a completed query).
253    async fn poll_until_complete(&self, handle: &str) -> Result<SnowflakeResponse, FaucetError> {
254        let url = format!("{}/{}", self.api_url(), handle);
255        let poll_timeout = self.config.poll_timeout;
256        let started = std::time::Instant::now();
257        loop {
258            // Re-resolve auth every iteration: a long-running async statement can
259            // outlive a short-lived OAuth token, so we re-ask the (single-flight,
260            // cached) provider for a current token rather than reusing the one
261            // minted at submit time — otherwise the poll 401s mid-run after a
262            // rotation (#146).
263            let (auth, token_type) = self.auth_header().await?;
264            let resp = self
265                .client
266                .get(&url)
267                .header("Authorization", &auth)
268                .header("Accept", "application/json")
269                .header("X-Snowflake-Authorization-Token-Type", token_type)
270                .send()
271                .await
272                .map_err(|e| FaucetError::Sink(format!("Snowflake poll request failed: {e}")))?;
273
274            let status = resp.status();
275            if status.as_u16() == 202 {
276                // `poll_timeout == 0` disables the cap (poll forever).
277                if !poll_timeout.is_zero() && started.elapsed() >= poll_timeout {
278                    return Err(FaucetError::Sink(format!(
279                        "Snowflake statement '{handle}' did not finish within poll_timeout ({}s); still HTTP 202",
280                        poll_timeout.as_secs()
281                    )));
282                }
283                tokio::time::sleep(std::time::Duration::from_millis(500)).await;
284                continue;
285            }
286            if !status.is_success() {
287                let body_text = resp.text().await.unwrap_or_default();
288                return Err(FaucetError::Sink(format!(
289                    "Snowflake poll returned HTTP {status}: {body_text}"
290                )));
291            }
292            let sf_resp: SnowflakeResponse = resp.json().await.map_err(|e| {
293                FaucetError::Sink(format!("failed to parse Snowflake poll response: {e}"))
294            })?;
295            check_statement_code(&sf_resp)?;
296            return Ok(sf_resp);
297        }
298    }
299
300    /// Create the exactly-once commit-token watermark table if it does not
301    /// exist — at most once per sink instance.
302    ///
303    /// Snowflake DDL auto-commits, so the `CREATE TABLE IF NOT EXISTS` must
304    /// be its own request, submitted before (never inside) the data
305    /// transaction. On failure the guard cell stays empty, so the next call
306    /// retries the DDL instead of proceeding against a possibly-missing
307    /// table.
308    async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
309        self.commit_table_ready
310            .get_or_try_init(|| async {
311                let sql = idempotent::build_create_commit_table(
312                    &self.config.database,
313                    &self.config.schema,
314                );
315                self.execute_sql(&sql, None).await
316            })
317            .await
318            .map(|_| ())
319    }
320
321    /// Compute the column set for an INSERT chunk as the **union of keys across
322    /// all records**, in first-seen order (stable, deterministic).
323    ///
324    /// All rows in one INSERT share a single column list, so a key that appears
325    /// only in a later record must still become a column — otherwise that
326    /// record's value for it is silently dropped (data-loss bug F16, audit
327    /// #264). Records missing a union column project to SQL `NULL` for that
328    /// column. Every record must be a JSON object; a chunk whose records carry
329    /// no fields at all is rejected.
330    fn column_union(records: &[Value]) -> Result<Vec<String>, FaucetError> {
331        let mut columns: Vec<String> = Vec::new();
332        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
333        for record in records {
334            let obj = record.as_object().ok_or_else(|| {
335                FaucetError::Sink("Snowflake sink requires JSON object records".into())
336            })?;
337            for key in obj.keys() {
338                if seen.insert(key.clone()) {
339                    columns.push(key.clone());
340                }
341            }
342        }
343        if columns.is_empty() {
344            return Err(FaucetError::Sink(
345                "Snowflake sink: records have no fields to insert".into(),
346            ));
347        }
348        Ok(columns)
349    }
350
351    /// Build an INSERT statement plus the JSON payload to bind to its single
352    /// `PARSE_JSON(?)` parameter.
353    ///
354    /// The record array travels as one bound `TEXT` parameter to
355    /// `PARSE_JSON(?)`, never interpolated into a SQL string literal:
356    /// interpolation was a SQL-injection vector and corrupted any value
357    /// containing an apostrophe (#78/#5). `FLATTEN` then yields one row per
358    /// array element, and each record field is projected into its matching
359    /// column.
360    ///
361    /// The projection is **per-column** — `value:"col"::string` for each key —
362    /// not `SELECT *`. `SELECT *` over `FLATTEN` returns FLATTEN's fixed
363    /// `SEQ, KEY, PATH, INDEX, VALUE, THIS` metadata columns, so the previous
364    /// statement inserted that metadata instead of the record's fields and was
365    /// non-functional for any normal table (audit #146 C2). The `::string` cast
366    /// strips the VARIANT's JSON quotes and lets Snowflake coerce the scalar
367    /// into the destination column's type on `INSERT` (text → number / boolean
368    /// / timestamp, etc.). The column set is the **union of keys across every
369    /// record in the chunk** (first-seen order), so a key present only in a
370    /// later record is never silently dropped (data-loss bug F16, audit #264);
371    /// a key missing from a given record projects to SQL `NULL` for that row
372    /// (the FLATTEN `value:"k"` path yields `NULL` when `k` is absent).
373    ///
374    /// Both the column identifiers and the JSON path keys are escaped via
375    /// [`quote_ident`] (double-quote doubling), so record keys cannot inject
376    /// SQL. Returns `(sql, json_payload)`.
377    ///
378    /// Note: a record key whose target column is semi-structured (`VARIANT` /
379    /// `OBJECT` / `ARRAY`) is stringified by the `::string` cast rather than
380    /// stored as structured JSON; this sink maps records to scalar columns.
381    fn build_insert(&self, records: &[Value]) -> Result<(String, String), FaucetError> {
382        let columns = Self::column_union(records)?;
383
384        // `quote_ident` produces a `"`-escaped quoted identifier, which is also
385        // the correct (injection-safe) form for a FLATTEN path key: `value:"k"`.
386        let col_list = columns
387            .iter()
388            .map(|c| quote_ident(c))
389            .collect::<Vec<_>>()
390            .join(", ");
391        let projection = columns
392            .iter()
393            .map(|c| format!("value:{}::string", quote_ident(c)))
394            .collect::<Vec<_>>()
395            .join(", ");
396
397        let payload = Value::Array(records.to_vec()).to_string();
398        let sql = format!(
399            "INSERT INTO {}.{}.{} ({}) SELECT {} FROM TABLE(FLATTEN(input => PARSE_JSON(?)))",
400            quote_ident(&self.config.database),
401            quote_ident(&self.config.schema),
402            quote_ident(&self.config.table),
403            col_list,
404            projection,
405        );
406        Ok((sql, payload))
407    }
408}
409
410#[async_trait]
411impl faucet_core::Sink for SnowflakeSink {
412    fn config_schema(&self) -> serde_json::Value {
413        serde_json::to_value(faucet_core::schema_for!(SnowflakeSinkConfig))
414            .expect("schema serialization")
415    }
416
417    fn dataset_uri(&self) -> String {
418        format!(
419            "snowflake://{}/{}/{}?table={}",
420            self.config.account, self.config.database, self.config.schema, self.config.table
421        )
422    }
423
424    /// Preflight check (`faucet doctor`).
425    ///
426    /// Runs a single read-only `SELECT 1` through the existing SQL REST API
427    /// request path (`execute_sql`), reusing the sink's
428    /// configured account/warehouse/auth. This resolves the effective
429    /// credential (inline or shared provider), builds the authorization
430    /// header, and confirms Snowflake accepts the session — without writing
431    /// any rows. Auth-resolution, network, and SQL-API errors surface as a
432    /// `Fail` probe with a hint. Tokens are never placed in the reason/hint.
433    async fn check(
434        &self,
435        ctx: &faucet_core::check::CheckContext,
436    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
437        use faucet_core::check::{CheckReport, Probe};
438
439        let started = std::time::Instant::now();
440
441        let result = tokio::time::timeout(ctx.timeout, self.execute_sql("SELECT 1", None)).await;
442
443        let probe = match result {
444            Ok(Ok(())) => Probe::pass("auth", started.elapsed()),
445            Ok(Err(e)) => Probe::fail_hint(
446                "auth",
447                started.elapsed(),
448                format!("Snowflake SELECT 1 failed: {e}"),
449                "Verify the account identifier, warehouse, and credentials \
450                 (OAuth token or key-pair JWT) and that the role can use the \
451                 configured warehouse.",
452            ),
453            Err(_elapsed) => Probe::fail_hint(
454                "auth",
455                started.elapsed(),
456                format!("Snowflake SELECT 1 timed out after {:?}", ctx.timeout),
457                "Check network reachability to the Snowflake SQL REST API \
458                 endpoint and that the warehouse can resume within the timeout.",
459            ),
460        };
461
462        Ok(CheckReport::single(probe))
463    }
464
465    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
466        if records.is_empty() {
467            return Ok(0);
468        }
469
470        // `batch_size = 0` is the "no batching" sentinel: forward whatever
471        // upstream handed us as a single INSERT, preserving `StreamPage`
472        // framing. Otherwise re-chunk into `batch_size` slices so each
473        // outbound REST request stays near Snowflake's documented sweet
474        // spot (~1000 rows).
475        let effective_chunk = if self.config.batch_size == 0 {
476            records.len()
477        } else {
478            self.config.batch_size
479        };
480
481        let mut total = 0;
482        for chunk in records.chunks(effective_chunk) {
483            let (sql, payload) = self.build_insert(chunk)?;
484            let bindings = json!({ "1": { "type": "TEXT", "value": payload } });
485            self.execute_sql(&sql, Some(bindings)).await?;
486            total += chunk.len();
487        }
488
489        tracing::info!(
490            table = %format!(
491                "{}.{}.{}",
492                self.config.database, self.config.schema, self.config.table
493            ),
494            rows = total,
495            "Snowflake write complete"
496        );
497        Ok(total)
498    }
499
500    fn supports_idempotent_writes(&self) -> bool {
501        true
502    }
503
504    /// Atomically write `records` and record `token` for `scope` in one
505    /// Snowflake multi-statement transaction: the sink's regular
506    /// parameterized page INSERT plus a watermark `MERGE` into
507    /// `_faucet_commit_token`, wrapped in `BEGIN`/`COMMIT`. Either both the
508    /// rows and the token commit, or neither does — so a crash/resume skips
509    /// the already-committed page (zero duplicates) and a failed page
510    /// replays cleanly.
511    ///
512    /// The entire page is one atomic unit — **no `batch_size` re-chunking on
513    /// this path** (core issues exactly one token per page; splitting the
514    /// page across transactions would break the atomicity of rows + token).
515    /// An empty page still advances the watermark via a commit-only
516    /// `BEGIN; MERGE; COMMIT;` transaction.
517    async fn write_batch_idempotent(
518        &self,
519        records: &[Value],
520        scope: &str,
521        token: &str,
522    ) -> Result<usize, FaucetError> {
523        self.ensure_commit_table().await?;
524
525        let (sql, bindings, count) = if records.is_empty() {
526            let sql =
527                idempotent::build_commit_only_statement(&self.config.database, &self.config.schema);
528            let bindings = json!({
529                "1": { "type": "TEXT", "value": scope },
530                "2": { "type": "TEXT", "value": token },
531            });
532            (sql, bindings, idempotent::COMMIT_ONLY_STATEMENT_COUNT)
533        } else {
534            let (insert_sql, payload) = self.build_insert(records)?;
535            let sql = idempotent::build_transaction_statement(
536                &insert_sql,
537                &self.config.database,
538                &self.config.schema,
539            );
540            let bindings = json!({
541                "1": { "type": "TEXT", "value": payload },
542                "2": { "type": "TEXT", "value": scope },
543                "3": { "type": "TEXT", "value": token },
544            });
545            (sql, bindings, idempotent::TRANSACTION_STATEMENT_COUNT)
546        };
547
548        let parameters = json!({ "MULTI_STATEMENT_COUNT": count.to_string() });
549        self.execute_statement(&sql, Some(bindings), Some(parameters))
550            .await?;
551
552        tracing::info!(
553            table = %format!(
554                "{}.{}.{}",
555                self.config.database, self.config.schema, self.config.table
556            ),
557            rows = records.len(),
558            token = %token,
559            "Snowflake exactly-once page committed"
560        );
561        Ok(records.len())
562    }
563
564    /// Read the last durably-committed token for `scope` from the watermark
565    /// table, so the pipeline can skip already-committed pages on resume.
566    ///
567    /// The token string is treated as **opaque** — it may carry a `#` + JSON
568    /// bookmark suffix appended by core; this sink never parses or validates
569    /// its format.
570    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
571        self.ensure_commit_table().await?;
572
573        let sql = idempotent::build_select_token(&self.config.database, &self.config.schema);
574        let bindings = json!({ "1": { "type": "TEXT", "value": scope } });
575        let resp = self.execute_statement(&sql, Some(bindings), None).await?;
576
577        // A completed SELECT always carries a `data` array (empty when the
578        // scope has no watermark row yet). If it is somehow absent we cannot
579        // tell "no committed token" from "token present but unreadable" — and
580        // a wrong `None` would replay an already-committed page, producing
581        // duplicates. Fail safe instead.
582        let rows = resp.data.ok_or_else(|| {
583            FaucetError::Sink(
584                "Snowflake watermark read returned no result data; cannot trust the token result"
585                    .into(),
586            )
587        })?;
588        match rows.first() {
589            None => Ok(None),
590            Some(row) => match row.first() {
591                Some(Value::String(token)) => Ok(Some(token.clone())),
592                other => Err(FaucetError::Sink(format!(
593                    "Snowflake watermark row has an unexpected token cell: {other:?}"
594                ))),
595            },
596        }
597    }
598
599    /// Columnar bulk-load is available only when a `bulk_load` external stage
600    /// is configured (#381). Otherwise the sink participates on the row path
601    /// via the default `write_batch_columnar` fallback.
602    #[cfg(feature = "arrow")]
603    fn supports_columnar(&self) -> bool {
604        self.config.bulk_load.is_some()
605    }
606
607    /// Write one Arrow [`RecordBatch`](arrow::array::RecordBatch) by encoding
608    /// it to a self-contained Parquet file, uploading it to the external
609    /// stage's backing storage, and issuing `COPY INTO … FILE_FORMAT=(TYPE=
610    /// PARQUET)` over the SQL REST API. Append-only — the exactly-once
611    /// watermark path stays on the `Value` route (the pipeline never selects
612    /// the columnar loop when exactly-once is configured).
613    #[cfg(feature = "arrow")]
614    async fn write_batch_columnar(
615        &self,
616        batch: &arrow::array::RecordBatch,
617    ) -> Result<usize, FaucetError> {
618        if batch.num_rows() == 0 {
619            return Ok(0);
620        }
621        let stage = self.config.bulk_load.as_ref().ok_or_else(|| {
622            FaucetError::Sink(
623                "Snowflake columnar write requested with no `bulk_load` stage configured".into(),
624            )
625        })?;
626
627        // Parquet encode is CPU-bound — keep it off the async runtime.
628        let batch_owned = batch.clone();
629        let bytes = tokio::task::spawn_blocking(move || crate::bulk::encode_parquet(&batch_owned))
630            .await
631            .map_err(|e| FaucetError::Sink(format!("parquet encode task panicked: {e}")))??;
632
633        // Resolve (once) the stage's object store, then upload the file.
634        let store = self
635            .bulk_store
636            .get_or_try_init(|| async { crate::bulk::resolve_store(stage) })
637            .await?;
638        let file = format!("faucet-{}.parquet", uuid::Uuid::new_v4());
639        crate::bulk::upload(store, &file, bytes).await?;
640
641        // Load the staged Parquet file into the target table.
642        let sql = crate::bulk::build_copy_into(
643            &self.config.database,
644            &self.config.schema,
645            &self.config.table,
646            stage,
647            &file,
648        );
649        self.execute_sql(&sql, None).await?;
650
651        tracing::info!(
652            table = %format!(
653                "{}.{}.{}",
654                self.config.database, self.config.schema, self.config.table
655            ),
656            rows = batch.num_rows(),
657            file = %file,
658            "Snowflake columnar bulk-load COPY complete"
659        );
660        Ok(batch.num_rows())
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::config::SnowflakeAuth;
668    use faucet_core::Sink as _;
669
670    #[test]
671    fn dataset_uri_includes_account_db_schema_table() {
672        let config = SnowflakeSinkConfig::new(
673            "myacct.us-east-1",
674            "wh",
675            "mydb",
676            "PUBLIC",
677            "events",
678            SnowflakeAuth::OAuth { token: "t".into() },
679        );
680        let sink = SnowflakeSink::new(config).unwrap();
681        assert_eq!(
682            sink.dataset_uri(),
683            "snowflake://myacct.us-east-1/mydb/PUBLIC?table=events"
684        );
685    }
686
687    #[cfg(feature = "arrow")]
688    #[test]
689    fn supports_columnar_only_with_bulk_load() {
690        use crate::config::SnowflakeStageConfig;
691        let base = SnowflakeSinkConfig::new(
692            "acct",
693            "wh",
694            "db",
695            "PUBLIC",
696            "t",
697            SnowflakeAuth::OAuth { token: "t".into() },
698        );
699        // No bulk_load → row path only.
700        let plain = SnowflakeSink::new(base.clone()).unwrap();
701        assert!(!plain.supports_columnar());
702
703        // bulk_load configured → columnar fast path advertised.
704        let staged = SnowflakeSink::new(base.with_bulk_load(SnowflakeStageConfig {
705            stage: "MY_STAGE".into(),
706            url: "s3://bucket/prefix/".into(),
707            storage_options: Default::default(),
708            match_by_column_name: "CASE_INSENSITIVE".into(),
709            purge: false,
710        }))
711        .unwrap();
712        assert!(staged.supports_columnar());
713    }
714
715    #[test]
716    fn new_rejects_oversized_batch_size() {
717        // Regression for #78/#44.
718        let config = SnowflakeSinkConfig::new(
719            "acct",
720            "wh",
721            "db",
722            "schema",
723            "tbl",
724            SnowflakeAuth::OAuth { token: "t".into() },
725        )
726        .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
727        assert!(SnowflakeSink::new(config).is_err());
728    }
729
730    #[test]
731    fn api_url_format() {
732        let config = SnowflakeSinkConfig::new(
733            "xy12345.us-east-1",
734            "wh",
735            "db",
736            "schema",
737            "tbl",
738            SnowflakeAuth::OAuth {
739                token: "tok".into(),
740            },
741        );
742        let sink = SnowflakeSink::new(config).unwrap();
743        assert_eq!(
744            sink.api_url(),
745            "https://xy12345.us-east-1.snowflakecomputing.com/api/v2/statements"
746        );
747    }
748
749    #[tokio::test]
750    async fn oauth_auth_header() {
751        let config = SnowflakeSinkConfig::new(
752            "acct",
753            "wh",
754            "db",
755            "schema",
756            "tbl",
757            SnowflakeAuth::OAuth {
758                token: "my-token".into(),
759            },
760        );
761        let sink = SnowflakeSink::new(config).unwrap();
762        let (header, token_type) = sink.auth_header().await.unwrap();
763        assert_eq!(header, "Snowflake Token=\"my-token\"");
764        assert_eq!(token_type, "OAUTH");
765    }
766
767    #[test]
768    fn api_url_honours_endpoint_override() {
769        let config = SnowflakeSinkConfig::new(
770            "acct",
771            "wh",
772            "db",
773            "schema",
774            "tbl",
775            SnowflakeAuth::OAuth { token: "t".into() },
776        );
777        let sink = SnowflakeSink::new(config)
778            .unwrap()
779            .with_endpoint("http://127.0.0.1:1234/api/v2/statements");
780        assert_eq!(sink.api_url(), "http://127.0.0.1:1234/api/v2/statements");
781    }
782
783    #[test]
784    fn build_insert_uses_quoted_identifiers() {
785        let config = SnowflakeSinkConfig::new(
786            "acct",
787            "wh",
788            "MY_DB",
789            "PUBLIC",
790            "events",
791            SnowflakeAuth::OAuth { token: "t".into() },
792        );
793        let sink = SnowflakeSink::new(config).unwrap();
794        let records = vec![serde_json::json!({"id": 1})];
795        let (sql, _payload) = sink.build_insert(&records).unwrap();
796        assert!(sql.contains("\"MY_DB\".\"PUBLIC\".\"events\""));
797    }
798
799    #[test]
800    fn build_insert_binds_payload_instead_of_interpolating() {
801        // Regression for #78/#5. The record JSON must travel as a bound TEXT
802        // parameter to PARSE_JSON(?), never interpolated into a SQL string
803        // literal — interpolation is a SQL-injection vector and breaks on any
804        // value containing an apostrophe.
805        let config = SnowflakeSinkConfig::new(
806            "acct",
807            "wh",
808            "db",
809            "schema",
810            "tbl",
811            SnowflakeAuth::OAuth { token: "t".into() },
812        );
813        let sink = SnowflakeSink::new(config).unwrap();
814        let records = vec![
815            serde_json::json!({"name": "O'Brien"}),
816            serde_json::json!({"note": "'); DROP TABLE events;--"}),
817        ];
818        let (sql, payload) = sink.build_insert(&records).unwrap();
819
820        // SQL is a parameterised placeholder — no record data, no literal.
821        assert!(sql.contains("PARSE_JSON(?)"), "sql: {sql}");
822        assert!(
823            !sql.contains('\''),
824            "sql must not embed a quoted literal: {sql}"
825        );
826        assert!(!sql.contains("O'Brien"));
827        assert!(!sql.contains("DROP TABLE"));
828
829        // The payload is the JSON array, carrying the apostrophe data intact.
830        let parsed: Value = serde_json::from_str(&payload).unwrap();
831        assert_eq!(parsed[0]["name"], "O'Brien");
832        assert_eq!(parsed[1]["note"], "'); DROP TABLE events;--");
833    }
834
835    #[test]
836    fn build_insert_maps_record_fields_to_columns_not_flatten_metadata() {
837        // C2 regression (audit #146): the INSERT must project each record field
838        // into its named column, NOT `SELECT *` over FLATTEN — `SELECT *` over
839        // FLATTEN returns the fixed SEQ/KEY/PATH/INDEX/VALUE/THIS metadata
840        // columns, so the old statement inserted metadata instead of the
841        // record's own fields.
842        let config = SnowflakeSinkConfig::new(
843            "acct",
844            "wh",
845            "db",
846            "schema",
847            "events",
848            SnowflakeAuth::OAuth { token: "t".into() },
849        );
850        let sink = SnowflakeSink::new(config).unwrap();
851        let records = vec![serde_json::json!({"user_id": 1, "event": "click"})];
852        let (sql, _payload) = sink.build_insert(&records).unwrap();
853
854        // Named column list + per-column projection from the FLATTEN `value`.
855        assert!(sql.contains("\"user_id\""), "sql: {sql}");
856        assert!(sql.contains("\"event\""), "sql: {sql}");
857        assert!(sql.contains("value:\"user_id\"::string"), "sql: {sql}");
858        assert!(sql.contains("value:\"event\"::string"), "sql: {sql}");
859        // Crucially, NOT a metadata-projecting `SELECT *`.
860        assert!(
861            !sql.contains("SELECT *"),
862            "must not SELECT * over FLATTEN: {sql}"
863        );
864        assert!(
865            sql.contains("FLATTEN(input => PARSE_JSON(?))"),
866            "sql: {sql}"
867        );
868    }
869
870    #[test]
871    fn build_insert_escapes_record_keys_in_columns_and_paths() {
872        // Record keys are user-controlled; a key containing a double quote must
873        // be `"`-doubled in both the column list and the FLATTEN path so it
874        // cannot break out of the identifier / path.
875        let config = SnowflakeSinkConfig::new(
876            "acct",
877            "wh",
878            "db",
879            "schema",
880            "events",
881            SnowflakeAuth::OAuth { token: "t".into() },
882        );
883        let sink = SnowflakeSink::new(config).unwrap();
884        let records = vec![serde_json::json!({"a\"b": 1})];
885        let (sql, _payload) = sink.build_insert(&records).unwrap();
886        // Column identifier and path key are both escaped as "a""b".
887        assert!(sql.contains("\"a\"\"b\""), "sql: {sql}");
888        assert!(sql.contains("value:\"a\"\"b\"::string"), "sql: {sql}");
889    }
890
891    #[test]
892    fn check_statement_code_maps_non_success_code_to_sink_error() {
893        // The error branch of `check_statement_code`: any code other than
894        // 090001 surfaces as a `FaucetError::Sink` carrying the code + message.
895        let resp = SnowflakeResponse {
896            message: Some("Object does not exist".into()),
897            code: Some("002003".into()),
898            statement_handle: None,
899            data: None,
900        };
901        match check_statement_code(&resp) {
902            Err(FaucetError::Sink(msg)) => {
903                assert!(msg.contains("002003"), "msg: {msg}");
904                assert!(msg.contains("Object does not exist"), "msg: {msg}");
905            }
906            other => panic!("expected a Sink error, got {other:?}"),
907        }
908    }
909
910    #[test]
911    fn check_statement_code_accepts_success_and_missing_code() {
912        let ok = SnowflakeResponse {
913            message: None,
914            code: Some("090001".into()),
915            statement_handle: None,
916            data: None,
917        };
918        assert!(check_statement_code(&ok).is_ok());
919        let no_code = SnowflakeResponse {
920            message: None,
921            code: None,
922            statement_handle: None,
923            data: None,
924        };
925        assert!(check_statement_code(&no_code).is_ok());
926    }
927
928    #[test]
929    fn build_insert_rejects_non_object_record() {
930        // A non-object record (here a JSON array) must surface a typed Sink
931        // error rather than panicking.
932        let config = SnowflakeSinkConfig::new(
933            "acct",
934            "wh",
935            "db",
936            "schema",
937            "events",
938            SnowflakeAuth::OAuth { token: "t".into() },
939        );
940        let sink = SnowflakeSink::new(config).unwrap();
941        let records = vec![serde_json::json!([1, 2, 3])];
942        match sink.build_insert(&records) {
943            Err(FaucetError::Sink(msg)) => {
944                assert!(msg.contains("requires JSON object records"), "msg: {msg}")
945            }
946            other => panic!("expected a Sink error, got {other:?}"),
947        }
948    }
949
950    #[test]
951    fn config_schema_reports_required_fields() {
952        let config = SnowflakeSinkConfig::new(
953            "acct",
954            "wh",
955            "db",
956            "schema",
957            "events",
958            SnowflakeAuth::OAuth { token: "t".into() },
959        );
960        let sink = SnowflakeSink::new(config).unwrap();
961        let schema = sink.config_schema();
962        assert!(schema["properties"]["account"].is_object());
963        assert!(schema["properties"]["table"].is_object());
964        let required = schema["required"].as_array().expect("required array");
965        assert!(required.iter().any(|v| v == "account"));
966        assert!(required.iter().any(|v| v == "table"));
967    }
968
969    #[test]
970    fn build_insert_uses_union_of_all_record_keys_not_just_first() {
971        // Data-loss regression F16 (audit #264): the column set must be the
972        // UNION of keys across every record in the chunk, not just the first
973        // record's keys. With differing key sets the union is {a, b, c} and
974        // every column must appear in both the column list and the FLATTEN
975        // projection so no record's value is silently dropped.
976        let config = SnowflakeSinkConfig::new(
977            "acct",
978            "wh",
979            "db",
980            "schema",
981            "events",
982            SnowflakeAuth::OAuth { token: "t".into() },
983        );
984        let sink = SnowflakeSink::new(config).unwrap();
985        let records = vec![
986            serde_json::json!({"a": 1}),
987            serde_json::json!({"b": 2}),
988            serde_json::json!({"a": 3, "b": 4, "c": 5}),
989        ];
990
991        // The union helper itself: first-seen order, all three columns.
992        let union = SnowflakeSink::column_union(&records).unwrap();
993        assert_eq!(union, vec!["a", "b", "c"]);
994
995        let (sql, _payload) = sink.build_insert(&records).unwrap();
996
997        // Every union column appears in the column list and the projection.
998        for col in ["a", "b", "c"] {
999            let quoted = format!("\"{col}\"");
1000            assert!(
1001                sql.contains(&quoted),
1002                "column {col} missing from column list: {sql}"
1003            );
1004            let proj = format!("value:\"{col}\"::string");
1005            assert!(sql.contains(&proj), "projection for {col} missing: {sql}");
1006        }
1007
1008        // Records missing a column rely on the FLATTEN `value:"col"` path
1009        // returning NULL — the projection covers all three columns, so the
1010        // first record (only `a`) yields NULL for `b` and `c`, etc. No column
1011        // is dropped.
1012        assert_eq!(
1013            sql.matches("value:").count(),
1014            3,
1015            "exactly 3 projections: {sql}"
1016        );
1017    }
1018
1019    #[test]
1020    fn column_union_collects_all_keys_without_duplicates() {
1021        // The union must contain every key across the chunk exactly once. The
1022        // absolute column ORDER is intentionally NOT asserted: it depends on
1023        // `serde_json::Map`'s key ordering (sorted BTreeMap vs insertion-order
1024        // IndexMap under the `preserve_order` feature, which feature unification
1025        // can toggle), and order does not affect correctness — the column list
1026        // and the value projection are built from the same `Vec`, so they stay
1027        // internally consistent regardless.
1028        let records = vec![
1029            serde_json::json!({"z": 1, "a": 2}),
1030            serde_json::json!({"m": 3, "z": 4}),
1031            serde_json::json!({"a": 5, "b": 6}),
1032        ];
1033        let mut union = SnowflakeSink::column_union(&records).unwrap();
1034        let len_before = union.len();
1035        union.sort();
1036        union.dedup();
1037        assert_eq!(union.len(), len_before, "no duplicate columns");
1038        assert_eq!(
1039            union,
1040            vec!["a", "b", "m", "z"],
1041            "every key present exactly once"
1042        );
1043    }
1044
1045    #[test]
1046    fn build_insert_rejects_all_empty_records() {
1047        let config = SnowflakeSinkConfig::new(
1048            "acct",
1049            "wh",
1050            "db",
1051            "schema",
1052            "events",
1053            SnowflakeAuth::OAuth { token: "t".into() },
1054        );
1055        let sink = SnowflakeSink::new(config).unwrap();
1056        let records = vec![serde_json::json!({})];
1057        assert!(sink.build_insert(&records).is_err());
1058    }
1059}