1use crate::types::target::{TargetColumnSpec, TargetStatus};
8use anyhow::{Context, Result, bail};
9use serde::Deserialize;
10use std::process::Command;
11
12#[derive(Debug, Clone, Deserialize)]
22pub struct LoadSection {
23 #[serde(flatten)]
24 pub target: LoadTarget,
25 #[serde(default)]
26 pub cleanup_source: bool,
27 #[serde(default)]
32 pub pk: Vec<String>,
33 #[serde(default)]
37 pub allow_source_drift: bool,
38 #[serde(default)]
47 pub gc_orphans: bool,
48 #[serde(default)]
51 pub cluster_by: Vec<String>,
52}
53
54#[derive(Debug, Deserialize)]
63#[serde(deny_unknown_fields)]
64struct LoadOverride {
65 #[serde(default)]
66 pk: Option<Vec<String>>,
67 #[serde(default)]
68 cleanup_source: Option<bool>,
69 #[serde(default)]
70 gc_orphans: Option<bool>,
71 #[serde(default)]
72 cluster_by: Option<Vec<String>>,
73 #[serde(default)]
74 allow_source_drift: Option<bool>,
75 #[serde(default)]
77 target: Option<serde_json::Value>,
78}
79
80impl LoadSection {
81 fn with_override(&self, o: &LoadOverride) -> LoadSection {
85 let mut eff = self.clone();
86 if let Some(pk) = &o.pk {
87 eff.pk = pk.clone();
88 }
89 if let Some(c) = o.cleanup_source {
90 eff.cleanup_source = c;
91 }
92 if let Some(g) = o.gc_orphans {
93 eff.gc_orphans = g;
94 }
95 if let Some(cb) = &o.cluster_by {
96 eff.cluster_by = cb.clone();
97 }
98 if let Some(d) = o.allow_source_drift {
99 eff.allow_source_drift = d;
100 }
101 eff
102 }
103}
104
105#[derive(Debug, Clone, Deserialize)]
107#[serde(tag = "target", rename_all = "lowercase")]
108pub enum LoadTarget {
109 Bigquery {
110 project: String,
111 dataset: String,
112 },
113 Snowflake {
114 connection: String,
115 warehouse: String,
116 database: String,
117 schema: String,
118 storage_integration: String,
119 },
120}
121
122impl LoadTarget {
123 pub fn name(&self) -> &'static str {
125 match self {
126 LoadTarget::Bigquery { .. } => "bigquery",
127 LoadTarget::Snowflake { .. } => "snowflake",
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum LoadMode {
141 Full,
142 Incremental,
143 Cdc,
144}
145
146impl LoadMode {
147 pub fn ledger_str(self) -> &'static str {
151 match self {
152 LoadMode::Full => "full",
153 LoadMode::Incremental => "incremental",
154 LoadMode::Cdc => "cdc",
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct LoadPlan {
162 pub table: String,
163 pub partition_by: Option<String>,
164 pub specs: Vec<TargetColumnSpec>,
165 pub gcs_prefix: String,
168 pub destination: crate::config::DestinationConfig,
171 pub load: LoadSection,
173 pub mode: LoadMode,
175 pub cursor_column: Option<String>,
178}
179
180#[derive(Deserialize)]
184struct ExportReport {
185 export: String,
186 columns: Vec<ColReport>,
187}
188
189#[derive(Deserialize)]
190struct ColReport {
191 column: String,
192 target_type: String,
193 target_status: String,
194}
195
196const LOAD_KEYS: &[&str] = &[
207 "target",
208 "cleanup_source",
209 "pk",
210 "allow_source_drift",
211 "gc_orphans",
212 "cluster_by",
213 "project",
215 "dataset",
216 "connection",
217 "warehouse",
218 "database",
219 "schema",
220 "storage_integration",
221];
222
223fn check_load_keys(value: &serde_json::Value, whose: &str) -> Result<()> {
226 if let Some(obj) = value.as_object() {
227 for k in obj.keys() {
228 if !LOAD_KEYS.contains(&k.as_str()) {
229 bail!(
230 "unknown key `{k}` in the {whose} `load:` block — valid keys are: {}",
231 LOAD_KEYS.join(", ")
232 );
233 }
234 }
235 }
236 Ok(())
237}
238
239pub fn plan_loads(config_path: &str, rivet_bin: &str) -> Result<Vec<LoadPlan>> {
240 let yaml = std::fs::read_to_string(config_path)
241 .with_context(|| format!("reading config {config_path}"))?;
242 let cfg = crate::config::Config::from_yaml(&yaml).context("parsing rivet config")?;
243 if cfg.exports.is_empty() {
244 bail!("config has no exports");
245 }
246
247 let load_value = cfg.load.clone().context(
250 "config has no top-level `load:` block — add `load: { target, ... }` to load into a warehouse",
251 )?;
252 check_load_keys(&load_value, "top-level")?;
253 let load: LoadSection =
254 serde_json::from_value(load_value).context("parsing the top-level `load:` block")?;
255
256 let out = Command::new(rivet_bin)
259 .args([
260 "check",
261 "-c",
262 config_path,
263 "--target",
264 load.target.name(),
265 "--json",
266 ])
267 .output()
268 .with_context(|| {
269 format!("running `{rivet_bin} check` — is rivet on PATH? pass --rivet-bin")
270 })?;
271 if !out.status.success() {
272 bail!(
273 "rivet check failed: {}",
274 String::from_utf8_lossy(&out.stderr).trim()
275 );
276 }
277 let reports: Vec<ExportReport> = serde_json::Deserializer::from_slice(&out.stdout)
278 .into_iter::<ExportReport>()
279 .collect::<Result<_, _>>()
280 .context("parsing `rivet check --json` (one document per export)")?;
281
282 build_plans(&cfg, &load, reports)
283}
284
285fn build_plans(
295 cfg: &crate::config::Config,
296 load: &LoadSection,
297 reports: Vec<ExportReport>,
298) -> Result<Vec<LoadPlan>> {
299 let mut plans = Vec::with_capacity(reports.len());
300 for report in reports {
301 let export = cfg
302 .exports
303 .iter()
304 .find(|e| e.name == report.export)
305 .with_context(|| {
306 format!(
307 "rivet check reported export `{}` not found in config",
308 report.export
309 )
310 })?;
311 let table = export.table.clone().unwrap_or_else(|| export.name.clone());
312
313 let dest = &export.destination;
314 let bucket = dest.bucket.as_deref().with_context(|| {
315 format!(
316 "export `{}` has no destination `bucket` — a GCS destination is required",
317 export.name
318 )
319 })?;
320 let prefix = dest.prefix.as_deref().unwrap_or("");
321 let base = prefix.split("{partition}").next().unwrap_or(prefix);
322 let gcs_prefix = format!("gs://{bucket}/{base}");
323
324 let specs = report
325 .columns
326 .into_iter()
327 .map(|c| TargetColumnSpec {
328 column_name: c.column,
329 target_type: c.target_type,
330 autoload_type: String::new(),
331 status: match c.target_status.as_str() {
332 "fail" => TargetStatus::Fail,
333 "warn" => TargetStatus::Warn,
334 _ => TargetStatus::Ok,
335 },
336 note: None,
337 cast_sql: None,
338 })
339 .collect();
340
341 let mode = match export.mode {
347 crate::config::ExportMode::Cdc => LoadMode::Cdc,
348 crate::config::ExportMode::Incremental => LoadMode::Incremental,
349 crate::config::ExportMode::Full => LoadMode::Full, crate::config::ExportMode::Chunked => LoadMode::Full, crate::config::ExportMode::TimeWindow => LoadMode::Full, };
353 let eff_load = match &export.load {
357 Some(v) => {
358 let o: LoadOverride = serde_json::from_value(v.clone()).with_context(|| {
359 format!("parsing export `{}` `load:` override", export.name)
360 })?;
361 if o.target.is_some() {
362 bail!(
363 "export `{}`: a per-export `load:` cannot override `target:` — the \
364 warehouse is shared; set `target:` in the top-level `load:` block only",
365 export.name
366 );
367 }
368 load.with_override(&o)
369 }
370 None => load.clone(),
371 };
372 plans.push(LoadPlan {
373 table,
374 partition_by: export.partition_by.clone(),
375 specs,
376 gcs_prefix,
377 destination: export.destination.clone(),
378 load: eff_load,
379 mode,
380 cursor_column: export.cursor_column.clone(),
381 });
382 }
383 reject_duplicate_target_tables(&plans.iter().map(|p| p.table.as_str()).collect::<Vec<_>>())?;
384 Ok(plans)
385}
386
387fn reject_duplicate_target_tables(tables: &[&str]) -> Result<()> {
393 let mut seen = std::collections::HashSet::new();
394 for t in tables {
395 if !seen.insert(*t) {
396 bail!(
397 "two exports resolve to the same load target table `{t}` — each would clobber \
398 the other (a full OVERWRITE vs a cdc/incremental append share the table and \
399 its ledger). Give each export its own `table:` or destination."
400 );
401 }
402 }
403 Ok(())
404}
405
406pub fn source_engine(config_path: &str) -> Result<crate::load::cdc::SourceEngine> {
413 use crate::config::SourceType;
414 use crate::load::cdc::SourceEngine;
415
416 let yaml = std::fs::read_to_string(config_path)
417 .with_context(|| format!("reading config {config_path}"))?;
418 let cfg = crate::config::Config::from_yaml(&yaml).context("parsing rivet config")?;
419 match cfg.source.source_type {
420 SourceType::Postgres => Ok(SourceEngine::Postgres),
421 SourceType::Mysql => Ok(SourceEngine::MySql),
422 SourceType::Mssql => Ok(SourceEngine::SqlServer),
423 SourceType::Mongo => Ok(SourceEngine::Mongo),
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn ledger_str_names_each_mode_stably() {
433 assert_eq!(LoadMode::Full.ledger_str(), "full");
437 assert_eq!(LoadMode::Incremental.ledger_str(), "incremental");
438 assert_eq!(LoadMode::Cdc.ledger_str(), "cdc");
439 }
440
441 fn col(name: &str, status: &str) -> ColReport {
443 ColReport {
444 column: name.into(),
445 target_type: "STRING".into(),
446 target_status: status.into(),
447 }
448 }
449
450 #[test]
456 fn build_plans_matches_by_name_maps_statuses_and_mode() {
457 let cfg = crate::config::Config::from_yaml(
458 r#"
459source:
460 type: postgres
461 url: "postgresql://localhost/test"
462exports:
463 - name: alpha
464 table: alpha_tbl
465 mode: full
466 format: parquet
467 destination:
468 type: gcs
469 bucket: b1
470 prefix: exports/alpha/
471 - name: beta
472 table: beta_tbl
473 mode: incremental
474 cursor_column: updated_at
475 format: parquet
476 destination:
477 type: gcs
478 bucket: b2
479 prefix: exports/beta/
480load:
481 target: bigquery
482 project: p
483 dataset: d
484"#,
485 )
486 .unwrap();
487 let load: LoadSection = serde_json::from_value(cfg.load.clone().unwrap()).unwrap();
488
489 let reports = vec![
492 ExportReport {
493 export: "beta".into(),
494 columns: vec![col("id", "ok"), col("f", "fail"), col("w", "warn")],
495 },
496 ExportReport {
497 export: "alpha".into(),
498 columns: vec![col("id", "ok")],
499 },
500 ];
501
502 let plans = build_plans(&cfg, &load, reports).unwrap();
503 assert_eq!(plans.len(), 2);
504
505 assert_eq!(
508 plans[0].table, "beta_tbl",
509 "found beta by name, not position"
510 );
511 assert_eq!(plans[0].mode, LoadMode::Incremental);
512 assert_eq!(plans[0].cursor_column.as_deref(), Some("updated_at"));
513 assert_eq!(plans[0].gcs_prefix, "gs://b2/exports/beta/");
514 let statuses: Vec<_> = plans[0].specs.iter().map(|s| s.status).collect();
517 assert_eq!(
518 statuses,
519 vec![TargetStatus::Ok, TargetStatus::Fail, TargetStatus::Warn]
520 );
521
522 assert_eq!(plans[1].table, "alpha_tbl");
524 assert_eq!(plans[1].mode, LoadMode::Full);
525 assert_eq!(plans[1].gcs_prefix, "gs://b1/exports/alpha/");
526 }
527
528 #[test]
529 fn build_plans_bails_on_a_report_for_an_unknown_export() {
530 let cfg = crate::config::Config::from_yaml(
531 "source:\n type: postgres\n url: \"postgresql://localhost/test\"\n\
532 exports:\n - name: a\n query: \"SELECT 1\"\n format: parquet\n \
533 destination:\n type: gcs\n bucket: b\n prefix: p/\nload:\n \
534 target: bigquery\n project: p\n dataset: d\n",
535 )
536 .unwrap();
537 let load: LoadSection = serde_json::from_value(cfg.load.clone().unwrap()).unwrap();
538 let reports = vec![ExportReport {
539 export: "ghost".into(),
540 columns: vec![],
541 }];
542 let err = build_plans(&cfg, &load, reports).unwrap_err().to_string();
543 assert!(err.contains("ghost") && err.contains("not found"), "{err}");
544 }
545
546 #[test]
547 fn reject_duplicate_target_tables_catches_a_collision() {
548 assert!(reject_duplicate_target_tables(&["orders", "events", "orders"]).is_err());
551 assert!(reject_duplicate_target_tables(&["orders", "events"]).is_ok());
552 assert!(reject_duplicate_target_tables(&[]).is_ok());
553 }
554
555 #[test]
556 fn multi_export_check_json_parses_as_a_stream() {
557 let raw = concat!(
561 "{\"export\":\"orders\",\"columns\":[{\"column\":\"id\",\"target_type\":\"NUMBER\",\"target_status\":\"ok\"}]}\n",
562 "{\"export\":\"customers\",\"columns\":[{\"column\":\"cid\",\"target_type\":\"NUMBER\",\"target_status\":\"ok\"}]}\n"
563 );
564 let reports: Vec<ExportReport> = serde_json::Deserializer::from_slice(raw.as_bytes())
565 .into_iter::<ExportReport>()
566 .collect::<Result<_, _>>()
567 .unwrap();
568 assert_eq!(reports.len(), 2);
569 assert_eq!(reports[0].export, "orders");
570 assert_eq!(reports[1].export, "customers");
571 assert_eq!(reports[1].columns[0].column, "cid");
572 }
573
574 #[test]
575 fn bigquery_load_section_deserializes_into_its_variant() {
576 let value = serde_json::json!({
577 "target": "bigquery", "project": "p", "dataset": "d",
578 "cleanup_source": true, "cluster_by": ["customer"]
579 });
580 let load: LoadSection = serde_json::from_value(value).unwrap();
581 assert_eq!(load.target.name(), "bigquery");
582 assert!(load.cleanup_source);
583 assert_eq!(load.cluster_by, vec!["customer"]);
584 match load.target {
585 LoadTarget::Bigquery { project, dataset } => {
586 assert_eq!((project.as_str(), dataset.as_str()), ("p", "d"));
587 }
588 _ => panic!("expected Bigquery variant"),
589 }
590 }
591
592 #[test]
593 fn snowflake_missing_field_is_unrepresentable_no_runtime_validate() {
594 let full = serde_json::json!({
595 "target": "snowflake", "connection": "rivet", "warehouse": "wh",
596 "database": "db", "schema": "sc", "storage_integration": "si"
597 });
598 let load: LoadSection = serde_json::from_value(full).unwrap();
599 assert_eq!(load.target.name(), "snowflake");
600
601 let partial = serde_json::json!({
604 "target": "snowflake", "connection": "rivet", "warehouse": "wh",
605 "database": "db", "schema": "sc"
606 });
607 let err = serde_json::from_value::<LoadSection>(partial).unwrap_err();
608 assert!(
609 err.to_string().contains("storage_integration"),
610 "error should name the missing field: {err}"
611 );
612 }
613
614 #[test]
615 fn unknown_target_is_rejected_at_deserialize() {
616 let value = serde_json::json!({ "target": "redshift", "project": "p" });
617 assert!(serde_json::from_value::<LoadSection>(value).is_err());
618 }
619
620 fn top_level_load() -> LoadSection {
621 serde_json::from_value(serde_json::json!({
622 "target": "bigquery", "project": "p", "dataset": "d",
623 "pk": ["top"], "cleanup_source": true, "gc_orphans": false,
624 "cluster_by": ["c0"], "allow_source_drift": false,
625 }))
626 .unwrap()
627 }
628
629 #[test]
630 fn with_override_replaces_some_fields_and_inherits_the_rest() {
631 let top = top_level_load();
632 let o: LoadOverride =
634 serde_json::from_value(serde_json::json!({ "pk": ["id"], "gc_orphans": true }))
635 .unwrap();
636 let eff = top.with_override(&o);
637 assert_eq!(eff.pk, vec!["id"], "pk replaced");
638 assert!(eff.gc_orphans, "gc_orphans replaced");
639 assert!(
640 eff.cleanup_source,
641 "cleanup_source inherited (top-level true)"
642 );
643 assert_eq!(eff.cluster_by, vec!["c0"], "cluster_by inherited");
644 assert!(!eff.allow_source_drift, "allow_source_drift inherited");
645 }
646
647 #[test]
648 fn override_parsing_leaves_omitted_fields_none() {
649 let o: LoadOverride = serde_json::from_value(serde_json::json!({ "pk": ["id"] })).unwrap();
650 assert_eq!(o.pk.as_deref(), Some(&["id".to_string()][..]));
651 assert!(o.cleanup_source.is_none());
652 assert!(o.gc_orphans.is_none());
653 assert!(o.cluster_by.is_none());
654 assert!(o.allow_source_drift.is_none());
655 assert!(o.target.is_none());
656 }
657
658 #[test]
659 fn empty_override_is_distinct_from_inherit() {
660 let top = top_level_load();
661 let cleared: LoadOverride =
663 serde_json::from_value(serde_json::json!({ "pk": [] })).unwrap();
664 assert!(
665 top.with_override(&cleared).pk.is_empty(),
666 "explicit [] clears"
667 );
668 let inherit: LoadOverride = serde_json::from_value(serde_json::json!({})).unwrap();
669 assert_eq!(
670 top.with_override(&inherit).pk,
671 vec!["top"],
672 "omitted inherits"
673 );
674 }
675
676 #[test]
677 fn override_carrying_target_is_detected() {
678 let o: LoadOverride =
681 serde_json::from_value(serde_json::json!({ "target": "snowflake" })).unwrap();
682 assert!(
683 o.target.is_some(),
684 "target captured for the plan_loads guard"
685 );
686 }
687
688 #[test]
689 fn unknown_top_level_load_key_is_rejected() {
690 let typo = serde_json::json!({
693 "target": "bigquery", "project": "p", "dataset": "d", "gc_orphan": true
694 });
695 let err = check_load_keys(&typo, "top-level").unwrap_err().to_string();
696 assert!(err.contains("gc_orphan"), "{err}");
697 let ok = serde_json::json!({
699 "target": "bigquery", "project": "p", "dataset": "d",
700 "gc_orphans": true, "cleanup_source": false, "pk": ["id"],
701 "allow_source_drift": true, "cluster_by": ["a"]
702 });
703 assert!(check_load_keys(&ok, "top-level").is_ok());
704 }
705
706 #[test]
707 fn unknown_per_export_override_key_is_rejected() {
708 let typo = serde_json::json!({ "pk": ["id"], "cluster_bye": ["x"] });
710 assert!(
711 serde_json::from_value::<LoadOverride>(typo).is_err(),
712 "a typo'd override key must fail to parse"
713 );
714 let ok = serde_json::json!({ "pk": ["id"], "cleanup_source": true });
715 assert!(serde_json::from_value::<LoadOverride>(ok).is_ok());
716 }
717}