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