1use crate::destination::gcs::GcsStore;
13use crate::types::target::{TargetColumnSpec, TargetStatus};
14use anyhow::{Context, Result, bail};
15
16mod bigquery;
17pub mod cdc;
18pub mod plan;
19pub mod reconcile;
20mod snowflake;
21
22pub use bigquery::BigQueryLoader;
23pub use snowflake::SnowflakeLoader;
24
25#[derive(Debug, Clone)]
27pub struct LoadReport {
28 pub rows_loaded: u64,
29 pub target_table: String,
30 pub source_cleaned: bool,
32}
33
34#[derive(Debug, Clone)]
37pub struct CdcLoadReport {
38 pub rows_appended: u64,
39 pub changes_table: String,
40 pub view: String,
41 pub source_cleaned: bool,
45}
46
47pub trait TargetLoader {
55 fn fqtn(&self, table: &str) -> String;
58
59 fn materialize(&self, table: &str, specs: &[TargetColumnSpec], uris: &[String]) -> Result<u64>;
62
63 fn append_changelog(
67 &self,
68 table: &str,
69 specs: &[TargetColumnSpec],
70 uris: &[String],
71 pk: &[String],
72 ) -> Result<u64>;
73
74 fn warehouse(&self) -> cdc::Warehouse;
78
79 fn create_view(&self, table: &str, view_sql: &str) -> Result<()>;
84}
85
86fn is_safe_load_ident(s: &str) -> bool {
91 !s.is_empty()
92 && s.chars()
93 .next()
94 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
95 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
96}
97
98fn ensure_safe_load_uris(uris: &[String]) -> Result<()> {
113 for u in uris {
114 if let Some(bad) = u
115 .chars()
116 .find(|&c| c == '\'' || c == '\\' || c.is_control())
117 {
118 bail!(
119 "refusing to load: Parquet URI `{}` contains {:?}, which is unsafe to splice \
120 into the warehouse load statement — the loader single-quotes URIs without \
121 escaping. rivet names its own parts safely, so this URI was not produced by a \
122 normal export; investigate the staging bucket before re-running.",
123 u.escape_default(),
124 bad
125 );
126 }
127 }
128 Ok(())
129}
130
131fn validate_specs(table: &str, specs: &[TargetColumnSpec]) -> Result<()> {
134 if specs.is_empty() {
135 bail!("no column specs for `{table}` — nothing to build a schema from");
136 }
137 if table.is_empty() || !table.split('.').all(is_safe_load_ident) {
141 bail!(
142 "cannot load: target table `{}` is not a plain (optionally dotted) SQL identifier — \
143 the loader splices it into DDL/COPY.",
144 table.escape_default()
145 );
146 }
147 for s in specs {
152 if !is_safe_load_ident(&s.column_name) {
153 bail!(
154 "cannot load `{table}`: column name `{}` is not a plain SQL identifier \
155 ([A-Za-z_][A-Za-z0-9_]*) — the warehouse loader splices it into DDL/COPY. \
156 Rename or alias the column in the export query.",
157 s.column_name.escape_default()
158 );
159 }
160 }
161 let failed: Vec<&str> = specs
162 .iter()
163 .filter(|s| s.status == TargetStatus::Fail)
164 .map(|s| s.column_name.as_str())
165 .collect();
166 if !failed.is_empty() {
167 bail!(
168 "cannot load `{table}`: {} column(s) do not map to the warehouse: {}",
169 failed.len(),
170 failed.join(", ")
171 );
172 }
173 Ok(())
174}
175
176fn maybe_cleanup(cleanup: Option<(&GcsStore, &str)>) -> bool {
181 match cleanup {
182 Some((store, prefix)) => match delete_under(store, prefix) {
183 Ok(()) => true,
184 Err(e) => {
185 eprintln!("warning: source cleanup failed (data is safely loaded): {e:#}");
186 false
187 }
188 },
189 None => false,
190 }
191}
192
193#[allow(private_interfaces)]
202pub fn run_load(
203 loader: &dyn TargetLoader,
204 table: &str,
205 specs: &[TargetColumnSpec],
206 uris: &[String],
207 expected_rows: Option<u64>,
208 cleanup: Option<(&GcsStore, &str)>,
209) -> Result<LoadReport> {
210 if uris.is_empty() {
211 bail!("no Parquet URIs to load into `{table}`");
212 }
213 ensure_safe_load_uris(uris)?;
214 validate_specs(table, specs)?;
215
216 let rows_loaded = loader.materialize(table, specs, uris)?;
217
218 if let Some(expected) = expected_rows
219 && rows_loaded != expected
220 {
221 bail!(
222 "count validation failed for `{}`: loaded {rows_loaded} rows, expected {expected} — \
223 NOT cleaning up source; investigate before re-running",
224 loader.fqtn(table)
225 );
226 }
227
228 let source_cleaned = maybe_cleanup(cleanup);
229 Ok(LoadReport {
230 rows_loaded,
231 target_table: loader.fqtn(table),
232 source_cleaned,
233 })
234}
235
236#[allow(clippy::too_many_arguments, private_interfaces)]
244fn append_and_view(
250 loader: &dyn TargetLoader,
251 table: &str,
252 specs: &[TargetColumnSpec],
253 uris: &[String],
254 pk: &[String],
255 expected_delta: Option<u64>,
256 cleanup: Option<(&GcsStore, &str)>,
257 label: &str,
258 build_view: impl FnOnce(&dyn TargetLoader) -> Result<()>,
259) -> Result<CdcLoadReport> {
260 if uris.is_empty() {
261 bail!("no Parquet URIs to append into `{table}__changes`");
262 }
263 ensure_safe_load_uris(uris)?;
264 if pk.is_empty() {
265 bail!("{label} load of `{table}` needs a primary key for the dedup view (pass --pk)");
266 }
267 for c in pk {
275 if !is_safe_load_ident(c) {
276 bail!(
277 "cannot load `{table}`: primary-key column `{}` is not a plain SQL identifier \
278 ([A-Za-z_][A-Za-z0-9_]*) — it is spliced into the dedup view's PARTITION BY. \
279 Rename or alias it in the export.",
280 c.escape_default()
281 );
282 }
283 }
284 validate_specs(&format!("{table}__changes"), specs)?;
285
286 let rows_appended = loader.append_changelog(table, specs, uris, pk)?;
287
288 if let Some(expected) = expected_delta
289 && rows_appended != expected
290 {
291 bail!(
292 "{label} count validation failed for `{}__changes`: appended {rows_appended} rows, \
293 expected {expected} from the run manifests — investigate before trusting the view",
294 table
295 );
296 }
297
298 build_view(loader)?;
299 let source_cleaned = maybe_cleanup(cleanup);
305
306 Ok(CdcLoadReport {
307 rows_appended,
308 changes_table: loader.fqtn(&format!("{table}__changes")),
309 view: loader.fqtn(table),
310 source_cleaned,
311 })
312}
313
314#[allow(clippy::too_many_arguments, private_interfaces)]
315pub fn run_load_cdc(
316 loader: &dyn TargetLoader,
317 table: &str,
318 specs: &[TargetColumnSpec],
319 uris: &[String],
320 pk: &[String],
321 engine: cdc::SourceEngine,
322 expected_delta: Option<u64>,
323 cleanup: Option<(&GcsStore, &str)>,
324) -> Result<CdcLoadReport> {
325 for c in pk {
328 if !is_safe_load_ident(c) {
329 bail!(
330 "cannot load `{table}`: CDC primary-key column `{}` is not a plain SQL \
331 identifier — it is spliced into the dedup view. Rename/alias it.",
332 c.escape_default()
333 );
334 }
335 }
336 append_and_view(
337 loader,
338 table,
339 specs,
340 uris,
341 pk,
342 expected_delta,
343 cleanup,
344 "CDC",
345 |l| {
346 let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
347 let sql = cdc::dedup_view_sql(
348 l.warehouse(),
349 &l.fqtn(table),
350 &l.fqtn(&format!("{table}__changes")),
351 &pk_refs,
352 engine,
353 );
354 l.create_view(table, &sql)
355 },
356 )
357}
358
359#[allow(clippy::too_many_arguments, private_interfaces)]
368pub fn run_load_incremental(
369 loader: &dyn TargetLoader,
370 table: &str,
371 specs: &[TargetColumnSpec],
372 uris: &[String],
373 pk: &[String],
374 cursor_column: &str,
375 expected_delta: Option<u64>,
376 cleanup: Option<(&GcsStore, &str)>,
377) -> Result<CdcLoadReport> {
378 if cursor_column.is_empty() {
380 bail!(
381 "incremental load of `{table}` needs a cursor column (the export's `cursor_column:`) \
382 for the dedup view's latest-per-PK ordering"
383 );
384 }
385 if !specs.iter().any(|s| s.column_name == cursor_column) {
392 let cols: Vec<&str> = specs.iter().map(|s| s.column_name.as_str()).collect();
393 bail!(
394 "incremental load of `{table}`: cursor_column `{cursor_column}` is not one of the \
395 exported columns [{}] — add it to the export's SELECT so the dedup view can order \
396 the change log by it",
397 cols.join(", ")
398 );
399 }
400 append_and_view(
401 loader,
402 table,
403 specs,
404 uris,
405 pk,
406 expected_delta,
407 cleanup,
408 "incremental",
409 |l| {
410 let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
411 let sql = cdc::inc_dedup_view_sql(
412 l.warehouse(),
413 &l.fqtn(table),
414 &l.fqtn(&format!("{table}__changes")),
415 &pk_refs,
416 cursor_column,
417 );
418 l.create_view(table, &sql)
419 },
420 )
421}
422
423pub(crate) fn split_gs_uri(uri: &str) -> Result<(&str, &str)> {
426 let (bucket, key) = uri
427 .strip_prefix("gs://")
428 .and_then(|rest| rest.split_once('/'))
429 .with_context(|| format!("not a `gs://bucket/path` URI: {uri}"))?;
430 if key.trim_matches('/').is_empty() {
439 anyhow::bail!(
440 "refusing a bucket-root staging prefix `{uri}`: a GCS load stages into and cleans up a \
441 DEDICATED prefix, so an empty prefix would list/delete the whole bucket. Set a \
442 non-empty `destination.prefix`, and put any `{{partition}}` token AFTER a literal \
443 segment (e.g. `exports/{{partition}}/`, not `{{partition}}/`)."
444 );
445 }
446 Ok((bucket, key))
447}
448
449pub(crate) fn delete_under(store: &GcsStore, gs_prefix: &str) -> Result<()> {
455 let (_, rel) = split_gs_uri(gs_prefix)?;
456 store
457 .remove_all(rel)
458 .with_context(|| format!("source cleanup (recursive delete of {gs_prefix}) failed"))
459}
460
461#[allow(private_interfaces)]
470pub fn open_store(dest: &crate::config::DestinationConfig) -> Result<GcsStore> {
471 GcsStore::new(dest)
472}
473
474pub fn build_loader(plan: &plan::LoadPlan, run_id: &str) -> Box<dyn TargetLoader> {
479 use plan::LoadTarget;
480 let load = &plan.load;
481 match &load.target {
482 LoadTarget::Bigquery { project, dataset } => Box::new(build_bigquery_loader(
483 project,
484 dataset,
485 plan.partition_by.as_deref(),
486 &load.cluster_by,
487 run_id,
488 )),
489 LoadTarget::Snowflake {
490 connection,
491 warehouse,
492 database,
493 schema,
494 storage_integration,
495 } => {
496 let mut l = SnowflakeLoader::new(connection.clone());
497 l.warehouse = warehouse.clone();
498 l.database = database.clone();
499 l.schema = schema.clone();
500 l.storage_integration = storage_integration.clone();
501 l.cluster_by = load.cluster_by.clone();
502 l.run_id = Some(run_id.to_string());
503 l.gcs_url = plan.gcs_prefix.replacen("gs://", "gcs://", 1);
505 l.private_key_path = std::env::var("RIVET_SNOWFLAKE_KEY").ok();
507 Box::new(l)
508 }
509 }
510}
511
512fn build_bigquery_loader(
518 project: &str,
519 dataset: &str,
520 partition_by: Option<&str>,
521 cluster_by: &[String],
522 run_id: &str,
523) -> BigQueryLoader {
524 let mut l = BigQueryLoader::new(project, dataset).run_id(run_id);
525 if let Some(part) = partition_by {
526 l = l.partition_by(part);
527 }
528 if !cluster_by.is_empty() {
529 l = l.cluster_by(cluster_by.to_vec());
530 }
531 l
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use std::cell::RefCell;
538
539 #[derive(Default)]
542 struct FakeLoader {
543 rows: u64,
544 materialized: RefCell<Vec<String>>,
545 appended: RefCell<Vec<String>>,
546 views: RefCell<Vec<String>>,
547 }
548
549 impl TargetLoader for FakeLoader {
550 fn fqtn(&self, table: &str) -> String {
551 format!("db.{table}")
552 }
553 fn materialize(&self, table: &str, _: &[TargetColumnSpec], _: &[String]) -> Result<u64> {
554 self.materialized.borrow_mut().push(table.into());
555 Ok(self.rows)
556 }
557 fn append_changelog(
558 &self,
559 table: &str,
560 _: &[TargetColumnSpec],
561 _: &[String],
562 _: &[String],
563 ) -> Result<u64> {
564 self.appended.borrow_mut().push(table.into());
565 Ok(self.rows)
566 }
567 fn warehouse(&self) -> cdc::Warehouse {
568 cdc::Warehouse::BigQuery
569 }
570 fn create_view(&self, table: &str, _view_sql: &str) -> Result<()> {
571 self.views.borrow_mut().push(table.into());
572 Ok(())
573 }
574 }
575
576 fn fs_store_with_prefix(dir: &tempfile::TempDir, rel: &str) -> GcsStore {
581 let obj = dir.path().join(rel).join("x.parquet");
582 std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
583 std::fs::write(obj, b"x").unwrap();
584 GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap()
585 }
586
587 fn prefix_populated(store: &GcsStore, rel: &str) -> bool {
589 !store.list_files(rel).unwrap().is_empty()
590 }
591
592 #[test]
593 fn delete_under_and_gc_orphans_refuse_the_bucket_root_and_spare_siblings() {
594 let dir = tempfile::tempdir().unwrap();
600 for rel in ["exportA/part.parquet", "innocent-neighbour/keep.parquet"] {
601 let obj = dir.path().join(rel);
602 std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
603 std::fs::write(obj, b"x").unwrap();
604 }
605 let store = GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap();
606
607 assert!(
609 delete_under(&store, "gs://bucket/").is_err(),
610 "cleanup_source must REFUSE a bucket-root prefix, not remove_all(\"\")"
611 );
612 assert!(
613 reconcile::gc_orphans(&store, "gs://bucket/", &[]).is_err(),
614 "gc_orphans must REFUSE a bucket-root prefix, not list+delete the whole bucket"
615 );
616 assert!(prefix_populated(&store, "exportA"), "exportA must survive");
618 assert!(
619 prefix_populated(&store, "innocent-neighbour"),
620 "an unrelated neighbour export must survive the refused root cleanup"
621 );
622
623 delete_under(&store, "gs://bucket/exportA").unwrap();
626 assert!(
627 !prefix_populated(&store, "exportA"),
628 "a real prefix cleanup still drains its own export"
629 );
630 assert!(
631 prefix_populated(&store, "innocent-neighbour"),
632 "a scoped cleanup spares the sibling"
633 );
634 }
635
636 fn spec(status: TargetStatus) -> Vec<TargetColumnSpec> {
637 vec![TargetColumnSpec {
638 column_name: "id".into(),
639 target_type: "INT64".into(),
640 autoload_type: String::new(),
641 status,
642 note: None,
643 cast_sql: None,
644 }]
645 }
646 fn uris() -> Vec<String> {
647 vec!["gs://b/p/x.parquet".into()]
648 }
649 const PREFIX: &str = "gs://b/p";
652 const REL: &str = "p";
653
654 #[test]
655 fn empty_uris_bail_before_materialize() {
656 let f = FakeLoader {
657 rows: 10,
658 ..Default::default()
659 };
660 assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &[], Some(10), None).is_err());
661 assert!(f.materialized.borrow().is_empty());
662 }
663
664 #[test]
665 fn fail_spec_bails_before_materialize() {
666 let f = FakeLoader::default();
667 assert!(run_load(&f, "t", &spec(TargetStatus::Fail), &uris(), Some(10), None).is_err());
668 assert!(f.materialized.borrow().is_empty());
669 }
670
671 #[test]
672 fn count_mismatch_bails_without_cleanup() {
673 let f = FakeLoader {
674 rows: 7,
675 ..Default::default()
676 };
677 let dir = tempfile::tempdir().unwrap();
678 let store = fs_store_with_prefix(&dir, REL);
679 let err = run_load(
680 &f,
681 "t",
682 &spec(TargetStatus::Ok),
683 &uris(),
684 Some(10),
685 Some((&store, PREFIX)),
686 )
687 .unwrap_err()
688 .to_string();
689 assert!(err.contains("count validation failed"), "{err}");
690 assert!(
691 prefix_populated(&store, REL),
692 "cleanup must not run on a failed gate — the source prefix stays intact"
693 );
694 }
695
696 #[test]
697 fn match_with_prefix_cleans_once() {
698 let f = FakeLoader {
699 rows: 10,
700 ..Default::default()
701 };
702 let dir = tempfile::tempdir().unwrap();
703 let store = fs_store_with_prefix(&dir, REL);
704 let r = run_load(
705 &f,
706 "t",
707 &spec(TargetStatus::Ok),
708 &uris(),
709 Some(10),
710 Some((&store, PREFIX)),
711 )
712 .unwrap();
713 assert!(r.source_cleaned);
714 assert!(
715 !prefix_populated(&store, REL),
716 "a passed gate drains the source prefix through the injected store"
717 );
718 assert_eq!(r.target_table, "db.t");
719 }
720
721 #[test]
722 fn match_without_prefix_does_not_clean() {
723 let f = FakeLoader {
724 rows: 10,
725 ..Default::default()
726 };
727 let r = run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), Some(10), None).unwrap();
728 assert!(!r.source_cleaned);
729 }
730
731 #[test]
732 fn none_expected_skips_the_gate() {
733 let f = FakeLoader {
734 rows: 999,
735 ..Default::default()
736 };
737 assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), None, None).is_ok());
739 }
740
741 #[test]
742 fn cdc_delta_mismatch_bails_without_view() {
743 let f = FakeLoader {
744 rows: 3,
745 ..Default::default()
746 };
747 let dir = tempfile::tempdir().unwrap();
748 let store = fs_store_with_prefix(&dir, REL);
749 let err = run_load_cdc(
750 &f,
751 "t",
752 &spec(TargetStatus::Ok),
753 &uris(),
754 &["id".into()],
755 cdc::SourceEngine::MySql,
756 Some(5),
757 Some((&store, PREFIX)),
758 )
759 .unwrap_err()
760 .to_string();
761 assert!(err.contains("CDC count validation failed"), "{err}");
762 assert!(
763 f.views.borrow().is_empty(),
764 "view must not be built on a failed gate"
765 );
766 assert!(
767 prefix_populated(&store, REL),
768 "cleanup must not run on a failed gate"
769 );
770 }
771
772 #[test]
773 fn cdc_match_builds_view_then_cleans() {
774 let f = FakeLoader {
775 rows: 5,
776 ..Default::default()
777 };
778 let dir = tempfile::tempdir().unwrap();
779 let store = fs_store_with_prefix(&dir, REL);
780 let r = run_load_cdc(
781 &f,
782 "t",
783 &spec(TargetStatus::Ok),
784 &uris(),
785 &["id".into()],
786 cdc::SourceEngine::MySql,
787 Some(5),
788 Some((&store, PREFIX)),
789 )
790 .unwrap();
791 assert_eq!(r.rows_appended, 5);
792 assert_eq!(*f.views.borrow(), vec!["t".to_string()]);
793 assert!(
794 !prefix_populated(&store, REL),
795 "a passed CDC gate drains the source prefix after the view is built"
796 );
797 assert_eq!(r.changes_table, "db.t__changes");
798 }
799
800 #[test]
801 fn delete_under_drains_the_prefix_through_the_store() {
802 let dir = tempfile::tempdir().unwrap();
803 let store = fs_store_with_prefix(&dir, REL);
804 assert!(prefix_populated(&store, REL), "seeded object is present");
805 delete_under(&store, PREFIX).unwrap();
806 assert!(
807 !prefix_populated(&store, REL),
808 "delete_under recursively removes the bucket-relative prefix behind the gs:// URI"
809 );
810 }
811
812 #[test]
813 fn build_bigquery_loader_wires_partition_and_cluster_keys() {
814 let l = build_bigquery_loader(
819 "proj",
820 "ds",
821 Some("day"),
822 &["customer_id".to_string(), "region".to_string()],
823 "run-1",
824 );
825 assert_eq!(l.cluster_by, ["customer_id", "region"]);
826 assert_eq!(l.partition_by.as_deref(), Some("day"));
827
828 let bare = build_bigquery_loader("proj", "ds", None, &[], "run-1");
830 assert!(bare.cluster_by.is_empty());
831 assert!(bare.partition_by.is_none());
832 }
833
834 #[test]
835 fn split_gs_uri_parses_bucket_and_bucket_relative_key() {
836 assert_eq!(split_gs_uri("gs://b/p").unwrap(), ("b", "p"));
842 assert_eq!(
843 split_gs_uri("gs://bucket/a/b/c.parquet").unwrap(),
844 ("bucket", "a/b/c.parquet"),
845 "only the FIRST '/' splits bucket from key; the rest is the key"
846 );
847 assert!(
848 split_gs_uri("s3://b/p").is_err(),
849 "a non-gs scheme is rejected"
850 );
851 assert!(
852 split_gs_uri("gs://bucket-only").is_err(),
853 "a bucket with no '/' has no (bucket, key) split"
854 );
855 for root in ["gs://bucket/", "gs://bucket//", "gs://bucket///"] {
861 assert!(
862 split_gs_uri(root).is_err(),
863 "bucket-root prefix {root:?} must be refused, not parsed to an empty (root) key"
864 );
865 }
866 assert_eq!(split_gs_uri("gs://b/exports/").unwrap(), ("b", "exports/"));
868 }
869
870 #[test]
871 fn cdc_empty_pk_bails() {
872 let f = FakeLoader::default();
873 assert!(
874 run_load_cdc(
875 &f,
876 "t",
877 &spec(TargetStatus::Ok),
878 &uris(),
879 &[],
880 cdc::SourceEngine::MySql,
881 None,
882 None
883 )
884 .is_err()
885 );
886 assert!(f.appended.borrow().is_empty());
887 }
888
889 #[test]
890 fn a_uri_with_a_quote_backslash_or_control_char_bails_before_the_driver_runs() {
891 for bad in [
897 "gs://b/p/x'; drop table t --.parquet", "gs://b/p/x\\.parquet", "gs://b/p/x\n.parquet", ] {
901 let f = FakeLoader {
902 rows: 1,
903 ..Default::default()
904 };
905 let uris = vec![bad.to_string()];
906 assert!(
907 run_load(&f, "t", &spec(TargetStatus::Ok), &uris, Some(1), None).is_err(),
908 "run_load must reject the injection URI {bad:?}"
909 );
910 assert!(
911 f.materialized.borrow().is_empty(),
912 "the driver must not be reached for {bad:?}"
913 );
914 assert!(
915 run_load_cdc(
916 &f,
917 "t",
918 &spec(TargetStatus::Ok),
919 &uris,
920 &["id".to_string()],
921 cdc::SourceEngine::MySql,
922 Some(1),
923 None,
924 )
925 .is_err(),
926 "run_load_cdc must reject the injection URI {bad:?}"
927 );
928 assert!(
929 f.appended.borrow().is_empty(),
930 "the CDC driver must not be reached for {bad:?}"
931 );
932 }
933 assert!(ensure_safe_load_uris(&uris()).is_ok());
935 }
936
937 #[test]
938 fn incremental_cursor_not_in_specs_bails_before_append() {
939 let f = FakeLoader::default();
940 let err = run_load_incremental(
945 &f,
946 "t",
947 &spec(TargetStatus::Ok),
948 &uris(),
949 &["id".to_string()],
950 "updated_at",
951 None,
952 None,
953 )
954 .unwrap_err()
955 .to_string();
956 assert!(
957 err.contains("updated_at") && err.contains("not one of the exported columns"),
958 "{err}"
959 );
960 assert!(
961 f.appended.borrow().is_empty(),
962 "nothing appended before the bail"
963 );
964 }
965
966 #[test]
972 fn incremental_hostile_pk_is_refused_before_the_view_splice() {
973 let f = FakeLoader::default();
974 let err = run_load_incremental(
975 &f,
976 "t",
977 &spec(TargetStatus::Ok), &uris(),
979 &["id) OR (1=1) --".to_string()], "id", None,
982 None,
983 )
984 .unwrap_err()
985 .to_string();
986 assert!(
987 err.contains("not a plain SQL identifier") && err.contains("PARTITION BY"),
988 "incremental load must refuse a non-identifier PK before splicing it into the view; got: {err}"
989 );
990 assert!(
991 f.appended.borrow().is_empty() && f.views.borrow().is_empty(),
992 "must bail before appending or building the view"
993 );
994 }
995}