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    // Gate the PK columns at the SHARED seam: both the CDC and the INCREMENTAL
268    // driver splice them into the dedup view's `PARTITION BY` via quote_ident
269    // (Snowflake emits them bare; BigQuery wraps in backticks WITHOUT escaping an
270    // internal backtick), so a name outside a plain identifier is an injection
271    // vector. Round-6 gated this inline in the CDC path only — the incremental
272    // path (`run_load_incremental`) reached the same splice UNGATED. Hoisting it
273    // here covers both, so no load driver can bypass it (the runner-bypass class).
274    for c in pk {
275        if !is_safe_load_ident(c) {
276            bail!(
277                "cannot load `{table}`: primary-key column `{}` is not a plain SQL identifier \
278                 ([A-Za-z_][A-Za-z0-9_]*) — it is spliced into the dedup view's PARTITION BY. \
279                 Rename or alias it in the export.",
280                c.escape_default()
281            );
282        }
283    }
284    validate_specs(&format!("{table}__changes"), specs)?;
285
286    let rows_appended = loader.append_changelog(table, specs, uris, pk)?;
287
288    if let Some(expected) = expected_delta
289        && rows_appended != expected
290    {
291        bail!(
292            "{label} count validation failed for `{}__changes`: appended {rows_appended} rows, \
293             expected {expected} from the run manifests — investigate before trusting the view",
294            table
295        );
296    }
297
298    build_view(loader)?;
299    // Cleanup runs here (inside the driver, after the gate), BEFORE the caller
300    // records the ledger in `execute_load`. A crash between the two re-appends
301    // this run next load — an at-least-once double-append the dedup view absorbs
302    // (and the count gate still guards) — accepted rather than ordering the
303    // irreversible delete after the durable record.
304    let source_cleaned = maybe_cleanup(cleanup);
305
306    Ok(CdcLoadReport {
307        rows_appended,
308        changes_table: loader.fqtn(&format!("{table}__changes")),
309        view: loader.fqtn(table),
310        source_cleaned,
311    })
312}
313
314#[allow(clippy::too_many_arguments, private_interfaces)]
315pub fn run_load_cdc(
316    loader: &dyn TargetLoader,
317    table: &str,
318    specs: &[TargetColumnSpec],
319    uris: &[String],
320    pk: &[String],
321    engine: cdc::SourceEngine,
322    expected_delta: Option<u64>,
323    cleanup: Option<(&GcsStore, &str)>,
324) -> Result<CdcLoadReport> {
325    // PK-injection gating lives in the SHARED seam `append_and_view` (below), which
326    // covers both the CDC and incremental drivers — no per-driver copy (the old
327    // Round-6 inline gate here was dead once the shared one was hoisted).
328    append_and_view(
329        loader,
330        table,
331        specs,
332        uris,
333        pk,
334        expected_delta,
335        cleanup,
336        "CDC",
337        |l| {
338            let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
339            let sql = cdc::dedup_view_sql(
340                l.warehouse(),
341                &l.fqtn(table),
342                &l.fqtn(&format!("{table}__changes")),
343                &pk_refs,
344                engine,
345            );
346            l.create_view(table, &sql)
347        },
348    )
349}
350
351/// Load an INCREMENTAL export's delta: APPEND the parquet into `<table>__changes`
352/// (reusing the CDC changelog append — the delta's rows land with NULL `__op`/
353/// `__pos`/`__seq`, which the view drops) and (re)build a current-state view
354/// deduped to the latest row per PK by `cursor_column`. The manifests' summed
355/// `row_count` gates the appended delta, and cleanup runs (only) after the gate —
356/// safe because the ledger, not the file prefix, records what's loaded.
357// Same arity shape as [`run_load_cdc`] (the cursor replaces the engine);
358// `allow(private_interfaces)` for the injected `GcsStore` — see [`run_load`].
359#[allow(clippy::too_many_arguments, private_interfaces)]
360pub fn run_load_incremental(
361    loader: &dyn TargetLoader,
362    table: &str,
363    specs: &[TargetColumnSpec],
364    uris: &[String],
365    pk: &[String],
366    cursor_column: &str,
367    expected_delta: Option<u64>,
368    cleanup: Option<(&GcsStore, &str)>,
369) -> Result<CdcLoadReport> {
370    // uris + pk are checked by `append_and_view`; the cursor guards are incremental-only.
371    if cursor_column.is_empty() {
372        bail!(
373            "incremental load of `{table}` needs a cursor column (the export's `cursor_column:`) \
374             for the dedup view's latest-per-PK ordering"
375        );
376    }
377    // The cursor must be an EXPORTED column: the dedup view orders `__changes` by
378    // it (`ORDER BY <cursor> DESC`). A cursor used only in the extract's WHERE and
379    // not projected (e.g. `SELECT id, v` with `cursor_column: updated_at`, or
380    // incremental-coalesce which strips its synthetic cursor) is absent from
381    // `__changes`, so the view creation would fail AFTER the append — turn that
382    // into a loud pre-append bail instead of a broken view + a retried re-append.
383    if !specs.iter().any(|s| s.column_name == cursor_column) {
384        let cols: Vec<&str> = specs.iter().map(|s| s.column_name.as_str()).collect();
385        bail!(
386            "incremental load of `{table}`: cursor_column `{cursor_column}` is not one of the \
387             exported columns [{}] — add it to the export's SELECT so the dedup view can order \
388             the change log by it",
389            cols.join(", ")
390        );
391    }
392    append_and_view(
393        loader,
394        table,
395        specs,
396        uris,
397        pk,
398        expected_delta,
399        cleanup,
400        "incremental",
401        |l| {
402            let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
403            let sql = cdc::inc_dedup_view_sql(
404                l.warehouse(),
405                &l.fqtn(table),
406                &l.fqtn(&format!("{table}__changes")),
407                &pk_refs,
408                cursor_column,
409            );
410            l.create_view(table, &sql)
411        },
412    )
413}
414
415/// Split a `gs://bucket/path` URI into `(bucket, bucket-relative path)` — the
416/// shape opendal's bucket-scoped operator wants.
417pub(crate) fn split_gs_uri(uri: &str) -> Result<(&str, &str)> {
418    let (bucket, key) = uri
419        .strip_prefix("gs://")
420        .and_then(|rest| rest.split_once('/'))
421        .with_context(|| format!("not a `gs://bucket/path` URI: {uri}"))?;
422    // Refuse an EMPTY bucket-relative key. It addresses the bucket ROOT, and the
423    // load's recursive cleanup (delete_under → remove_all) and gc_orphans (list +
424    // remove) would then wipe the ENTIRE bucket — including unrelated exports and
425    // pre-existing objects — on a LEGAL config: a GCS export with no
426    // `destination.prefix`, or a prefix that leads with `{partition}` so the
427    // pre-`{partition}` base collapses to "". No load/cleanup lifecycle ever
428    // legitimately targets the bucket root, so fail LOUD here rather than delete
429    // everything. (Trailing/only slashes collapse to empty too.)
430    if key.trim_matches('/').is_empty() {
431        anyhow::bail!(
432            "refusing a bucket-root staging prefix `{uri}`: a GCS load stages into and cleans up a \
433             DEDICATED prefix, so an empty prefix would list/delete the whole bucket. Set a \
434             non-empty `destination.prefix`, and put any `{{partition}}` token AFTER a literal \
435             segment (e.g. `exports/{{partition}}/`, not `{{partition}}/`)."
436        );
437    }
438    Ok((bucket, key))
439}
440
441/// Recursively delete a whole export-dedicated `gs://…/` prefix through an
442/// injected [`GcsStore`] — the driver's post-gate source cleanup, over the same
443/// native opendal GCS client the export destination uses (no `gcloud`). Taking
444/// the store as an argument (rather than each adapter building one from a
445/// config) is what lets an fs-backed store exercise this delete offline.
446pub(crate) fn delete_under(store: &GcsStore, gs_prefix: &str) -> Result<()> {
447    let (_, rel) = split_gs_uri(gs_prefix)?;
448    store
449        .remove_all(rel)
450        .with_context(|| format!("source cleanup (recursive delete of {gs_prefix}) failed"))
451}
452
453/// Open the one [`GcsStore`] a load reuses for reconcile, URI listing, and
454/// post-gate cleanup — the single production constructor `cli::dispatch` calls.
455///
456/// `pub` (a public-API root the lib keeps alive) even though its only caller is
457/// the binary-only dispatch: it re-anchors `GcsStore`'s real-GCS constructor in
458/// the lib compilation unit, which no longer reaches it through a load adapter.
459/// `#[allow(private_interfaces)]` for the crate-private return — same rationale
460/// as [`reconcile::fetch_manifests_keyed`].
461#[allow(private_interfaces)]
462pub fn open_store(dest: &crate::config::DestinationConfig) -> Result<GcsStore> {
463    GcsStore::new(dest)
464}
465
466/// The one place a resolved plan's [`LoadTarget`](plan::LoadTarget) maps to a
467/// concrete [`TargetLoader`] adapter — wiring partition / cluster / connection /
468/// run-id from the config. The count gate and cleanup are the driver's, so the
469/// adapter carries no `expected_rows`.
470pub fn build_loader(plan: &plan::LoadPlan, run_id: &str) -> Box<dyn TargetLoader> {
471    use plan::LoadTarget;
472    let load = &plan.load;
473    match &load.target {
474        LoadTarget::Bigquery { project, dataset } => Box::new(build_bigquery_loader(
475            project,
476            dataset,
477            plan.partition_by.as_deref(),
478            &load.cluster_by,
479            run_id,
480        )),
481        LoadTarget::Snowflake {
482            connection,
483            warehouse,
484            database,
485            schema,
486            storage_integration,
487        } => {
488            let mut l = SnowflakeLoader::new(connection.clone());
489            l.warehouse = warehouse.clone();
490            l.database = database.clone();
491            l.schema = schema.clone();
492            l.storage_integration = storage_integration.clone();
493            l.cluster_by = load.cluster_by.clone();
494            l.run_id = Some(run_id.to_string());
495            // Snowflake's external stage wants the `gcs://` scheme, not `gs://`.
496            l.gcs_url = plan.gcs_prefix.replacen("gs://", "gcs://", 1);
497            // The `snow` CLI does not expand `~`; pass an absolute key path.
498            l.private_key_path = std::env::var("RIVET_SNOWFLAKE_KEY").ok();
499            Box::new(l)
500        }
501    }
502}
503
504/// Wire a [`BigQueryLoader`] from a resolved plan's fields — `partition_by` and
505/// `cluster_by` applied ONLY when set. A concrete return (not the boxed trait)
506/// so the wiring is unit-testable: a mis-guarded key would silently DROP the
507/// clustering/partitioning the config asked for — a degradation invisible
508/// through `Box<dyn TargetLoader>`.
509fn build_bigquery_loader(
510    project: &str,
511    dataset: &str,
512    partition_by: Option<&str>,
513    cluster_by: &[String],
514    run_id: &str,
515) -> BigQueryLoader {
516    let mut l = BigQueryLoader::new(project, dataset).run_id(run_id);
517    if let Some(part) = partition_by {
518        l = l.partition_by(part);
519    }
520    if !cluster_by.is_empty() {
521        l = l.cluster_by(cluster_by.to_vec());
522    }
523    l
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use std::cell::RefCell;
530
531    /// Records every call and returns a canned row count — the seam the driver's
532    /// invariants are asserted through, offline.
533    #[derive(Default)]
534    struct FakeLoader {
535        rows: u64,
536        materialized: RefCell<Vec<String>>,
537        appended: RefCell<Vec<String>>,
538        views: RefCell<Vec<String>>,
539    }
540
541    impl TargetLoader for FakeLoader {
542        fn fqtn(&self, table: &str) -> String {
543            format!("db.{table}")
544        }
545        fn materialize(&self, table: &str, _: &[TargetColumnSpec], _: &[String]) -> Result<u64> {
546            self.materialized.borrow_mut().push(table.into());
547            Ok(self.rows)
548        }
549        fn append_changelog(
550            &self,
551            table: &str,
552            _: &[TargetColumnSpec],
553            _: &[String],
554            _: &[String],
555        ) -> Result<u64> {
556            self.appended.borrow_mut().push(table.into());
557            Ok(self.rows)
558        }
559        fn warehouse(&self) -> cdc::Warehouse {
560            cdc::Warehouse::BigQuery
561        }
562        fn create_view(&self, table: &str, _view_sql: &str) -> Result<()> {
563            self.views.borrow_mut().push(table.into());
564            Ok(())
565        }
566    }
567
568    /// An fs-backed [`GcsStore`] seeded with one object under the bucket-relative
569    /// `rel` — stands in for the export's live GCS source prefix so the driver's
570    /// real delete path (`delete_under` → `remove_all`) runs offline. Returns the
571    /// store; the caller keeps `dir` alive for the store's lifetime.
572    fn fs_store_with_prefix(dir: &tempfile::TempDir, rel: &str) -> GcsStore {
573        let obj = dir.path().join(rel).join("x.parquet");
574        std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
575        std::fs::write(obj, b"x").unwrap();
576        GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap()
577    }
578
579    /// Whether the fs store still holds an object under bucket-relative `rel`.
580    fn prefix_populated(store: &GcsStore, rel: &str) -> bool {
581        !store.list_files(rel).unwrap().is_empty()
582    }
583
584    #[test]
585    fn delete_under_and_gc_orphans_refuse_the_bucket_root_and_spare_siblings() {
586        // #8 e2e against the REAL opendal fs-backed store (the load layer's offline
587        // e2e seam — `delete_under`/`gc_orphans` run their real recursive delete
588        // here). A bucket-ROOT prefix (the empty resolved key a no-`prefix` or
589        // `{partition}`-leading GCS export + cleanup_source produces) must be
590        // REFUSED, never wiped — a real `remove_all("")` destroys UNRELATED exports.
591        let dir = tempfile::tempdir().unwrap();
592        for rel in ["exportA/part.parquet", "innocent-neighbour/keep.parquet"] {
593            let obj = dir.path().join(rel);
594            std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
595            std::fs::write(obj, b"x").unwrap();
596        }
597        let store = GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap();
598
599        // The destructive paths refuse the root — BEFORE touching the store.
600        assert!(
601            delete_under(&store, "gs://bucket/").is_err(),
602            "cleanup_source must REFUSE a bucket-root prefix, not remove_all(\"\")"
603        );
604        assert!(
605            reconcile::gc_orphans(&store, "gs://bucket/", &[], false).is_err(),
606            "gc_orphans must REFUSE a bucket-root prefix, not list+delete the whole bucket"
607        );
608        // Nothing was deleted — both independent exports survive intact.
609        assert!(prefix_populated(&store, "exportA"), "exportA must survive");
610        assert!(
611            prefix_populated(&store, "innocent-neighbour"),
612            "an unrelated neighbour export must survive the refused root cleanup"
613        );
614
615        // Contrast — the guard does NOT over-block: a REAL per-export prefix still
616        // drains its own subtree and spares the neighbour.
617        delete_under(&store, "gs://bucket/exportA").unwrap();
618        assert!(
619            !prefix_populated(&store, "exportA"),
620            "a real prefix cleanup still drains its own export"
621        );
622        assert!(
623            prefix_populated(&store, "innocent-neighbour"),
624            "a scoped cleanup spares the sibling"
625        );
626    }
627
628    fn spec(status: TargetStatus) -> Vec<TargetColumnSpec> {
629        vec![TargetColumnSpec {
630            column_name: "id".into(),
631            target_type: "INT64".into(),
632            autoload_type: String::new(),
633            status,
634            note: None,
635            cast_sql: None,
636        }]
637    }
638    fn uris() -> Vec<String> {
639        vec!["gs://b/p/x.parquet".into()]
640    }
641    /// The cleanup prefix the driver receives (a `gs://bucket/…` URI) and its
642    /// bucket-relative form the fs store is keyed by.
643    const PREFIX: &str = "gs://b/p";
644    const REL: &str = "p";
645
646    #[test]
647    fn empty_uris_bail_before_materialize() {
648        let f = FakeLoader {
649            rows: 10,
650            ..Default::default()
651        };
652        assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &[], Some(10), None).is_err());
653        assert!(f.materialized.borrow().is_empty());
654    }
655
656    #[test]
657    fn fail_spec_bails_before_materialize() {
658        let f = FakeLoader::default();
659        assert!(run_load(&f, "t", &spec(TargetStatus::Fail), &uris(), Some(10), None).is_err());
660        assert!(f.materialized.borrow().is_empty());
661    }
662
663    #[test]
664    fn count_mismatch_bails_without_cleanup() {
665        let f = FakeLoader {
666            rows: 7,
667            ..Default::default()
668        };
669        let dir = tempfile::tempdir().unwrap();
670        let store = fs_store_with_prefix(&dir, REL);
671        let err = run_load(
672            &f,
673            "t",
674            &spec(TargetStatus::Ok),
675            &uris(),
676            Some(10),
677            Some((&store, PREFIX)),
678        )
679        .unwrap_err()
680        .to_string();
681        assert!(err.contains("count validation failed"), "{err}");
682        assert!(
683            prefix_populated(&store, REL),
684            "cleanup must not run on a failed gate — the source prefix stays intact"
685        );
686    }
687
688    #[test]
689    fn match_with_prefix_cleans_once() {
690        let f = FakeLoader {
691            rows: 10,
692            ..Default::default()
693        };
694        let dir = tempfile::tempdir().unwrap();
695        let store = fs_store_with_prefix(&dir, REL);
696        let r = run_load(
697            &f,
698            "t",
699            &spec(TargetStatus::Ok),
700            &uris(),
701            Some(10),
702            Some((&store, PREFIX)),
703        )
704        .unwrap();
705        assert!(r.source_cleaned);
706        assert!(
707            !prefix_populated(&store, REL),
708            "a passed gate drains the source prefix through the injected store"
709        );
710        assert_eq!(r.target_table, "db.t");
711    }
712
713    #[test]
714    fn match_without_prefix_does_not_clean() {
715        let f = FakeLoader {
716            rows: 10,
717            ..Default::default()
718        };
719        let r = run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), Some(10), None).unwrap();
720        assert!(!r.source_cleaned);
721    }
722
723    #[test]
724    fn none_expected_skips_the_gate() {
725        let f = FakeLoader {
726            rows: 999,
727            ..Default::default()
728        };
729        // No expected count → any landed rows pass (an ad-hoc load).
730        assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), None, None).is_ok());
731    }
732
733    #[test]
734    fn cdc_delta_mismatch_bails_without_view() {
735        let f = FakeLoader {
736            rows: 3,
737            ..Default::default()
738        };
739        let dir = tempfile::tempdir().unwrap();
740        let store = fs_store_with_prefix(&dir, REL);
741        let err = run_load_cdc(
742            &f,
743            "t",
744            &spec(TargetStatus::Ok),
745            &uris(),
746            &["id".into()],
747            cdc::SourceEngine::MySql,
748            Some(5),
749            Some((&store, PREFIX)),
750        )
751        .unwrap_err()
752        .to_string();
753        assert!(err.contains("CDC count validation failed"), "{err}");
754        assert!(
755            f.views.borrow().is_empty(),
756            "view must not be built on a failed gate"
757        );
758        assert!(
759            prefix_populated(&store, REL),
760            "cleanup must not run on a failed gate"
761        );
762    }
763
764    #[test]
765    fn cdc_match_builds_view_then_cleans() {
766        let f = FakeLoader {
767            rows: 5,
768            ..Default::default()
769        };
770        let dir = tempfile::tempdir().unwrap();
771        let store = fs_store_with_prefix(&dir, REL);
772        let r = run_load_cdc(
773            &f,
774            "t",
775            &spec(TargetStatus::Ok),
776            &uris(),
777            &["id".into()],
778            cdc::SourceEngine::MySql,
779            Some(5),
780            Some((&store, PREFIX)),
781        )
782        .unwrap();
783        assert_eq!(r.rows_appended, 5);
784        assert_eq!(*f.views.borrow(), vec!["t".to_string()]);
785        assert!(
786            !prefix_populated(&store, REL),
787            "a passed CDC gate drains the source prefix after the view is built"
788        );
789        assert_eq!(r.changes_table, "db.t__changes");
790    }
791
792    #[test]
793    fn delete_under_drains_the_prefix_through_the_store() {
794        let dir = tempfile::tempdir().unwrap();
795        let store = fs_store_with_prefix(&dir, REL);
796        assert!(prefix_populated(&store, REL), "seeded object is present");
797        delete_under(&store, PREFIX).unwrap();
798        assert!(
799            !prefix_populated(&store, REL),
800            "delete_under recursively removes the bucket-relative prefix behind the gs:// URI"
801        );
802    }
803
804    #[test]
805    fn build_bigquery_loader_wires_partition_and_cluster_keys() {
806        // A non-empty cluster/partition MUST reach the loader; if the guard
807        // inverts, a real key is silently dropped and the load omits the
808        // clustering the config asked for. Reading cluster_by/partition_by pins
809        // the wiring that `Box<dyn TargetLoader>` hides.
810        let l = build_bigquery_loader(
811            "proj",
812            "ds",
813            Some("day"),
814            &["customer_id".to_string(), "region".to_string()],
815            "run-1",
816        );
817        assert_eq!(l.cluster_by, ["customer_id", "region"]);
818        assert_eq!(l.partition_by.as_deref(), Some("day"));
819
820        // No keys set → neither clause (the default), never a spurious one.
821        let bare = build_bigquery_loader("proj", "ds", None, &[], "run-1");
822        assert!(bare.cluster_by.is_empty());
823        assert!(bare.partition_by.is_none());
824    }
825
826    #[test]
827    fn split_gs_uri_parses_bucket_and_bucket_relative_key() {
828        // The parse every load op addresses through: (bucket, bucket-relative
829        // key). The `delete_under` test above can't pin this — it drains by REL
830        // regardless of what split returns — so a mangled split (wrong bucket, or
831        // an empty key that lists/deletes the whole bucket root) is invisible
832        // there. Pin it directly.
833        assert_eq!(split_gs_uri("gs://b/p").unwrap(), ("b", "p"));
834        assert_eq!(
835            split_gs_uri("gs://bucket/a/b/c.parquet").unwrap(),
836            ("bucket", "a/b/c.parquet"),
837            "only the FIRST '/' splits bucket from key; the rest is the key"
838        );
839        assert!(
840            split_gs_uri("s3://b/p").is_err(),
841            "a non-gs scheme is rejected"
842        );
843        assert!(
844            split_gs_uri("gs://bucket-only").is_err(),
845            "a bucket with no '/' has no (bucket, key) split"
846        );
847        // The bucket-ROOT prefix must be REFUSED, never returned as an empty key:
848        // an empty key sends delete_under → remove_all("") / gc_orphans across the
849        // WHOLE bucket. A GCS export with no prefix, or a `{partition}`-leading
850        // prefix (base collapses to ""), resolves to exactly these — a legal config
851        // that would otherwise wipe unrelated data. (RED before the empty-key guard.)
852        for root in ["gs://bucket/", "gs://bucket//", "gs://bucket///"] {
853            assert!(
854                split_gs_uri(root).is_err(),
855                "bucket-root prefix {root:?} must be refused, not parsed to an empty (root) key"
856            );
857        }
858        // A non-empty key with a trailing slash is still a real prefix.
859        assert_eq!(split_gs_uri("gs://b/exports/").unwrap(), ("b", "exports/"));
860    }
861
862    #[test]
863    fn cdc_empty_pk_bails() {
864        let f = FakeLoader::default();
865        assert!(
866            run_load_cdc(
867                &f,
868                "t",
869                &spec(TargetStatus::Ok),
870                &uris(),
871                &[],
872                cdc::SourceEngine::MySql,
873                None,
874                None
875            )
876            .is_err()
877        );
878        assert!(f.appended.borrow().is_empty());
879    }
880
881    #[test]
882    fn a_uri_with_a_quote_backslash_or_control_char_bails_before_the_driver_runs() {
883        // Storage-sourced injection: URIs come from the live GCS listing and are
884        // spliced single-quoted, UNESCAPED, into COPY FILES=()/LOAD uris=[]. A
885        // planted object key carrying the delimiter must be REFUSED loudly before
886        // any driver SQL runs — never escaped-and-hoped. The driver must not be
887        // touched (materialize/append never called).
888        for bad in [
889            "gs://b/p/x'; drop table t --.parquet", // breaks out of the '…' literal
890            "gs://b/p/x\\.parquet",                 // backslash: Snowflake in-string escape
891            "gs://b/p/x\n.parquet",                 // control char
892        ] {
893            let f = FakeLoader {
894                rows: 1,
895                ..Default::default()
896            };
897            let uris = vec![bad.to_string()];
898            assert!(
899                run_load(&f, "t", &spec(TargetStatus::Ok), &uris, Some(1), None).is_err(),
900                "run_load must reject the injection URI {bad:?}"
901            );
902            assert!(
903                f.materialized.borrow().is_empty(),
904                "the driver must not be reached for {bad:?}"
905            );
906            assert!(
907                run_load_cdc(
908                    &f,
909                    "t",
910                    &spec(TargetStatus::Ok),
911                    &uris,
912                    &["id".to_string()],
913                    cdc::SourceEngine::MySql,
914                    Some(1),
915                    None,
916                )
917                .is_err(),
918                "run_load_cdc must reject the injection URI {bad:?}"
919            );
920            assert!(
921                f.appended.borrow().is_empty(),
922                "the CDC driver must not be reached for {bad:?}"
923            );
924        }
925        // A normal rivet-produced URI still passes the gate.
926        assert!(ensure_safe_load_uris(&uris()).is_ok());
927    }
928
929    #[test]
930    fn incremental_cursor_not_in_specs_bails_before_append() {
931        let f = FakeLoader::default();
932        // The exported columns are just `id`; a cursor `updated_at` used only in
933        // the extract's WHERE (not projected) is absent from `__changes`. The
934        // driver must bail BEFORE appending — else the view creation fails after
935        // the append and every retry re-appends (bloat).
936        let err = run_load_incremental(
937            &f,
938            "t",
939            &spec(TargetStatus::Ok),
940            &uris(),
941            &["id".to_string()],
942            "updated_at",
943            None,
944            None,
945        )
946        .unwrap_err()
947        .to_string();
948        assert!(
949            err.contains("updated_at") && err.contains("not one of the exported columns"),
950            "{err}"
951        );
952        assert!(
953            f.appended.borrow().is_empty(),
954            "nothing appended before the bail"
955        );
956    }
957
958    // RED before the pk gate moved into append_and_view: the CDC path gated its
959    // PK columns (round-6), but the INCREMENTAL path spliced pk into the dedup
960    // view's PARTITION BY (via quote_ident — bare on Snowflake, unescaped
961    // backticks on BigQuery) WITHOUT the same gate. A non-identifier PK name is
962    // an injection vector, and it must be refused before the driver runs.
963    #[test]
964    fn incremental_hostile_pk_is_refused_before_the_view_splice() {
965        let f = FakeLoader::default();
966        let err = run_load_incremental(
967            &f,
968            "t",
969            &spec(TargetStatus::Ok), // exported column: `id`
970            &uris(),
971            &["id) OR (1=1) --".to_string()], // hostile PK spliced into PARTITION BY
972            "id",                             // valid cursor (in specs) so we reach the pk gate
973            None,
974            None,
975        )
976        .unwrap_err()
977        .to_string();
978        assert!(
979            err.contains("not a plain SQL identifier") && err.contains("PARTITION BY"),
980            "incremental load must refuse a non-identifier PK before splicing it into the view; got: {err}"
981        );
982        assert!(
983            f.appended.borrow().is_empty() && f.views.borrow().is_empty(),
984            "must bail before appending or building the view"
985        );
986    }
987}