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::job::Job;
14use gcp_bigquery_client::model::query_parameter::QueryParameter;
15use gcp_bigquery_client::model::query_parameter_type::QueryParameterType;
16use gcp_bigquery_client::model::query_parameter_value::QueryParameterValue;
17use gcp_bigquery_client::model::query_request::QueryRequest;
18use gcp_bigquery_client::model::query_response::{QueryResponse, ResultSet};
19use gcp_bigquery_client::model::table_data_insert_all_request::TableDataInsertAllRequest;
20use gcp_bigquery_client::model::table_data_insert_all_response::TableDataInsertAllResponse;
21use serde_json::Value;
22use std::time::Duration;
23use tokio::sync::RwLock;
24
25const IDEMPOTENT_JOB_TIMEOUT: Duration = Duration::from_secs(120);
29
30const JOB_POLL_LONG_POLL_MS: i32 = 10_000;
33
34fn is_table_not_found(err: &BQError) -> bool {
38 matches!(err, BQError::ResponseError { error } if error.error.code == 404)
39}
40
41fn dml_affected_rows(job: &Job) -> u64 {
49 job.statistics
50 .as_ref()
51 .and_then(|s| s.query.as_ref())
52 .and_then(|q| q.num_dml_affected_rows.as_deref())
53 .and_then(|n| n.parse::<u64>().ok())
54 .unwrap_or(0)
55}
56
57fn scope_to_payload(scope: &std::collections::BTreeMap<String, Value>) -> String {
60 let obj: serde_json::Map<String, Value> =
61 scope.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
62 Value::Object(obj).to_string()
63}
64
65fn deletes_to_payload(deletes: &[faucet_core::KeyTuple]) -> String {
68 let arr: Vec<Value> = deletes
69 .iter()
70 .map(|kt| {
71 let mut obj = serde_json::Map::new();
72 for (k, v) in &kt.0 {
73 obj.insert(k.clone(), v.clone());
74 }
75 Value::Object(obj)
76 })
77 .collect();
78 Value::Array(arr).to_string()
79}
80
81pub struct BigQuerySink {
84 config: BigQuerySinkConfig,
85 client: Client,
86 schema_cache: RwLock<Option<Vec<idempotent::FieldSpec>>>,
92 #[cfg(feature = "arrow")]
96 gcs_store: tokio::sync::OnceCell<google_cloud_storage::client::Storage>,
97}
98
99impl BigQuerySink {
100 pub async fn new(config: BigQuerySinkConfig) -> Result<Self, FaucetError> {
105 faucet_core::validate_batch_size(config.batch_size)?;
106 config.write.validate()?;
107 let client = build_client(&config.auth).await?;
108 Ok(Self {
109 config,
110 client,
111 schema_cache: RwLock::new(None),
112 #[cfg(feature = "arrow")]
113 gcs_store: tokio::sync::OnceCell::new(),
114 })
115 }
116
117 #[doc(hidden)]
126 pub fn from_parts(config: BigQuerySinkConfig, client: Client) -> Self {
127 Self {
128 config,
129 client,
130 schema_cache: RwLock::new(None),
131 #[cfg(feature = "arrow")]
132 gcs_store: tokio::sync::OnceCell::new(),
133 }
134 }
135
136 async fn insert_chunk_raw(
153 &self,
154 rows: &[Value],
155 skip_invalid_rows: bool,
156 ) -> Result<TableDataInsertAllResponse, FaucetError> {
157 let mut insert_request = TableDataInsertAllRequest::new();
158 if skip_invalid_rows {
159 insert_request.skip_invalid_rows();
160 }
161 for row in rows {
162 let insert_id = self.config.insert_id_field.as_ref().and_then(|field| {
166 row.get(field).map(|v| match v {
167 Value::String(s) => s.clone(),
168 other => other.to_string(),
169 })
170 });
171 insert_request.add_row(insert_id, row).map_err(|e| {
172 FaucetError::Sink(format!("failed to serialize row for BigQuery: {e}"))
173 })?;
174 }
175 self.client
176 .tabledata()
177 .insert_all(
178 &self.config.project_id,
179 &self.config.dataset_id,
180 &self.config.table_id,
181 insert_request,
182 )
183 .await
184 .map_err(|e| FaucetError::Sink(format!("BigQuery insertAll failed: {e}")))
185 }
186
187 async fn insert_batch(&self, rows: &[Value]) -> Result<usize, FaucetError> {
195 if rows.is_empty() {
196 return Ok(0);
197 }
198
199 let response = self.insert_chunk_raw(rows, false).await?;
204
205 if let Some(errors) = response.insert_errors
207 && !errors.is_empty()
208 {
209 let count = errors.len();
210 let first = &errors[0];
211 return Err(FaucetError::Sink(format!(
212 "BigQuery insertAll: {count} row(s) failed; first error on row {:?}: {:?}",
213 first.index,
214 first
215 .errors
216 .as_ref()
217 .and_then(|errs| errs.first())
218 .map(|e| &e.message),
219 )));
220 }
221
222 Ok(rows.len())
223 }
224
225 fn string_param(name: &str, value: &str) -> QueryParameter {
231 QueryParameter {
232 name: Some(name.to_string()),
233 parameter_type: Some(QueryParameterType {
234 r#type: "STRING".to_string(),
235 array_type: None,
236 struct_types: None,
237 }),
238 parameter_value: Some(QueryParameterValue {
239 value: Some(value.to_string()),
240 array_values: None,
241 struct_values: None,
242 }),
243 }
244 }
245
246 async fn fetch_schema_fields(&self) -> Result<Vec<idempotent::FieldSpec>, BQError> {
250 let table = self
251 .client
252 .table()
253 .get(
254 &self.config.project_id,
255 &self.config.dataset_id,
256 &self.config.table_id,
257 Some(vec!["schema"]),
258 )
259 .await?;
260 Ok(table
262 .schema
263 .fields
264 .as_ref()
265 .map(|fs| {
266 fs.iter()
267 .map(idempotent::FieldSpec::from_table_field)
268 .collect()
269 })
270 .unwrap_or_default())
271 }
272
273 async fn target_schema(&self) -> Result<Vec<idempotent::FieldSpec>, FaucetError> {
281 if let Some(fields) = self.schema_cache.read().await.as_ref() {
282 return Ok(fields.clone());
283 }
284 let mut guard = self.schema_cache.write().await;
288 if let Some(fields) = guard.as_ref() {
289 return Ok(fields.clone());
290 }
291 let fields = self
292 .fetch_schema_fields()
293 .await
294 .map_err(|e| FaucetError::Sink(format!("BigQuery tables.get (schema) failed: {e}")))?;
295 if fields.is_empty() {
296 return Err(FaucetError::Sink(format!(
297 "BigQuery target table {}.{}.{} has no schema fields; exactly-once \
298 delivery requires a table with a defined schema",
299 self.config.project_id, self.config.dataset_id, self.config.table_id
300 )));
301 }
302 *guard = Some(fields.clone());
303 Ok(fields)
304 }
305
306 fn table_ref(&self) -> String {
308 idempotent::table_ref(
309 &self.config.project_id,
310 &self.config.dataset_id,
311 &self.config.table_id,
312 )
313 }
314
315 async fn run_ddl(&self, sql: String) -> Result<(), FaucetError> {
319 let mut req = QueryRequest::new(sql);
320 req.use_legacy_sql = false;
321 let resp = self
322 .client
323 .job()
324 .query(&self.config.project_id, req)
325 .await
326 .map_err(|e| FaucetError::Sink(format!("BigQuery schema-evolution DDL failed: {e}")))?;
327 self.await_query_complete(resp).await
328 }
329
330 async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
332 let sql =
333 idempotent::build_create_commit_table(&self.config.project_id, &self.config.dataset_id);
334 let mut req = QueryRequest::new(sql);
335 req.use_legacy_sql = false;
336 let resp = self
337 .client
338 .job()
339 .query(&self.config.project_id, req)
340 .await
341 .map_err(|e| FaucetError::Sink(format!("BigQuery commit-table create failed: {e}")))?;
342 self.await_query_complete(resp).await
343 }
344
345 async fn await_query_complete(&self, initial: QueryResponse) -> Result<(), FaucetError> {
348 self.await_query_job(initial).await.map(|_| ())
349 }
350
351 async fn await_query_job(&self, initial: QueryResponse) -> Result<Job, FaucetError> {
363 let (job_id, location) = Self::job_reference(&initial)?;
364
365 if !initial.job_complete.unwrap_or(false) {
367 let started = std::time::Instant::now();
368 loop {
369 let params = GetQueryResultsParameters {
370 location: location.clone(),
371 timeout_ms: Some(JOB_POLL_LONG_POLL_MS),
372 max_results: Some(0),
373 ..Default::default()
374 };
375 let resp = self
376 .client
377 .job()
378 .get_query_results(&self.config.project_id, &job_id, params)
379 .await
380 .map_err(|e| {
381 FaucetError::Sink(format!("BigQuery jobs.getQueryResults failed: {e}"))
382 })?;
383 if resp.job_complete.unwrap_or(false) {
384 break;
385 }
386 if started.elapsed() >= IDEMPOTENT_JOB_TIMEOUT {
387 return Err(FaucetError::Sink(format!(
388 "BigQuery job '{job_id}' did not complete within {}s",
389 IDEMPOTENT_JOB_TIMEOUT.as_secs()
390 )));
391 }
392 tokio::time::sleep(Duration::from_millis(250)).await;
396 }
397 }
398
399 let job = self
407 .client
408 .job()
409 .get_job(&self.config.project_id, &job_id, location.as_deref())
410 .await
411 .map_err(|e| FaucetError::Sink(format!("BigQuery jobs.get failed: {e}")))?;
412 let (state, error_result) = {
415 let status = job.status.as_ref().ok_or_else(|| {
416 FaucetError::Sink(format!(
417 "BigQuery job '{job_id}' returned no status; cannot confirm durable commit"
418 ))
419 })?;
420 (
421 status.state.clone(),
422 status.error_result.as_ref().map(|e| e.to_string()),
423 )
424 };
425 if let Some(err) = error_result {
426 return Err(FaucetError::Sink(format!(
427 "BigQuery query job '{job_id}' failed: {err}"
428 )));
429 }
430 match state.as_deref() {
431 Some("DONE") => Ok(job),
432 other => Err(FaucetError::Sink(format!(
433 "BigQuery job '{job_id}' is in state {other:?}, not DONE; cannot confirm durable commit"
434 ))),
435 }
436 }
437
438 fn job_reference(qr: &QueryResponse) -> Result<(String, Option<String>), FaucetError> {
440 let r = qr.job_reference.as_ref().ok_or_else(|| {
441 FaucetError::Sink("BigQuery query response missing jobReference".to_string())
442 })?;
443 let job_id = r
444 .job_id
445 .clone()
446 .ok_or_else(|| FaucetError::Sink("BigQuery jobReference missing jobId".to_string()))?;
447 Ok((job_id, r.location.clone()))
448 }
449
450 async fn run_upsert_script(
458 &self,
459 plan: &faucet_core::WritePlan,
460 token: Option<(&str, &str)>,
461 ) -> Result<usize, FaucetError> {
462 let columns = self.target_schema().await?;
463 merge::validate_keys_present(&columns, &self.config.write.key)?;
464
465 let has_upserts = !plan.upserts.is_empty();
466 let has_deletes = !plan.deletes.is_empty();
467 if !has_upserts && !has_deletes {
468 if token.is_none() {
471 return Ok(0);
472 }
473 }
474
475 let key = &self.config.write.key;
476 let (project, dataset, table) = (
477 &self.config.project_id,
478 &self.config.dataset_id,
479 &self.config.table_id,
480 );
481 let sql = match token {
482 Some(_) => merge::build_upsert_idempotent_sql(
483 &columns,
484 key,
485 has_upserts,
486 has_deletes,
487 project,
488 dataset,
489 table,
490 ),
491 None => merge::build_upsert_transaction_sql(
492 &columns,
493 key,
494 has_upserts,
495 has_deletes,
496 project,
497 dataset,
498 table,
499 ),
500 };
501
502 let mut params = Vec::new();
503 if has_upserts {
504 let payload = serde_json::to_string(&plan.upserts).map_err(|e| {
505 FaucetError::Sink(format!("bigquery upsert: serialize payload: {e}"))
506 })?;
507 params.push(Self::string_param("payload", &payload));
508 }
509 if has_deletes {
510 let deletes = deletes_to_payload(&plan.deletes);
511 params.push(Self::string_param("deletes", &deletes));
512 }
513 if let Some((scope, tok)) = token {
514 params.push(Self::string_param("scope", scope));
515 params.push(Self::string_param("token", tok));
516 }
517
518 let mut req = QueryRequest::new(sql);
519 req.use_legacy_sql = false;
520 req.parameter_mode = Some("NAMED".to_string());
521 if let Some((scope, tok)) = token {
525 req.request_id = Some(idempotent::build_request_id(scope, tok));
526 }
527 req.query_parameters = Some(params);
528
529 let resp = self
530 .client
531 .job()
532 .query(&self.config.project_id, req)
533 .await
534 .map_err(|e| FaucetError::Sink(format!("bigquery upsert write failed: {e}")))?;
535 self.await_query_complete(resp).await?;
536
537 Ok(plan.upserts.len() + plan.deletes.len())
538 }
539
540 async fn cleanup_scope_impl(
555 &self,
556 scope: &std::collections::BTreeMap<String, Value>,
557 seen: &faucet_core::SeenKeys,
558 ) -> Result<u64, FaucetError> {
559 let key = &self.config.write.key;
560 if key.is_empty() {
561 return Err(FaucetError::Sink(
562 "bigquery cleanup requires a non-empty `key`".to_string(),
563 ));
564 }
565 let columns = self.target_schema().await?;
566 let scope_cols: Vec<String> = scope.keys().cloned().collect();
567 merge::validate_cleanup_columns(&columns, &scope_cols, key)?;
568
569 let keys_payload = deletes_to_payload(seen.keys());
570 merge::check_cleanup_payload_size(keys_payload.len(), seen.len())?;
571
572 let sql = merge::build_cleanup_delete(
573 &columns,
574 &scope_cols,
575 key,
576 &self.config.project_id,
577 &self.config.dataset_id,
578 &self.config.table_id,
579 );
580 let mut req = QueryRequest::new(sql);
581 req.use_legacy_sql = false;
582 req.parameter_mode = Some("NAMED".to_string());
583 req.query_parameters = Some(vec![
584 Self::string_param("scope", &scope_to_payload(scope)),
585 Self::string_param("keys", &keys_payload),
586 ]);
587
588 let resp = self
589 .client
590 .job()
591 .query(&self.config.project_id, req)
592 .await
593 .map_err(|e| FaucetError::Sink(format!("bigquery cleanup delete failed: {e}")))?;
594 let job = self.await_query_job(resp).await?;
595 let deleted = dml_affected_rows(&job);
596
597 tracing::info!(
598 table = %format!(
599 "{}.{}.{}",
600 self.config.project_id, self.config.dataset_id, self.config.table_id
601 ),
602 deleted,
603 written_keys = seen.len(),
604 "BigQuery scoped cleanup complete"
605 );
606 Ok(deleted)
607 }
608}
609
610#[async_trait]
611impl faucet_core::Sink for BigQuerySink {
612 fn connector_name(&self) -> &'static str {
613 "bigquery"
614 }
615
616 fn config_schema(&self) -> serde_json::Value {
617 serde_json::to_value(faucet_core::schema_for!(BigQuerySinkConfig))
618 .expect("schema serialization")
619 }
620
621 fn dataset_uri(&self) -> String {
622 format!(
623 "bigquery://{}.{}.{}",
624 self.config.project_id, self.config.dataset_id, self.config.table_id
625 )
626 }
627
628 async fn check(
639 &self,
640 ctx: &faucet_core::check::CheckContext,
641 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
642 use faucet_core::check::{CheckReport, Probe};
643
644 let started = std::time::Instant::now();
645 let fqn = format!(
646 "{}.{}.{}",
647 self.config.project_id, self.config.dataset_id, self.config.table_id
648 );
649
650 let result = tokio::time::timeout(
651 ctx.timeout,
652 self.client.table().get(
653 &self.config.project_id,
654 &self.config.dataset_id,
655 &self.config.table_id,
656 Some(vec!["tableReference"]),
657 ),
658 )
659 .await;
660
661 let probe = match result {
662 Ok(Ok(_table)) => Probe::pass("auth", started.elapsed()),
663 Ok(Err(e)) => Probe::fail_hint(
664 "auth",
665 started.elapsed(),
666 format!("BigQuery tables.get on {fqn} failed: {e}"),
667 "Verify the service account has roles/bigquery.dataViewer (or \
668 read access) on the dataset and that the project, dataset, and \
669 table IDs are correct.",
670 ),
671 Err(_elapsed) => Probe::fail_hint(
672 "auth",
673 started.elapsed(),
674 format!(
675 "BigQuery tables.get on {fqn} timed out after {:?}",
676 ctx.timeout
677 ),
678 "Check network reachability to bigquery.googleapis.com and that \
679 credentials can be minted within the timeout.",
680 ),
681 };
682
683 Ok(CheckReport::single(probe))
684 }
685
686 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
695 if records.is_empty() {
696 return Ok(0);
697 }
698
699 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
700 let plan = faucet_core::plan_writes(records, &self.config.write);
701 if let Some((idx, msg)) = plan.failed.first() {
702 return Err(FaucetError::Sink(format!(
703 "bigquery {}: row {idx}: {msg}",
704 self.config.write.write_mode.as_str()
705 )));
706 }
707 return self.run_upsert_script(&plan, None).await;
708 }
709
710 let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
711 vec![records]
714 } else {
715 records.chunks(self.config.batch_size).collect()
716 };
717
718 let mut total = 0;
719 for chunk in chunks {
720 total += self.insert_batch(chunk).await?;
721 }
722
723 tracing::info!(
724 table = %format!(
725 "{}.{}.{}",
726 self.config.project_id, self.config.dataset_id, self.config.table_id
727 ),
728 rows = total,
729 "BigQuery write complete"
730 );
731 Ok(total)
732 }
733
734 async fn write_batch_partial(
752 &self,
753 records: &[Value],
754 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
755 use std::collections::HashMap;
756
757 if records.is_empty() {
758 return Ok(Vec::new());
759 }
760
761 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
762 let plan = faucet_core::plan_writes(records, &self.config.write);
763 self.run_upsert_script(&plan, None).await?;
764 let mut outcomes: Vec<faucet_core::RowOutcome> =
765 records.iter().map(|_| Ok(())).collect();
766 for (idx, msg) in &plan.failed {
767 outcomes[*idx] = Err(FaucetError::Sink(format!(
768 "bigquery {}: {msg}",
769 self.config.write.write_mode.as_str()
770 )));
771 }
772 return Ok(outcomes);
773 }
774
775 let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
776 vec![records]
777 } else {
778 records.chunks(self.config.batch_size).collect()
779 };
780
781 let mut outcomes: Vec<faucet_core::RowOutcome> = Vec::with_capacity(records.len());
782
783 for chunk in chunks {
784 let response = self.insert_chunk_raw(chunk, true).await?;
791
792 let failed: HashMap<usize, String> = response
794 .insert_errors
795 .unwrap_or_default()
796 .into_iter()
797 .filter_map(|e| {
798 let idx = e.index? as usize;
799 let msg = e
800 .errors
801 .as_ref()
802 .and_then(|v| v.first())
803 .map(|er| er.message.clone().unwrap_or_default())
804 .unwrap_or_default();
805 Some((idx, msg))
806 })
807 .collect();
808
809 for i in 0..chunk.len() {
810 match failed.get(&i) {
811 Some(msg) => outcomes.push(Err(FaucetError::Sink(format!(
812 "BigQuery row rejected: {msg}"
813 )))),
814 None => outcomes.push(Ok(())),
815 }
816 }
817 }
818
819 Ok(outcomes)
820 }
821
822 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
823 &[
824 faucet_core::WriteMode::Append,
825 faucet_core::WriteMode::Upsert,
826 faucet_core::WriteMode::Delete,
827 ]
828 }
829
830 fn dedups_by_key(&self) -> bool {
831 self.config.write.dedups_by_key()
832 }
833
834 fn supports_cleanup(&self) -> bool {
838 true
839 }
840
841 async fn cleanup_scope(
842 &self,
843 scope: &std::collections::BTreeMap<String, Value>,
844 seen: &faucet_core::SeenKeys,
845 ) -> Result<u64, FaucetError> {
846 self.cleanup_scope_impl(scope, seen).await
847 }
848
849 fn supports_idempotent_writes(&self) -> bool {
850 true
851 }
852
853 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
856 self.ensure_commit_table().await?;
857 let mut req = QueryRequest::new(idempotent::build_select_token(
858 &self.config.project_id,
859 &self.config.dataset_id,
860 ));
861 req.use_legacy_sql = false;
862 req.parameter_mode = Some("NAMED".to_string());
863 req.query_parameters = Some(vec![Self::string_param("scope", scope)]);
864
865 let resp = self
866 .client
867 .job()
868 .query(&self.config.project_id, req)
869 .await
870 .map_err(|e| FaucetError::Sink(format!("BigQuery token read failed: {e}")))?;
871
872 if !resp.job_complete.unwrap_or(false) {
877 return Err(FaucetError::Sink(
878 "BigQuery watermark read did not complete synchronously".to_string(),
879 ));
880 }
881 if resp.schema.is_none() {
886 return Err(FaucetError::Sink(
887 "BigQuery watermark read returned no schema; cannot trust the token result"
888 .to_string(),
889 ));
890 }
891
892 let mut rs = ResultSet::new_from_query_response(resp);
893 if rs.next_row() {
894 rs.get_string_by_name(COMMIT_TOKEN_TOKEN_COL)
895 .map_err(|e| FaucetError::Sink(format!("BigQuery token decode failed: {e}")))
896 } else {
897 Ok(None)
898 }
899 }
900
901 async fn write_batch_idempotent(
912 &self,
913 records: &[Value],
914 scope: &str,
915 token: &str,
916 ) -> Result<usize, FaucetError> {
917 self.ensure_commit_table().await?;
918
919 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
920 let plan = faucet_core::plan_writes(records, &self.config.write);
921 if let Some((idx, msg)) = plan.failed.first() {
922 return Err(FaucetError::Sink(format!(
923 "bigquery {}: row {idx}: {msg}",
924 self.config.write.write_mode.as_str()
925 )));
926 }
927 return self.run_upsert_script(&plan, Some((scope, token))).await;
928 }
929
930 let columns = self.target_schema().await?;
931
932 let payload = serde_json::to_string(records).map_err(|e| {
933 FaucetError::Sink(format!(
934 "BigQuery exactly-once: serialize page payload: {e}"
935 ))
936 })?;
937
938 let sql = idempotent::build_transaction_sql(
939 &columns,
940 &self.config.project_id,
941 &self.config.dataset_id,
942 &self.config.table_id,
943 );
944 let mut req = QueryRequest::new(sql);
945 req.use_legacy_sql = false;
946 req.parameter_mode = Some("NAMED".to_string());
947 req.request_id = Some(idempotent::build_request_id(scope, token));
948 req.query_parameters = Some(vec![
949 Self::string_param("payload", &payload),
950 Self::string_param("scope", scope),
951 Self::string_param("token", token),
952 ]);
953
954 let resp = self
955 .client
956 .job()
957 .query(&self.config.project_id, req)
958 .await
959 .map_err(|e| FaucetError::Sink(format!("BigQuery idempotent write failed: {e}")))?;
960 self.await_query_complete(resp).await?;
961
962 tracing::info!(
963 table = %format!(
964 "{}.{}.{}",
965 self.config.project_id, self.config.dataset_id, self.config.table_id
966 ),
967 rows = records.len(),
968 token = %token,
969 "BigQuery exactly-once page committed"
970 );
971 Ok(records.len())
972 }
973
974 fn supports_schema_evolution(&self) -> bool {
979 true
980 }
981
982 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
990 match self.fetch_schema_fields().await {
991 Ok(fields) if fields.is_empty() => Ok(None),
992 Ok(fields) => Ok(Some(idempotent::fieldspecs_to_json_schema(&fields))),
993 Err(e) if is_table_not_found(&e) => Ok(None),
994 Err(e) => Err(FaucetError::Sink(format!(
995 "BigQuery current_schema (tables.get) failed: {e}"
996 ))),
997 }
998 }
999
1000 async fn evolve_schema(
1012 &self,
1013 evolution: &faucet_core::SchemaEvolution,
1014 ) -> Result<(), FaucetError> {
1015 let table_ref = self.table_ref();
1016
1017 for c in &evolution.additions {
1018 let bq = idempotent::base_to_bq(
1019 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
1020 );
1021 self.run_ddl(idempotent::build_add_column_ddl(&table_ref, &c.name, bq))
1022 .await?;
1023 }
1024 for c in &evolution.widenings {
1025 let bq = idempotent::base_to_bq(
1026 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
1027 );
1028 self.run_ddl(idempotent::build_alter_type_ddl(&table_ref, &c.name, bq))
1029 .await?;
1030 }
1031 for col in &evolution.relax_nullability {
1032 self.run_ddl(idempotent::build_drop_not_null_ddl(&table_ref, col))
1033 .await?;
1034 }
1035
1036 *self.schema_cache.write().await = None;
1039 Ok(())
1040 }
1041
1042 #[cfg(feature = "arrow")]
1047 fn supports_columnar(&self) -> bool {
1048 self.config.bulk_load.is_some()
1049 && self.config.write.write_mode == faucet_core::WriteMode::Append
1050 }
1051
1052 #[cfg(feature = "arrow")]
1058 async fn write_batch_columnar(
1059 &self,
1060 batch: &arrow::array::RecordBatch,
1061 ) -> Result<usize, FaucetError> {
1062 crate::load::write_columnar(&self.client, &self.config, &self.gcs_store, batch).await
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::{Job, deletes_to_payload, dml_affected_rows, scope_to_payload};
1069 use faucet_core::KeyTuple;
1070 use serde_json::json;
1071
1072 #[test]
1077 fn deletes_to_payload_preserves_number_type() {
1078 let p = deletes_to_payload(&[KeyTuple(vec![("id".into(), json!(2))])]);
1082 let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
1083 assert_eq!(v, json!([{"id": 2}]));
1084 assert!(v[0]["id"].is_number(), "id must serialize as a number: {p}");
1085 }
1086
1087 #[test]
1088 fn deletes_to_payload_composite_key_roundtrips() {
1089 let p = deletes_to_payload(&[KeyTuple(vec![
1090 ("tenant".into(), json!("acme")),
1091 ("id".into(), json!(7)),
1092 ])]);
1093 let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
1094 assert_eq!(v, json!([{"tenant": "acme", "id": 7}]));
1095 }
1096
1097 #[test]
1100 fn scope_to_payload_is_one_object_with_typed_values() {
1101 let scope = std::collections::BTreeMap::from([
1102 ("contact_id".to_string(), json!(42)),
1103 ("region".to_string(), json!("eu")),
1104 ]);
1105 let v: serde_json::Value = serde_json::from_str(&scope_to_payload(&scope)).expect("JSON");
1106 assert_eq!(v, json!({"contact_id": 42, "region": "eu"}));
1107 assert!(v["contact_id"].is_number());
1110 }
1111
1112 #[test]
1113 fn seen_keys_serialize_through_the_same_payload_shape() {
1114 assert_eq!(deletes_to_payload(&[]), "[]");
1117 }
1118
1119 #[test]
1120 fn dml_affected_rows_reads_the_job_statistics() {
1121 use gcp_bigquery_client::model::job_statistics::JobStatistics;
1122 use gcp_bigquery_client::model::job_statistics2::JobStatistics2;
1123
1124 let job = |n: Option<&str>| Job {
1125 statistics: Some(JobStatistics {
1126 query: Some(JobStatistics2 {
1127 num_dml_affected_rows: n.map(str::to_string),
1128 ..Default::default()
1129 }),
1130 ..Default::default()
1131 }),
1132 ..Default::default()
1133 };
1134 assert_eq!(dml_affected_rows(&job(Some("7"))), 7);
1135 assert_eq!(dml_affected_rows(&job(Some("0"))), 0);
1136 assert_eq!(dml_affected_rows(&job(None)), 0);
1139 assert_eq!(dml_affected_rows(&job(Some("not-a-number"))), 0);
1140 assert_eq!(dml_affected_rows(&Job::default()), 0);
1141 }
1142
1143 #[test]
1144 fn deletes_to_payload_multiple_rows() {
1145 let p = deletes_to_payload(&[
1146 KeyTuple(vec![("id".into(), json!(1))]),
1147 KeyTuple(vec![("id".into(), json!(2))]),
1148 ]);
1149 let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
1150 assert_eq!(v, json!([{"id": 1}, {"id": 2}]));
1151 }
1152}