1use crate::config::BigQuerySinkConfig;
4use crate::idempotent;
5use crate::merge;
6use async_trait::async_trait;
7use faucet_common_bigquery::build_client;
8use faucet_core::FaucetError;
9use faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL;
10use gcp_bigquery_client::Client;
11use gcp_bigquery_client::error::BQError;
12use gcp_bigquery_client::model::get_query_results_parameters::GetQueryResultsParameters;
13use gcp_bigquery_client::model::query_parameter::QueryParameter;
14use gcp_bigquery_client::model::query_parameter_type::QueryParameterType;
15use gcp_bigquery_client::model::query_parameter_value::QueryParameterValue;
16use gcp_bigquery_client::model::query_request::QueryRequest;
17use gcp_bigquery_client::model::query_response::{QueryResponse, ResultSet};
18use gcp_bigquery_client::model::table_data_insert_all_request::TableDataInsertAllRequest;
19use gcp_bigquery_client::model::table_data_insert_all_response::TableDataInsertAllResponse;
20use serde_json::Value;
21use std::time::Duration;
22use tokio::sync::RwLock;
23
24const IDEMPOTENT_JOB_TIMEOUT: Duration = Duration::from_secs(120);
28
29const JOB_POLL_LONG_POLL_MS: i32 = 10_000;
32
33fn is_table_not_found(err: &BQError) -> bool {
37 matches!(err, BQError::ResponseError { error } if error.error.code == 404)
38}
39
40fn deletes_to_payload(deletes: &[faucet_core::KeyTuple]) -> String {
43 let arr: Vec<Value> = deletes
44 .iter()
45 .map(|kt| {
46 let mut obj = serde_json::Map::new();
47 for (k, v) in &kt.0 {
48 obj.insert(k.clone(), v.clone());
49 }
50 Value::Object(obj)
51 })
52 .collect();
53 Value::Array(arr).to_string()
54}
55
56pub struct BigQuerySink {
59 config: BigQuerySinkConfig,
60 client: Client,
61 schema_cache: RwLock<Option<Vec<idempotent::FieldSpec>>>,
67 #[cfg(feature = "arrow")]
71 gcs_store: tokio::sync::OnceCell<google_cloud_storage::client::Storage>,
72}
73
74impl BigQuerySink {
75 pub async fn new(config: BigQuerySinkConfig) -> Result<Self, FaucetError> {
80 faucet_core::validate_batch_size(config.batch_size)?;
81 config.write.validate()?;
82 let client = build_client(&config.auth).await?;
83 Ok(Self {
84 config,
85 client,
86 schema_cache: RwLock::new(None),
87 #[cfg(feature = "arrow")]
88 gcs_store: tokio::sync::OnceCell::new(),
89 })
90 }
91
92 #[doc(hidden)]
101 pub fn from_parts(config: BigQuerySinkConfig, client: Client) -> Self {
102 Self {
103 config,
104 client,
105 schema_cache: RwLock::new(None),
106 #[cfg(feature = "arrow")]
107 gcs_store: tokio::sync::OnceCell::new(),
108 }
109 }
110
111 async fn insert_chunk_raw(
128 &self,
129 rows: &[Value],
130 skip_invalid_rows: bool,
131 ) -> Result<TableDataInsertAllResponse, FaucetError> {
132 let mut insert_request = TableDataInsertAllRequest::new();
133 if skip_invalid_rows {
134 insert_request.skip_invalid_rows();
135 }
136 for row in rows {
137 let insert_id = self.config.insert_id_field.as_ref().and_then(|field| {
141 row.get(field).map(|v| match v {
142 Value::String(s) => s.clone(),
143 other => other.to_string(),
144 })
145 });
146 insert_request.add_row(insert_id, row).map_err(|e| {
147 FaucetError::Sink(format!("failed to serialize row for BigQuery: {e}"))
148 })?;
149 }
150 self.client
151 .tabledata()
152 .insert_all(
153 &self.config.project_id,
154 &self.config.dataset_id,
155 &self.config.table_id,
156 insert_request,
157 )
158 .await
159 .map_err(|e| FaucetError::Sink(format!("BigQuery insertAll failed: {e}")))
160 }
161
162 async fn insert_batch(&self, rows: &[Value]) -> Result<usize, FaucetError> {
170 if rows.is_empty() {
171 return Ok(0);
172 }
173
174 let response = self.insert_chunk_raw(rows, false).await?;
179
180 if let Some(errors) = response.insert_errors
182 && !errors.is_empty()
183 {
184 let count = errors.len();
185 let first = &errors[0];
186 return Err(FaucetError::Sink(format!(
187 "BigQuery insertAll: {count} row(s) failed; first error on row {:?}: {:?}",
188 first.index,
189 first
190 .errors
191 .as_ref()
192 .and_then(|errs| errs.first())
193 .map(|e| &e.message),
194 )));
195 }
196
197 Ok(rows.len())
198 }
199
200 fn string_param(name: &str, value: &str) -> QueryParameter {
206 QueryParameter {
207 name: Some(name.to_string()),
208 parameter_type: Some(QueryParameterType {
209 r#type: "STRING".to_string(),
210 array_type: None,
211 struct_types: None,
212 }),
213 parameter_value: Some(QueryParameterValue {
214 value: Some(value.to_string()),
215 array_values: None,
216 struct_values: None,
217 }),
218 }
219 }
220
221 async fn fetch_schema_fields(&self) -> Result<Vec<idempotent::FieldSpec>, BQError> {
225 let table = self
226 .client
227 .table()
228 .get(
229 &self.config.project_id,
230 &self.config.dataset_id,
231 &self.config.table_id,
232 Some(vec!["schema"]),
233 )
234 .await?;
235 Ok(table
237 .schema
238 .fields
239 .as_ref()
240 .map(|fs| {
241 fs.iter()
242 .map(idempotent::FieldSpec::from_table_field)
243 .collect()
244 })
245 .unwrap_or_default())
246 }
247
248 async fn target_schema(&self) -> Result<Vec<idempotent::FieldSpec>, FaucetError> {
256 if let Some(fields) = self.schema_cache.read().await.as_ref() {
257 return Ok(fields.clone());
258 }
259 let mut guard = self.schema_cache.write().await;
263 if let Some(fields) = guard.as_ref() {
264 return Ok(fields.clone());
265 }
266 let fields = self
267 .fetch_schema_fields()
268 .await
269 .map_err(|e| FaucetError::Sink(format!("BigQuery tables.get (schema) failed: {e}")))?;
270 if fields.is_empty() {
271 return Err(FaucetError::Sink(format!(
272 "BigQuery target table {}.{}.{} has no schema fields; exactly-once \
273 delivery requires a table with a defined schema",
274 self.config.project_id, self.config.dataset_id, self.config.table_id
275 )));
276 }
277 *guard = Some(fields.clone());
278 Ok(fields)
279 }
280
281 fn table_ref(&self) -> String {
283 idempotent::table_ref(
284 &self.config.project_id,
285 &self.config.dataset_id,
286 &self.config.table_id,
287 )
288 }
289
290 async fn run_ddl(&self, sql: String) -> Result<(), FaucetError> {
294 let mut req = QueryRequest::new(sql);
295 req.use_legacy_sql = false;
296 let resp = self
297 .client
298 .job()
299 .query(&self.config.project_id, req)
300 .await
301 .map_err(|e| FaucetError::Sink(format!("BigQuery schema-evolution DDL failed: {e}")))?;
302 self.await_query_complete(resp).await
303 }
304
305 async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
307 let sql =
308 idempotent::build_create_commit_table(&self.config.project_id, &self.config.dataset_id);
309 let mut req = QueryRequest::new(sql);
310 req.use_legacy_sql = false;
311 let resp = self
312 .client
313 .job()
314 .query(&self.config.project_id, req)
315 .await
316 .map_err(|e| FaucetError::Sink(format!("BigQuery commit-table create failed: {e}")))?;
317 self.await_query_complete(resp).await
318 }
319
320 async fn await_query_complete(&self, initial: QueryResponse) -> Result<(), FaucetError> {
331 let (job_id, location) = Self::job_reference(&initial)?;
332
333 if !initial.job_complete.unwrap_or(false) {
335 let started = std::time::Instant::now();
336 loop {
337 let params = GetQueryResultsParameters {
338 location: location.clone(),
339 timeout_ms: Some(JOB_POLL_LONG_POLL_MS),
340 max_results: Some(0),
341 ..Default::default()
342 };
343 let resp = self
344 .client
345 .job()
346 .get_query_results(&self.config.project_id, &job_id, params)
347 .await
348 .map_err(|e| {
349 FaucetError::Sink(format!("BigQuery jobs.getQueryResults failed: {e}"))
350 })?;
351 if resp.job_complete.unwrap_or(false) {
352 break;
353 }
354 if started.elapsed() >= IDEMPOTENT_JOB_TIMEOUT {
355 return Err(FaucetError::Sink(format!(
356 "BigQuery job '{job_id}' did not complete within {}s",
357 IDEMPOTENT_JOB_TIMEOUT.as_secs()
358 )));
359 }
360 tokio::time::sleep(Duration::from_millis(250)).await;
364 }
365 }
366
367 let job = self
375 .client
376 .job()
377 .get_job(&self.config.project_id, &job_id, location.as_deref())
378 .await
379 .map_err(|e| FaucetError::Sink(format!("BigQuery jobs.get failed: {e}")))?;
380 let status = job.status.ok_or_else(|| {
381 FaucetError::Sink(format!(
382 "BigQuery job '{job_id}' returned no status; cannot confirm durable commit"
383 ))
384 })?;
385 if let Some(err) = status.error_result {
386 return Err(FaucetError::Sink(format!(
387 "BigQuery query job '{job_id}' failed: {err}"
388 )));
389 }
390 match status.state.as_deref() {
391 Some("DONE") => Ok(()),
392 other => Err(FaucetError::Sink(format!(
393 "BigQuery job '{job_id}' is in state {other:?}, not DONE; cannot confirm durable commit"
394 ))),
395 }
396 }
397
398 fn job_reference(qr: &QueryResponse) -> Result<(String, Option<String>), FaucetError> {
400 let r = qr.job_reference.as_ref().ok_or_else(|| {
401 FaucetError::Sink("BigQuery query response missing jobReference".to_string())
402 })?;
403 let job_id = r
404 .job_id
405 .clone()
406 .ok_or_else(|| FaucetError::Sink("BigQuery jobReference missing jobId".to_string()))?;
407 Ok((job_id, r.location.clone()))
408 }
409
410 async fn run_upsert_script(
418 &self,
419 plan: &faucet_core::WritePlan,
420 token: Option<(&str, &str)>,
421 ) -> Result<usize, FaucetError> {
422 let columns = self.target_schema().await?;
423 merge::validate_keys_present(&columns, &self.config.write.key)?;
424
425 let has_upserts = !plan.upserts.is_empty();
426 let has_deletes = !plan.deletes.is_empty();
427 if !has_upserts && !has_deletes {
428 if token.is_none() {
431 return Ok(0);
432 }
433 }
434
435 let key = &self.config.write.key;
436 let (project, dataset, table) = (
437 &self.config.project_id,
438 &self.config.dataset_id,
439 &self.config.table_id,
440 );
441 let sql = match token {
442 Some(_) => merge::build_upsert_idempotent_sql(
443 &columns,
444 key,
445 has_upserts,
446 has_deletes,
447 project,
448 dataset,
449 table,
450 ),
451 None => merge::build_upsert_transaction_sql(
452 &columns,
453 key,
454 has_upserts,
455 has_deletes,
456 project,
457 dataset,
458 table,
459 ),
460 };
461
462 let mut params = Vec::new();
463 if has_upserts {
464 let payload = serde_json::to_string(&plan.upserts).map_err(|e| {
465 FaucetError::Sink(format!("bigquery upsert: serialize payload: {e}"))
466 })?;
467 params.push(Self::string_param("payload", &payload));
468 }
469 if has_deletes {
470 let deletes = deletes_to_payload(&plan.deletes);
471 params.push(Self::string_param("deletes", &deletes));
472 }
473 if let Some((scope, tok)) = token {
474 params.push(Self::string_param("scope", scope));
475 params.push(Self::string_param("token", tok));
476 }
477
478 let mut req = QueryRequest::new(sql);
479 req.use_legacy_sql = false;
480 req.parameter_mode = Some("NAMED".to_string());
481 if let Some((scope, tok)) = token {
485 req.request_id = Some(idempotent::build_request_id(scope, tok));
486 }
487 req.query_parameters = Some(params);
488
489 let resp = self
490 .client
491 .job()
492 .query(&self.config.project_id, req)
493 .await
494 .map_err(|e| FaucetError::Sink(format!("bigquery upsert write failed: {e}")))?;
495 self.await_query_complete(resp).await?;
496
497 Ok(plan.upserts.len() + plan.deletes.len())
498 }
499}
500
501#[async_trait]
502impl faucet_core::Sink for BigQuerySink {
503 fn connector_name(&self) -> &'static str {
504 "bigquery"
505 }
506
507 fn config_schema(&self) -> serde_json::Value {
508 serde_json::to_value(faucet_core::schema_for!(BigQuerySinkConfig))
509 .expect("schema serialization")
510 }
511
512 fn dataset_uri(&self) -> String {
513 format!(
514 "bigquery://{}.{}.{}",
515 self.config.project_id, self.config.dataset_id, self.config.table_id
516 )
517 }
518
519 async fn check(
530 &self,
531 ctx: &faucet_core::check::CheckContext,
532 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
533 use faucet_core::check::{CheckReport, Probe};
534
535 let started = std::time::Instant::now();
536 let fqn = format!(
537 "{}.{}.{}",
538 self.config.project_id, self.config.dataset_id, self.config.table_id
539 );
540
541 let result = tokio::time::timeout(
542 ctx.timeout,
543 self.client.table().get(
544 &self.config.project_id,
545 &self.config.dataset_id,
546 &self.config.table_id,
547 Some(vec!["tableReference"]),
548 ),
549 )
550 .await;
551
552 let probe = match result {
553 Ok(Ok(_table)) => Probe::pass("auth", started.elapsed()),
554 Ok(Err(e)) => Probe::fail_hint(
555 "auth",
556 started.elapsed(),
557 format!("BigQuery tables.get on {fqn} failed: {e}"),
558 "Verify the service account has roles/bigquery.dataViewer (or \
559 read access) on the dataset and that the project, dataset, and \
560 table IDs are correct.",
561 ),
562 Err(_elapsed) => Probe::fail_hint(
563 "auth",
564 started.elapsed(),
565 format!(
566 "BigQuery tables.get on {fqn} timed out after {:?}",
567 ctx.timeout
568 ),
569 "Check network reachability to bigquery.googleapis.com and that \
570 credentials can be minted within the timeout.",
571 ),
572 };
573
574 Ok(CheckReport::single(probe))
575 }
576
577 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
586 if records.is_empty() {
587 return Ok(0);
588 }
589
590 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
591 let plan = faucet_core::plan_writes(records, &self.config.write);
592 if let Some((idx, msg)) = plan.failed.first() {
593 return Err(FaucetError::Sink(format!(
594 "bigquery {}: row {idx}: {msg}",
595 self.config.write.write_mode.as_str()
596 )));
597 }
598 return self.run_upsert_script(&plan, None).await;
599 }
600
601 let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
602 vec![records]
605 } else {
606 records.chunks(self.config.batch_size).collect()
607 };
608
609 let mut total = 0;
610 for chunk in chunks {
611 total += self.insert_batch(chunk).await?;
612 }
613
614 tracing::info!(
615 table = %format!(
616 "{}.{}.{}",
617 self.config.project_id, self.config.dataset_id, self.config.table_id
618 ),
619 rows = total,
620 "BigQuery write complete"
621 );
622 Ok(total)
623 }
624
625 async fn write_batch_partial(
643 &self,
644 records: &[Value],
645 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
646 use std::collections::HashMap;
647
648 if records.is_empty() {
649 return Ok(Vec::new());
650 }
651
652 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
653 let plan = faucet_core::plan_writes(records, &self.config.write);
654 self.run_upsert_script(&plan, None).await?;
655 let mut outcomes: Vec<faucet_core::RowOutcome> =
656 records.iter().map(|_| Ok(())).collect();
657 for (idx, msg) in &plan.failed {
658 outcomes[*idx] = Err(FaucetError::Sink(format!(
659 "bigquery {}: {msg}",
660 self.config.write.write_mode.as_str()
661 )));
662 }
663 return Ok(outcomes);
664 }
665
666 let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
667 vec![records]
668 } else {
669 records.chunks(self.config.batch_size).collect()
670 };
671
672 let mut outcomes: Vec<faucet_core::RowOutcome> = Vec::with_capacity(records.len());
673
674 for chunk in chunks {
675 let response = self.insert_chunk_raw(chunk, true).await?;
682
683 let failed: HashMap<usize, String> = response
685 .insert_errors
686 .unwrap_or_default()
687 .into_iter()
688 .filter_map(|e| {
689 let idx = e.index? as usize;
690 let msg = e
691 .errors
692 .as_ref()
693 .and_then(|v| v.first())
694 .map(|er| er.message.clone().unwrap_or_default())
695 .unwrap_or_default();
696 Some((idx, msg))
697 })
698 .collect();
699
700 for i in 0..chunk.len() {
701 match failed.get(&i) {
702 Some(msg) => outcomes.push(Err(FaucetError::Sink(format!(
703 "BigQuery row rejected: {msg}"
704 )))),
705 None => outcomes.push(Ok(())),
706 }
707 }
708 }
709
710 Ok(outcomes)
711 }
712
713 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
714 &[
715 faucet_core::WriteMode::Append,
716 faucet_core::WriteMode::Upsert,
717 faucet_core::WriteMode::Delete,
718 ]
719 }
720
721 fn dedups_by_key(&self) -> bool {
722 self.config.write.dedups_by_key()
723 }
724
725 fn supports_idempotent_writes(&self) -> bool {
726 true
727 }
728
729 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
732 self.ensure_commit_table().await?;
733 let mut req = QueryRequest::new(idempotent::build_select_token(
734 &self.config.project_id,
735 &self.config.dataset_id,
736 ));
737 req.use_legacy_sql = false;
738 req.parameter_mode = Some("NAMED".to_string());
739 req.query_parameters = Some(vec![Self::string_param("scope", scope)]);
740
741 let resp = self
742 .client
743 .job()
744 .query(&self.config.project_id, req)
745 .await
746 .map_err(|e| FaucetError::Sink(format!("BigQuery token read failed: {e}")))?;
747
748 if !resp.job_complete.unwrap_or(false) {
753 return Err(FaucetError::Sink(
754 "BigQuery watermark read did not complete synchronously".to_string(),
755 ));
756 }
757 if resp.schema.is_none() {
762 return Err(FaucetError::Sink(
763 "BigQuery watermark read returned no schema; cannot trust the token result"
764 .to_string(),
765 ));
766 }
767
768 let mut rs = ResultSet::new_from_query_response(resp);
769 if rs.next_row() {
770 rs.get_string_by_name(COMMIT_TOKEN_TOKEN_COL)
771 .map_err(|e| FaucetError::Sink(format!("BigQuery token decode failed: {e}")))
772 } else {
773 Ok(None)
774 }
775 }
776
777 async fn write_batch_idempotent(
788 &self,
789 records: &[Value],
790 scope: &str,
791 token: &str,
792 ) -> Result<usize, FaucetError> {
793 self.ensure_commit_table().await?;
794
795 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
796 let plan = faucet_core::plan_writes(records, &self.config.write);
797 if let Some((idx, msg)) = plan.failed.first() {
798 return Err(FaucetError::Sink(format!(
799 "bigquery {}: row {idx}: {msg}",
800 self.config.write.write_mode.as_str()
801 )));
802 }
803 return self.run_upsert_script(&plan, Some((scope, token))).await;
804 }
805
806 let columns = self.target_schema().await?;
807
808 let payload = serde_json::to_string(records).map_err(|e| {
809 FaucetError::Sink(format!(
810 "BigQuery exactly-once: serialize page payload: {e}"
811 ))
812 })?;
813
814 let sql = idempotent::build_transaction_sql(
815 &columns,
816 &self.config.project_id,
817 &self.config.dataset_id,
818 &self.config.table_id,
819 );
820 let mut req = QueryRequest::new(sql);
821 req.use_legacy_sql = false;
822 req.parameter_mode = Some("NAMED".to_string());
823 req.request_id = Some(idempotent::build_request_id(scope, token));
824 req.query_parameters = Some(vec![
825 Self::string_param("payload", &payload),
826 Self::string_param("scope", scope),
827 Self::string_param("token", token),
828 ]);
829
830 let resp = self
831 .client
832 .job()
833 .query(&self.config.project_id, req)
834 .await
835 .map_err(|e| FaucetError::Sink(format!("BigQuery idempotent write failed: {e}")))?;
836 self.await_query_complete(resp).await?;
837
838 tracing::info!(
839 table = %format!(
840 "{}.{}.{}",
841 self.config.project_id, self.config.dataset_id, self.config.table_id
842 ),
843 rows = records.len(),
844 token = %token,
845 "BigQuery exactly-once page committed"
846 );
847 Ok(records.len())
848 }
849
850 fn supports_schema_evolution(&self) -> bool {
855 true
856 }
857
858 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
866 match self.fetch_schema_fields().await {
867 Ok(fields) if fields.is_empty() => Ok(None),
868 Ok(fields) => Ok(Some(idempotent::fieldspecs_to_json_schema(&fields))),
869 Err(e) if is_table_not_found(&e) => Ok(None),
870 Err(e) => Err(FaucetError::Sink(format!(
871 "BigQuery current_schema (tables.get) failed: {e}"
872 ))),
873 }
874 }
875
876 async fn evolve_schema(
888 &self,
889 evolution: &faucet_core::SchemaEvolution,
890 ) -> Result<(), FaucetError> {
891 let table_ref = self.table_ref();
892
893 for c in &evolution.additions {
894 let bq = idempotent::base_to_bq(
895 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
896 );
897 self.run_ddl(idempotent::build_add_column_ddl(&table_ref, &c.name, bq))
898 .await?;
899 }
900 for c in &evolution.widenings {
901 let bq = idempotent::base_to_bq(
902 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
903 );
904 self.run_ddl(idempotent::build_alter_type_ddl(&table_ref, &c.name, bq))
905 .await?;
906 }
907 for col in &evolution.relax_nullability {
908 self.run_ddl(idempotent::build_drop_not_null_ddl(&table_ref, col))
909 .await?;
910 }
911
912 *self.schema_cache.write().await = None;
915 Ok(())
916 }
917
918 #[cfg(feature = "arrow")]
923 fn supports_columnar(&self) -> bool {
924 self.config.bulk_load.is_some()
925 && self.config.write.write_mode == faucet_core::WriteMode::Append
926 }
927
928 #[cfg(feature = "arrow")]
934 async fn write_batch_columnar(
935 &self,
936 batch: &arrow::array::RecordBatch,
937 ) -> Result<usize, FaucetError> {
938 crate::load::write_columnar(&self.client, &self.config, &self.gcs_store, batch).await
939 }
940}
941
942#[cfg(test)]
943mod tests {
944 use super::deletes_to_payload;
945 use faucet_core::KeyTuple;
946 use serde_json::json;
947
948 #[test]
953 fn deletes_to_payload_preserves_number_type() {
954 let p = deletes_to_payload(&[KeyTuple(vec![("id".into(), json!(2))])]);
958 let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
959 assert_eq!(v, json!([{"id": 2}]));
960 assert!(v[0]["id"].is_number(), "id must serialize as a number: {p}");
961 }
962
963 #[test]
964 fn deletes_to_payload_composite_key_roundtrips() {
965 let p = deletes_to_payload(&[KeyTuple(vec![
966 ("tenant".into(), json!("acme")),
967 ("id".into(), json!(7)),
968 ])]);
969 let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
970 assert_eq!(v, json!([{"tenant": "acme", "id": 7}]));
971 }
972
973 #[test]
974 fn deletes_to_payload_multiple_rows() {
975 let p = deletes_to_payload(&[
976 KeyTuple(vec![("id".into(), json!(1))]),
977 KeyTuple(vec![("id".into(), json!(2))]),
978 ]);
979 let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
980 assert_eq!(v, json!([{"id": 1}, {"id": 2}]));
981 }
982}