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 append_and_view(
329 loader,
330 table,
331 specs,
332 uris,
333 pk,
334 expected_delta,
335 cleanup,
336 "CDC",
337 |l| {
338 let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
339 let sql = cdc::dedup_view_sql(
340 l.warehouse(),
341 &l.fqtn(table),
342 &l.fqtn(&format!("{table}__changes")),
343 &pk_refs,
344 engine,
345 );
346 l.create_view(table, &sql)
347 },
348 )
349}
350
351#[allow(clippy::too_many_arguments, private_interfaces)]
360pub fn run_load_incremental(
361 loader: &dyn TargetLoader,
362 table: &str,
363 specs: &[TargetColumnSpec],
364 uris: &[String],
365 pk: &[String],
366 cursor_column: &str,
367 expected_delta: Option<u64>,
368 cleanup: Option<(&GcsStore, &str)>,
369) -> Result<CdcLoadReport> {
370 if cursor_column.is_empty() {
372 bail!(
373 "incremental load of `{table}` needs a cursor column (the export's `cursor_column:`) \
374 for the dedup view's latest-per-PK ordering"
375 );
376 }
377 if !specs.iter().any(|s| s.column_name == cursor_column) {
384 let cols: Vec<&str> = specs.iter().map(|s| s.column_name.as_str()).collect();
385 bail!(
386 "incremental load of `{table}`: cursor_column `{cursor_column}` is not one of the \
387 exported columns [{}] — add it to the export's SELECT so the dedup view can order \
388 the change log by it",
389 cols.join(", ")
390 );
391 }
392 append_and_view(
393 loader,
394 table,
395 specs,
396 uris,
397 pk,
398 expected_delta,
399 cleanup,
400 "incremental",
401 |l| {
402 let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
403 let sql = cdc::inc_dedup_view_sql(
404 l.warehouse(),
405 &l.fqtn(table),
406 &l.fqtn(&format!("{table}__changes")),
407 &pk_refs,
408 cursor_column,
409 );
410 l.create_view(table, &sql)
411 },
412 )
413}
414
415pub(crate) fn split_gs_uri(uri: &str) -> Result<(&str, &str)> {
418 let (bucket, key) = uri
419 .strip_prefix("gs://")
420 .and_then(|rest| rest.split_once('/'))
421 .with_context(|| format!("not a `gs://bucket/path` URI: {uri}"))?;
422 if key.trim_matches('/').is_empty() {
431 anyhow::bail!(
432 "refusing a bucket-root staging prefix `{uri}`: a GCS load stages into and cleans up a \
433 DEDICATED prefix, so an empty prefix would list/delete the whole bucket. Set a \
434 non-empty `destination.prefix`, and put any `{{partition}}` token AFTER a literal \
435 segment (e.g. `exports/{{partition}}/`, not `{{partition}}/`)."
436 );
437 }
438 Ok((bucket, key))
439}
440
441pub(crate) fn delete_under(store: &GcsStore, gs_prefix: &str) -> Result<()> {
447 let (_, rel) = split_gs_uri(gs_prefix)?;
448 store
449 .remove_all(rel)
450 .with_context(|| format!("source cleanup (recursive delete of {gs_prefix}) failed"))
451}
452
453#[allow(private_interfaces)]
462pub fn open_store(dest: &crate::config::DestinationConfig) -> Result<GcsStore> {
463 GcsStore::new(dest)
464}
465
466pub fn build_loader(plan: &plan::LoadPlan, run_id: &str) -> Box<dyn TargetLoader> {
471 use plan::LoadTarget;
472 let load = &plan.load;
473 match &load.target {
474 LoadTarget::Bigquery { project, dataset } => Box::new(build_bigquery_loader(
475 project,
476 dataset,
477 plan.partition_by.as_deref(),
478 &load.cluster_by,
479 run_id,
480 )),
481 LoadTarget::Snowflake {
482 connection,
483 warehouse,
484 database,
485 schema,
486 storage_integration,
487 } => {
488 let mut l = SnowflakeLoader::new(connection.clone());
489 l.warehouse = warehouse.clone();
490 l.database = database.clone();
491 l.schema = schema.clone();
492 l.storage_integration = storage_integration.clone();
493 l.cluster_by = load.cluster_by.clone();
494 l.run_id = Some(run_id.to_string());
495 l.gcs_url = plan.gcs_prefix.replacen("gs://", "gcs://", 1);
497 l.private_key_path = std::env::var("RIVET_SNOWFLAKE_KEY").ok();
499 Box::new(l)
500 }
501 }
502}
503
504fn build_bigquery_loader(
510 project: &str,
511 dataset: &str,
512 partition_by: Option<&str>,
513 cluster_by: &[String],
514 run_id: &str,
515) -> BigQueryLoader {
516 let mut l = BigQueryLoader::new(project, dataset).run_id(run_id);
517 if let Some(part) = partition_by {
518 l = l.partition_by(part);
519 }
520 if !cluster_by.is_empty() {
521 l = l.cluster_by(cluster_by.to_vec());
522 }
523 l
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use std::cell::RefCell;
530
531 #[derive(Default)]
534 struct FakeLoader {
535 rows: u64,
536 materialized: RefCell<Vec<String>>,
537 appended: RefCell<Vec<String>>,
538 views: RefCell<Vec<String>>,
539 }
540
541 impl TargetLoader for FakeLoader {
542 fn fqtn(&self, table: &str) -> String {
543 format!("db.{table}")
544 }
545 fn materialize(&self, table: &str, _: &[TargetColumnSpec], _: &[String]) -> Result<u64> {
546 self.materialized.borrow_mut().push(table.into());
547 Ok(self.rows)
548 }
549 fn append_changelog(
550 &self,
551 table: &str,
552 _: &[TargetColumnSpec],
553 _: &[String],
554 _: &[String],
555 ) -> Result<u64> {
556 self.appended.borrow_mut().push(table.into());
557 Ok(self.rows)
558 }
559 fn warehouse(&self) -> cdc::Warehouse {
560 cdc::Warehouse::BigQuery
561 }
562 fn create_view(&self, table: &str, _view_sql: &str) -> Result<()> {
563 self.views.borrow_mut().push(table.into());
564 Ok(())
565 }
566 }
567
568 fn fs_store_with_prefix(dir: &tempfile::TempDir, rel: &str) -> GcsStore {
573 let obj = dir.path().join(rel).join("x.parquet");
574 std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
575 std::fs::write(obj, b"x").unwrap();
576 GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap()
577 }
578
579 fn prefix_populated(store: &GcsStore, rel: &str) -> bool {
581 !store.list_files(rel).unwrap().is_empty()
582 }
583
584 #[test]
585 fn delete_under_and_gc_orphans_refuse_the_bucket_root_and_spare_siblings() {
586 let dir = tempfile::tempdir().unwrap();
592 for rel in ["exportA/part.parquet", "innocent-neighbour/keep.parquet"] {
593 let obj = dir.path().join(rel);
594 std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
595 std::fs::write(obj, b"x").unwrap();
596 }
597 let store = GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap();
598
599 assert!(
601 delete_under(&store, "gs://bucket/").is_err(),
602 "cleanup_source must REFUSE a bucket-root prefix, not remove_all(\"\")"
603 );
604 assert!(
605 reconcile::gc_orphans(&store, "gs://bucket/", &[], false).is_err(),
606 "gc_orphans must REFUSE a bucket-root prefix, not list+delete the whole bucket"
607 );
608 assert!(prefix_populated(&store, "exportA"), "exportA must survive");
610 assert!(
611 prefix_populated(&store, "innocent-neighbour"),
612 "an unrelated neighbour export must survive the refused root cleanup"
613 );
614
615 delete_under(&store, "gs://bucket/exportA").unwrap();
618 assert!(
619 !prefix_populated(&store, "exportA"),
620 "a real prefix cleanup still drains its own export"
621 );
622 assert!(
623 prefix_populated(&store, "innocent-neighbour"),
624 "a scoped cleanup spares the sibling"
625 );
626 }
627
628 fn spec(status: TargetStatus) -> Vec<TargetColumnSpec> {
629 vec![TargetColumnSpec {
630 column_name: "id".into(),
631 target_type: "INT64".into(),
632 autoload_type: String::new(),
633 status,
634 note: None,
635 cast_sql: None,
636 }]
637 }
638 fn uris() -> Vec<String> {
639 vec!["gs://b/p/x.parquet".into()]
640 }
641 const PREFIX: &str = "gs://b/p";
644 const REL: &str = "p";
645
646 #[test]
647 fn empty_uris_bail_before_materialize() {
648 let f = FakeLoader {
649 rows: 10,
650 ..Default::default()
651 };
652 assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &[], Some(10), None).is_err());
653 assert!(f.materialized.borrow().is_empty());
654 }
655
656 #[test]
657 fn fail_spec_bails_before_materialize() {
658 let f = FakeLoader::default();
659 assert!(run_load(&f, "t", &spec(TargetStatus::Fail), &uris(), Some(10), None).is_err());
660 assert!(f.materialized.borrow().is_empty());
661 }
662
663 #[test]
664 fn count_mismatch_bails_without_cleanup() {
665 let f = FakeLoader {
666 rows: 7,
667 ..Default::default()
668 };
669 let dir = tempfile::tempdir().unwrap();
670 let store = fs_store_with_prefix(&dir, REL);
671 let err = run_load(
672 &f,
673 "t",
674 &spec(TargetStatus::Ok),
675 &uris(),
676 Some(10),
677 Some((&store, PREFIX)),
678 )
679 .unwrap_err()
680 .to_string();
681 assert!(err.contains("count validation failed"), "{err}");
682 assert!(
683 prefix_populated(&store, REL),
684 "cleanup must not run on a failed gate — the source prefix stays intact"
685 );
686 }
687
688 #[test]
689 fn match_with_prefix_cleans_once() {
690 let f = FakeLoader {
691 rows: 10,
692 ..Default::default()
693 };
694 let dir = tempfile::tempdir().unwrap();
695 let store = fs_store_with_prefix(&dir, REL);
696 let r = run_load(
697 &f,
698 "t",
699 &spec(TargetStatus::Ok),
700 &uris(),
701 Some(10),
702 Some((&store, PREFIX)),
703 )
704 .unwrap();
705 assert!(r.source_cleaned);
706 assert!(
707 !prefix_populated(&store, REL),
708 "a passed gate drains the source prefix through the injected store"
709 );
710 assert_eq!(r.target_table, "db.t");
711 }
712
713 #[test]
714 fn match_without_prefix_does_not_clean() {
715 let f = FakeLoader {
716 rows: 10,
717 ..Default::default()
718 };
719 let r = run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), Some(10), None).unwrap();
720 assert!(!r.source_cleaned);
721 }
722
723 #[test]
724 fn none_expected_skips_the_gate() {
725 let f = FakeLoader {
726 rows: 999,
727 ..Default::default()
728 };
729 assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), None, None).is_ok());
731 }
732
733 #[test]
734 fn cdc_delta_mismatch_bails_without_view() {
735 let f = FakeLoader {
736 rows: 3,
737 ..Default::default()
738 };
739 let dir = tempfile::tempdir().unwrap();
740 let store = fs_store_with_prefix(&dir, REL);
741 let err = run_load_cdc(
742 &f,
743 "t",
744 &spec(TargetStatus::Ok),
745 &uris(),
746 &["id".into()],
747 cdc::SourceEngine::MySql,
748 Some(5),
749 Some((&store, PREFIX)),
750 )
751 .unwrap_err()
752 .to_string();
753 assert!(err.contains("CDC count validation failed"), "{err}");
754 assert!(
755 f.views.borrow().is_empty(),
756 "view must not be built on a failed gate"
757 );
758 assert!(
759 prefix_populated(&store, REL),
760 "cleanup must not run on a failed gate"
761 );
762 }
763
764 #[test]
765 fn cdc_match_builds_view_then_cleans() {
766 let f = FakeLoader {
767 rows: 5,
768 ..Default::default()
769 };
770 let dir = tempfile::tempdir().unwrap();
771 let store = fs_store_with_prefix(&dir, REL);
772 let r = run_load_cdc(
773 &f,
774 "t",
775 &spec(TargetStatus::Ok),
776 &uris(),
777 &["id".into()],
778 cdc::SourceEngine::MySql,
779 Some(5),
780 Some((&store, PREFIX)),
781 )
782 .unwrap();
783 assert_eq!(r.rows_appended, 5);
784 assert_eq!(*f.views.borrow(), vec!["t".to_string()]);
785 assert!(
786 !prefix_populated(&store, REL),
787 "a passed CDC gate drains the source prefix after the view is built"
788 );
789 assert_eq!(r.changes_table, "db.t__changes");
790 }
791
792 #[test]
793 fn delete_under_drains_the_prefix_through_the_store() {
794 let dir = tempfile::tempdir().unwrap();
795 let store = fs_store_with_prefix(&dir, REL);
796 assert!(prefix_populated(&store, REL), "seeded object is present");
797 delete_under(&store, PREFIX).unwrap();
798 assert!(
799 !prefix_populated(&store, REL),
800 "delete_under recursively removes the bucket-relative prefix behind the gs:// URI"
801 );
802 }
803
804 #[test]
805 fn build_bigquery_loader_wires_partition_and_cluster_keys() {
806 let l = build_bigquery_loader(
811 "proj",
812 "ds",
813 Some("day"),
814 &["customer_id".to_string(), "region".to_string()],
815 "run-1",
816 );
817 assert_eq!(l.cluster_by, ["customer_id", "region"]);
818 assert_eq!(l.partition_by.as_deref(), Some("day"));
819
820 let bare = build_bigquery_loader("proj", "ds", None, &[], "run-1");
822 assert!(bare.cluster_by.is_empty());
823 assert!(bare.partition_by.is_none());
824 }
825
826 #[test]
827 fn split_gs_uri_parses_bucket_and_bucket_relative_key() {
828 assert_eq!(split_gs_uri("gs://b/p").unwrap(), ("b", "p"));
834 assert_eq!(
835 split_gs_uri("gs://bucket/a/b/c.parquet").unwrap(),
836 ("bucket", "a/b/c.parquet"),
837 "only the FIRST '/' splits bucket from key; the rest is the key"
838 );
839 assert!(
840 split_gs_uri("s3://b/p").is_err(),
841 "a non-gs scheme is rejected"
842 );
843 assert!(
844 split_gs_uri("gs://bucket-only").is_err(),
845 "a bucket with no '/' has no (bucket, key) split"
846 );
847 for root in ["gs://bucket/", "gs://bucket//", "gs://bucket///"] {
853 assert!(
854 split_gs_uri(root).is_err(),
855 "bucket-root prefix {root:?} must be refused, not parsed to an empty (root) key"
856 );
857 }
858 assert_eq!(split_gs_uri("gs://b/exports/").unwrap(), ("b", "exports/"));
860 }
861
862 #[test]
863 fn cdc_empty_pk_bails() {
864 let f = FakeLoader::default();
865 assert!(
866 run_load_cdc(
867 &f,
868 "t",
869 &spec(TargetStatus::Ok),
870 &uris(),
871 &[],
872 cdc::SourceEngine::MySql,
873 None,
874 None
875 )
876 .is_err()
877 );
878 assert!(f.appended.borrow().is_empty());
879 }
880
881 #[test]
882 fn a_uri_with_a_quote_backslash_or_control_char_bails_before_the_driver_runs() {
883 for bad in [
889 "gs://b/p/x'; drop table t --.parquet", "gs://b/p/x\\.parquet", "gs://b/p/x\n.parquet", ] {
893 let f = FakeLoader {
894 rows: 1,
895 ..Default::default()
896 };
897 let uris = vec![bad.to_string()];
898 assert!(
899 run_load(&f, "t", &spec(TargetStatus::Ok), &uris, Some(1), None).is_err(),
900 "run_load must reject the injection URI {bad:?}"
901 );
902 assert!(
903 f.materialized.borrow().is_empty(),
904 "the driver must not be reached for {bad:?}"
905 );
906 assert!(
907 run_load_cdc(
908 &f,
909 "t",
910 &spec(TargetStatus::Ok),
911 &uris,
912 &["id".to_string()],
913 cdc::SourceEngine::MySql,
914 Some(1),
915 None,
916 )
917 .is_err(),
918 "run_load_cdc must reject the injection URI {bad:?}"
919 );
920 assert!(
921 f.appended.borrow().is_empty(),
922 "the CDC driver must not be reached for {bad:?}"
923 );
924 }
925 assert!(ensure_safe_load_uris(&uris()).is_ok());
927 }
928
929 #[test]
930 fn incremental_cursor_not_in_specs_bails_before_append() {
931 let f = FakeLoader::default();
932 let err = run_load_incremental(
937 &f,
938 "t",
939 &spec(TargetStatus::Ok),
940 &uris(),
941 &["id".to_string()],
942 "updated_at",
943 None,
944 None,
945 )
946 .unwrap_err()
947 .to_string();
948 assert!(
949 err.contains("updated_at") && err.contains("not one of the exported columns"),
950 "{err}"
951 );
952 assert!(
953 f.appended.borrow().is_empty(),
954 "nothing appended before the bail"
955 );
956 }
957
958 #[test]
964 fn incremental_hostile_pk_is_refused_before_the_view_splice() {
965 let f = FakeLoader::default();
966 let err = run_load_incremental(
967 &f,
968 "t",
969 &spec(TargetStatus::Ok), &uris(),
971 &["id) OR (1=1) --".to_string()], "id", None,
974 None,
975 )
976 .unwrap_err()
977 .to_string();
978 assert!(
979 err.contains("not a plain SQL identifier") && err.contains("PARTITION BY"),
980 "incremental load must refuse a non-identifier PK before splicing it into the view; got: {err}"
981 );
982 assert!(
983 f.appended.borrow().is_empty() && f.views.borrow().is_empty(),
984 "must bail before appending or building the view"
985 );
986 }
987}