Skip to main content

rivet/load/
plan.rs

1//! Config-driven load planning — derive a BigQuery load (native schema, table,
2//! partition, source URIs) from a rivet export config, so a client never
3//! hand-types column types. The schema comes from rivet's own type resolver
4//! via `rivet check --target bigquery --json` (the argv/process boundary,
5//! ADR-0026); the table/partition/destination come from the parsed config.
6
7use crate::types::target::{TargetColumnSpec, TargetStatus};
8use anyhow::{Context, Result, bail};
9use serde::Deserialize;
10use std::process::Command;
11
12/// The warehouse load target — the config's **top-level `load:` block**,
13/// declared ONCE for all exports. OSS accepts and ignores this block (a
14/// reserved passthrough); the loader reads it here. One config file drives
15/// both the export and the load — no second file, no per-table repetition.
16///
17/// `cleanup_source`/`cluster_by` are target-agnostic; the warehouse and its
18/// connection config live in [`LoadTarget`], keyed on the `target:`
19/// discriminator — so a config that names `snowflake` cannot carry BigQuery
20/// fields (invalid combos fail to deserialize; no runtime `validate()`).
21#[derive(Debug, Clone, Deserialize)]
22pub struct LoadSection {
23    #[serde(flatten)]
24    pub target: LoadTarget,
25    #[serde(default)]
26    pub cleanup_source: bool,
27    /// Primary key column(s) for the incremental/CDC current-state dedup view —
28    /// the view's PARTITION BY. Required for `mode: incremental` / `mode: cdc`;
29    /// ignored for `full` (which overwrites, no view). Composite key = several
30    /// columns, e.g. `pk: [tenant, id]`.
31    #[serde(default)]
32    pub pk: Vec<String>,
33    /// Load even when a run manifest's source count disagrees with what it
34    /// extracted (source→file drift): warn instead of blocking. The
35    /// file→warehouse count gate and manifest gates still apply.
36    #[serde(default)]
37    pub allow_source_drift: bool,
38    /// After a successful load, delete staged Parquet under the export prefix
39    /// that no `Success` manifest references — crash leftovers from an
40    /// interrupted extract. Keeps the current run's files, manifests, and
41    /// `_SUCCESS`; strictly gentler than `cleanup_source`, which wipes the whole
42    /// prefix. Off by default. ⚠️ Only enable when no extract writes this prefix
43    /// concurrently — it can't tell a crash orphan from a live run's in-flight
44    /// parts (see `reconcile::gc_orphans`); the normal load-after-extract flow is
45    /// safe.
46    #[serde(default)]
47    pub gc_orphans: bool,
48    /// Clustering key column(s) — BigQuery `CLUSTER BY` / Snowflake `CLUSTER BY`.
49    /// Empty = none. Applies at table creation.
50    #[serde(default)]
51    pub cluster_by: Vec<String>,
52}
53
54/// Per-export overrides of the top-level [`LoadSection`] — every field optional,
55/// `None` inherits the top-level value. `target` is present ONLY to reject it:
56/// the warehouse is shared (`plan_loads` runs one `rivet check --target`), so it
57/// stays top-level.
58// `deny_unknown_fields` so a per-export `load:` typo (`gc_orphan`, `cleanupsrc`)
59// fails loudly instead of silently deserializing to the default and dropping the
60// override. (LoadOverride has no `#[serde(flatten)]`, so unlike LoadSection this
61// works directly.)
62#[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    /// Only to REJECT — a per-export `load:` cannot re-target the warehouse.
76    #[serde(default)]
77    target: Option<serde_json::Value>,
78}
79
80impl LoadSection {
81    /// The effective load config for one export: this top-level section with the
82    /// export's [`LoadOverride`] applied — each `Some` field replaces, each
83    /// `None` inherits. `target` is never overridden.
84    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/// A warehouse and its connection config. `target:` is the serde discriminator.
106#[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    /// The `--target` name to pass to `rivet check`.
124    pub fn name(&self) -> &'static str {
125        match self {
126            LoadTarget::Bigquery { .. } => "bigquery",
127            LoadTarget::Snowflake { .. } => "snowflake",
128        }
129    }
130}
131
132/// Which load strategy an export's `mode` maps to. Drives BOTH the ledger's
133/// file selection and the warehouse write path:
134/// - `Full` — the export is a complete snapshot; load the LATEST run only and
135///   OVERWRITE (chunked is a parallel full snapshot, same handling).
136/// - `Incremental` — the export is a delta since a cursor; APPEND it to
137///   `<table>__changes` and dedup to current state ordered by the cursor.
138/// - `Cdc` — a change stream; APPEND + dedup by `(__pos, __seq)` with tombstones.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum LoadMode {
141    Full,
142    Incremental,
143    Cdc,
144}
145
146impl LoadMode {
147    /// The ledger's `mode` discriminator (the `load_run.mode` column) — the single
148    /// source of truth for the string that names each strategy in the state DB, so
149    /// no call site hand-writes a stringly-typed `"full"`/`"cdc"` that can drift.
150    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/// What a rivet config resolves to for a BigQuery load.
160#[derive(Debug, Clone)]
161pub struct LoadPlan {
162    pub table: String,
163    pub partition_by: Option<String>,
164    pub specs: Vec<TargetColumnSpec>,
165    /// `gs://bucket/base/` — the destination prefix up to the `{partition}`
166    /// token, i.e. the root to list source Parquet under.
167    pub gcs_prefix: String,
168    /// The export's GCS destination (bucket + auth) — the native opendal client
169    /// the load layer lists / reads / deletes through.
170    pub destination: crate::config::DestinationConfig,
171    /// The `load:` target from the same config.
172    pub load: LoadSection,
173    /// The export's mode → the load strategy (see [`LoadMode`]).
174    pub mode: LoadMode,
175    /// The incremental cursor column (from `cursor_column:`) — the dedup view's
176    /// latest-per-PK ordering key. `Some` only for [`LoadMode::Incremental`].
177    pub cursor_column: Option<String>,
178}
179
180/// One export's slice of `rivet check --target X --json`. The tool emits one
181/// such JSON document **per export** (concatenated), so a multi-table config
182/// yields a stream of these — parsed with a streaming deserializer.
183#[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
196/// Resolve a rivet config into **one [`LoadPlan`] per export** — the shared
197/// top-level `load:` target plus each export's own table / partition / GCS
198/// destination / native schema. `rivet check --json` emits one JSON document
199/// per export, so a multi-table config produces a plan per table, all pointed
200/// at the same warehouse target.
201/// Every key a `load:` block may carry — the [`LoadSection`] fields plus the
202/// flattened [`LoadTarget`] variant fields. The top-level block can't use serde
203/// `deny_unknown_fields` (incompatible with the flattened `target` enum), so we
204/// check its keys by hand — else a typo (`gc_orphan`, `cleanupsource`) silently
205/// deserializes to the default and the setting never applies.
206const LOAD_KEYS: &[&str] = &[
207    "target",
208    "cleanup_source",
209    "pk",
210    "allow_source_drift",
211    "gc_orphans",
212    "cluster_by",
213    // LoadTarget::{Bigquery, Snowflake} variant fields (flattened in).
214    "project",
215    "dataset",
216    "connection",
217    "warehouse",
218    "database",
219    "schema",
220    "storage_integration",
221];
222
223/// Reject any key in a `load:` block that isn't in [`LOAD_KEYS`] — turns a
224/// silently-ignored typo into a loud error naming the valid keys.
225fn 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    // The `load:` target from the same config (OSS accepts + ignores it),
248    // shared by every export.
249    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    // Native schema from rivet's own resolver, for the load target — no
257    // hand-typing. One JSON document per export, so parse a stream.
258    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
285/// The **pure core** of [`plan_loads`]: map the parsed `rivet check` reports onto
286/// one [`LoadPlan`] per export, given the config and the shared `load:` section.
287///
288/// No I/O — the subprocess and filesystem work is done by [`plan_loads`], and
289/// everything they produced arrives in the args. That makes the per-export
290/// resolution unit-testable without a `rivet` binary: the export→report name
291/// match, `target_status`→[`TargetStatus`] mapping, [`ExportMode`]→[`LoadMode`]
292/// mapping, the `gs://` prefix, the per-export `load:` override, and the
293/// duplicate-target guard.
294fn 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        // Complete-snapshot modes → overwrite the latest run; delta modes → their
342        // own append path. Exhaustive (no `_`) on purpose: a future delta-style
343        // ExportMode then fails to COMPILE here until someone picks its load
344        // semantics, instead of silently defaulting to OVERWRITE (the
345        // incremental-overwrite data-loss class).
346        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, // whole result set
350            crate::config::ExportMode::Chunked => LoadMode::Full, // parallel full snapshot
351            crate::config::ExportMode::TimeWindow => LoadMode::Full, // whole rolling window
352        };
353        // Effective load config: the shared top-level `load:`, with this export's
354        // own `load:` block overriding the table-specific fields (pk, cleanup, …).
355        // The warehouse `target` is shared and cannot be re-targeted per export.
356        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
387/// Reject two exports that resolve to the SAME warehouse table. The `target:` is
388/// shared, so two exports whose `table:` (or `name:`) resolves alike land on one
389/// warehouse object — a full OVERWRITE would clobber what a cdc/incremental
390/// export appends a `<table>__changes` view over, and they'd share one ledger
391/// skip-set. Pure + unit-testable; caught here, not silently at load time.
392fn 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
406/// Resolve the config's source engine into the CDC [`SourceEngine`] the dedup
407/// view's `__pos` parse is keyed on. One config has one source, so this is a
408/// job-wide property. MongoDB is supported too: its change stream carries a
409/// document `_id` (the dedup partition key) and an order-preserving `_data`
410/// resume token in `__pos`, so the current-state view applies just as it does to
411/// the relational engines.
412pub 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        // The state DB's `load_run.mode` discriminator — every mode must map to
434        // its exact stable string, since retry/skip logic keys off it. A drifted
435        // value would mislabel loads in the ledger.
436        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    /// A `ColReport` with an explicit `target_status`.
442    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    /// Drive the PURE `build_plans` (no `rivet` subprocess) — the deepened core
451    /// of `plan_loads`. Kills the mutation survivors that live in the per-export
452    /// resolution: the export→report name match (`==`→`!=`) and the `fail`/`warn`
453    /// `target_status` arms. Also pins mode mapping, the `gs://` prefix, table
454    /// resolution, and the cursor column.
455    #[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        // Reports arrive in the OPPOSITE order to the exports, so a plan only
490        // lands on the right table if its export is found by NAME, not position.
491        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        // reports[0] = beta → matched by name to the 2nd export (kills `==`→`!=`,
506        // which would resolve the first NON-matching export instead).
507        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        // target_status → spec.status (kills the `fail`/`warn` arm deletions,
515        // which would collapse those columns to Ok and load an unmappable column).
516        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        // reports[1] = alpha → the full-snapshot export.
523        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        // Two exports resolving to the same warehouse table would clobber each
549        // other — caught at plan time, not silently at load time.
550        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        // `rivet check --json` emits ONE document per export (no wrapping array).
558        // A single-object parse fails "trailing characters"; the stream parser
559        // yields one ExportReport per table. Guards the multi-table regression.
560        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        // A snowflake block missing storage_integration doesn't deserialize —
602        // the type makes it unrepresentable, so there is no runtime validate().
603        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        // Override ONLY pk + gc_orphans; the rest must inherit the top-level.
633        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        // An EXPLICIT empty pk clears the inherited one; a missing pk keeps it.
662        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        // The plan_loads guard rejects a per-export `load:` that re-targets the
679        // warehouse; the override captures `target:` so the guard can see it.
680        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        // A typo (`gc_orphan` for `gc_orphans`) must fail loudly, not silently
691        // deserialize to the default so the setting never applies.
692        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        // Every valid LoadSection + LoadTarget key passes.
698        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        // `deny_unknown_fields` on LoadOverride catches per-export typos.
709        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}