Skip to main content

rivet/load/
mod.rs

1//! Warehouse load layer — the `TargetLoader` seam, its per-warehouse adapters,
2//! and the warehouse-neutral load driver.
3//!
4//! OSS decides *what* a column becomes in the warehouse (`TargetColumnSpec` via
5//! `ExportTarget::resolve_table`). A [`TargetLoader`] **adapter** runs the
6//! warehouse-specific load ([`bigquery`] — free `LOAD DATA`; [`snowflake`] —
7//! `COPY` off a GCS external stage). The **driver** ([`run_load`] /
8//! [`run_load_cdc`]) owns the invariant orchestration — spec validation, the
9//! count-integrity gate, the dedup-view wiring, and cleanup ordering — so those
10//! invariants are exercised once through a fake adapter, not per warehouse.
11
12use crate::destination::gcs::GcsStore;
13use crate::types::target::{TargetColumnSpec, TargetStatus};
14use anyhow::{Context, Result, bail};
15
16mod bigquery;
17pub mod cdc;
18pub mod plan;
19pub mod reconcile;
20mod snowflake;
21
22pub use bigquery::BigQueryLoader;
23pub use snowflake::SnowflakeLoader;
24
25/// Outcome of a successful batch load.
26#[derive(Debug, Clone)]
27pub struct LoadReport {
28    pub rows_loaded: u64,
29    pub target_table: String,
30    /// True when the source GCS objects were deleted after a verified load.
31    pub source_cleaned: bool,
32}
33
34/// Outcome of a CDC change-log load: rows appended to the `<table>__changes`
35/// log plus the current-state dedup view rebuilt over it.
36#[derive(Debug, Clone)]
37pub struct CdcLoadReport {
38    pub rows_appended: u64,
39    pub changes_table: String,
40    pub view: String,
41    /// Whether `cleanup_source` wiped the staged Parquet after this load — mirrors
42    /// [`LoadReport::source_cleaned`] so the report + logs reflect it for CDC/
43    /// incremental too, instead of discarding it.
44    pub source_cleaned: bool,
45}
46
47/// A warehouse **adapter** — the small, warehouse-specific seam the
48/// [driver](run_load) drives. Dialect + CLI (`bq` / `snow`), the external stage,
49/// BigQuery's 4,000-partition batch split, and `PARSE_JSON` all live *behind*
50/// these primitives.
51///
52/// Idempotent under retry: Rivet is at-least-once at the file layer, so the same
53/// Parquet object may be presented more than once; `materialize` overwrites.
54pub trait TargetLoader {
55    /// Fully-qualify `table` for this warehouse (`project.dataset.t` /
56    /// `db.schema.t`).
57    fn fqtn(&self, table: &str) -> String;
58
59    /// Overwrite `table` with the Parquet at `uris`, materializing the native
60    /// column types in `specs`. Returns the rows the load landed.
61    fn materialize(&self, table: &str, specs: &[TargetColumnSpec], uris: &[String]) -> Result<u64>;
62
63    /// Append the CDC change Parquet into `<table>__changes` (created if absent),
64    /// prepending the `__op` / `__pos` / `__seq` meta columns to `specs`. Returns
65    /// the rows this call appended.
66    fn append_changelog(
67        &self,
68        table: &str,
69        specs: &[TargetColumnSpec],
70        uris: &[String],
71        pk: &[String],
72    ) -> Result<u64>;
73
74    /// The warehouse this adapter targets — lets the shared driver build the
75    /// current-state view SQL (dialect keyword + identifier quoting) in ONE place
76    /// per mode instead of once per adapter.
77    fn warehouse(&self) -> cdc::Warehouse;
78
79    /// `CREATE OR REPLACE` the current-state view `<table>` from pre-built
80    /// `view_sql` (the driver builds it via [`cdc::dedup_view_sql`] for CDC or
81    /// [`cdc::inc_dedup_view_sql`] for incremental). The adapter only executes it
82    /// its way (e.g. Snowflake prefixes a `QUERY_TAG`).
83    fn create_view(&self, table: &str, view_sql: &str) -> Result<()>;
84}
85
86/// Refuse a load whose specs can't materialize: empty, or any `Fail`-status
87/// column (a silent-loss class — never drop it, name it).
88fn validate_specs(table: &str, specs: &[TargetColumnSpec]) -> Result<()> {
89    if specs.is_empty() {
90        bail!("no column specs for `{table}` — nothing to build a schema from");
91    }
92    let failed: Vec<&str> = specs
93        .iter()
94        .filter(|s| s.status == TargetStatus::Fail)
95        .map(|s| s.column_name.as_str())
96        .collect();
97    if !failed.is_empty() {
98        bail!(
99            "cannot load `{table}`: {} column(s) do not map to the warehouse: {}",
100            failed.len(),
101            failed.join(", ")
102        );
103    }
104    Ok(())
105}
106
107/// Clean up iff `cleanup` is `Some`, downgrading a failure to a warning — the
108/// data is loaded and gated, so a stuck delete must not fail the load. Cleanup
109/// runs the driver's own [`delete_under`] over an injected [`GcsStore`], so no
110/// adapter owns a delete path. Returns whether the source was actually cleaned.
111fn maybe_cleanup(cleanup: Option<(&GcsStore, &str)>) -> bool {
112    match cleanup {
113        Some((store, prefix)) => match delete_under(store, prefix) {
114            Ok(()) => true,
115            Err(e) => {
116                eprintln!("warning: source cleanup failed (data is safely loaded): {e:#}");
117                false
118            }
119        },
120        None => false,
121    }
122}
123
124/// **Batch load driver.** Materialize `table` from `uris`, gate the landed rows
125/// against `expected_rows` (the reconciled file count; `None` skips the gate),
126/// and — only after the gate passes — clean up the source via `cleanup`
127/// (`Some((store, gs_prefix))` to delete, `None` to keep it).
128///
129/// `#[allow(private_interfaces)]` for the injected `GcsStore` — same rationale as
130/// [`reconcile::fetch_manifests_keyed`]: a `pub` public-API root over a
131/// deliberately crate-private `destination` type.
132#[allow(private_interfaces)]
133pub fn run_load(
134    loader: &dyn TargetLoader,
135    table: &str,
136    specs: &[TargetColumnSpec],
137    uris: &[String],
138    expected_rows: Option<u64>,
139    cleanup: Option<(&GcsStore, &str)>,
140) -> Result<LoadReport> {
141    if uris.is_empty() {
142        bail!("no Parquet URIs to load into `{table}`");
143    }
144    validate_specs(table, specs)?;
145
146    let rows_loaded = loader.materialize(table, specs, uris)?;
147
148    if let Some(expected) = expected_rows
149        && rows_loaded != expected
150    {
151        bail!(
152            "count validation failed for `{}`: loaded {rows_loaded} rows, expected {expected} — \
153             NOT cleaning up source; investigate before re-running",
154            loader.fqtn(table)
155        );
156    }
157
158    let source_cleaned = maybe_cleanup(cleanup);
159    Ok(LoadReport {
160        rows_loaded,
161        target_table: loader.fqtn(table),
162        source_cleaned,
163    })
164}
165
166/// **CDC load driver.** Append the change log, gate the appended delta against
167/// `expected_delta` (`None` skips the gate), (re)build the current-state dedup
168/// view, then clean up the source.
169// The arity is the CDC load's real surface: adapter + table + specs + uris are
170// the load, pk + engine shape the dedup view, expected_delta + cleanup are the
171// gate and cleanup. Bundling them would only move the fields elsewhere.
172// `allow(private_interfaces)` for the injected `GcsStore` — see [`run_load`].
173#[allow(clippy::too_many_arguments, private_interfaces)]
174/// The shared append-log + dedup-view driver for the two append modes (CDC and
175/// incremental). They differ ONLY in a label (for error text) and which view the
176/// `build_view` closure creates; everything else — the empty-uris/pk bails, the
177/// `__changes` append, the count gate, cleanup ordering, and the report — is
178/// identical, so it lives here. `label` is `"CDC"` / `"incremental"`.
179fn append_and_view(
180    loader: &dyn TargetLoader,
181    table: &str,
182    specs: &[TargetColumnSpec],
183    uris: &[String],
184    pk: &[String],
185    expected_delta: Option<u64>,
186    cleanup: Option<(&GcsStore, &str)>,
187    label: &str,
188    build_view: impl FnOnce(&dyn TargetLoader) -> Result<()>,
189) -> Result<CdcLoadReport> {
190    if uris.is_empty() {
191        bail!("no Parquet URIs to append into `{table}__changes`");
192    }
193    if pk.is_empty() {
194        bail!("{label} load of `{table}` needs a primary key for the dedup view (pass --pk)");
195    }
196    validate_specs(&format!("{table}__changes"), specs)?;
197
198    let rows_appended = loader.append_changelog(table, specs, uris, pk)?;
199
200    if let Some(expected) = expected_delta
201        && rows_appended != expected
202    {
203        bail!(
204            "{label} count validation failed for `{}__changes`: appended {rows_appended} rows, \
205             expected {expected} from the run manifests — investigate before trusting the view",
206            table
207        );
208    }
209
210    build_view(loader)?;
211    // Cleanup runs here (inside the driver, after the gate), BEFORE the caller
212    // records the ledger in `execute_load`. A crash between the two re-appends
213    // this run next load — an at-least-once double-append the dedup view absorbs
214    // (and the count gate still guards) — accepted rather than ordering the
215    // irreversible delete after the durable record.
216    let source_cleaned = maybe_cleanup(cleanup);
217
218    Ok(CdcLoadReport {
219        rows_appended,
220        changes_table: loader.fqtn(&format!("{table}__changes")),
221        view: loader.fqtn(table),
222        source_cleaned,
223    })
224}
225
226#[allow(clippy::too_many_arguments, private_interfaces)]
227pub fn run_load_cdc(
228    loader: &dyn TargetLoader,
229    table: &str,
230    specs: &[TargetColumnSpec],
231    uris: &[String],
232    pk: &[String],
233    engine: cdc::SourceEngine,
234    expected_delta: Option<u64>,
235    cleanup: Option<(&GcsStore, &str)>,
236) -> Result<CdcLoadReport> {
237    append_and_view(
238        loader,
239        table,
240        specs,
241        uris,
242        pk,
243        expected_delta,
244        cleanup,
245        "CDC",
246        |l| {
247            let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
248            let sql = cdc::dedup_view_sql(
249                l.warehouse(),
250                &l.fqtn(table),
251                &l.fqtn(&format!("{table}__changes")),
252                &pk_refs,
253                engine,
254            );
255            l.create_view(table, &sql)
256        },
257    )
258}
259
260/// Load an INCREMENTAL export's delta: APPEND the parquet into `<table>__changes`
261/// (reusing the CDC changelog append — the delta's rows land with NULL `__op`/
262/// `__pos`/`__seq`, which the view drops) and (re)build a current-state view
263/// deduped to the latest row per PK by `cursor_column`. The manifests' summed
264/// `row_count` gates the appended delta, and cleanup runs (only) after the gate —
265/// safe because the ledger, not the file prefix, records what's loaded.
266// Same arity shape as [`run_load_cdc`] (the cursor replaces the engine);
267// `allow(private_interfaces)` for the injected `GcsStore` — see [`run_load`].
268#[allow(clippy::too_many_arguments, private_interfaces)]
269pub fn run_load_incremental(
270    loader: &dyn TargetLoader,
271    table: &str,
272    specs: &[TargetColumnSpec],
273    uris: &[String],
274    pk: &[String],
275    cursor_column: &str,
276    expected_delta: Option<u64>,
277    cleanup: Option<(&GcsStore, &str)>,
278) -> Result<CdcLoadReport> {
279    // uris + pk are checked by `append_and_view`; the cursor guards are incremental-only.
280    if cursor_column.is_empty() {
281        bail!(
282            "incremental load of `{table}` needs a cursor column (the export's `cursor_column:`) \
283             for the dedup view's latest-per-PK ordering"
284        );
285    }
286    // The cursor must be an EXPORTED column: the dedup view orders `__changes` by
287    // it (`ORDER BY <cursor> DESC`). A cursor used only in the extract's WHERE and
288    // not projected (e.g. `SELECT id, v` with `cursor_column: updated_at`, or
289    // incremental-coalesce which strips its synthetic cursor) is absent from
290    // `__changes`, so the view creation would fail AFTER the append — turn that
291    // into a loud pre-append bail instead of a broken view + a retried re-append.
292    if !specs.iter().any(|s| s.column_name == cursor_column) {
293        let cols: Vec<&str> = specs.iter().map(|s| s.column_name.as_str()).collect();
294        bail!(
295            "incremental load of `{table}`: cursor_column `{cursor_column}` is not one of the \
296             exported columns [{}] — add it to the export's SELECT so the dedup view can order \
297             the change log by it",
298            cols.join(", ")
299        );
300    }
301    append_and_view(
302        loader,
303        table,
304        specs,
305        uris,
306        pk,
307        expected_delta,
308        cleanup,
309        "incremental",
310        |l| {
311            let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
312            let sql = cdc::inc_dedup_view_sql(
313                l.warehouse(),
314                &l.fqtn(table),
315                &l.fqtn(&format!("{table}__changes")),
316                &pk_refs,
317                cursor_column,
318            );
319            l.create_view(table, &sql)
320        },
321    )
322}
323
324/// Split a `gs://bucket/path` URI into `(bucket, bucket-relative path)` — the
325/// shape opendal's bucket-scoped operator wants.
326pub(crate) fn split_gs_uri(uri: &str) -> Result<(&str, &str)> {
327    uri.strip_prefix("gs://")
328        .and_then(|rest| rest.split_once('/'))
329        .with_context(|| format!("not a `gs://bucket/path` URI: {uri}"))
330}
331
332/// Recursively delete a whole export-dedicated `gs://…/` prefix through an
333/// injected [`GcsStore`] — the driver's post-gate source cleanup, over the same
334/// native opendal GCS client the export destination uses (no `gcloud`). Taking
335/// the store as an argument (rather than each adapter building one from a
336/// config) is what lets an fs-backed store exercise this delete offline.
337pub(crate) fn delete_under(store: &GcsStore, gs_prefix: &str) -> Result<()> {
338    let (_, rel) = split_gs_uri(gs_prefix)?;
339    store
340        .remove_all(rel)
341        .with_context(|| format!("source cleanup (recursive delete of {gs_prefix}) failed"))
342}
343
344/// Open the one [`GcsStore`] a load reuses for reconcile, URI listing, and
345/// post-gate cleanup — the single production constructor `cli::dispatch` calls.
346///
347/// `pub` (a public-API root the lib keeps alive) even though its only caller is
348/// the binary-only dispatch: it re-anchors `GcsStore`'s real-GCS constructor in
349/// the lib compilation unit, which no longer reaches it through a load adapter.
350/// `#[allow(private_interfaces)]` for the crate-private return — same rationale
351/// as [`reconcile::fetch_manifests_keyed`].
352#[allow(private_interfaces)]
353pub fn open_store(dest: &crate::config::DestinationConfig) -> Result<GcsStore> {
354    GcsStore::new(dest)
355}
356
357/// The one place a resolved plan's [`LoadTarget`](plan::LoadTarget) maps to a
358/// concrete [`TargetLoader`] adapter — wiring partition / cluster / connection /
359/// run-id from the config. The count gate and cleanup are the driver's, so the
360/// adapter carries no `expected_rows`.
361pub fn build_loader(plan: &plan::LoadPlan, run_id: &str) -> Box<dyn TargetLoader> {
362    use plan::LoadTarget;
363    let load = &plan.load;
364    match &load.target {
365        LoadTarget::Bigquery { project, dataset } => Box::new(build_bigquery_loader(
366            project,
367            dataset,
368            plan.partition_by.as_deref(),
369            &load.cluster_by,
370            run_id,
371        )),
372        LoadTarget::Snowflake {
373            connection,
374            warehouse,
375            database,
376            schema,
377            storage_integration,
378        } => {
379            let mut l = SnowflakeLoader::new(connection.clone());
380            l.warehouse = warehouse.clone();
381            l.database = database.clone();
382            l.schema = schema.clone();
383            l.storage_integration = storage_integration.clone();
384            l.cluster_by = load.cluster_by.clone();
385            l.run_id = Some(run_id.to_string());
386            // Snowflake's external stage wants the `gcs://` scheme, not `gs://`.
387            l.gcs_url = plan.gcs_prefix.replacen("gs://", "gcs://", 1);
388            // The `snow` CLI does not expand `~`; pass an absolute key path.
389            l.private_key_path = std::env::var("RIVET_SNOWFLAKE_KEY").ok();
390            Box::new(l)
391        }
392    }
393}
394
395/// Wire a [`BigQueryLoader`] from a resolved plan's fields — `partition_by` and
396/// `cluster_by` applied ONLY when set. A concrete return (not the boxed trait)
397/// so the wiring is unit-testable: a mis-guarded key would silently DROP the
398/// clustering/partitioning the config asked for — a degradation invisible
399/// through `Box<dyn TargetLoader>`.
400fn build_bigquery_loader(
401    project: &str,
402    dataset: &str,
403    partition_by: Option<&str>,
404    cluster_by: &[String],
405    run_id: &str,
406) -> BigQueryLoader {
407    let mut l = BigQueryLoader::new(project, dataset).run_id(run_id);
408    if let Some(part) = partition_by {
409        l = l.partition_by(part);
410    }
411    if !cluster_by.is_empty() {
412        l = l.cluster_by(cluster_by.to_vec());
413    }
414    l
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use std::cell::RefCell;
421
422    /// Records every call and returns a canned row count — the seam the driver's
423    /// invariants are asserted through, offline.
424    #[derive(Default)]
425    struct FakeLoader {
426        rows: u64,
427        materialized: RefCell<Vec<String>>,
428        appended: RefCell<Vec<String>>,
429        views: RefCell<Vec<String>>,
430    }
431
432    impl TargetLoader for FakeLoader {
433        fn fqtn(&self, table: &str) -> String {
434            format!("db.{table}")
435        }
436        fn materialize(&self, table: &str, _: &[TargetColumnSpec], _: &[String]) -> Result<u64> {
437            self.materialized.borrow_mut().push(table.into());
438            Ok(self.rows)
439        }
440        fn append_changelog(
441            &self,
442            table: &str,
443            _: &[TargetColumnSpec],
444            _: &[String],
445            _: &[String],
446        ) -> Result<u64> {
447            self.appended.borrow_mut().push(table.into());
448            Ok(self.rows)
449        }
450        fn warehouse(&self) -> cdc::Warehouse {
451            cdc::Warehouse::BigQuery
452        }
453        fn create_view(&self, table: &str, _view_sql: &str) -> Result<()> {
454            self.views.borrow_mut().push(table.into());
455            Ok(())
456        }
457    }
458
459    /// An fs-backed [`GcsStore`] seeded with one object under the bucket-relative
460    /// `rel` — stands in for the export's live GCS source prefix so the driver's
461    /// real delete path (`delete_under` → `remove_all`) runs offline. Returns the
462    /// store; the caller keeps `dir` alive for the store's lifetime.
463    fn fs_store_with_prefix(dir: &tempfile::TempDir, rel: &str) -> GcsStore {
464        let obj = dir.path().join(rel).join("x.parquet");
465        std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
466        std::fs::write(obj, b"x").unwrap();
467        GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap()
468    }
469
470    /// Whether the fs store still holds an object under bucket-relative `rel`.
471    fn prefix_populated(store: &GcsStore, rel: &str) -> bool {
472        !store.list_files(rel).unwrap().is_empty()
473    }
474
475    fn spec(status: TargetStatus) -> Vec<TargetColumnSpec> {
476        vec![TargetColumnSpec {
477            column_name: "id".into(),
478            target_type: "INT64".into(),
479            autoload_type: String::new(),
480            status,
481            note: None,
482            cast_sql: None,
483        }]
484    }
485    fn uris() -> Vec<String> {
486        vec!["gs://b/p/x.parquet".into()]
487    }
488    /// The cleanup prefix the driver receives (a `gs://bucket/…` URI) and its
489    /// bucket-relative form the fs store is keyed by.
490    const PREFIX: &str = "gs://b/p";
491    const REL: &str = "p";
492
493    #[test]
494    fn empty_uris_bail_before_materialize() {
495        let f = FakeLoader {
496            rows: 10,
497            ..Default::default()
498        };
499        assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &[], Some(10), None).is_err());
500        assert!(f.materialized.borrow().is_empty());
501    }
502
503    #[test]
504    fn fail_spec_bails_before_materialize() {
505        let f = FakeLoader::default();
506        assert!(run_load(&f, "t", &spec(TargetStatus::Fail), &uris(), Some(10), None).is_err());
507        assert!(f.materialized.borrow().is_empty());
508    }
509
510    #[test]
511    fn count_mismatch_bails_without_cleanup() {
512        let f = FakeLoader {
513            rows: 7,
514            ..Default::default()
515        };
516        let dir = tempfile::tempdir().unwrap();
517        let store = fs_store_with_prefix(&dir, REL);
518        let err = run_load(
519            &f,
520            "t",
521            &spec(TargetStatus::Ok),
522            &uris(),
523            Some(10),
524            Some((&store, PREFIX)),
525        )
526        .unwrap_err()
527        .to_string();
528        assert!(err.contains("count validation failed"), "{err}");
529        assert!(
530            prefix_populated(&store, REL),
531            "cleanup must not run on a failed gate — the source prefix stays intact"
532        );
533    }
534
535    #[test]
536    fn match_with_prefix_cleans_once() {
537        let f = FakeLoader {
538            rows: 10,
539            ..Default::default()
540        };
541        let dir = tempfile::tempdir().unwrap();
542        let store = fs_store_with_prefix(&dir, REL);
543        let r = run_load(
544            &f,
545            "t",
546            &spec(TargetStatus::Ok),
547            &uris(),
548            Some(10),
549            Some((&store, PREFIX)),
550        )
551        .unwrap();
552        assert!(r.source_cleaned);
553        assert!(
554            !prefix_populated(&store, REL),
555            "a passed gate drains the source prefix through the injected store"
556        );
557        assert_eq!(r.target_table, "db.t");
558    }
559
560    #[test]
561    fn match_without_prefix_does_not_clean() {
562        let f = FakeLoader {
563            rows: 10,
564            ..Default::default()
565        };
566        let r = run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), Some(10), None).unwrap();
567        assert!(!r.source_cleaned);
568    }
569
570    #[test]
571    fn none_expected_skips_the_gate() {
572        let f = FakeLoader {
573            rows: 999,
574            ..Default::default()
575        };
576        // No expected count → any landed rows pass (an ad-hoc load).
577        assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), None, None).is_ok());
578    }
579
580    #[test]
581    fn cdc_delta_mismatch_bails_without_view() {
582        let f = FakeLoader {
583            rows: 3,
584            ..Default::default()
585        };
586        let dir = tempfile::tempdir().unwrap();
587        let store = fs_store_with_prefix(&dir, REL);
588        let err = run_load_cdc(
589            &f,
590            "t",
591            &spec(TargetStatus::Ok),
592            &uris(),
593            &["id".into()],
594            cdc::SourceEngine::MySql,
595            Some(5),
596            Some((&store, PREFIX)),
597        )
598        .unwrap_err()
599        .to_string();
600        assert!(err.contains("CDC count validation failed"), "{err}");
601        assert!(
602            f.views.borrow().is_empty(),
603            "view must not be built on a failed gate"
604        );
605        assert!(
606            prefix_populated(&store, REL),
607            "cleanup must not run on a failed gate"
608        );
609    }
610
611    #[test]
612    fn cdc_match_builds_view_then_cleans() {
613        let f = FakeLoader {
614            rows: 5,
615            ..Default::default()
616        };
617        let dir = tempfile::tempdir().unwrap();
618        let store = fs_store_with_prefix(&dir, REL);
619        let r = run_load_cdc(
620            &f,
621            "t",
622            &spec(TargetStatus::Ok),
623            &uris(),
624            &["id".into()],
625            cdc::SourceEngine::MySql,
626            Some(5),
627            Some((&store, PREFIX)),
628        )
629        .unwrap();
630        assert_eq!(r.rows_appended, 5);
631        assert_eq!(*f.views.borrow(), vec!["t".to_string()]);
632        assert!(
633            !prefix_populated(&store, REL),
634            "a passed CDC gate drains the source prefix after the view is built"
635        );
636        assert_eq!(r.changes_table, "db.t__changes");
637    }
638
639    #[test]
640    fn delete_under_drains_the_prefix_through_the_store() {
641        let dir = tempfile::tempdir().unwrap();
642        let store = fs_store_with_prefix(&dir, REL);
643        assert!(prefix_populated(&store, REL), "seeded object is present");
644        delete_under(&store, PREFIX).unwrap();
645        assert!(
646            !prefix_populated(&store, REL),
647            "delete_under recursively removes the bucket-relative prefix behind the gs:// URI"
648        );
649    }
650
651    #[test]
652    fn build_bigquery_loader_wires_partition_and_cluster_keys() {
653        // A non-empty cluster/partition MUST reach the loader; if the guard
654        // inverts, a real key is silently dropped and the load omits the
655        // clustering the config asked for. Reading cluster_by/partition_by pins
656        // the wiring that `Box<dyn TargetLoader>` hides.
657        let l = build_bigquery_loader(
658            "proj",
659            "ds",
660            Some("day"),
661            &["customer_id".to_string(), "region".to_string()],
662            "run-1",
663        );
664        assert_eq!(l.cluster_by, ["customer_id", "region"]);
665        assert_eq!(l.partition_by.as_deref(), Some("day"));
666
667        // No keys set → neither clause (the default), never a spurious one.
668        let bare = build_bigquery_loader("proj", "ds", None, &[], "run-1");
669        assert!(bare.cluster_by.is_empty());
670        assert!(bare.partition_by.is_none());
671    }
672
673    #[test]
674    fn split_gs_uri_parses_bucket_and_bucket_relative_key() {
675        // The parse every load op addresses through: (bucket, bucket-relative
676        // key). The `delete_under` test above can't pin this — it drains by REL
677        // regardless of what split returns — so a mangled split (wrong bucket, or
678        // an empty key that lists/deletes the whole bucket root) is invisible
679        // there. Pin it directly.
680        assert_eq!(split_gs_uri("gs://b/p").unwrap(), ("b", "p"));
681        assert_eq!(
682            split_gs_uri("gs://bucket/a/b/c.parquet").unwrap(),
683            ("bucket", "a/b/c.parquet"),
684            "only the FIRST '/' splits bucket from key; the rest is the key"
685        );
686        assert!(
687            split_gs_uri("s3://b/p").is_err(),
688            "a non-gs scheme is rejected"
689        );
690        assert!(
691            split_gs_uri("gs://bucket-only").is_err(),
692            "a bucket with no '/' has no (bucket, key) split"
693        );
694    }
695
696    #[test]
697    fn cdc_empty_pk_bails() {
698        let f = FakeLoader::default();
699        assert!(
700            run_load_cdc(
701                &f,
702                "t",
703                &spec(TargetStatus::Ok),
704                &uris(),
705                &[],
706                cdc::SourceEngine::MySql,
707                None,
708                None
709            )
710            .is_err()
711        );
712        assert!(f.appended.borrow().is_empty());
713    }
714
715    #[test]
716    fn incremental_cursor_not_in_specs_bails_before_append() {
717        let f = FakeLoader::default();
718        // The exported columns are just `id`; a cursor `updated_at` used only in
719        // the extract's WHERE (not projected) is absent from `__changes`. The
720        // driver must bail BEFORE appending — else the view creation fails after
721        // the append and every retry re-appends (bloat).
722        let err = run_load_incremental(
723            &f,
724            "t",
725            &spec(TargetStatus::Ok),
726            &uris(),
727            &["id".to_string()],
728            "updated_at",
729            None,
730            None,
731        )
732        .unwrap_err()
733        .to_string();
734        assert!(
735            err.contains("updated_at") && err.contains("not one of the exported columns"),
736            "{err}"
737        );
738        assert!(
739            f.appended.borrow().is_empty(),
740            "nothing appended before the bail"
741        );
742    }
743}