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 crate::load::cdc::is_meta_column(name)
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 {
384 let list = uris
385 .iter()
386 .map(|u| format!(" '{u}'"))
387 .collect::<Vec<_>>()
388 .join(",\n");
389 format!(
390 "FROM FILES (\n format = 'PARQUET',\n enable_list_inference = true,\n uris = [\n{list}\n ]\n)"
391 )
392}
393
394fn build_schema(specs: &[TargetColumnSpec]) -> String {
399 specs
400 .iter()
401 .map(|s| format!(" {} {}", s.column_name, s.target_type))
402 .collect::<Vec<_>>()
403 .join(",\n")
404}
405
406fn build_load_data_sql(
409 fqtn: &str,
410 overwrite: bool,
411 schema: &str,
412 partition_by: &Option<String>,
413 cluster_by: &[String],
414 uris: &[String],
415) -> String {
416 let kw = if overwrite { "OVERWRITE" } else { "INTO" };
417 let clauses = table_shape_clauses(partition_by, cluster_by);
418 format!(
419 "LOAD DATA {kw} `{fqtn}` (\n{schema}\n){clauses}\n{};",
420 from_files(uris)
421 )
422}
423
424fn query_args(sql: &str, labels: &[String]) -> Vec<String> {
425 let mut a = vec![
427 "query".into(),
428 "--use_legacy_sql=false".into(),
429 "--format=none".into(),
430 ];
431 a.extend_from_slice(labels);
432 a.push(sql.into());
433 a
434}
435
436fn count_args(fqtn: &str, labels: &[String]) -> Vec<String> {
437 let mut a = vec![
438 "query".into(),
439 "--use_legacy_sql=false".into(),
440 "--format=csv".into(),
441 ];
442 a.extend_from_slice(labels);
443 a.push(format!("SELECT COUNT(*) AS n FROM `{fqtn}`"));
444 a
445}
446
447fn build_label_flags(op: &str, table: &str, run_id: Option<&str>) -> Vec<String> {
453 let mut labels: Vec<(String, String)> = vec![
454 ("managed_by".into(), "rivet".into()),
455 ("rivet_op".into(), sanitize_label(op)),
456 ("rivet_table".into(), sanitize_label(table)),
457 ];
458 if let Some(id) = run_id {
459 labels.push(("rivet_run".into(), sanitize_label(id)));
460 }
461 labels
462 .into_iter()
463 .flat_map(|(k, v)| ["--label".to_string(), format!("{k}:{v}")])
464 .collect()
465}
466
467fn sanitize_label(s: &str) -> String {
470 let mut out: String = s
471 .chars()
472 .map(|c| {
473 let c = c.to_ascii_lowercase();
474 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
475 c
476 } else {
477 '_'
478 }
479 })
480 .collect();
481 out.truncate(63);
482 if out.is_empty() {
483 "unnamed".clone_into(&mut out);
484 }
485 out
486}
487
488fn parse_count_csv(stdout: &str) -> Result<u64> {
491 stdout
492 .lines()
493 .rev()
494 .find_map(|l| l.trim().parse::<u64>().ok())
495 .context("could not parse a row count from bq output")
496}
497
498fn clean_bq_output(bytes: &[u8]) -> String {
502 String::from_utf8_lossy(bytes)
503 .replace('\r', "\n")
504 .lines()
505 .map(str::trim)
506 .filter(|l| !l.is_empty() && !l.starts_with("Waiting on") && !l.contains("Current status:"))
507 .collect::<Vec<_>>()
508 .join(" ")
509}
510
511fn augment_partition_limit(e: anyhow::Error) -> anyhow::Error {
513 let s = e.to_string().to_lowercase();
514 if s.contains("partition")
515 && (s.contains("4000") || s.contains("quota") || s.contains("exceed"))
516 {
517 return e.context(
518 "BigQuery caps a single load/query job at 4,000 modified partitions — split the \
519 Parquet URIs into batches whose partition span is <= 4,000 (e.g. load by date range)",
520 );
521 }
522 e
523}
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::types::target::TargetStatus;
528
529 fn spec(name: &str, cast: Option<&str>, status: TargetStatus) -> TargetColumnSpec {
530 TargetColumnSpec {
531 column_name: name.into(),
532 target_type: "X".into(),
533 autoload_type: "Y".into(),
534 status,
535 note: None,
536 cast_sql: cast.map(String::from),
537 }
538 }
539
540 fn uris() -> Vec<String> {
541 vec!["gs://b/a.parquet".into(), "gs://b/b.parquet".into()]
542 }
543
544 fn typed(name: &str, target_type: &str) -> TargetColumnSpec {
545 TargetColumnSpec {
546 column_name: name.into(),
547 target_type: target_type.into(),
548 autoload_type: "BYTES".into(),
549 status: TargetStatus::Ok,
550 note: None,
551 cast_sql: None,
552 }
553 }
554
555 #[test]
556 fn schema_declares_each_columns_native_target_type() {
557 let s = build_schema(&[
558 typed("id", "INT64"),
559 typed("json_col", "JSON"),
560 typed("dt_col", "DATETIME"),
561 ]);
562 assert!(s.contains("id INT64"));
563 assert!(s.contains("json_col JSON"));
564 assert!(s.contains("dt_col DATETIME"));
565 }
566
567 #[test]
568 fn load_data_declares_native_schema_and_is_a_free_batch_load() {
569 let schema = build_schema(&[typed("id", "INT64"), typed("json_col", "JSON")]);
570 let sql = build_load_data_sql("p.d.orders", true, &schema, &None, &[], &uris());
571 assert!(sql.starts_with("LOAD DATA OVERWRITE `p.d.orders` ("));
572 assert!(sql.contains("json_col JSON"));
574 assert!(sql.contains("format = 'PARQUET'"));
575 assert!(sql.contains("'gs://b/a.parquet'"));
576 assert!(!sql.contains("PARTITION BY"));
577 }
578
579 #[test]
580 fn load_data_append_uses_into() {
581 let schema = build_schema(&[typed("id", "INT64")]);
582 let sql = build_load_data_sql("p.d.orders", false, &schema, &None, &[], &uris());
583 assert!(sql.starts_with("LOAD DATA INTO `p.d.orders`"));
584 }
585
586 #[test]
587 fn load_data_emits_partition_and_cluster_when_configured() {
588 let schema = build_schema(&[typed("id", "INT64")]);
589 let sql = build_load_data_sql(
590 "p.d.orders",
591 true,
592 &schema,
593 &Some("DATE(created_at)".into()),
594 &["customer_id".into(), "region".into()],
595 &uris(),
596 );
597 assert!(sql.contains("PARTITION BY DATE(created_at)"));
598 assert!(sql.contains("CLUSTER BY customer_id, region"));
599 }
600
601 #[test]
602 fn create_changes_clusters_on_pk_capped_at_four_columns() {
603 let schema = build_schema(&[typed("__op", "STRING"), typed("id", "INT64")]);
604 let sql = build_create_changes_sql("p.d.orders__changes", &schema, &["id".into()]);
605 assert!(sql.starts_with("CREATE TABLE IF NOT EXISTS `p.d.orders__changes` ("));
606 assert!(sql.contains("CLUSTER BY id"));
607 let wide: Vec<String> = ["a", "b", "c", "d", "e"]
609 .iter()
610 .map(|s| s.to_string())
611 .collect();
612 let sql2 = build_create_changes_sql("t", &schema, &wide);
613 assert!(sql2.contains("CLUSTER BY a, b, c, d"));
614 assert!(!sql2.contains(", e"));
615 }
616
617 #[test]
618 fn is_meta_column_matches_only_the_three_cdc_columns() {
619 assert!(is_meta_column("__op") && is_meta_column("__pos") && is_meta_column("__seq"));
620 assert!(!is_meta_column("id") && !is_meta_column("__op_code"));
621 }
622
623 #[test]
624 fn count_csv_skips_header() {
625 assert_eq!(parse_count_csv("n\n42\n").unwrap(), 42);
626 assert_eq!(parse_count_csv("n\n0\n").unwrap(), 0);
627 assert!(parse_count_csv("n\n").is_err());
628 }
629
630 #[test]
631 fn clean_bq_output_drops_standalone_status_and_waiting_lines() {
632 let raw = b"Waiting on bqjob_x\nCurrent status: RUNNING\nError: boom\n";
636 assert_eq!(clean_bq_output(raw), "Error: boom");
637 }
638
639 #[test]
640 fn augment_partition_limit_fires_only_on_partition_plus_signal() {
641 let aug = |m: &str| augment_partition_limit(anyhow::anyhow!("{m}")).to_string();
642 assert!(aug("too many partitions, allowed 4000").contains("split the"));
644 assert!(aug("partition quota reached").contains("split the"));
645 assert!(aug("partition count will exceed the limit").contains("split the"));
646 assert!(!aug("partition pruning is disabled").contains("split the"));
648 assert!(!aug("row quota 4000 reached").contains("split the"));
649 }
650
651 #[test]
652 fn partition_limit_error_is_augmented() {
653 let raw = anyhow::anyhow!("Too many partitions: cannot modify more than 4000 partitions");
654 let msg = augment_partition_limit(raw).to_string();
655 assert!(
656 msg.contains("split the"),
657 "expected the actionable hint: {msg}"
658 );
659 }
660
661 #[test]
662 fn job_labels_tag_managed_by_op_and_table() {
663 let flags = build_label_flags("recover", "Orders", Some("Run-7"));
664 let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
665 assert!(kv.iter().any(|s| *s == "managed_by:rivet"));
666 assert!(kv.iter().any(|s| *s == "rivet_op:recover"));
667 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"));
671 }
672
673 #[test]
674 fn no_run_id_omits_the_rivet_run_label() {
675 let flags = build_label_flags("load", "orders", None);
676 let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
677 assert!(kv.iter().any(|s| *s == "rivet_table:orders"));
678 assert!(!kv.iter().any(|s| s.starts_with("rivet_run:")));
679 }
680
681 #[test]
682 fn fqtn_qualifies_project_dataset_table() {
683 let l = BigQueryLoader::new("proj", "ds");
684 assert_eq!(l.fqtn("orders"), "proj.ds.orders");
685 }
686
687 #[test]
688 fn sanitize_label_coerces_to_bq_charset() {
689 assert_eq!(sanitize_label("My.Table!"), "my_table_");
690 assert_eq!(sanitize_label(""), "unnamed");
691 assert_eq!(sanitize_label("ok-name_1"), "ok-name_1");
692 assert_eq!(sanitize_label(&"x".repeat(80)).len(), 63);
693 }
694
695 #[test]
696 fn clean_bq_output_keeps_real_error_drops_spinner() {
697 let stdout = b"Error in query string: Too many partitions produced by query, \
701 allowed 4000, query produces at least 4200 partitions";
702 let cleaned = clean_bq_output(stdout);
703 assert!(cleaned.contains("Too many partitions") && cleaned.contains("4000"));
704 let augmented = augment_partition_limit(anyhow::anyhow!("{cleaned}")).to_string();
706 assert!(augmented.contains("split the"), "{augmented}");
707 let stderr = "Waiting on bqjob_x ... (0s) Current status: RUNNING\r\
709 Waiting on bqjob_x ... (0s) Current status: DONE";
710 assert!(clean_bq_output(stderr.as_bytes()).is_empty());
711 }
712
713 #[test]
714 fn materialize_refuses_too_many_cluster_columns() {
715 let l = BigQueryLoader::new("p", "d").cluster_by(vec![
719 "a".into(),
720 "b".into(),
721 "c".into(),
722 "d".into(),
723 "e".into(),
724 ]);
725 let err = l
726 .materialize("t", &[spec("id", None, TargetStatus::Ok)], &uris())
727 .unwrap_err()
728 .to_string();
729 assert!(err.contains("clustering"), "{err}");
730 }
731
732 #[test]
733 fn materialize_refuses_a_non_identifier_cluster_column() {
734 let l = BigQueryLoader::new("p", "d").cluster_by(vec!["id) FROM secrets; --".into()]);
739 let err = l
740 .materialize("t", &[spec("id", None, TargetStatus::Ok)], &uris())
741 .unwrap_err()
742 .to_string();
743 assert!(
744 err.contains("not a plain SQL identifier") && err.contains("CLUSTER BY"),
745 "{err}"
746 );
747 }
748
749 #[test]
750 fn hive_partition_value_parses_col_segment() {
751 assert_eq!(
752 hive_partition_value("gs://b/t/d=2023-01-01/part-0.parquet", "d").as_deref(),
753 Some("2023-01-01")
754 );
755 assert_eq!(
756 hive_partition_value("gs://b/t/created_at=2023-01-01/p.parquet", "created_at")
757 .as_deref(),
758 Some("2023-01-01")
759 );
760 assert!(hive_partition_value("gs://b/t/part-0.parquet", "d").is_none());
761 }
762
763 #[test]
764 fn is_bare_column_rejects_expressions() {
765 assert!(is_bare_column("d"));
766 assert!(is_bare_column("created_at"));
767 assert!(!is_bare_column("DATE(d)"));
768 assert!(!is_bare_column("DATE_TRUNC(d, MONTH)"));
769 assert!(!is_bare_column(""));
770 }
771
772 #[test]
773 fn hive_batches_split_by_distinct_partition_cap() {
774 let uris: Vec<String> = [
776 "gs://b/t/d=2023-01-01/a.parquet",
777 "gs://b/t/d=2023-01-01/b.parquet",
778 "gs://b/t/d=2023-01-02/a.parquet",
779 "gs://b/t/d=2023-01-03/a.parquet",
780 "gs://b/t/d=2023-01-04/a.parquet",
781 "gs://b/t/d=2023-01-05/a.parquet",
782 ]
783 .iter()
784 .map(|s| s.to_string())
785 .collect();
786 let batches = plan_hive_batches(&uris, "d", 2).unwrap();
787 assert_eq!(batches.len(), 3);
788 for b in &batches {
789 let mut days: Vec<_> = b
790 .iter()
791 .map(|u| hive_partition_value(u, "d").unwrap())
792 .collect();
793 days.sort();
794 days.dedup();
795 assert!(
796 days.len() <= 2,
797 "batch touches {} distinct days",
798 days.len()
799 );
800 }
801 assert_eq!(batches.iter().map(Vec::len).sum::<usize>(), uris.len());
803 }
804
805 #[test]
806 fn hive_batches_single_when_under_cap() {
807 let uris = vec![
808 "gs://b/t/d=2023-01-01/a.parquet".to_string(),
809 "gs://b/t/d=2023-01-02/a.parquet".to_string(),
810 ];
811 assert_eq!(plan_hive_batches(&uris, "d", 4000).unwrap().len(), 1);
812 }
813
814 #[test]
815 fn hive_batches_error_when_uri_lacks_segment() {
816 let uris = vec!["gs://b/t/no-hive/a.parquet".to_string()];
817 assert!(plan_hive_batches(&uris, "d", 2).is_err());
818 }
819
820 #[test]
827 #[ignore = "live: needs bq CLI + ADC + a GCS Parquet fixture"]
828 fn bigquery_live_load_round_trips() {
829 let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
833 eprintln!("skipping bigquery_live_load_round_trips: BIGQUERY_TEST_PROJECT unset");
834 return;
835 };
836 let dataset =
837 std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
838 let uri = std::env::var("RIVET_BQ_TEST_PARQUET_URI").expect(
839 "set RIVET_BQ_TEST_PARQUET_URI to a GCS Parquet object matching the specs below",
840 );
841
842 let specs = vec![spec("id", None, TargetStatus::Ok)];
844
845 let loader = BigQueryLoader::new(project, dataset);
846 let report =
849 crate::load::run_load(&loader, "rivet_bq_live_test", &specs, &[uri], None, None)
850 .expect("live load should succeed");
851 assert!(
852 report.rows_loaded > 0,
853 "expected rows, got {}",
854 report.rows_loaded
855 );
856 }
857
858 #[test]
872 #[ignore = "live: needs bq CLI + ADC + a CDC change-log Parquet fixture"]
873 fn bigquery_live_cdc_view_dedups_at_least_once() {
874 let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
876 eprintln!(
877 "skipping bigquery_live_cdc_view_dedups_at_least_once: BIGQUERY_TEST_PROJECT unset"
878 );
879 return;
880 };
881 let dataset =
882 std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
883 let uri = std::env::var("RIVET_BQ_CDC_PARQUET_URI")
884 .expect("set RIVET_BQ_CDC_PARQUET_URI to a CDC change-log Parquet object");
885 let pk = std::env::var("RIVET_BQ_CDC_PK").unwrap_or_else(|_| "id".to_string());
886 let data_cols =
889 std::env::var("RIVET_BQ_CDC_DATA_COLS").unwrap_or_else(|_| "id:INT64".to_string());
890 let specs: Vec<TargetColumnSpec> = data_cols
891 .split(',')
892 .map(|c| {
893 let (name, ty) = c.split_once(':').expect("data col must be name:TYPE");
894 typed(name, ty)
895 })
896 .collect();
897 let expected_state: u64 = std::env::var("RIVET_BQ_CDC_EXPECTED_STATE")
898 .ok()
899 .and_then(|s| s.parse().ok())
900 .unwrap_or(0);
901
902 let table = "rivet_bq_live_cdc_test";
903 let pk_cols: Vec<String> = pk.split(',').map(str::to_string).collect();
904 let loader = BigQueryLoader::new(&project, &dataset);
905
906 crate::load::run_load_cdc(
909 &loader,
910 table,
911 &specs,
912 std::slice::from_ref(&uri),
913 &pk_cols,
914 crate::load::cdc::SourceEngine::MySql,
915 None,
916 None,
917 )
918 .expect("first CDC append + view build should succeed");
919 let second = crate::load::run_load_cdc(
920 &loader,
921 table,
922 &specs,
923 &[uri],
924 &pk_cols,
925 crate::load::cdc::SourceEngine::MySql,
926 None,
927 None,
928 )
929 .expect("second CDC append (at-least-once) should succeed");
930 assert!(second.rows_appended > 0, "second append added rows");
931
932 let state_rows = loader
935 .count_rows(&second.view, table)
936 .expect("counting the dedup view should succeed");
937 if expected_state > 0 {
938 assert_eq!(
939 state_rows, expected_state,
940 "the view must collapse duplicates to {expected_state} distinct-PK rows \
941 (incl tombstones), got {state_rows}"
942 );
943 }
944 }
945}