1use crate::types::target::{TargetColumnSpec, TargetStatus};
8use anyhow::{Context, Result, bail};
9use serde::Deserialize;
10use std::process::Command;
11
12#[derive(Debug, Clone, Deserialize)]
25pub struct LoadSection {
26 #[serde(flatten)]
27 pub target: LoadTarget,
28 #[serde(default)]
29 pub cleanup_source: bool,
30 #[serde(default)]
35 pub pk: Vec<String>,
36 #[serde(default)]
40 pub allow_source_drift: bool,
41 #[serde(default)]
50 pub gc_orphans: bool,
51 #[serde(default)]
54 pub cluster_by: Vec<String>,
55}
56
57#[derive(Debug, Deserialize)]
66#[serde(deny_unknown_fields)]
67struct LoadOverride {
68 #[serde(default)]
69 pk: Option<Vec<String>>,
70 #[serde(default)]
71 cleanup_source: Option<bool>,
72 #[serde(default)]
73 gc_orphans: Option<bool>,
74 #[serde(default)]
75 cluster_by: Option<Vec<String>>,
76 #[serde(default)]
77 allow_source_drift: Option<bool>,
78 #[serde(default)]
80 target: Option<serde_json::Value>,
81}
82
83impl LoadSection {
84 fn with_override(&self, o: &LoadOverride) -> LoadSection {
88 let mut eff = self.clone();
89 if let Some(pk) = &o.pk {
90 eff.pk = pk.clone();
91 }
92 if let Some(c) = o.cleanup_source {
93 eff.cleanup_source = c;
94 }
95 if let Some(g) = o.gc_orphans {
96 eff.gc_orphans = g;
97 }
98 if let Some(cb) = &o.cluster_by {
99 eff.cluster_by = cb.clone();
100 }
101 if let Some(d) = o.allow_source_drift {
102 eff.allow_source_drift = d;
103 }
104 eff
105 }
106}
107
108#[derive(Debug, Clone, Deserialize)]
110#[serde(tag = "target", rename_all = "lowercase")]
111pub enum LoadTarget {
112 Bigquery {
113 project: String,
114 dataset: String,
115 },
116 Snowflake {
117 connection: String,
118 warehouse: String,
119 database: String,
120 schema: String,
121 storage_integration: String,
122 },
123}
124
125impl LoadTarget {
126 pub fn name(&self) -> &'static str {
128 match self {
129 LoadTarget::Bigquery { .. } => "bigquery",
130 LoadTarget::Snowflake { .. } => "snowflake",
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum LoadMode {
144 Full,
145 Incremental,
146 Cdc,
147}
148
149impl LoadMode {
150 pub fn ledger_str(self) -> &'static str {
154 match self {
155 LoadMode::Full => "full",
156 LoadMode::Incremental => "incremental",
157 LoadMode::Cdc => "cdc",
158 }
159 }
160}
161
162#[derive(Debug, Clone)]
164pub struct LoadPlan {
165 pub export_name: String,
170 pub table: String,
171 pub partition_by: Option<String>,
172 pub specs: Vec<TargetColumnSpec>,
173 pub gcs_prefix: String,
176 pub destination: crate::config::DestinationConfig,
179 pub load: LoadSection,
181 pub mode: LoadMode,
183 pub cursor_column: Option<String>,
186}
187
188#[derive(Deserialize)]
192struct ExportReport {
193 export: String,
194 columns: Vec<ColReport>,
195}
196
197#[derive(Deserialize)]
198struct ColReport {
199 column: String,
200 target_type: String,
201 target_status: String,
202}
203
204const LOAD_KEYS: &[&str] = &[
215 "target",
216 "cleanup_source",
217 "pk",
218 "allow_source_drift",
219 "gc_orphans",
220 "cluster_by",
221 "project",
223 "dataset",
224 "connection",
225 "warehouse",
226 "database",
227 "schema",
228 "storage_integration",
229];
230
231fn check_load_keys(value: &serde_json::Value, whose: &str) -> Result<()> {
234 if let Some(obj) = value.as_object() {
235 for k in obj.keys() {
236 if !LOAD_KEYS.contains(&k.as_str()) {
237 bail!(
238 "unknown key `{k}` in the {whose} `load:` block — valid keys are: {}",
239 LOAD_KEYS.join(", ")
240 );
241 }
242 }
243 }
244 Ok(())
245}
246
247const BIGQUERY_ONLY: &[&str] = &["project", "dataset"];
249const SNOWFLAKE_ONLY: &[&str] = &[
250 "connection",
251 "warehouse",
252 "database",
253 "schema",
254 "storage_integration",
255];
256
257fn reject_foreign_target_fields(
263 value: &serde_json::Value,
264 target: &str,
265 whose: &str,
266) -> Result<()> {
267 let (foreign, other) = match target {
268 "bigquery" => (SNOWFLAKE_ONLY, "snowflake"),
269 "snowflake" => (BIGQUERY_ONLY, "bigquery"),
270 _ => return Ok(()),
271 };
272 if let Some(obj) = value.as_object() {
273 for k in obj.keys() {
274 if foreign.contains(&k.as_str()) {
275 bail!(
276 "the {whose} `load:` block targets `{target}` but carries `{k}`, a `{other}` \
277 field — remove it (it would be silently ignored, masking a mis-configured load)"
278 );
279 }
280 }
281 }
282 Ok(())
283}
284
285fn resolve_load_prefix(
301 dest: &crate::config::DestinationConfig,
302 export_name: &str,
303 bucket: &str,
304) -> Result<String> {
305 let raw_prefix = dest.prefix.as_deref().unwrap_or("");
309 let raw_base = raw_prefix.split("{partition}").next().unwrap_or(raw_prefix);
310 if raw_base.contains("{date}") {
311 bail!(
312 "export `{}`: destination.prefix `{}` puts a day-specific `{{date}}` in the load base. \
313 `rivet load` lists the LOAD-day prefix, so an export written on a different day (a \
314 nightly export + an after-midnight load) lands under a different, EMPTY prefix and is \
315 silently reported 'up to date'. Remove `{{date}}` from the load base, or place it \
316 BELOW `{{partition}}` so the load can list a stable prefix.",
317 export_name,
318 raw_base
319 );
320 }
321 let ctx = crate::destination::placeholder::PlaceholderContext::for_today(export_name);
322 let expanded = crate::destination::placeholder::expand_destination(dest.clone(), &ctx);
323 let prefix = expanded.prefix.as_deref().unwrap_or("");
324 let base = prefix.split("{partition}").next().unwrap_or(prefix);
325 if base.contains('{') {
326 bail!(
327 "export `{}`: load prefix `{}` still has an unresolved placeholder after expansion — \
328 `rivet load` cannot reconstruct which run's output to load (a `{{run_id}}` prefix is \
329 run-specific). Drop the run-specific token from `destination.prefix`, or run \
330 `rivet load` from the context that wrote the export.",
331 export_name,
332 base
333 );
334 }
335 Ok(format!("gs://{bucket}/{base}"))
336}
337
338pub fn plan_loads(config_path: &str, rivet_bin: &str) -> Result<Vec<LoadPlan>> {
339 let yaml = std::fs::read_to_string(config_path)
340 .with_context(|| format!("reading config {config_path}"))?;
341 let cfg = crate::config::Config::from_yaml(&yaml).context("parsing rivet config")?;
342 if cfg.exports.is_empty() {
343 bail!("config has no exports");
344 }
345
346 let load_value = cfg.load.clone().context(
349 "config has no top-level `load:` block — add `load: { target, ... }` to load into a warehouse",
350 )?;
351 check_load_keys(&load_value, "top-level")?;
352 let load: LoadSection = serde_json::from_value(load_value.clone())
353 .context("parsing the top-level `load:` block")?;
354 reject_foreign_target_fields(&load_value, load.target.name(), "top-level")?;
355
356 let out = Command::new(rivet_bin)
359 .args([
360 "check",
361 "-c",
362 config_path,
363 "--target",
364 load.target.name(),
365 "--json",
366 ])
367 .output()
368 .with_context(|| {
369 format!("running `{rivet_bin} check` — is rivet on PATH? pass --rivet-bin")
370 })?;
371 if !out.status.success() {
372 bail!(
373 "rivet check failed: {}",
374 String::from_utf8_lossy(&out.stderr).trim()
375 );
376 }
377 let reports: Vec<ExportReport> = serde_json::Deserializer::from_slice(&out.stdout)
378 .into_iter::<ExportReport>()
379 .collect::<Result<_, _>>()
380 .context("parsing `rivet check --json` (one document per export)")?;
381
382 build_plans(&cfg, &load, reports)
383}
384
385fn build_plans(
395 cfg: &crate::config::Config,
396 load: &LoadSection,
397 reports: Vec<ExportReport>,
398) -> Result<Vec<LoadPlan>> {
399 let mut plans = Vec::with_capacity(reports.len());
400 for report in reports {
401 let export = cfg
402 .exports
403 .iter()
404 .find(|e| e.name == report.export)
405 .with_context(|| {
406 format!(
407 "rivet check reported export `{}` not found in config",
408 report.export
409 )
410 })?;
411 let table = export.table.clone().unwrap_or_else(|| export.name.clone());
412
413 let dest = &export.destination;
414 let bucket = dest.bucket.as_deref().with_context(|| {
415 format!(
416 "export `{}` has no destination `bucket` — a GCS destination is required",
417 export.name
418 )
419 })?;
420 let gcs_prefix = resolve_load_prefix(dest, &export.name, bucket)?;
421
422 let mut specs: Vec<TargetColumnSpec> = report
423 .columns
424 .into_iter()
425 .map(|c| TargetColumnSpec {
426 column_name: c.column,
427 target_type: c.target_type,
428 autoload_type: String::new(),
429 status: match c.target_status.as_str() {
430 "fail" => TargetStatus::Fail,
431 "warn" => TargetStatus::Warn,
432 _ => TargetStatus::Ok,
433 },
434 note: None,
435 cast_sql: None,
436 })
437 .collect();
438
439 let mut meta_specs: Vec<(&str, crate::types::RivetType)> = Vec::new();
455 if export.meta_columns.exported_at {
456 meta_specs.push((
457 crate::enrich::COL_EXPORTED_AT,
458 crate::types::RivetType::Timestamp {
459 unit: crate::types::TimeUnit::Microsecond,
460 timezone: Some("UTC".into()),
461 },
462 ));
463 }
464 if export.meta_columns.row_hash.enabled() {
465 meta_specs.push((crate::enrich::COL_ROW_HASH, crate::types::RivetType::Int64));
466 }
467 if !meta_specs.is_empty() {
468 let target = crate::types::target::ExportTarget::parse(load.target.name())
469 .with_context(|| format!("unknown load target `{}`", load.target.name()))?;
470 for (name, rivet_type) in &meta_specs {
471 specs.push(target.resolve_column(crate::types::target::TargetInput {
472 column_name: name,
473 rivet_type,
474 arrow_type: None,
475 fidelity: crate::types::TypeFidelity::Exact,
476 }));
477 }
478 }
479
480 let mode = match export.mode {
486 crate::config::ExportMode::Cdc => LoadMode::Cdc,
487 crate::config::ExportMode::Incremental => LoadMode::Incremental,
488 crate::config::ExportMode::Full => LoadMode::Full, crate::config::ExportMode::Chunked => LoadMode::Full, crate::config::ExportMode::TimeWindow => LoadMode::Full, };
492 let eff_load = match &export.load {
496 Some(v) => {
497 let o: LoadOverride = serde_json::from_value(v.clone()).with_context(|| {
498 format!("parsing export `{}` `load:` override", export.name)
499 })?;
500 if o.target.is_some() {
501 bail!(
502 "export `{}`: a per-export `load:` cannot override `target:` — the \
503 warehouse is shared; set `target:` in the top-level `load:` block only",
504 export.name
505 );
506 }
507 load.with_override(&o)
508 }
509 None => load.clone(),
510 };
511 plans.push(LoadPlan {
512 export_name: export.name.clone(),
513 table,
514 partition_by: export.partition_by.clone(),
515 specs,
516 gcs_prefix,
517 destination: export.destination.clone(),
518 load: eff_load,
519 mode,
520 cursor_column: export.cursor_column.clone(),
521 });
522 }
523 reject_duplicate_target_tables(&plans.iter().map(|p| p.table.as_str()).collect::<Vec<_>>())?;
524 Ok(plans)
525}
526
527fn reject_duplicate_target_tables(tables: &[&str]) -> Result<()> {
533 let mut seen = std::collections::HashSet::new();
534 for t in tables {
535 if !seen.insert(*t) {
536 bail!(
537 "two exports resolve to the same load target table `{t}` — each would clobber \
538 the other (a full OVERWRITE vs a cdc/incremental append share the table and \
539 its ledger). Give each export its own `table:` or destination."
540 );
541 }
542 }
543 Ok(())
544}
545
546pub fn source_engine(config_path: &str) -> Result<crate::load::cdc::SourceEngine> {
553 use crate::config::SourceType;
554 use crate::load::cdc::SourceEngine;
555
556 let yaml = std::fs::read_to_string(config_path)
557 .with_context(|| format!("reading config {config_path}"))?;
558 let cfg = crate::config::Config::from_yaml(&yaml).context("parsing rivet config")?;
559 match cfg.source.source_type {
560 SourceType::Postgres => Ok(SourceEngine::Postgres),
561 SourceType::Mysql => Ok(SourceEngine::MySql),
562 SourceType::Mssql => Ok(SourceEngine::SqlServer),
563 SourceType::Mongo => Ok(SourceEngine::Mongo),
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 #[test]
572 fn ledger_str_names_each_mode_stably() {
573 assert_eq!(LoadMode::Full.ledger_str(), "full");
577 assert_eq!(LoadMode::Incremental.ledger_str(), "incremental");
578 assert_eq!(LoadMode::Cdc.ledger_str(), "cdc");
579 }
580
581 #[test]
582 fn resolve_load_prefix_expands_deterministic_tokens_and_refuses_run_specific() {
583 use crate::config::{DestinationConfig, DestinationType};
584 let dest = |prefix: &str| DestinationConfig {
585 destination_type: DestinationType::Gcs,
586 bucket: Some("BKT".into()),
587 prefix: Some(prefix.into()),
588 ..Default::default()
589 };
590
591 assert_eq!(
595 resolve_load_prefix(&dest("exports/{export}/"), "orders", "BKT").unwrap(),
596 "gs://BKT/exports/orders/"
597 );
598 assert_eq!(
600 resolve_load_prefix(&dest("e/{table}/{partition}/"), "orders", "BKT").unwrap(),
601 "gs://BKT/e/orders/"
602 );
603 assert!(resolve_load_prefix(&dest("d/{date}/{export}/"), "orders", "BKT").is_err());
607
608 let err = resolve_load_prefix(&dest("e/{run_id}/"), "orders", "BKT").unwrap_err();
611 assert!(
612 err.to_string().contains("unresolved placeholder"),
613 "a run-specific token must be refused, not silently listed: {err}"
614 );
615 }
616
617 fn col(name: &str, status: &str) -> ColReport {
619 ColReport {
620 column: name.into(),
621 target_type: "STRING".into(),
622 target_status: status.into(),
623 }
624 }
625
626 #[test]
632 fn build_plans_matches_by_name_maps_statuses_and_mode() {
633 let cfg = crate::config::Config::from_yaml(
634 r#"
635source:
636 type: postgres
637 url: "postgresql://localhost/test"
638exports:
639 - name: alpha
640 table: alpha_tbl
641 mode: full
642 format: parquet
643 destination:
644 type: gcs
645 bucket: b1
646 prefix: exports/alpha/
647 - name: beta
648 table: beta_tbl
649 mode: incremental
650 cursor_column: updated_at
651 format: parquet
652 destination:
653 type: gcs
654 bucket: b2
655 prefix: exports/beta/
656load:
657 target: bigquery
658 project: p
659 dataset: d
660"#,
661 )
662 .unwrap();
663 let load: LoadSection = serde_json::from_value(cfg.load.clone().unwrap()).unwrap();
664
665 let reports = vec![
668 ExportReport {
669 export: "beta".into(),
670 columns: vec![col("id", "ok"), col("f", "fail"), col("w", "warn")],
671 },
672 ExportReport {
673 export: "alpha".into(),
674 columns: vec![col("id", "ok")],
675 },
676 ];
677
678 let plans = build_plans(&cfg, &load, reports).unwrap();
679 assert_eq!(plans.len(), 2);
680
681 assert_eq!(
684 plans[0].table, "beta_tbl",
685 "found beta by name, not position"
686 );
687 assert_eq!(plans[0].mode, LoadMode::Incremental);
688 assert_eq!(plans[0].cursor_column.as_deref(), Some("updated_at"));
689 assert_eq!(plans[0].gcs_prefix, "gs://b2/exports/beta/");
690 let statuses: Vec<_> = plans[0].specs.iter().map(|s| s.status).collect();
693 assert_eq!(
694 statuses,
695 vec![TargetStatus::Ok, TargetStatus::Fail, TargetStatus::Warn]
696 );
697
698 assert_eq!(plans[1].table, "alpha_tbl");
700 assert_eq!(plans[1].mode, LoadMode::Full);
701 assert_eq!(plans[1].gcs_prefix, "gs://b1/exports/alpha/");
702 }
703
704 #[test]
709 fn build_plans_appends_a_spec_for_the_extraction_hash() {
710 let yaml = |hash_block: &str| {
711 format!(
712 "source:\n type: postgres\n url: \"postgresql://localhost/test\"\n\
713 exports:\n - name: a\n table: t\n mode: full\n format: parquet\n\
714 \x20 destination:\n type: gcs\n bucket: b\n prefix: p/\n{hash_block}\
715 load:\n target: bigquery\n project: p\n dataset: d\n"
716 )
717 };
718 let reports = || {
719 vec![ExportReport {
720 export: "a".into(),
721 columns: vec![col("id", "ok"), col("status", "ok")],
722 }]
723 };
724
725 let without = crate::config::Config::from_yaml(&yaml("")).unwrap();
726 let load: LoadSection = serde_json::from_value(without.load.clone().unwrap()).unwrap();
727 let plans = build_plans(&without, &load, reports()).unwrap();
728 assert_eq!(
729 plans[0]
730 .specs
731 .iter()
732 .map(|s| s.column_name.as_str())
733 .collect::<Vec<_>>(),
734 vec!["id", "status"],
735 "no row_hash configured ⇒ no extra column"
736 );
737
738 let with = crate::config::Config::from_yaml(&yaml(
739 " meta_columns:\n row_hash: [id, status]\n",
740 ))
741 .unwrap();
742 let load: LoadSection = serde_json::from_value(with.load.clone().unwrap()).unwrap();
743 let plans = build_plans(&with, &load, reports()).unwrap();
744 let last = plans[0].specs.last().unwrap();
745 assert_eq!(last.column_name, crate::enrich::COL_ROW_HASH);
746 assert_eq!(last.target_type, "INT64");
749 assert_eq!(last.status, TargetStatus::Ok);
750
751 let both = crate::config::Config::from_yaml(&yaml(
760 " meta_columns:\n exported_at: true\n row_hash: true\n",
761 ))
762 .unwrap();
763 let load: LoadSection = serde_json::from_value(both.load.clone().unwrap()).unwrap();
764 let plans = build_plans(&both, &load, reports()).unwrap();
765 let names: Vec<&str> = plans[0]
766 .specs
767 .iter()
768 .map(|s| s.column_name.as_str())
769 .collect();
770 assert_eq!(
771 names,
772 vec![
773 "id",
774 "status",
775 crate::enrich::COL_EXPORTED_AT,
776 crate::enrich::COL_ROW_HASH,
777 ],
778 "every extraction-written meta column needs a spec, in enrich_schema's order"
779 );
780 let ts = plans[0].specs.iter().rev().nth(1).unwrap();
781 assert_eq!(
782 ts.column_name,
783 crate::enrich::COL_EXPORTED_AT,
784 "the timestamp spec sits before the hash spec"
785 );
786 assert_eq!(
787 ts.target_type, "TIMESTAMP",
788 "resolved through the per-target resolver, not hardcoded — BigQuery's \
789 instant type for a Timestamp(us, UTC)"
790 );
791 }
792
793 #[test]
794 fn build_plans_bails_on_a_report_for_an_unknown_export() {
795 let cfg = crate::config::Config::from_yaml(
796 "source:\n type: postgres\n url: \"postgresql://localhost/test\"\n\
797 exports:\n - name: a\n query: \"SELECT 1\"\n format: parquet\n \
798 destination:\n type: gcs\n bucket: b\n prefix: p/\nload:\n \
799 target: bigquery\n project: p\n dataset: d\n",
800 )
801 .unwrap();
802 let load: LoadSection = serde_json::from_value(cfg.load.clone().unwrap()).unwrap();
803 let reports = vec![ExportReport {
804 export: "ghost".into(),
805 columns: vec![],
806 }];
807 let err = build_plans(&cfg, &load, reports).unwrap_err().to_string();
808 assert!(err.contains("ghost") && err.contains("not found"), "{err}");
809 }
810
811 #[test]
812 fn reject_duplicate_target_tables_catches_a_collision() {
813 assert!(reject_duplicate_target_tables(&["orders", "events", "orders"]).is_err());
816 assert!(reject_duplicate_target_tables(&["orders", "events"]).is_ok());
817 assert!(reject_duplicate_target_tables(&[]).is_ok());
818 }
819
820 #[test]
821 fn multi_export_check_json_parses_as_a_stream() {
822 let raw = concat!(
826 "{\"export\":\"orders\",\"columns\":[{\"column\":\"id\",\"target_type\":\"NUMBER\",\"target_status\":\"ok\"}]}\n",
827 "{\"export\":\"customers\",\"columns\":[{\"column\":\"cid\",\"target_type\":\"NUMBER\",\"target_status\":\"ok\"}]}\n"
828 );
829 let reports: Vec<ExportReport> = serde_json::Deserializer::from_slice(raw.as_bytes())
830 .into_iter::<ExportReport>()
831 .collect::<Result<_, _>>()
832 .unwrap();
833 assert_eq!(reports.len(), 2);
834 assert_eq!(reports[0].export, "orders");
835 assert_eq!(reports[1].export, "customers");
836 assert_eq!(reports[1].columns[0].column, "cid");
837 }
838
839 #[test]
840 fn bigquery_load_section_deserializes_into_its_variant() {
841 let value = serde_json::json!({
842 "target": "bigquery", "project": "p", "dataset": "d",
843 "cleanup_source": true, "cluster_by": ["customer"]
844 });
845 let load: LoadSection = serde_json::from_value(value).unwrap();
846 assert_eq!(load.target.name(), "bigquery");
847 assert!(load.cleanup_source);
848 assert_eq!(load.cluster_by, vec!["customer"]);
849 match load.target {
850 LoadTarget::Bigquery { project, dataset } => {
851 assert_eq!((project.as_str(), dataset.as_str()), ("p", "d"));
852 }
853 _ => panic!("expected Bigquery variant"),
854 }
855 }
856
857 #[test]
858 fn snowflake_missing_field_is_unrepresentable_no_runtime_validate() {
859 let full = serde_json::json!({
860 "target": "snowflake", "connection": "rivet", "warehouse": "wh",
861 "database": "db", "schema": "sc", "storage_integration": "si"
862 });
863 let load: LoadSection = serde_json::from_value(full).unwrap();
864 assert_eq!(load.target.name(), "snowflake");
865
866 let partial = serde_json::json!({
869 "target": "snowflake", "connection": "rivet", "warehouse": "wh",
870 "database": "db", "schema": "sc"
871 });
872 let err = serde_json::from_value::<LoadSection>(partial).unwrap_err();
873 assert!(
874 err.to_string().contains("storage_integration"),
875 "error should name the missing field: {err}"
876 );
877 }
878
879 #[test]
880 fn unknown_target_is_rejected_at_deserialize() {
881 let value = serde_json::json!({ "target": "redshift", "project": "p" });
882 assert!(serde_json::from_value::<LoadSection>(value).is_err());
883 }
884
885 fn top_level_load() -> LoadSection {
886 serde_json::from_value(serde_json::json!({
887 "target": "bigquery", "project": "p", "dataset": "d",
888 "pk": ["top"], "cleanup_source": true, "gc_orphans": false,
889 "cluster_by": ["c0"], "allow_source_drift": false,
890 }))
891 .unwrap()
892 }
893
894 #[test]
895 fn with_override_replaces_some_fields_and_inherits_the_rest() {
896 let top = top_level_load();
897 let o: LoadOverride =
899 serde_json::from_value(serde_json::json!({ "pk": ["id"], "gc_orphans": true }))
900 .unwrap();
901 let eff = top.with_override(&o);
902 assert_eq!(eff.pk, vec!["id"], "pk replaced");
903 assert!(eff.gc_orphans, "gc_orphans replaced");
904 assert!(
905 eff.cleanup_source,
906 "cleanup_source inherited (top-level true)"
907 );
908 assert_eq!(eff.cluster_by, vec!["c0"], "cluster_by inherited");
909 assert!(!eff.allow_source_drift, "allow_source_drift inherited");
910 }
911
912 #[test]
913 fn override_parsing_leaves_omitted_fields_none() {
914 let o: LoadOverride = serde_json::from_value(serde_json::json!({ "pk": ["id"] })).unwrap();
915 assert_eq!(o.pk.as_deref(), Some(&["id".to_string()][..]));
916 assert!(o.cleanup_source.is_none());
917 assert!(o.gc_orphans.is_none());
918 assert!(o.cluster_by.is_none());
919 assert!(o.allow_source_drift.is_none());
920 assert!(o.target.is_none());
921 }
922
923 #[test]
924 fn empty_override_is_distinct_from_inherit() {
925 let top = top_level_load();
926 let cleared: LoadOverride =
928 serde_json::from_value(serde_json::json!({ "pk": [] })).unwrap();
929 assert!(
930 top.with_override(&cleared).pk.is_empty(),
931 "explicit [] clears"
932 );
933 let inherit: LoadOverride = serde_json::from_value(serde_json::json!({})).unwrap();
934 assert_eq!(
935 top.with_override(&inherit).pk,
936 vec!["top"],
937 "omitted inherits"
938 );
939 }
940
941 #[test]
942 fn override_carrying_target_is_detected() {
943 let o: LoadOverride =
946 serde_json::from_value(serde_json::json!({ "target": "snowflake" })).unwrap();
947 assert!(
948 o.target.is_some(),
949 "target captured for the plan_loads guard"
950 );
951 }
952
953 #[test]
954 fn unknown_top_level_load_key_is_rejected() {
955 let typo = serde_json::json!({
958 "target": "bigquery", "project": "p", "dataset": "d", "gc_orphan": true
959 });
960 let err = check_load_keys(&typo, "top-level").unwrap_err().to_string();
961 assert!(err.contains("gc_orphan"), "{err}");
962 let ok = serde_json::json!({
964 "target": "bigquery", "project": "p", "dataset": "d",
965 "gc_orphans": true, "cleanup_source": false, "pk": ["id"],
966 "allow_source_drift": true, "cluster_by": ["a"]
967 });
968 assert!(check_load_keys(&ok, "top-level").is_ok());
969 }
970
971 #[test]
972 fn unknown_per_export_override_key_is_rejected() {
973 let typo = serde_json::json!({ "pk": ["id"], "cluster_bye": ["x"] });
975 assert!(
976 serde_json::from_value::<LoadOverride>(typo).is_err(),
977 "a typo'd override key must fail to parse"
978 );
979 let ok = serde_json::json!({ "pk": ["id"], "cleanup_source": true });
980 assert!(serde_json::from_value::<LoadOverride>(ok).is_ok());
981 }
982
983 #[test]
984 fn resolve_load_prefix_refuses_day_specific_date_in_the_base() {
985 let dest = |p: &str| crate::config::DestinationConfig {
989 prefix: Some(p.to_string()),
990 ..Default::default()
991 };
992 let err = resolve_load_prefix(&dest("exports/{date}/{export}/"), "orders", "bkt")
993 .unwrap_err()
994 .to_string();
995 assert!(
996 err.contains("{date}") && err.contains("load base"),
997 "must refuse a day-specific date base: {err}"
998 );
999 assert!(resolve_load_prefix(&dest("exports/{export}/"), "orders", "bkt").is_ok());
1000 assert!(
1002 resolve_load_prefix(
1003 &dest("exports/{export}/{partition}/{date}/"),
1004 "orders",
1005 "bkt"
1006 )
1007 .is_ok()
1008 );
1009 }
1010
1011 #[test]
1012 fn cross_warehouse_load_fields_are_rejected() {
1013 let snow_with_bq = serde_json::json!({
1017 "target": "snowflake", "connection": "c", "warehouse": "w",
1018 "database": "db", "schema": "s", "storage_integration": "si",
1019 "project": "STALE", "dataset": "STALE"
1020 });
1021 let err = reject_foreign_target_fields(&snow_with_bq, "snowflake", "top-level")
1022 .unwrap_err()
1023 .to_string();
1024 assert!(
1025 err.contains("project") && err.contains("bigquery"),
1026 "snowflake+project must name the foreign field and warehouse: {err}"
1027 );
1028 let bq_with_snow = serde_json::json!({
1030 "target": "bigquery", "project": "p", "dataset": "d", "warehouse": "WH"
1031 });
1032 assert!(reject_foreign_target_fields(&bq_with_snow, "bigquery", "top-level").is_err());
1033 let clean = serde_json::json!({ "target": "bigquery", "project": "p", "dataset": "d" });
1035 assert!(reject_foreign_target_fields(&clean, "bigquery", "top-level").is_ok());
1036 }
1037}