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