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 specs = 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 mode = match export.mode {
445 crate::config::ExportMode::Cdc => LoadMode::Cdc,
446 crate::config::ExportMode::Incremental => LoadMode::Incremental,
447 crate::config::ExportMode::Full => LoadMode::Full, crate::config::ExportMode::Chunked => LoadMode::Full, crate::config::ExportMode::TimeWindow => LoadMode::Full, };
451 let eff_load = match &export.load {
455 Some(v) => {
456 let o: LoadOverride = serde_json::from_value(v.clone()).with_context(|| {
457 format!("parsing export `{}` `load:` override", export.name)
458 })?;
459 if o.target.is_some() {
460 bail!(
461 "export `{}`: a per-export `load:` cannot override `target:` — the \
462 warehouse is shared; set `target:` in the top-level `load:` block only",
463 export.name
464 );
465 }
466 load.with_override(&o)
467 }
468 None => load.clone(),
469 };
470 plans.push(LoadPlan {
471 export_name: export.name.clone(),
472 table,
473 partition_by: export.partition_by.clone(),
474 specs,
475 gcs_prefix,
476 destination: export.destination.clone(),
477 load: eff_load,
478 mode,
479 cursor_column: export.cursor_column.clone(),
480 });
481 }
482 reject_duplicate_target_tables(&plans.iter().map(|p| p.table.as_str()).collect::<Vec<_>>())?;
483 Ok(plans)
484}
485
486fn reject_duplicate_target_tables(tables: &[&str]) -> Result<()> {
492 let mut seen = std::collections::HashSet::new();
493 for t in tables {
494 if !seen.insert(*t) {
495 bail!(
496 "two exports resolve to the same load target table `{t}` — each would clobber \
497 the other (a full OVERWRITE vs a cdc/incremental append share the table and \
498 its ledger). Give each export its own `table:` or destination."
499 );
500 }
501 }
502 Ok(())
503}
504
505pub fn source_engine(config_path: &str) -> Result<crate::load::cdc::SourceEngine> {
512 use crate::config::SourceType;
513 use crate::load::cdc::SourceEngine;
514
515 let yaml = std::fs::read_to_string(config_path)
516 .with_context(|| format!("reading config {config_path}"))?;
517 let cfg = crate::config::Config::from_yaml(&yaml).context("parsing rivet config")?;
518 match cfg.source.source_type {
519 SourceType::Postgres => Ok(SourceEngine::Postgres),
520 SourceType::Mysql => Ok(SourceEngine::MySql),
521 SourceType::Mssql => Ok(SourceEngine::SqlServer),
522 SourceType::Mongo => Ok(SourceEngine::Mongo),
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn ledger_str_names_each_mode_stably() {
532 assert_eq!(LoadMode::Full.ledger_str(), "full");
536 assert_eq!(LoadMode::Incremental.ledger_str(), "incremental");
537 assert_eq!(LoadMode::Cdc.ledger_str(), "cdc");
538 }
539
540 #[test]
541 fn resolve_load_prefix_expands_deterministic_tokens_and_refuses_run_specific() {
542 use crate::config::{DestinationConfig, DestinationType};
543 let dest = |prefix: &str| DestinationConfig {
544 destination_type: DestinationType::Gcs,
545 bucket: Some("BKT".into()),
546 prefix: Some(prefix.into()),
547 ..Default::default()
548 };
549
550 assert_eq!(
554 resolve_load_prefix(&dest("exports/{export}/"), "orders", "BKT").unwrap(),
555 "gs://BKT/exports/orders/"
556 );
557 assert_eq!(
559 resolve_load_prefix(&dest("e/{table}/{partition}/"), "orders", "BKT").unwrap(),
560 "gs://BKT/e/orders/"
561 );
562 assert!(resolve_load_prefix(&dest("d/{date}/{export}/"), "orders", "BKT").is_err());
566
567 let err = resolve_load_prefix(&dest("e/{run_id}/"), "orders", "BKT").unwrap_err();
570 assert!(
571 err.to_string().contains("unresolved placeholder"),
572 "a run-specific token must be refused, not silently listed: {err}"
573 );
574 }
575
576 fn col(name: &str, status: &str) -> ColReport {
578 ColReport {
579 column: name.into(),
580 target_type: "STRING".into(),
581 target_status: status.into(),
582 }
583 }
584
585 #[test]
591 fn build_plans_matches_by_name_maps_statuses_and_mode() {
592 let cfg = crate::config::Config::from_yaml(
593 r#"
594source:
595 type: postgres
596 url: "postgresql://localhost/test"
597exports:
598 - name: alpha
599 table: alpha_tbl
600 mode: full
601 format: parquet
602 destination:
603 type: gcs
604 bucket: b1
605 prefix: exports/alpha/
606 - name: beta
607 table: beta_tbl
608 mode: incremental
609 cursor_column: updated_at
610 format: parquet
611 destination:
612 type: gcs
613 bucket: b2
614 prefix: exports/beta/
615load:
616 target: bigquery
617 project: p
618 dataset: d
619"#,
620 )
621 .unwrap();
622 let load: LoadSection = serde_json::from_value(cfg.load.clone().unwrap()).unwrap();
623
624 let reports = vec![
627 ExportReport {
628 export: "beta".into(),
629 columns: vec![col("id", "ok"), col("f", "fail"), col("w", "warn")],
630 },
631 ExportReport {
632 export: "alpha".into(),
633 columns: vec![col("id", "ok")],
634 },
635 ];
636
637 let plans = build_plans(&cfg, &load, reports).unwrap();
638 assert_eq!(plans.len(), 2);
639
640 assert_eq!(
643 plans[0].table, "beta_tbl",
644 "found beta by name, not position"
645 );
646 assert_eq!(plans[0].mode, LoadMode::Incremental);
647 assert_eq!(plans[0].cursor_column.as_deref(), Some("updated_at"));
648 assert_eq!(plans[0].gcs_prefix, "gs://b2/exports/beta/");
649 let statuses: Vec<_> = plans[0].specs.iter().map(|s| s.status).collect();
652 assert_eq!(
653 statuses,
654 vec![TargetStatus::Ok, TargetStatus::Fail, TargetStatus::Warn]
655 );
656
657 assert_eq!(plans[1].table, "alpha_tbl");
659 assert_eq!(plans[1].mode, LoadMode::Full);
660 assert_eq!(plans[1].gcs_prefix, "gs://b1/exports/alpha/");
661 }
662
663 #[test]
664 fn build_plans_bails_on_a_report_for_an_unknown_export() {
665 let cfg = crate::config::Config::from_yaml(
666 "source:\n type: postgres\n url: \"postgresql://localhost/test\"\n\
667 exports:\n - name: a\n query: \"SELECT 1\"\n format: parquet\n \
668 destination:\n type: gcs\n bucket: b\n prefix: p/\nload:\n \
669 target: bigquery\n project: p\n dataset: d\n",
670 )
671 .unwrap();
672 let load: LoadSection = serde_json::from_value(cfg.load.clone().unwrap()).unwrap();
673 let reports = vec![ExportReport {
674 export: "ghost".into(),
675 columns: vec![],
676 }];
677 let err = build_plans(&cfg, &load, reports).unwrap_err().to_string();
678 assert!(err.contains("ghost") && err.contains("not found"), "{err}");
679 }
680
681 #[test]
682 fn reject_duplicate_target_tables_catches_a_collision() {
683 assert!(reject_duplicate_target_tables(&["orders", "events", "orders"]).is_err());
686 assert!(reject_duplicate_target_tables(&["orders", "events"]).is_ok());
687 assert!(reject_duplicate_target_tables(&[]).is_ok());
688 }
689
690 #[test]
691 fn multi_export_check_json_parses_as_a_stream() {
692 let raw = concat!(
696 "{\"export\":\"orders\",\"columns\":[{\"column\":\"id\",\"target_type\":\"NUMBER\",\"target_status\":\"ok\"}]}\n",
697 "{\"export\":\"customers\",\"columns\":[{\"column\":\"cid\",\"target_type\":\"NUMBER\",\"target_status\":\"ok\"}]}\n"
698 );
699 let reports: Vec<ExportReport> = serde_json::Deserializer::from_slice(raw.as_bytes())
700 .into_iter::<ExportReport>()
701 .collect::<Result<_, _>>()
702 .unwrap();
703 assert_eq!(reports.len(), 2);
704 assert_eq!(reports[0].export, "orders");
705 assert_eq!(reports[1].export, "customers");
706 assert_eq!(reports[1].columns[0].column, "cid");
707 }
708
709 #[test]
710 fn bigquery_load_section_deserializes_into_its_variant() {
711 let value = serde_json::json!({
712 "target": "bigquery", "project": "p", "dataset": "d",
713 "cleanup_source": true, "cluster_by": ["customer"]
714 });
715 let load: LoadSection = serde_json::from_value(value).unwrap();
716 assert_eq!(load.target.name(), "bigquery");
717 assert!(load.cleanup_source);
718 assert_eq!(load.cluster_by, vec!["customer"]);
719 match load.target {
720 LoadTarget::Bigquery { project, dataset } => {
721 assert_eq!((project.as_str(), dataset.as_str()), ("p", "d"));
722 }
723 _ => panic!("expected Bigquery variant"),
724 }
725 }
726
727 #[test]
728 fn snowflake_missing_field_is_unrepresentable_no_runtime_validate() {
729 let full = serde_json::json!({
730 "target": "snowflake", "connection": "rivet", "warehouse": "wh",
731 "database": "db", "schema": "sc", "storage_integration": "si"
732 });
733 let load: LoadSection = serde_json::from_value(full).unwrap();
734 assert_eq!(load.target.name(), "snowflake");
735
736 let partial = serde_json::json!({
739 "target": "snowflake", "connection": "rivet", "warehouse": "wh",
740 "database": "db", "schema": "sc"
741 });
742 let err = serde_json::from_value::<LoadSection>(partial).unwrap_err();
743 assert!(
744 err.to_string().contains("storage_integration"),
745 "error should name the missing field: {err}"
746 );
747 }
748
749 #[test]
750 fn unknown_target_is_rejected_at_deserialize() {
751 let value = serde_json::json!({ "target": "redshift", "project": "p" });
752 assert!(serde_json::from_value::<LoadSection>(value).is_err());
753 }
754
755 fn top_level_load() -> LoadSection {
756 serde_json::from_value(serde_json::json!({
757 "target": "bigquery", "project": "p", "dataset": "d",
758 "pk": ["top"], "cleanup_source": true, "gc_orphans": false,
759 "cluster_by": ["c0"], "allow_source_drift": false,
760 }))
761 .unwrap()
762 }
763
764 #[test]
765 fn with_override_replaces_some_fields_and_inherits_the_rest() {
766 let top = top_level_load();
767 let o: LoadOverride =
769 serde_json::from_value(serde_json::json!({ "pk": ["id"], "gc_orphans": true }))
770 .unwrap();
771 let eff = top.with_override(&o);
772 assert_eq!(eff.pk, vec!["id"], "pk replaced");
773 assert!(eff.gc_orphans, "gc_orphans replaced");
774 assert!(
775 eff.cleanup_source,
776 "cleanup_source inherited (top-level true)"
777 );
778 assert_eq!(eff.cluster_by, vec!["c0"], "cluster_by inherited");
779 assert!(!eff.allow_source_drift, "allow_source_drift inherited");
780 }
781
782 #[test]
783 fn override_parsing_leaves_omitted_fields_none() {
784 let o: LoadOverride = serde_json::from_value(serde_json::json!({ "pk": ["id"] })).unwrap();
785 assert_eq!(o.pk.as_deref(), Some(&["id".to_string()][..]));
786 assert!(o.cleanup_source.is_none());
787 assert!(o.gc_orphans.is_none());
788 assert!(o.cluster_by.is_none());
789 assert!(o.allow_source_drift.is_none());
790 assert!(o.target.is_none());
791 }
792
793 #[test]
794 fn empty_override_is_distinct_from_inherit() {
795 let top = top_level_load();
796 let cleared: LoadOverride =
798 serde_json::from_value(serde_json::json!({ "pk": [] })).unwrap();
799 assert!(
800 top.with_override(&cleared).pk.is_empty(),
801 "explicit [] clears"
802 );
803 let inherit: LoadOverride = serde_json::from_value(serde_json::json!({})).unwrap();
804 assert_eq!(
805 top.with_override(&inherit).pk,
806 vec!["top"],
807 "omitted inherits"
808 );
809 }
810
811 #[test]
812 fn override_carrying_target_is_detected() {
813 let o: LoadOverride =
816 serde_json::from_value(serde_json::json!({ "target": "snowflake" })).unwrap();
817 assert!(
818 o.target.is_some(),
819 "target captured for the plan_loads guard"
820 );
821 }
822
823 #[test]
824 fn unknown_top_level_load_key_is_rejected() {
825 let typo = serde_json::json!({
828 "target": "bigquery", "project": "p", "dataset": "d", "gc_orphan": true
829 });
830 let err = check_load_keys(&typo, "top-level").unwrap_err().to_string();
831 assert!(err.contains("gc_orphan"), "{err}");
832 let ok = serde_json::json!({
834 "target": "bigquery", "project": "p", "dataset": "d",
835 "gc_orphans": true, "cleanup_source": false, "pk": ["id"],
836 "allow_source_drift": true, "cluster_by": ["a"]
837 });
838 assert!(check_load_keys(&ok, "top-level").is_ok());
839 }
840
841 #[test]
842 fn unknown_per_export_override_key_is_rejected() {
843 let typo = serde_json::json!({ "pk": ["id"], "cluster_bye": ["x"] });
845 assert!(
846 serde_json::from_value::<LoadOverride>(typo).is_err(),
847 "a typo'd override key must fail to parse"
848 );
849 let ok = serde_json::json!({ "pk": ["id"], "cleanup_source": true });
850 assert!(serde_json::from_value::<LoadOverride>(ok).is_ok());
851 }
852
853 #[test]
854 fn resolve_load_prefix_refuses_day_specific_date_in_the_base() {
855 let dest = |p: &str| crate::config::DestinationConfig {
859 prefix: Some(p.to_string()),
860 ..Default::default()
861 };
862 let err = resolve_load_prefix(&dest("exports/{date}/{export}/"), "orders", "bkt")
863 .unwrap_err()
864 .to_string();
865 assert!(
866 err.contains("{date}") && err.contains("load base"),
867 "must refuse a day-specific date base: {err}"
868 );
869 assert!(resolve_load_prefix(&dest("exports/{export}/"), "orders", "bkt").is_ok());
870 assert!(
872 resolve_load_prefix(
873 &dest("exports/{export}/{partition}/{date}/"),
874 "orders",
875 "bkt"
876 )
877 .is_ok()
878 );
879 }
880
881 #[test]
882 fn cross_warehouse_load_fields_are_rejected() {
883 let snow_with_bq = serde_json::json!({
887 "target": "snowflake", "connection": "c", "warehouse": "w",
888 "database": "db", "schema": "s", "storage_integration": "si",
889 "project": "STALE", "dataset": "STALE"
890 });
891 let err = reject_foreign_target_fields(&snow_with_bq, "snowflake", "top-level")
892 .unwrap_err()
893 .to_string();
894 assert!(
895 err.contains("project") && err.contains("bigquery"),
896 "snowflake+project must name the foreign field and warehouse: {err}"
897 );
898 let bq_with_snow = serde_json::json!({
900 "target": "bigquery", "project": "p", "dataset": "d", "warehouse": "WH"
901 });
902 assert!(reject_foreign_target_fields(&bq_with_snow, "bigquery", "top-level").is_err());
903 let clean = serde_json::json!({ "target": "bigquery", "project": "p", "dataset": "d" });
905 assert!(reject_foreign_target_fields(&clean, "bigquery", "top-level").is_ok());
906 }
907}