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/// A plain SQL identifier the load layer can safely interpolate into DDL/COPY
87/// without quoting: `[A-Za-z_][A-Za-z0-9_]*`. Round-5: column names are
88/// SOURCE-derived and spliced raw into executed warehouse SQL (build_schema,
89/// build_copy_select, …), so a name outside this set is an injection vector.
90fn is_safe_load_ident(s: &str) -> bool {
91    !s.is_empty()
92        && s.chars()
93            .next()
94            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
95        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
96}
97
98/// Refuse any Parquet URI that can't be splice-safely single-quoted into the
99/// warehouse load statement. The drivers emit each URI as `'{uri}'` into
100/// Snowflake `COPY … FILES=(…)` and BigQuery `LOAD DATA … uris=[…]` with NO
101/// escaping (snowflake::copy_files_clause, bigquery::from_files) — so a URI
102/// carrying the string delimiter `'`, a backslash (Snowflake treats `\` as an
103/// in-string escape), or a control char could break out of the literal and
104/// inject SQL that runs with the warehouse's (broad) role. Unlike the operator-
105/// typed dataset/warehouse names, these URIs come from the LIVE GCS object
106/// listing (reconcile::select_load_uris → store.list_files), and GCS object
107/// names legally permit `'`/`\`/`;` — so a party with staging-bucket write plus
108/// a crafted passing manifest is otherwise an injection vector. This is the
109/// storage-sourced sibling of the column/table/pk gate; rivet names its own
110/// parts run-uniquely and filename-sanitized, so a legitimate URI never trips
111/// it — default-deny, fail loud rather than escape-and-hope.
112fn ensure_safe_load_uris(uris: &[String]) -> Result<()> {
113    for u in uris {
114        if let Some(bad) = u
115            .chars()
116            .find(|&c| c == '\'' || c == '\\' || c.is_control())
117        {
118            bail!(
119                "refusing to load: Parquet URI `{}` contains {:?}, which is unsafe to splice \
120                 into the warehouse load statement — the loader single-quotes URIs without \
121                 escaping. rivet names its own parts safely, so this URI was not produced by a \
122                 normal export; investigate the staging bucket before re-running.",
123                u.escape_default(),
124                bad
125            );
126        }
127    }
128    Ok(())
129}
130
131/// Refuse a load whose specs can't materialize: empty, any `Fail`-status column
132/// (a silent-loss class — never drop it, name it), or an unsafe column identifier.
133fn validate_specs(table: &str, specs: &[TargetColumnSpec]) -> Result<()> {
134    if specs.is_empty() {
135        bail!("no column specs for `{table}` — nothing to build a schema from");
136    }
137    // Round-6: the target table name is interpolated raw into the fqtn / DDL / dedup
138    // view too (a sibling injection surface of the column names). Gate it, tolerating
139    // a qualified `dataset.table` — each dot-separated component must be a plain ident.
140    if table.is_empty() || !table.split('.').all(is_safe_load_ident) {
141        bail!(
142            "cannot load: target table `{}` is not a plain (optionally dotted) SQL identifier — \
143             the loader splices it into DDL/COPY.",
144            table.escape_default()
145        );
146    }
147    // Round-5: refuse a source-derived column name that isn't a plain identifier —
148    // the warehouse drivers interpolate it into executed DDL/COPY with no quoting,
149    // so a hostile/odd name (`x); DROP TABLE …`, an embedded quote/backtick) must
150    // fail LOUDLY here rather than run as SQL. The one gate covers every load target.
151    for s in specs {
152        if !is_safe_load_ident(&s.column_name) {
153            bail!(
154                "cannot load `{table}`: column name `{}` is not a plain SQL identifier \
155                 ([A-Za-z_][A-Za-z0-9_]*) — the warehouse loader splices it into DDL/COPY. \
156                 Rename or alias the column in the export query.",
157                s.column_name.escape_default()
158            );
159        }
160    }
161    let failed: Vec<&str> = specs
162        .iter()
163        .filter(|s| s.status == TargetStatus::Fail)
164        .map(|s| s.column_name.as_str())
165        .collect();
166    if !failed.is_empty() {
167        bail!(
168            "cannot load `{table}`: {} column(s) do not map to the warehouse: {}",
169            failed.len(),
170            failed.join(", ")
171        );
172    }
173    Ok(())
174}
175
176/// Clean up iff `cleanup` is `Some`, downgrading a failure to a warning — the
177/// data is loaded and gated, so a stuck delete must not fail the load. Cleanup
178/// runs the driver's own [`delete_under`] over an injected [`GcsStore`], so no
179/// adapter owns a delete path. Returns whether the source was actually cleaned.
180fn maybe_cleanup(cleanup: Option<(&GcsStore, &str)>) -> bool {
181    match cleanup {
182        Some((store, prefix)) => match delete_under(store, prefix) {
183            Ok(()) => true,
184            Err(e) => {
185                eprintln!("warning: source cleanup failed (data is safely loaded): {e:#}");
186                false
187            }
188        },
189        None => false,
190    }
191}
192
193/// **Batch load driver.** Materialize `table` from `uris`, gate the landed rows
194/// against `expected_rows` (the reconciled file count; `None` skips the gate),
195/// and — only after the gate passes — clean up the source via `cleanup`
196/// (`Some((store, gs_prefix))` to delete, `None` to keep it).
197///
198/// `#[allow(private_interfaces)]` for the injected `GcsStore` — same rationale as
199/// [`reconcile::fetch_manifests_keyed`]: a `pub` public-API root over a
200/// deliberately crate-private `destination` type.
201#[allow(private_interfaces)]
202pub fn run_load(
203    loader: &dyn TargetLoader,
204    table: &str,
205    specs: &[TargetColumnSpec],
206    uris: &[String],
207    expected_rows: Option<u64>,
208    cleanup: Option<(&GcsStore, &str)>,
209) -> Result<LoadReport> {
210    if uris.is_empty() {
211        bail!("no Parquet URIs to load into `{table}`");
212    }
213    ensure_safe_load_uris(uris)?;
214    validate_specs(table, specs)?;
215
216    let rows_loaded = loader.materialize(table, specs, uris)?;
217
218    if let Some(expected) = expected_rows
219        && rows_loaded != expected
220    {
221        bail!(
222            "count validation failed for `{}`: loaded {rows_loaded} rows, expected {expected} — \
223             NOT cleaning up source; investigate before re-running",
224            loader.fqtn(table)
225        );
226    }
227
228    let source_cleaned = maybe_cleanup(cleanup);
229    Ok(LoadReport {
230        rows_loaded,
231        target_table: loader.fqtn(table),
232        source_cleaned,
233    })
234}
235
236/// **CDC load driver.** Append the change log, gate the appended delta against
237/// `expected_delta` (`None` skips the gate), (re)build the current-state dedup
238/// view, then clean up the source.
239// The arity is the CDC load's real surface: adapter + table + specs + uris are
240// the load, pk + engine shape the dedup view, expected_delta + cleanup are the
241// gate and cleanup. Bundling them would only move the fields elsewhere.
242// `allow(private_interfaces)` for the injected `GcsStore` — see [`run_load`].
243#[allow(clippy::too_many_arguments, private_interfaces)]
244/// The shared append-log + dedup-view driver for the two append modes (CDC and
245/// incremental). They differ ONLY in a label (for error text) and which view the
246/// `build_view` closure creates; everything else — the empty-uris/pk bails, the
247/// `__changes` append, the count gate, cleanup ordering, and the report — is
248/// identical, so it lives here. `label` is `"CDC"` / `"incremental"`.
249fn append_and_view(
250    loader: &dyn TargetLoader,
251    table: &str,
252    specs: &[TargetColumnSpec],
253    uris: &[String],
254    pk: &[String],
255    expected_delta: Option<u64>,
256    cleanup: Option<(&GcsStore, &str)>,
257    label: &str,
258    build_view: impl FnOnce(&dyn TargetLoader) -> Result<()>,
259) -> Result<CdcLoadReport> {
260    if uris.is_empty() {
261        bail!("no Parquet URIs to append into `{table}__changes`");
262    }
263    ensure_safe_load_uris(uris)?;
264    if pk.is_empty() {
265        bail!("{label} load of `{table}` needs a primary key for the dedup view (pass --pk)");
266    }
267    validate_specs(&format!("{table}__changes"), specs)?;
268
269    let rows_appended = loader.append_changelog(table, specs, uris, pk)?;
270
271    if let Some(expected) = expected_delta
272        && rows_appended != expected
273    {
274        bail!(
275            "{label} count validation failed for `{}__changes`: appended {rows_appended} rows, \
276             expected {expected} from the run manifests — investigate before trusting the view",
277            table
278        );
279    }
280
281    build_view(loader)?;
282    // Cleanup runs here (inside the driver, after the gate), BEFORE the caller
283    // records the ledger in `execute_load`. A crash between the two re-appends
284    // this run next load — an at-least-once double-append the dedup view absorbs
285    // (and the count gate still guards) — accepted rather than ordering the
286    // irreversible delete after the durable record.
287    let source_cleaned = maybe_cleanup(cleanup);
288
289    Ok(CdcLoadReport {
290        rows_appended,
291        changes_table: loader.fqtn(&format!("{table}__changes")),
292        view: loader.fqtn(table),
293        source_cleaned,
294    })
295}
296
297#[allow(clippy::too_many_arguments, private_interfaces)]
298pub fn run_load_cdc(
299    loader: &dyn TargetLoader,
300    table: &str,
301    specs: &[TargetColumnSpec],
302    uris: &[String],
303    pk: &[String],
304    engine: cdc::SourceEngine,
305    expected_delta: Option<u64>,
306    cleanup: Option<(&GcsStore, &str)>,
307) -> Result<CdcLoadReport> {
308    // Round-6: the CDC primary-key columns are interpolated raw into the dedup view's
309    // PARTITION BY — gate them like the column names (source-derived injection surface).
310    for c in pk {
311        if !is_safe_load_ident(c) {
312            bail!(
313                "cannot load `{table}`: CDC primary-key column `{}` is not a plain SQL \
314                 identifier — it is spliced into the dedup view. Rename/alias it.",
315                c.escape_default()
316            );
317        }
318    }
319    append_and_view(
320        loader,
321        table,
322        specs,
323        uris,
324        pk,
325        expected_delta,
326        cleanup,
327        "CDC",
328        |l| {
329            let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
330            let sql = cdc::dedup_view_sql(
331                l.warehouse(),
332                &l.fqtn(table),
333                &l.fqtn(&format!("{table}__changes")),
334                &pk_refs,
335                engine,
336            );
337            l.create_view(table, &sql)
338        },
339    )
340}
341
342/// Load an INCREMENTAL export's delta: APPEND the parquet into `<table>__changes`
343/// (reusing the CDC changelog append — the delta's rows land with NULL `__op`/
344/// `__pos`/`__seq`, which the view drops) and (re)build a current-state view
345/// deduped to the latest row per PK by `cursor_column`. The manifests' summed
346/// `row_count` gates the appended delta, and cleanup runs (only) after the gate —
347/// safe because the ledger, not the file prefix, records what's loaded.
348// Same arity shape as [`run_load_cdc`] (the cursor replaces the engine);
349// `allow(private_interfaces)` for the injected `GcsStore` — see [`run_load`].
350#[allow(clippy::too_many_arguments, private_interfaces)]
351pub fn run_load_incremental(
352    loader: &dyn TargetLoader,
353    table: &str,
354    specs: &[TargetColumnSpec],
355    uris: &[String],
356    pk: &[String],
357    cursor_column: &str,
358    expected_delta: Option<u64>,
359    cleanup: Option<(&GcsStore, &str)>,
360) -> Result<CdcLoadReport> {
361    // uris + pk are checked by `append_and_view`; the cursor guards are incremental-only.
362    if cursor_column.is_empty() {
363        bail!(
364            "incremental load of `{table}` needs a cursor column (the export's `cursor_column:`) \
365             for the dedup view's latest-per-PK ordering"
366        );
367    }
368    // The cursor must be an EXPORTED column: the dedup view orders `__changes` by
369    // it (`ORDER BY <cursor> DESC`). A cursor used only in the extract's WHERE and
370    // not projected (e.g. `SELECT id, v` with `cursor_column: updated_at`, or
371    // incremental-coalesce which strips its synthetic cursor) is absent from
372    // `__changes`, so the view creation would fail AFTER the append — turn that
373    // into a loud pre-append bail instead of a broken view + a retried re-append.
374    if !specs.iter().any(|s| s.column_name == cursor_column) {
375        let cols: Vec<&str> = specs.iter().map(|s| s.column_name.as_str()).collect();
376        bail!(
377            "incremental load of `{table}`: cursor_column `{cursor_column}` is not one of the \
378             exported columns [{}] — add it to the export's SELECT so the dedup view can order \
379             the change log by it",
380            cols.join(", ")
381        );
382    }
383    append_and_view(
384        loader,
385        table,
386        specs,
387        uris,
388        pk,
389        expected_delta,
390        cleanup,
391        "incremental",
392        |l| {
393            let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
394            let sql = cdc::inc_dedup_view_sql(
395                l.warehouse(),
396                &l.fqtn(table),
397                &l.fqtn(&format!("{table}__changes")),
398                &pk_refs,
399                cursor_column,
400            );
401            l.create_view(table, &sql)
402        },
403    )
404}
405
406/// Split a `gs://bucket/path` URI into `(bucket, bucket-relative path)` — the
407/// shape opendal's bucket-scoped operator wants.
408pub(crate) fn split_gs_uri(uri: &str) -> Result<(&str, &str)> {
409    let (bucket, key) = uri
410        .strip_prefix("gs://")
411        .and_then(|rest| rest.split_once('/'))
412        .with_context(|| format!("not a `gs://bucket/path` URI: {uri}"))?;
413    // Refuse an EMPTY bucket-relative key. It addresses the bucket ROOT, and the
414    // load's recursive cleanup (delete_under → remove_all) and gc_orphans (list +
415    // remove) would then wipe the ENTIRE bucket — including unrelated exports and
416    // pre-existing objects — on a LEGAL config: a GCS export with no
417    // `destination.prefix`, or a prefix that leads with `{partition}` so the
418    // pre-`{partition}` base collapses to "". No load/cleanup lifecycle ever
419    // legitimately targets the bucket root, so fail LOUD here rather than delete
420    // everything. (Trailing/only slashes collapse to empty too.)
421    if key.trim_matches('/').is_empty() {
422        anyhow::bail!(
423            "refusing a bucket-root staging prefix `{uri}`: a GCS load stages into and cleans up a \
424             DEDICATED prefix, so an empty prefix would list/delete the whole bucket. Set a \
425             non-empty `destination.prefix`, and put any `{{partition}}` token AFTER a literal \
426             segment (e.g. `exports/{{partition}}/`, not `{{partition}}/`)."
427        );
428    }
429    Ok((bucket, key))
430}
431
432/// Recursively delete a whole export-dedicated `gs://…/` prefix through an
433/// injected [`GcsStore`] — the driver's post-gate source cleanup, over the same
434/// native opendal GCS client the export destination uses (no `gcloud`). Taking
435/// the store as an argument (rather than each adapter building one from a
436/// config) is what lets an fs-backed store exercise this delete offline.
437pub(crate) fn delete_under(store: &GcsStore, gs_prefix: &str) -> Result<()> {
438    let (_, rel) = split_gs_uri(gs_prefix)?;
439    store
440        .remove_all(rel)
441        .with_context(|| format!("source cleanup (recursive delete of {gs_prefix}) failed"))
442}
443
444/// Open the one [`GcsStore`] a load reuses for reconcile, URI listing, and
445/// post-gate cleanup — the single production constructor `cli::dispatch` calls.
446///
447/// `pub` (a public-API root the lib keeps alive) even though its only caller is
448/// the binary-only dispatch: it re-anchors `GcsStore`'s real-GCS constructor in
449/// the lib compilation unit, which no longer reaches it through a load adapter.
450/// `#[allow(private_interfaces)]` for the crate-private return — same rationale
451/// as [`reconcile::fetch_manifests_keyed`].
452#[allow(private_interfaces)]
453pub fn open_store(dest: &crate::config::DestinationConfig) -> Result<GcsStore> {
454    GcsStore::new(dest)
455}
456
457/// The one place a resolved plan's [`LoadTarget`](plan::LoadTarget) maps to a
458/// concrete [`TargetLoader`] adapter — wiring partition / cluster / connection /
459/// run-id from the config. The count gate and cleanup are the driver's, so the
460/// adapter carries no `expected_rows`.
461pub fn build_loader(plan: &plan::LoadPlan, run_id: &str) -> Box<dyn TargetLoader> {
462    use plan::LoadTarget;
463    let load = &plan.load;
464    match &load.target {
465        LoadTarget::Bigquery { project, dataset } => Box::new(build_bigquery_loader(
466            project,
467            dataset,
468            plan.partition_by.as_deref(),
469            &load.cluster_by,
470            run_id,
471        )),
472        LoadTarget::Snowflake {
473            connection,
474            warehouse,
475            database,
476            schema,
477            storage_integration,
478        } => {
479            let mut l = SnowflakeLoader::new(connection.clone());
480            l.warehouse = warehouse.clone();
481            l.database = database.clone();
482            l.schema = schema.clone();
483            l.storage_integration = storage_integration.clone();
484            l.cluster_by = load.cluster_by.clone();
485            l.run_id = Some(run_id.to_string());
486            // Snowflake's external stage wants the `gcs://` scheme, not `gs://`.
487            l.gcs_url = plan.gcs_prefix.replacen("gs://", "gcs://", 1);
488            // The `snow` CLI does not expand `~`; pass an absolute key path.
489            l.private_key_path = std::env::var("RIVET_SNOWFLAKE_KEY").ok();
490            Box::new(l)
491        }
492    }
493}
494
495/// Wire a [`BigQueryLoader`] from a resolved plan's fields — `partition_by` and
496/// `cluster_by` applied ONLY when set. A concrete return (not the boxed trait)
497/// so the wiring is unit-testable: a mis-guarded key would silently DROP the
498/// clustering/partitioning the config asked for — a degradation invisible
499/// through `Box<dyn TargetLoader>`.
500fn build_bigquery_loader(
501    project: &str,
502    dataset: &str,
503    partition_by: Option<&str>,
504    cluster_by: &[String],
505    run_id: &str,
506) -> BigQueryLoader {
507    let mut l = BigQueryLoader::new(project, dataset).run_id(run_id);
508    if let Some(part) = partition_by {
509        l = l.partition_by(part);
510    }
511    if !cluster_by.is_empty() {
512        l = l.cluster_by(cluster_by.to_vec());
513    }
514    l
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use std::cell::RefCell;
521
522    /// Records every call and returns a canned row count — the seam the driver's
523    /// invariants are asserted through, offline.
524    #[derive(Default)]
525    struct FakeLoader {
526        rows: u64,
527        materialized: RefCell<Vec<String>>,
528        appended: RefCell<Vec<String>>,
529        views: RefCell<Vec<String>>,
530    }
531
532    impl TargetLoader for FakeLoader {
533        fn fqtn(&self, table: &str) -> String {
534            format!("db.{table}")
535        }
536        fn materialize(&self, table: &str, _: &[TargetColumnSpec], _: &[String]) -> Result<u64> {
537            self.materialized.borrow_mut().push(table.into());
538            Ok(self.rows)
539        }
540        fn append_changelog(
541            &self,
542            table: &str,
543            _: &[TargetColumnSpec],
544            _: &[String],
545            _: &[String],
546        ) -> Result<u64> {
547            self.appended.borrow_mut().push(table.into());
548            Ok(self.rows)
549        }
550        fn warehouse(&self) -> cdc::Warehouse {
551            cdc::Warehouse::BigQuery
552        }
553        fn create_view(&self, table: &str, _view_sql: &str) -> Result<()> {
554            self.views.borrow_mut().push(table.into());
555            Ok(())
556        }
557    }
558
559    /// An fs-backed [`GcsStore`] seeded with one object under the bucket-relative
560    /// `rel` — stands in for the export's live GCS source prefix so the driver's
561    /// real delete path (`delete_under` → `remove_all`) runs offline. Returns the
562    /// store; the caller keeps `dir` alive for the store's lifetime.
563    fn fs_store_with_prefix(dir: &tempfile::TempDir, rel: &str) -> GcsStore {
564        let obj = dir.path().join(rel).join("x.parquet");
565        std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
566        std::fs::write(obj, b"x").unwrap();
567        GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap()
568    }
569
570    /// Whether the fs store still holds an object under bucket-relative `rel`.
571    fn prefix_populated(store: &GcsStore, rel: &str) -> bool {
572        !store.list_files(rel).unwrap().is_empty()
573    }
574
575    #[test]
576    fn delete_under_and_gc_orphans_refuse_the_bucket_root_and_spare_siblings() {
577        // #8 e2e against the REAL opendal fs-backed store (the load layer's offline
578        // e2e seam — `delete_under`/`gc_orphans` run their real recursive delete
579        // here). A bucket-ROOT prefix (the empty resolved key a no-`prefix` or
580        // `{partition}`-leading GCS export + cleanup_source produces) must be
581        // REFUSED, never wiped — a real `remove_all("")` destroys UNRELATED exports.
582        let dir = tempfile::tempdir().unwrap();
583        for rel in ["exportA/part.parquet", "innocent-neighbour/keep.parquet"] {
584            let obj = dir.path().join(rel);
585            std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
586            std::fs::write(obj, b"x").unwrap();
587        }
588        let store = GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap();
589
590        // The destructive paths refuse the root — BEFORE touching the store.
591        assert!(
592            delete_under(&store, "gs://bucket/").is_err(),
593            "cleanup_source must REFUSE a bucket-root prefix, not remove_all(\"\")"
594        );
595        assert!(
596            reconcile::gc_orphans(&store, "gs://bucket/", &[]).is_err(),
597            "gc_orphans must REFUSE a bucket-root prefix, not list+delete the whole bucket"
598        );
599        // Nothing was deleted — both independent exports survive intact.
600        assert!(prefix_populated(&store, "exportA"), "exportA must survive");
601        assert!(
602            prefix_populated(&store, "innocent-neighbour"),
603            "an unrelated neighbour export must survive the refused root cleanup"
604        );
605
606        // Contrast — the guard does NOT over-block: a REAL per-export prefix still
607        // drains its own subtree and spares the neighbour.
608        delete_under(&store, "gs://bucket/exportA").unwrap();
609        assert!(
610            !prefix_populated(&store, "exportA"),
611            "a real prefix cleanup still drains its own export"
612        );
613        assert!(
614            prefix_populated(&store, "innocent-neighbour"),
615            "a scoped cleanup spares the sibling"
616        );
617    }
618
619    fn spec(status: TargetStatus) -> Vec<TargetColumnSpec> {
620        vec![TargetColumnSpec {
621            column_name: "id".into(),
622            target_type: "INT64".into(),
623            autoload_type: String::new(),
624            status,
625            note: None,
626            cast_sql: None,
627        }]
628    }
629    fn uris() -> Vec<String> {
630        vec!["gs://b/p/x.parquet".into()]
631    }
632    /// The cleanup prefix the driver receives (a `gs://bucket/…` URI) and its
633    /// bucket-relative form the fs store is keyed by.
634    const PREFIX: &str = "gs://b/p";
635    const REL: &str = "p";
636
637    #[test]
638    fn empty_uris_bail_before_materialize() {
639        let f = FakeLoader {
640            rows: 10,
641            ..Default::default()
642        };
643        assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &[], Some(10), None).is_err());
644        assert!(f.materialized.borrow().is_empty());
645    }
646
647    #[test]
648    fn fail_spec_bails_before_materialize() {
649        let f = FakeLoader::default();
650        assert!(run_load(&f, "t", &spec(TargetStatus::Fail), &uris(), Some(10), None).is_err());
651        assert!(f.materialized.borrow().is_empty());
652    }
653
654    #[test]
655    fn count_mismatch_bails_without_cleanup() {
656        let f = FakeLoader {
657            rows: 7,
658            ..Default::default()
659        };
660        let dir = tempfile::tempdir().unwrap();
661        let store = fs_store_with_prefix(&dir, REL);
662        let err = run_load(
663            &f,
664            "t",
665            &spec(TargetStatus::Ok),
666            &uris(),
667            Some(10),
668            Some((&store, PREFIX)),
669        )
670        .unwrap_err()
671        .to_string();
672        assert!(err.contains("count validation failed"), "{err}");
673        assert!(
674            prefix_populated(&store, REL),
675            "cleanup must not run on a failed gate — the source prefix stays intact"
676        );
677    }
678
679    #[test]
680    fn match_with_prefix_cleans_once() {
681        let f = FakeLoader {
682            rows: 10,
683            ..Default::default()
684        };
685        let dir = tempfile::tempdir().unwrap();
686        let store = fs_store_with_prefix(&dir, REL);
687        let r = run_load(
688            &f,
689            "t",
690            &spec(TargetStatus::Ok),
691            &uris(),
692            Some(10),
693            Some((&store, PREFIX)),
694        )
695        .unwrap();
696        assert!(r.source_cleaned);
697        assert!(
698            !prefix_populated(&store, REL),
699            "a passed gate drains the source prefix through the injected store"
700        );
701        assert_eq!(r.target_table, "db.t");
702    }
703
704    #[test]
705    fn match_without_prefix_does_not_clean() {
706        let f = FakeLoader {
707            rows: 10,
708            ..Default::default()
709        };
710        let r = run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), Some(10), None).unwrap();
711        assert!(!r.source_cleaned);
712    }
713
714    #[test]
715    fn none_expected_skips_the_gate() {
716        let f = FakeLoader {
717            rows: 999,
718            ..Default::default()
719        };
720        // No expected count → any landed rows pass (an ad-hoc load).
721        assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), None, None).is_ok());
722    }
723
724    #[test]
725    fn cdc_delta_mismatch_bails_without_view() {
726        let f = FakeLoader {
727            rows: 3,
728            ..Default::default()
729        };
730        let dir = tempfile::tempdir().unwrap();
731        let store = fs_store_with_prefix(&dir, REL);
732        let err = run_load_cdc(
733            &f,
734            "t",
735            &spec(TargetStatus::Ok),
736            &uris(),
737            &["id".into()],
738            cdc::SourceEngine::MySql,
739            Some(5),
740            Some((&store, PREFIX)),
741        )
742        .unwrap_err()
743        .to_string();
744        assert!(err.contains("CDC count validation failed"), "{err}");
745        assert!(
746            f.views.borrow().is_empty(),
747            "view must not be built on a failed gate"
748        );
749        assert!(
750            prefix_populated(&store, REL),
751            "cleanup must not run on a failed gate"
752        );
753    }
754
755    #[test]
756    fn cdc_match_builds_view_then_cleans() {
757        let f = FakeLoader {
758            rows: 5,
759            ..Default::default()
760        };
761        let dir = tempfile::tempdir().unwrap();
762        let store = fs_store_with_prefix(&dir, REL);
763        let r = run_load_cdc(
764            &f,
765            "t",
766            &spec(TargetStatus::Ok),
767            &uris(),
768            &["id".into()],
769            cdc::SourceEngine::MySql,
770            Some(5),
771            Some((&store, PREFIX)),
772        )
773        .unwrap();
774        assert_eq!(r.rows_appended, 5);
775        assert_eq!(*f.views.borrow(), vec!["t".to_string()]);
776        assert!(
777            !prefix_populated(&store, REL),
778            "a passed CDC gate drains the source prefix after the view is built"
779        );
780        assert_eq!(r.changes_table, "db.t__changes");
781    }
782
783    #[test]
784    fn delete_under_drains_the_prefix_through_the_store() {
785        let dir = tempfile::tempdir().unwrap();
786        let store = fs_store_with_prefix(&dir, REL);
787        assert!(prefix_populated(&store, REL), "seeded object is present");
788        delete_under(&store, PREFIX).unwrap();
789        assert!(
790            !prefix_populated(&store, REL),
791            "delete_under recursively removes the bucket-relative prefix behind the gs:// URI"
792        );
793    }
794
795    #[test]
796    fn build_bigquery_loader_wires_partition_and_cluster_keys() {
797        // A non-empty cluster/partition MUST reach the loader; if the guard
798        // inverts, a real key is silently dropped and the load omits the
799        // clustering the config asked for. Reading cluster_by/partition_by pins
800        // the wiring that `Box<dyn TargetLoader>` hides.
801        let l = build_bigquery_loader(
802            "proj",
803            "ds",
804            Some("day"),
805            &["customer_id".to_string(), "region".to_string()],
806            "run-1",
807        );
808        assert_eq!(l.cluster_by, ["customer_id", "region"]);
809        assert_eq!(l.partition_by.as_deref(), Some("day"));
810
811        // No keys set → neither clause (the default), never a spurious one.
812        let bare = build_bigquery_loader("proj", "ds", None, &[], "run-1");
813        assert!(bare.cluster_by.is_empty());
814        assert!(bare.partition_by.is_none());
815    }
816
817    #[test]
818    fn split_gs_uri_parses_bucket_and_bucket_relative_key() {
819        // The parse every load op addresses through: (bucket, bucket-relative
820        // key). The `delete_under` test above can't pin this — it drains by REL
821        // regardless of what split returns — so a mangled split (wrong bucket, or
822        // an empty key that lists/deletes the whole bucket root) is invisible
823        // there. Pin it directly.
824        assert_eq!(split_gs_uri("gs://b/p").unwrap(), ("b", "p"));
825        assert_eq!(
826            split_gs_uri("gs://bucket/a/b/c.parquet").unwrap(),
827            ("bucket", "a/b/c.parquet"),
828            "only the FIRST '/' splits bucket from key; the rest is the key"
829        );
830        assert!(
831            split_gs_uri("s3://b/p").is_err(),
832            "a non-gs scheme is rejected"
833        );
834        assert!(
835            split_gs_uri("gs://bucket-only").is_err(),
836            "a bucket with no '/' has no (bucket, key) split"
837        );
838        // The bucket-ROOT prefix must be REFUSED, never returned as an empty key:
839        // an empty key sends delete_under → remove_all("") / gc_orphans across the
840        // WHOLE bucket. A GCS export with no prefix, or a `{partition}`-leading
841        // prefix (base collapses to ""), resolves to exactly these — a legal config
842        // that would otherwise wipe unrelated data. (RED before the empty-key guard.)
843        for root in ["gs://bucket/", "gs://bucket//", "gs://bucket///"] {
844            assert!(
845                split_gs_uri(root).is_err(),
846                "bucket-root prefix {root:?} must be refused, not parsed to an empty (root) key"
847            );
848        }
849        // A non-empty key with a trailing slash is still a real prefix.
850        assert_eq!(split_gs_uri("gs://b/exports/").unwrap(), ("b", "exports/"));
851    }
852
853    #[test]
854    fn cdc_empty_pk_bails() {
855        let f = FakeLoader::default();
856        assert!(
857            run_load_cdc(
858                &f,
859                "t",
860                &spec(TargetStatus::Ok),
861                &uris(),
862                &[],
863                cdc::SourceEngine::MySql,
864                None,
865                None
866            )
867            .is_err()
868        );
869        assert!(f.appended.borrow().is_empty());
870    }
871
872    #[test]
873    fn a_uri_with_a_quote_backslash_or_control_char_bails_before_the_driver_runs() {
874        // Storage-sourced injection: URIs come from the live GCS listing and are
875        // spliced single-quoted, UNESCAPED, into COPY FILES=()/LOAD uris=[]. A
876        // planted object key carrying the delimiter must be REFUSED loudly before
877        // any driver SQL runs — never escaped-and-hoped. The driver must not be
878        // touched (materialize/append never called).
879        for bad in [
880            "gs://b/p/x'; drop table t --.parquet", // breaks out of the '…' literal
881            "gs://b/p/x\\.parquet",                 // backslash: Snowflake in-string escape
882            "gs://b/p/x\n.parquet",                 // control char
883        ] {
884            let f = FakeLoader {
885                rows: 1,
886                ..Default::default()
887            };
888            let uris = vec![bad.to_string()];
889            assert!(
890                run_load(&f, "t", &spec(TargetStatus::Ok), &uris, Some(1), None).is_err(),
891                "run_load must reject the injection URI {bad:?}"
892            );
893            assert!(
894                f.materialized.borrow().is_empty(),
895                "the driver must not be reached for {bad:?}"
896            );
897            assert!(
898                run_load_cdc(
899                    &f,
900                    "t",
901                    &spec(TargetStatus::Ok),
902                    &uris,
903                    &["id".to_string()],
904                    cdc::SourceEngine::MySql,
905                    Some(1),
906                    None,
907                )
908                .is_err(),
909                "run_load_cdc must reject the injection URI {bad:?}"
910            );
911            assert!(
912                f.appended.borrow().is_empty(),
913                "the CDC driver must not be reached for {bad:?}"
914            );
915        }
916        // A normal rivet-produced URI still passes the gate.
917        assert!(ensure_safe_load_uris(&uris()).is_ok());
918    }
919
920    #[test]
921    fn incremental_cursor_not_in_specs_bails_before_append() {
922        let f = FakeLoader::default();
923        // The exported columns are just `id`; a cursor `updated_at` used only in
924        // the extract's WHERE (not projected) is absent from `__changes`. The
925        // driver must bail BEFORE appending — else the view creation fails after
926        // the append and every retry re-appends (bloat).
927        let err = run_load_incremental(
928            &f,
929            "t",
930            &spec(TargetStatus::Ok),
931            &uris(),
932            &["id".to_string()],
933            "updated_at",
934            None,
935            None,
936        )
937        .unwrap_err()
938        .to_string();
939        assert!(
940            err.contains("updated_at") && err.contains("not one of the exported columns"),
941            "{err}"
942        );
943        assert!(
944            f.appended.borrow().is_empty(),
945            "nothing appended before the bail"
946        );
947    }
948}