1use super::TargetLoader;
75use crate::types::target::TargetColumnSpec;
76use anyhow::{Context, Result, bail};
77use std::collections::HashMap;
78use std::process::{Command, Output};
79const MAX_CLUSTER_COLUMNS: usize = 4;
83
84const DEFAULT_MAX_PARTITIONS_PER_JOB: usize = 4000;
86
87#[derive(Debug, Clone)]
89pub struct BigQueryLoader {
90 pub project: String,
91 pub dataset: String,
92 pub partition_by: Option<String>,
95 pub cluster_by: Vec<String>,
97 pub run_id: Option<String>,
102 pub max_partitions_per_job: usize,
108}
109
110impl BigQueryLoader {
111 pub fn new(project: impl Into<String>, dataset: impl Into<String>) -> Self {
112 Self {
113 project: project.into(),
114 dataset: dataset.into(),
115 partition_by: None,
116 cluster_by: Vec::new(),
117 run_id: None,
118 max_partitions_per_job: DEFAULT_MAX_PARTITIONS_PER_JOB,
119 }
120 }
121
122 pub fn partition_by(mut self, expr: impl Into<String>) -> Self {
123 self.partition_by = Some(expr.into());
124 self
125 }
126
127 pub fn run_id(mut self, id: impl Into<String>) -> Self {
129 self.run_id = Some(id.into());
130 self
131 }
132
133 pub fn cluster_by(mut self, columns: Vec<String>) -> Self {
134 self.cluster_by = columns;
135 self
136 }
137
138 fn run_bq(&self, args: &[String]) -> Result<Output> {
143 let out = Command::new("bq")
144 .arg(format!("--project_id={}", self.project))
145 .args(args)
146 .output()
147 .context("failed to run `bq` — is the Google Cloud SDK installed and on PATH?")?;
148 if !out.status.success() {
149 let detail = [clean_bq_output(&out.stdout), clean_bq_output(&out.stderr)]
150 .into_iter()
151 .filter(|s| !s.is_empty())
152 .collect::<Vec<_>>()
153 .join(" | ");
154 bail!(
155 "bq {} failed: {detail}",
156 args.first()
157 .map(String::as_str)
158 .unwrap_or("<no-subcommand>"),
159 );
160 }
161 Ok(out)
162 }
163
164 fn label_flags(&self, op: &str, table: &str) -> Vec<String> {
166 build_label_flags(op, table, self.run_id.as_deref())
167 }
168
169 fn run_sql(&self, sql: &str, op: &str, table: &str) -> Result<Output> {
172 self.run_bq(&query_args(sql, &self.label_flags(op, table)))
173 .map_err(augment_partition_limit)
174 }
175
176 fn count_rows(&self, fqtn: &str, table: &str) -> Result<u64> {
177 let out = self.run_bq(&count_args(fqtn, &self.label_flags("count", table)))?;
179 parse_count_csv(&String::from_utf8_lossy(&out.stdout))
180 }
181
182 fn plan_load_batches(&self, uris: &[String]) -> Vec<Vec<String>> {
188 match self.partition_by.as_deref() {
189 Some(col) if is_bare_column(col) => {
190 plan_hive_batches(uris, col, self.max_partitions_per_job)
191 .unwrap_or_else(|_| vec![uris.to_vec()])
192 }
193 _ => vec![uris.to_vec()],
194 }
195 }
196}
197
198impl TargetLoader for BigQueryLoader {
199 fn fqtn(&self, table: &str) -> String {
200 format!("{}.{}.{}", self.project, self.dataset, table)
201 }
202
203 fn materialize(&self, table: &str, specs: &[TargetColumnSpec], uris: &[String]) -> Result<u64> {
204 if self.cluster_by.len() > MAX_CLUSTER_COLUMNS {
205 bail!(
206 "BigQuery allows at most {MAX_CLUSTER_COLUMNS} clustering columns, got {}",
207 self.cluster_by.len()
208 );
209 }
210 for c in &self.cluster_by {
217 if !super::is_safe_load_ident(c) {
218 bail!(
219 "BigQuery load: clustering column `{}` is not a plain SQL identifier \
220 ([A-Za-z_][A-Za-z0-9_]*) — it splices into CLUSTER BY. Rename it.",
221 c.escape_default()
222 );
223 }
224 }
225 let target = self.fqtn(table);
226 let schema = build_schema(specs);
227
228 for (i, batch) in self.plan_load_batches(uris).iter().enumerate() {
235 let sql = build_load_data_sql(
236 &target,
237 i == 0, &schema,
239 &self.partition_by,
240 &self.cluster_by,
241 batch,
242 );
243 self.run_sql(&sql, "load", table)?;
244 }
245 self.count_rows(&target, table)
249 }
250
251 fn append_changelog(
252 &self,
253 table: &str,
254 specs: &[TargetColumnSpec],
255 uris: &[String],
256 pk: &[String],
257 ) -> Result<u64> {
258 use crate::load::cdc::Warehouse;
259 let mut full = crate::load::cdc::meta_column_specs(Warehouse::BigQuery);
262 full.extend(
263 specs
264 .iter()
265 .filter(|s| !is_meta_column(&s.column_name))
266 .cloned(),
267 );
268 let schema = build_schema(&full);
269
270 let changes = format!("{table}__changes");
271 let changes_fqtn = self.fqtn(&changes);
272
273 let create = build_create_changes_sql(&changes_fqtn, &schema, pk);
276 self.run_sql(&create, "create", &changes)?;
277
278 let before = self.count_rows(&changes_fqtn, &changes)?;
281 let load = build_load_data_sql(&changes_fqtn, false, &schema, &None, &[], uris);
282 self.run_sql(&load, "load", &changes)?;
283 let after = self.count_rows(&changes_fqtn, &changes)?;
284 Ok(after.saturating_sub(before))
285 }
286
287 fn warehouse(&self) -> crate::load::cdc::Warehouse {
288 crate::load::cdc::Warehouse::BigQuery
289 }
290
291 fn create_view(&self, table: &str, view_sql: &str) -> Result<()> {
292 self.run_sql(view_sql, "view", table)?;
293 Ok(())
294 }
295}
296
297fn is_meta_column(name: &str) -> bool {
301 matches!(name, "__op" | "__pos" | "__seq")
302}
303
304fn build_create_changes_sql(fqtn: &str, schema: &str, pk: &[String]) -> String {
308 let cluster_cols = pk
309 .iter()
310 .take(MAX_CLUSTER_COLUMNS)
311 .cloned()
312 .collect::<Vec<_>>()
313 .join(", ");
314 format!("CREATE TABLE IF NOT EXISTS `{fqtn}` (\n{schema}\n)\nCLUSTER BY {cluster_cols};")
315}
316
317fn is_bare_column(c: &str) -> bool {
320 !c.is_empty() && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
321}
322
323fn hive_partition_value(uri: &str, column: &str) -> Option<String> {
326 let needle = format!("{column}=");
327 uri.split('/')
328 .find_map(|seg| seg.strip_prefix(&needle).map(str::to_string))
329}
330
331fn plan_hive_batches(uris: &[String], column: &str, max: usize) -> Result<Vec<Vec<String>>> {
335 let pairs: Vec<(&String, String)> = uris
336 .iter()
337 .map(|u| {
338 hive_partition_value(u, column)
339 .map(|v| (u, v))
340 .ok_or_else(|| anyhow::anyhow!("uri has no `{column}=` Hive segment: {u}"))
341 })
342 .collect::<Result<_>>()?;
343
344 let mut values: Vec<&str> = pairs.iter().map(|(_, v)| v.as_str()).collect();
345 values.sort_unstable();
346 values.dedup();
347 if values.len() <= max {
348 return Ok(vec![uris.to_vec()]);
349 }
350
351 let batch_of: HashMap<&str, usize> = values
353 .iter()
354 .enumerate()
355 .map(|(i, v)| (*v, i / max))
356 .collect();
357 let mut batches: Vec<Vec<String>> = vec![Vec::new(); values.len().div_ceil(max)];
358 for (u, v) in &pairs {
359 batches[batch_of[v.as_str()]].push((*u).clone());
360 }
361 Ok(batches)
362}
363
364fn table_shape_clauses(partition_by: &Option<String>, cluster_by: &[String]) -> String {
367 let mut s = String::new();
368 if let Some(expr) = partition_by {
369 s.push_str(&format!("\nPARTITION BY {expr}"));
370 }
371 if !cluster_by.is_empty() {
372 s.push_str(&format!("\nCLUSTER BY {}", cluster_by.join(", ")));
373 }
374 s
375}
376
377fn from_files(uris: &[String]) -> String {
379 let list = uris
380 .iter()
381 .map(|u| format!(" '{u}'"))
382 .collect::<Vec<_>>()
383 .join(",\n");
384 format!("FROM FILES (\n format = 'PARQUET',\n uris = [\n{list}\n ]\n)")
385}
386
387fn build_schema(specs: &[TargetColumnSpec]) -> String {
392 specs
393 .iter()
394 .map(|s| format!(" {} {}", s.column_name, s.target_type))
395 .collect::<Vec<_>>()
396 .join(",\n")
397}
398
399fn build_load_data_sql(
402 fqtn: &str,
403 overwrite: bool,
404 schema: &str,
405 partition_by: &Option<String>,
406 cluster_by: &[String],
407 uris: &[String],
408) -> String {
409 let kw = if overwrite { "OVERWRITE" } else { "INTO" };
410 let clauses = table_shape_clauses(partition_by, cluster_by);
411 format!(
412 "LOAD DATA {kw} `{fqtn}` (\n{schema}\n){clauses}\n{};",
413 from_files(uris)
414 )
415}
416
417fn query_args(sql: &str, labels: &[String]) -> Vec<String> {
418 let mut a = vec![
420 "query".into(),
421 "--use_legacy_sql=false".into(),
422 "--format=none".into(),
423 ];
424 a.extend_from_slice(labels);
425 a.push(sql.into());
426 a
427}
428
429fn count_args(fqtn: &str, labels: &[String]) -> Vec<String> {
430 let mut a = vec![
431 "query".into(),
432 "--use_legacy_sql=false".into(),
433 "--format=csv".into(),
434 ];
435 a.extend_from_slice(labels);
436 a.push(format!("SELECT COUNT(*) AS n FROM `{fqtn}`"));
437 a
438}
439
440fn build_label_flags(op: &str, table: &str, run_id: Option<&str>) -> Vec<String> {
446 let mut labels: Vec<(String, String)> = vec![
447 ("managed_by".into(), "rivet".into()),
448 ("rivet_op".into(), sanitize_label(op)),
449 ("rivet_table".into(), sanitize_label(table)),
450 ];
451 if let Some(id) = run_id {
452 labels.push(("rivet_run".into(), sanitize_label(id)));
453 }
454 labels
455 .into_iter()
456 .flat_map(|(k, v)| ["--label".to_string(), format!("{k}:{v}")])
457 .collect()
458}
459
460fn sanitize_label(s: &str) -> String {
463 let mut out: String = s
464 .chars()
465 .map(|c| {
466 let c = c.to_ascii_lowercase();
467 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
468 c
469 } else {
470 '_'
471 }
472 })
473 .collect();
474 out.truncate(63);
475 if out.is_empty() {
476 "unnamed".clone_into(&mut out);
477 }
478 out
479}
480
481fn parse_count_csv(stdout: &str) -> Result<u64> {
484 stdout
485 .lines()
486 .rev()
487 .find_map(|l| l.trim().parse::<u64>().ok())
488 .context("could not parse a row count from bq output")
489}
490
491fn clean_bq_output(bytes: &[u8]) -> String {
495 String::from_utf8_lossy(bytes)
496 .replace('\r', "\n")
497 .lines()
498 .map(str::trim)
499 .filter(|l| !l.is_empty() && !l.starts_with("Waiting on") && !l.contains("Current status:"))
500 .collect::<Vec<_>>()
501 .join(" ")
502}
503
504fn augment_partition_limit(e: anyhow::Error) -> anyhow::Error {
506 let s = e.to_string().to_lowercase();
507 if s.contains("partition")
508 && (s.contains("4000") || s.contains("quota") || s.contains("exceed"))
509 {
510 return e.context(
511 "BigQuery caps a single load/query job at 4,000 modified partitions — split the \
512 Parquet URIs into batches whose partition span is <= 4,000 (e.g. load by date range)",
513 );
514 }
515 e
516}
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::types::target::TargetStatus;
521
522 fn spec(name: &str, cast: Option<&str>, status: TargetStatus) -> TargetColumnSpec {
523 TargetColumnSpec {
524 column_name: name.into(),
525 target_type: "X".into(),
526 autoload_type: "Y".into(),
527 status,
528 note: None,
529 cast_sql: cast.map(String::from),
530 }
531 }
532
533 fn uris() -> Vec<String> {
534 vec!["gs://b/a.parquet".into(), "gs://b/b.parquet".into()]
535 }
536
537 fn typed(name: &str, target_type: &str) -> TargetColumnSpec {
538 TargetColumnSpec {
539 column_name: name.into(),
540 target_type: target_type.into(),
541 autoload_type: "BYTES".into(),
542 status: TargetStatus::Ok,
543 note: None,
544 cast_sql: None,
545 }
546 }
547
548 #[test]
549 fn schema_declares_each_columns_native_target_type() {
550 let s = build_schema(&[
551 typed("id", "INT64"),
552 typed("json_col", "JSON"),
553 typed("dt_col", "DATETIME"),
554 ]);
555 assert!(s.contains("id INT64"));
556 assert!(s.contains("json_col JSON"));
557 assert!(s.contains("dt_col DATETIME"));
558 }
559
560 #[test]
561 fn load_data_declares_native_schema_and_is_a_free_batch_load() {
562 let schema = build_schema(&[typed("id", "INT64"), typed("json_col", "JSON")]);
563 let sql = build_load_data_sql("p.d.orders", true, &schema, &None, &[], &uris());
564 assert!(sql.starts_with("LOAD DATA OVERWRITE `p.d.orders` ("));
565 assert!(sql.contains("json_col JSON"));
567 assert!(sql.contains("format = 'PARQUET'"));
568 assert!(sql.contains("'gs://b/a.parquet'"));
569 assert!(!sql.contains("PARTITION BY"));
570 }
571
572 #[test]
573 fn load_data_append_uses_into() {
574 let schema = build_schema(&[typed("id", "INT64")]);
575 let sql = build_load_data_sql("p.d.orders", false, &schema, &None, &[], &uris());
576 assert!(sql.starts_with("LOAD DATA INTO `p.d.orders`"));
577 }
578
579 #[test]
580 fn load_data_emits_partition_and_cluster_when_configured() {
581 let schema = build_schema(&[typed("id", "INT64")]);
582 let sql = build_load_data_sql(
583 "p.d.orders",
584 true,
585 &schema,
586 &Some("DATE(created_at)".into()),
587 &["customer_id".into(), "region".into()],
588 &uris(),
589 );
590 assert!(sql.contains("PARTITION BY DATE(created_at)"));
591 assert!(sql.contains("CLUSTER BY customer_id, region"));
592 }
593
594 #[test]
595 fn create_changes_clusters_on_pk_capped_at_four_columns() {
596 let schema = build_schema(&[typed("__op", "STRING"), typed("id", "INT64")]);
597 let sql = build_create_changes_sql("p.d.orders__changes", &schema, &["id".into()]);
598 assert!(sql.starts_with("CREATE TABLE IF NOT EXISTS `p.d.orders__changes` ("));
599 assert!(sql.contains("CLUSTER BY id"));
600 let wide: Vec<String> = ["a", "b", "c", "d", "e"]
602 .iter()
603 .map(|s| s.to_string())
604 .collect();
605 let sql2 = build_create_changes_sql("t", &schema, &wide);
606 assert!(sql2.contains("CLUSTER BY a, b, c, d"));
607 assert!(!sql2.contains(", e"));
608 }
609
610 #[test]
611 fn is_meta_column_matches_only_the_three_cdc_columns() {
612 assert!(is_meta_column("__op") && is_meta_column("__pos") && is_meta_column("__seq"));
613 assert!(!is_meta_column("id") && !is_meta_column("__op_code"));
614 }
615
616 #[test]
617 fn count_csv_skips_header() {
618 assert_eq!(parse_count_csv("n\n42\n").unwrap(), 42);
619 assert_eq!(parse_count_csv("n\n0\n").unwrap(), 0);
620 assert!(parse_count_csv("n\n").is_err());
621 }
622
623 #[test]
624 fn clean_bq_output_drops_standalone_status_and_waiting_lines() {
625 let raw = b"Waiting on bqjob_x\nCurrent status: RUNNING\nError: boom\n";
629 assert_eq!(clean_bq_output(raw), "Error: boom");
630 }
631
632 #[test]
633 fn augment_partition_limit_fires_only_on_partition_plus_signal() {
634 let aug = |m: &str| augment_partition_limit(anyhow::anyhow!("{m}")).to_string();
635 assert!(aug("too many partitions, allowed 4000").contains("split the"));
637 assert!(aug("partition quota reached").contains("split the"));
638 assert!(aug("partition count will exceed the limit").contains("split the"));
639 assert!(!aug("partition pruning is disabled").contains("split the"));
641 assert!(!aug("row quota 4000 reached").contains("split the"));
642 }
643
644 #[test]
645 fn partition_limit_error_is_augmented() {
646 let raw = anyhow::anyhow!("Too many partitions: cannot modify more than 4000 partitions");
647 let msg = augment_partition_limit(raw).to_string();
648 assert!(
649 msg.contains("split the"),
650 "expected the actionable hint: {msg}"
651 );
652 }
653
654 #[test]
655 fn job_labels_tag_managed_by_op_and_table() {
656 let flags = build_label_flags("recover", "Orders", Some("Run-7"));
657 let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
658 assert!(kv.iter().any(|s| *s == "managed_by:rivet"));
659 assert!(kv.iter().any(|s| *s == "rivet_op:recover"));
660 assert!(kv.iter().any(|s| *s == "rivet_table:orders")); assert!(kv.iter().any(|s| *s == "rivet_run:run-7")); assert!(flags.iter().step_by(2).all(|s| s == "--label"));
664 }
665
666 #[test]
667 fn no_run_id_omits_the_rivet_run_label() {
668 let flags = build_label_flags("load", "orders", None);
669 let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
670 assert!(kv.iter().any(|s| *s == "rivet_table:orders"));
671 assert!(!kv.iter().any(|s| s.starts_with("rivet_run:")));
672 }
673
674 #[test]
675 fn fqtn_qualifies_project_dataset_table() {
676 let l = BigQueryLoader::new("proj", "ds");
677 assert_eq!(l.fqtn("orders"), "proj.ds.orders");
678 }
679
680 #[test]
681 fn sanitize_label_coerces_to_bq_charset() {
682 assert_eq!(sanitize_label("My.Table!"), "my_table_");
683 assert_eq!(sanitize_label(""), "unnamed");
684 assert_eq!(sanitize_label("ok-name_1"), "ok-name_1");
685 assert_eq!(sanitize_label(&"x".repeat(80)).len(), 63);
686 }
687
688 #[test]
689 fn clean_bq_output_keeps_real_error_drops_spinner() {
690 let stdout = b"Error in query string: Too many partitions produced by query, \
694 allowed 4000, query produces at least 4200 partitions";
695 let cleaned = clean_bq_output(stdout);
696 assert!(cleaned.contains("Too many partitions") && cleaned.contains("4000"));
697 let augmented = augment_partition_limit(anyhow::anyhow!("{cleaned}")).to_string();
699 assert!(augmented.contains("split the"), "{augmented}");
700 let stderr = "Waiting on bqjob_x ... (0s) Current status: RUNNING\r\
702 Waiting on bqjob_x ... (0s) Current status: DONE";
703 assert!(clean_bq_output(stderr.as_bytes()).is_empty());
704 }
705
706 #[test]
707 fn materialize_refuses_too_many_cluster_columns() {
708 let l = BigQueryLoader::new("p", "d").cluster_by(vec![
712 "a".into(),
713 "b".into(),
714 "c".into(),
715 "d".into(),
716 "e".into(),
717 ]);
718 let err = l
719 .materialize("t", &[spec("id", None, TargetStatus::Ok)], &uris())
720 .unwrap_err()
721 .to_string();
722 assert!(err.contains("clustering"), "{err}");
723 }
724
725 #[test]
726 fn materialize_refuses_a_non_identifier_cluster_column() {
727 let l = BigQueryLoader::new("p", "d").cluster_by(vec!["id) FROM secrets; --".into()]);
732 let err = l
733 .materialize("t", &[spec("id", None, TargetStatus::Ok)], &uris())
734 .unwrap_err()
735 .to_string();
736 assert!(
737 err.contains("not a plain SQL identifier") && err.contains("CLUSTER BY"),
738 "{err}"
739 );
740 }
741
742 #[test]
743 fn hive_partition_value_parses_col_segment() {
744 assert_eq!(
745 hive_partition_value("gs://b/t/d=2023-01-01/part-0.parquet", "d").as_deref(),
746 Some("2023-01-01")
747 );
748 assert_eq!(
749 hive_partition_value("gs://b/t/created_at=2023-01-01/p.parquet", "created_at")
750 .as_deref(),
751 Some("2023-01-01")
752 );
753 assert!(hive_partition_value("gs://b/t/part-0.parquet", "d").is_none());
754 }
755
756 #[test]
757 fn is_bare_column_rejects_expressions() {
758 assert!(is_bare_column("d"));
759 assert!(is_bare_column("created_at"));
760 assert!(!is_bare_column("DATE(d)"));
761 assert!(!is_bare_column("DATE_TRUNC(d, MONTH)"));
762 assert!(!is_bare_column(""));
763 }
764
765 #[test]
766 fn hive_batches_split_by_distinct_partition_cap() {
767 let uris: Vec<String> = [
769 "gs://b/t/d=2023-01-01/a.parquet",
770 "gs://b/t/d=2023-01-01/b.parquet",
771 "gs://b/t/d=2023-01-02/a.parquet",
772 "gs://b/t/d=2023-01-03/a.parquet",
773 "gs://b/t/d=2023-01-04/a.parquet",
774 "gs://b/t/d=2023-01-05/a.parquet",
775 ]
776 .iter()
777 .map(|s| s.to_string())
778 .collect();
779 let batches = plan_hive_batches(&uris, "d", 2).unwrap();
780 assert_eq!(batches.len(), 3);
781 for b in &batches {
782 let mut days: Vec<_> = b
783 .iter()
784 .map(|u| hive_partition_value(u, "d").unwrap())
785 .collect();
786 days.sort();
787 days.dedup();
788 assert!(
789 days.len() <= 2,
790 "batch touches {} distinct days",
791 days.len()
792 );
793 }
794 assert_eq!(batches.iter().map(Vec::len).sum::<usize>(), uris.len());
796 }
797
798 #[test]
799 fn hive_batches_single_when_under_cap() {
800 let uris = vec![
801 "gs://b/t/d=2023-01-01/a.parquet".to_string(),
802 "gs://b/t/d=2023-01-02/a.parquet".to_string(),
803 ];
804 assert_eq!(plan_hive_batches(&uris, "d", 4000).unwrap().len(), 1);
805 }
806
807 #[test]
808 fn hive_batches_error_when_uri_lacks_segment() {
809 let uris = vec!["gs://b/t/no-hive/a.parquet".to_string()];
810 assert!(plan_hive_batches(&uris, "d", 2).is_err());
811 }
812
813 #[test]
820 #[ignore = "live: needs bq CLI + ADC + a GCS Parquet fixture"]
821 fn bigquery_live_load_round_trips() {
822 let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
826 eprintln!("skipping bigquery_live_load_round_trips: BIGQUERY_TEST_PROJECT unset");
827 return;
828 };
829 let dataset =
830 std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
831 let uri = std::env::var("RIVET_BQ_TEST_PARQUET_URI").expect(
832 "set RIVET_BQ_TEST_PARQUET_URI to a GCS Parquet object matching the specs below",
833 );
834
835 let specs = vec![spec("id", None, TargetStatus::Ok)];
837
838 let loader = BigQueryLoader::new(project, dataset);
839 let report =
842 crate::load::run_load(&loader, "rivet_bq_live_test", &specs, &[uri], None, None)
843 .expect("live load should succeed");
844 assert!(
845 report.rows_loaded > 0,
846 "expected rows, got {}",
847 report.rows_loaded
848 );
849 }
850
851 #[test]
865 #[ignore = "live: needs bq CLI + ADC + a CDC change-log Parquet fixture"]
866 fn bigquery_live_cdc_view_dedups_at_least_once() {
867 let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
869 eprintln!(
870 "skipping bigquery_live_cdc_view_dedups_at_least_once: BIGQUERY_TEST_PROJECT unset"
871 );
872 return;
873 };
874 let dataset =
875 std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
876 let uri = std::env::var("RIVET_BQ_CDC_PARQUET_URI")
877 .expect("set RIVET_BQ_CDC_PARQUET_URI to a CDC change-log Parquet object");
878 let pk = std::env::var("RIVET_BQ_CDC_PK").unwrap_or_else(|_| "id".to_string());
879 let data_cols =
882 std::env::var("RIVET_BQ_CDC_DATA_COLS").unwrap_or_else(|_| "id:INT64".to_string());
883 let specs: Vec<TargetColumnSpec> = data_cols
884 .split(',')
885 .map(|c| {
886 let (name, ty) = c.split_once(':').expect("data col must be name:TYPE");
887 typed(name, ty)
888 })
889 .collect();
890 let expected_state: u64 = std::env::var("RIVET_BQ_CDC_EXPECTED_STATE")
891 .ok()
892 .and_then(|s| s.parse().ok())
893 .unwrap_or(0);
894
895 let table = "rivet_bq_live_cdc_test";
896 let pk_cols: Vec<String> = pk.split(',').map(str::to_string).collect();
897 let loader = BigQueryLoader::new(&project, &dataset);
898
899 crate::load::run_load_cdc(
902 &loader,
903 table,
904 &specs,
905 std::slice::from_ref(&uri),
906 &pk_cols,
907 crate::load::cdc::SourceEngine::MySql,
908 None,
909 None,
910 )
911 .expect("first CDC append + view build should succeed");
912 let second = crate::load::run_load_cdc(
913 &loader,
914 table,
915 &specs,
916 &[uri],
917 &pk_cols,
918 crate::load::cdc::SourceEngine::MySql,
919 None,
920 None,
921 )
922 .expect("second CDC append (at-least-once) should succeed");
923 assert!(second.rows_appended > 0, "second append added rows");
924
925 let state_rows = loader
928 .count_rows(&second.view, table)
929 .expect("counting the dedup view should succeed");
930 if expected_state > 0 {
931 assert_eq!(
932 state_rows, expected_state,
933 "the view must collapse duplicates to {expected_state} distinct-PK rows \
934 (incl tombstones), got {state_rows}"
935 );
936 }
937 }
938}