1use 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
16pub struct SnowflakeSink {
19 config: SnowflakeSinkConfig,
20 client: Client,
21 endpoint: Option<String>,
25 auth_provider: Option<SharedAuthProvider>,
29 commit_table_ready: OnceCell<()>,
35 #[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 #[serde(rename = "statementHandle", default)]
50 statement_handle: Option<String>,
51 #[serde(default)]
55 data: Option<Vec<Vec<Value>>>,
56}
57
58fn 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 pub fn new(config: SnowflakeSinkConfig) -> Result<Self, FaucetError> {
80 faucet_core::validate_batch_size(config.batch_size)?;
81 #[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 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
113 self.auth_provider = Some(provider);
114 self
115 }
116
117 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
122 self.endpoint = Some(endpoint.into());
123 self
124 }
125
126 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 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 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 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 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 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 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 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 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 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 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 fn build_insert(&self, records: &[Value]) -> Result<(String, String), FaucetError> {
382 let columns = Self::column_union(records)?;
383
384 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 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 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 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 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 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 #[cfg(feature = "arrow")]
603 fn supports_columnar(&self) -> bool {
604 self.config.bulk_load.is_some()
605 }
606
607 #[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 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 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 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 let plain = SnowflakeSink::new(base.clone()).unwrap();
701 assert!(!plain.supports_columnar());
702
703 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 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 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 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 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 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 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 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 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 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 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 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 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 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 for col in ["a", "b", "c"] {
999 let quoted = format!("\"{col}\"");
1000 assert!(
1001 sql.contains("ed),
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 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 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}