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}
36
37#[derive(Deserialize)]
38struct SnowflakeResponse {
39 message: Option<String>,
40 #[serde(default)]
41 code: Option<String>,
42 #[serde(rename = "statementHandle", default)]
45 statement_handle: Option<String>,
46 #[serde(default)]
50 data: Option<Vec<Vec<Value>>>,
51}
52
53fn 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 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 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
95 self.auth_provider = Some(provider);
96 self
97 }
98
99 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
104 self.endpoint = Some(endpoint.into());
105 self
106 }
107
108 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 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 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 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 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 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 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 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 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 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 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 fn build_insert(&self, records: &[Value]) -> Result<(String, String), FaucetError> {
364 let columns = Self::column_union(records)?;
365
366 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 for col in ["a", "b", "c"] {
889 let quoted = format!("\"{col}\"");
890 assert!(
891 sql.contains("ed),
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 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 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}