Skip to main content

rivet/load/
snowflake.rs

1//! Snowflake loader.
2//!
3//! Loads Rivet Parquet from GCS into a native-typed Snowflake table via a
4//! `COPY INTO` off an external stage (a GCS `STORAGE INTEGRATION`). Unlike
5//! BigQuery — where `LOAD DATA` with a declared schema coerces the Parquet for
6//! free — Snowflake:
7//!   * bills warehouse compute for the `COPY` (there is no free load), and
8//!   * does NOT parse a Parquet JSON string into a navigable `VARIANT`: a plain
9//!     `COPY` lands it as a `VARIANT`-wrapped string (`meta:key` → NULL). So a
10//!     `VARIANT` column is loaded through a `PARSE_JSON($1:col)` transform in
11//!     the same `COPY` (one billed pass), which yields a navigable `OBJECT`.
12//!
13//! Both facts were verified live before this was written.
14//!
15//! Cost attribution rides a `QUERY_TAG` (Snowflake's analogue of BigQuery job
16//! labels): the tag shows up in `ACCOUNT_USAGE.QUERY_HISTORY`, so per-`rivet_op`
17//! warehouse credits can be summed after the fact.
18
19use super::TargetLoader;
20use crate::types::target::TargetColumnSpec;
21use anyhow::{Context, Result, bail};
22use std::process::Command;
23
24/// Loads Rivet Parquet from GCS into Snowflake.
25#[derive(Debug, Default, Clone)]
26pub struct SnowflakeLoader {
27    /// The `snow` CLI connection name (e.g. `rivet`).
28    pub connection: String,
29    pub warehouse: String,
30    pub database: String,
31    pub schema: String,
32    /// A pre-created GCS `STORAGE INTEGRATION` (grants Snowflake read on the
33    /// bucket). The external stage is created per-load using it.
34    pub storage_integration: String,
35    /// `gcs://bucket/prefix/` — the external stage's URL (Snowflake wants the
36    /// `gcs://` scheme, not `gs://`).
37    pub gcs_url: String,
38    /// Clustering key — column(s)/expression(s) for `CLUSTER BY`, enabling
39    /// background auto-clustering. Empty = no clustering key. Applies only at
40    /// table creation.
41    pub cluster_by: Vec<String>,
42    /// Absolute path to the connection's private key. The `snow` CLI does not
43    /// expand `~`, so a `~`-relative `private_key_path` in the connection file
44    /// must be overridden with an absolute path via env.
45    pub private_key_path: Option<String>,
46    /// Load-run correlation id, emitted in the `QUERY_TAG` JSON as `rivet_run`
47    /// so every statement of one `rivet load` invocation shares a run key —
48    /// cost slices per run (across tables) as well as per table. `None` omits it.
49    pub run_id: Option<String>,
50}
51
52impl SnowflakeLoader {
53    pub fn new(connection: impl Into<String>) -> Self {
54        Self {
55            connection: connection.into(),
56            ..Default::default()
57        }
58    }
59
60    /// Fully-qualified `db.schema.table`. Identifiers are passed **unquoted**:
61    /// the warehouse/database/schema are pre-existing objects, and quoting a
62    /// lowercase name would miss an unquoted-created (upper-cased) object. The
63    /// tradeoff is that a reserved-word / special-char column is not handled —
64    /// a hardening TODO once a real source needs it.
65    fn fqtn(&self, table: &str) -> String {
66        format!("{}.{}.{}", self.database, self.schema, table)
67    }
68
69    /// `  id NUMBER(38,0),\n  meta VARIANT` — the native column DDL.
70    fn build_schema_ddl(specs: &[TargetColumnSpec]) -> String {
71        specs
72            .iter()
73            .map(|s| format!("  {} {}", s.column_name, s.target_type))
74            .collect::<Vec<_>>()
75            .join(",\n")
76    }
77
78    /// The `COPY` transform projection: a `VARIANT` column is parsed with
79    /// `PARSE_JSON` (a plain `COPY` would leave it a string), everything else is
80    /// passed through `$1:col` (the path key preserves the Parquet field case)
81    /// and coerced by the target column's type.
82    fn build_copy_select(specs: &[TargetColumnSpec]) -> String {
83        specs
84            .iter()
85            .map(|s| {
86                let path = format!("$1:{}", s.column_name);
87                if needs_parse_json(s) {
88                    format!("PARSE_JSON({path})")
89                } else {
90                    path
91                }
92            })
93            .collect::<Vec<_>>()
94            .join(", ")
95    }
96
97    /// `id, meta` — the explicit column list the transform loads into.
98    fn build_column_list(specs: &[TargetColumnSpec]) -> String {
99        specs
100            .iter()
101            .map(|s| s.column_name.clone())
102            .collect::<Vec<_>>()
103            .join(", ")
104    }
105
106    /// ` CLUSTER BY (created, region)` — the clustering-key clause (empty when
107    /// no key). Snowflake wraps the key in parentheses (unlike BigQuery).
108    fn cluster_clause(cluster_by: &[String]) -> String {
109        if cluster_by.is_empty() {
110            String::new()
111        } else {
112            format!(" CLUSTER BY ({})", cluster_by.join(", "))
113        }
114    }
115
116    /// A JSON query tag for post-hoc cost attribution in `QUERY_HISTORY`.
117    /// Carries `rivet_run` too when a load-run id is set, so credits summed from
118    /// `QUERY_ATTRIBUTION_HISTORY` slice per run as well as per table.
119    fn query_tag(&self, table: &str) -> String {
120        let run = self
121            .run_id
122            .as_deref()
123            .map(|r| format!(r#","rivet_run":"{}""#, sanitize_tag(r)))
124            .unwrap_or_default();
125        format!(
126            r#"{{"managed_by":"rivet","rivet_op":"load","rivet_table":"{}"{run}}}"#,
127            sanitize_tag(table)
128        )
129    }
130
131    /// Run a SQL script through `snow sql`, returning parsed JSON blocks.
132    fn run_snow(&self, sql: &str) -> Result<serde_json::Value> {
133        let mut cmd = Command::new("snow");
134        cmd.args(["sql", "-c", &self.connection, "--format", "json", "-q", sql]);
135        if let Some(key) = &self.private_key_path {
136            // snow reads SNOWFLAKE_CONNECTIONS_<CONN>_PRIVATE_KEY_PATH.
137            let env_key = format!(
138                "SNOWFLAKE_CONNECTIONS_{}_PRIVATE_KEY_PATH",
139                self.connection.to_uppercase()
140            );
141            cmd.env(env_key, key);
142        }
143        let out = cmd
144            .output()
145            .context("running `snow sql` — is the Snowflake CLI installed?")?;
146        if !out.status.success() {
147            bail!(
148                "snow sql failed: {}",
149                String::from_utf8_lossy(&out.stderr).trim()
150            );
151        }
152        serde_json::from_slice(&out.stdout).with_context(|| {
153            format!(
154                "parsing snow sql JSON output: {}",
155                String::from_utf8_lossy(&out.stdout)
156            )
157        })
158    }
159}
160
161impl TargetLoader for SnowflakeLoader {
162    fn fqtn(&self, table: &str) -> String {
163        format!("{}.{}.{}", self.database, self.schema, table)
164    }
165
166    fn materialize(&self, table: &str, specs: &[TargetColumnSpec], uris: &[String]) -> Result<u64> {
167        let fqtn = self.fqtn(table);
168        let ddl = Self::build_schema_ddl(specs);
169        let select = Self::build_copy_select(specs);
170        let columns = Self::build_column_list(specs);
171        // A per-load external stage over the export's GCS prefix; the COPY loads
172        // exactly the driver-selected files (`FILES=(…)`), NOT every Parquet under
173        // the prefix — so the mode-aware/ledger per-run selection is honored (a
174        // `PATTERN` over the prefix would load stale runs and fail the count gate).
175        let stage = format!("rivet_stage_{}", sanitize_tag(table));
176        let files = copy_files_clause(&self.gcs_url, uris)?;
177        let cluster = Self::cluster_clause(&self.cluster_by);
178
179        // `CREATE OR REPLACE` (overwrite): storage is the source of truth. Pin
180        // the session to UTC before the COPY — Snowflake otherwise stamps a
181        // Parquet timestamp with the session offset, shifting a `timestamptz`.
182        let sql = format!(
183            "ALTER SESSION SET QUERY_TAG = '{tag}';\n\
184             ALTER SESSION SET TIMEZONE = 'UTC';\n\
185             USE WAREHOUSE {wh};\n\
186             USE SCHEMA {db}.{sc};\n\
187             CREATE FILE FORMAT IF NOT EXISTS rivet_pq TYPE=PARQUET BINARY_AS_TEXT=FALSE;\n\
188             CREATE OR REPLACE STAGE {stage} URL='{url}' STORAGE_INTEGRATION={si} FILE_FORMAT=rivet_pq;\n\
189             CREATE OR REPLACE TABLE {fqtn} (\n{ddl}\n){cluster};\n\
190             COPY INTO {fqtn} ({columns})\n\
191             \x20 FROM (SELECT {select} FROM @{stage})\n\
192             \x20 FILE_FORMAT=(FORMAT_NAME=rivet_pq) {files};\n\
193             SELECT COUNT(*) AS ROWS_ FROM {fqtn};",
194            tag = self.query_tag(table),
195            wh = self.warehouse,
196            db = self.database,
197            sc = self.schema,
198            si = self.storage_integration,
199            url = self.gcs_url,
200        );
201
202        let result = self.run_snow(&sql)?;
203        // ponytail: rows via COUNT(*); can become the COPY's `rows_loaded`
204        // (metadata) behind this seam, no driver change.
205        extract_count(&result)
206            .context("COPY ran but the row count could not be read from snow output")
207    }
208
209    fn append_changelog(
210        &self,
211        table: &str,
212        specs: &[TargetColumnSpec],
213        uris: &[String],
214        pk: &[String],
215    ) -> Result<u64> {
216        let sql = self.build_append_changelog_sql(table, specs, uris, pk)?;
217        let result = self.run_snow(&sql)?;
218        let before = extract_named(&result, "BEFORE_")
219            .context("CDC load ran but the pre-append count (BEFORE_) could not be read")?;
220        let after = extract_named(&result, "AFTER_")
221            .context("CDC load ran but the post-append count (AFTER_) could not be read")?;
222        Ok(after.saturating_sub(before))
223    }
224
225    fn warehouse(&self) -> crate::load::cdc::Warehouse {
226        crate::load::cdc::Warehouse::Snowflake
227    }
228
229    fn create_view(&self, table: &str, view_sql: &str) -> Result<()> {
230        // Fully-qualified DDL; a QUERY_TAG keeps it cost-attributable. CREATE VIEW
231        // is metadata — no warehouse compute needed.
232        let sql = format!(
233            "ALTER SESSION SET QUERY_TAG = '{tag}';\n{view_sql}",
234            tag = self.query_tag(table),
235        );
236        self.run_snow(&sql)?;
237        Ok(())
238    }
239}
240
241impl SnowflakeLoader {
242    /// The append script for one CDC load, separated from its EXECUTION so the
243    /// schema-reconciliation step can be asserted without a Snowflake account.
244    /// The reconciling `ALTER` shipped on BigQuery only, so "the builder exists"
245    /// is not evidence that this adapter calls it — that is what the test pins.
246    fn build_append_changelog_sql(
247        &self,
248        table: &str,
249        specs: &[TargetColumnSpec],
250        uris: &[String],
251        pk: &[String],
252    ) -> Result<String> {
253        use crate::load::cdc::Warehouse;
254        // Full change-log schema: rivet's `__op`/`__pos`/`__seq` meta columns
255        // (not reported by `rivet check`) ahead of the resolved data columns.
256        let mut full = crate::load::cdc::meta_column_specs(Warehouse::Snowflake);
257        full.extend(
258            specs
259                .iter()
260                .filter(|s| !is_meta_column(&s.column_name))
261                .cloned(),
262        );
263
264        let changes = format!("{table}__changes");
265        let changes_fqtn = self.fqtn(&changes);
266        let ddl = Self::build_schema_ddl(&full);
267        let select = Self::build_copy_select(&full);
268        let columns = Self::build_column_list(&full);
269        let cluster = Self::cluster_clause(pk);
270        let stage = format!("rivet_stage_{}", sanitize_tag(&changes));
271        let files = copy_files_clause(&self.gcs_url, uris)?;
272
273        // Ensure the log exists (clustered on PK), COUNT before, append via COPY,
274        // COUNT after — the delta is what THIS load added; the driver gates it.
275        let sql = format!(
276            "ALTER SESSION SET QUERY_TAG = '{tag}';\n\
277             ALTER SESSION SET TIMEZONE = 'UTC';\n\
278             USE WAREHOUSE {wh};\n\
279             USE SCHEMA {db}.{sc};\n\
280             CREATE FILE FORMAT IF NOT EXISTS rivet_pq TYPE=PARQUET BINARY_AS_TEXT=FALSE;\n\
281             CREATE OR REPLACE STAGE {stage} URL='{url}' STORAGE_INTEGRATION={si} FILE_FORMAT=rivet_pq;\n\
282             CREATE TABLE IF NOT EXISTS {changes_fqtn} (\n{ddl}\n){cluster};\n\
283             {alter}\
284             SELECT COUNT(*) AS BEFORE_ FROM {changes_fqtn};\n\
285             COPY INTO {changes_fqtn} ({columns})\n\
286             \x20 FROM (SELECT {select} FROM @{stage})\n\
287             \x20 FILE_FORMAT=(FORMAT_NAME=rivet_pq) FORCE=TRUE {files};\n\
288             SELECT COUNT(*) AS AFTER_ FROM {changes_fqtn};",
289            tag = self.query_tag(&changes),
290            wh = self.warehouse,
291            db = self.database,
292            sc = self.schema,
293            si = self.storage_integration,
294            url = self.gcs_url,
295            alter = build_alter_add_columns_sql(&changes_fqtn, &full),
296        );
297        Ok(sql)
298    }
299}
300
301/// Whether a column name is one of rivet's CDC meta columns.
302fn is_meta_column(name: &str) -> bool {
303    crate::load::cdc::is_meta_column(name)
304}
305
306/// Bring an EXISTING change log's schema up to the declared one by ADDING what
307/// is missing — never by replacing the table, which would impose rivet's schema
308/// on history rivet does not own.
309///
310/// `CREATE TABLE IF NOT EXISTS` is a no-op on a table that already exists, so a
311/// log rivet did not create — one an operator pointed rivet at — keeps whatever shape its previous
312/// owner gave it, and a log that predates a new column keeps the old one. The
313/// `COPY INTO … (<declared columns>)` below then names a column the table lacks
314/// and Snowflake fails the whole load with `invalid identifier` — after the
315/// extract has already been paid for. `_rivet_row_hash` made that concrete: it
316/// is written at extraction and gained a load spec, so every pre-existing log
317/// was suddenly one column short.
318///
319/// `ADD COLUMN IF NOT EXISTS` is the only safe verb: additive, idempotent,
320/// metadata-only, and existing rows read NULL for the new column. Trailing
321/// newline (not `Option`) so the caller interpolates it unconditionally; an
322/// empty spec list yields an empty string rather than a bare `ALTER TABLE t ;`.
323fn build_alter_add_columns_sql(fqtn: &str, specs: &[TargetColumnSpec]) -> String {
324    if specs.is_empty() {
325        return String::new();
326    }
327    let adds = specs
328        .iter()
329        // Bare identifiers, matching `build_schema_ddl` / `build_column_list`:
330        // this loader creates its columns unquoted (Snowflake upper-cases them),
331        // so a quoted `"col"` here would ADD a second, case-sensitive column
332        // instead of matching the one the COPY names.
333        .map(|s| {
334            format!(
335                "ADD COLUMN IF NOT EXISTS {} {}",
336                s.column_name, s.target_type
337            )
338        })
339        .collect::<Vec<_>>()
340        .join(",\n  ");
341    format!("ALTER TABLE {fqtn}\n  {adds};\n")
342}
343
344/// A column is loaded through `PARSE_JSON` iff its native type is `VARIANT`
345/// (Rivet's Snowflake resolver maps JSON → `VARIANT`); a plain `COPY` would
346/// leave it a string.
347fn needs_parse_json(spec: &TargetColumnSpec) -> bool {
348    spec.target_type.eq_ignore_ascii_case("VARIANT")
349}
350
351/// Keep a table name safe for a stage name / query tag (alnum + underscore).
352fn sanitize_tag(s: &str) -> String {
353    s.chars()
354        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
355        .collect()
356}
357
358/// Strip either GCS URI scheme — `gcs://` (Snowflake's stage scheme) or `gs://`
359/// (what the load driver hands us) — so a stage URL and a driver-selected URI
360/// compare on bucket/key alone, not scheme. Without this the two never matched.
361fn strip_gcs_scheme(s: &str) -> &str {
362    s.strip_prefix("gcs://")
363        .or_else(|| s.strip_prefix("gs://"))
364        .unwrap_or(s)
365}
366
367/// Build the COPY `FILES=('a.parquet', 'b/c.parquet', …)` clause from the
368/// driver-selected `uris`, each made relative to the stage URL (`gcs_url`). This
369/// is what makes Snowflake honor the mode-aware/ledger per-run selection instead
370/// of loading every Parquet under the prefix — a `PATTERN` over the prefix would
371/// re-load stale/already-loaded runs and fail the count gate.
372fn copy_files_clause(gcs_url: &str, uris: &[String]) -> Result<String> {
373    if uris.is_empty() {
374        bail!("Snowflake COPY: no Parquet URIs selected to load");
375    }
376    // Snowflake caps an explicit FILES=() list at 1000 entries; a normal run is a
377    // handful. Batching past that is a follow-up — fail loud rather than silently
378    // fall back to a whole-prefix PATTERN (the bug this fix closes).
379    if uris.len() > 1000 {
380        bail!(
381            "Snowflake COPY FILES=() caps at 1000 files, got {} — batch the load or reduce parallelism",
382            uris.len()
383        );
384    }
385    // Scheme-blind: the stage URL is `gcs://` (Snowflake's scheme) but the driver
386    // hands `gs://` uris. Strip both before matching so FILES entries come out
387    // stage-RELATIVE — else strip_prefix never matches, the FULL uri leaks into
388    // FILES=(), and Snowflake resolves it relative to the stage → a doubled
389    // `gcs://…/gs://…` path (the live-caught "file not found" bug).
390    let base = strip_gcs_scheme(gcs_url).trim_end_matches('/');
391    let files = uris
392        .iter()
393        .map(|u| {
394            let stripped = strip_gcs_scheme(u);
395            let rel = stripped
396                .strip_prefix(base)
397                .unwrap_or(stripped)
398                .trim_start_matches('/');
399            format!("'{rel}'")
400        })
401        .collect::<Vec<_>>()
402        .join(", ");
403    Ok(format!("FILES=({files})"))
404}
405
406/// Pull the `ROWS_` count out of snow's JSON (array of statement result blocks).
407fn extract_count(value: &serde_json::Value) -> Option<u64> {
408    extract_named(value, "ROWS_")
409}
410
411/// Pull a named integer column (e.g. `BEFORE_` / `AFTER_` / `ROWS_`) out of
412/// snow's JSON — an array of statement result blocks, each an array of row
413/// objects. Returns the first block carrying the key.
414fn extract_named(value: &serde_json::Value, key: &str) -> Option<u64> {
415    let blocks = value.as_array()?;
416    for block in blocks {
417        if let Some(rows) = block.as_array() {
418            for row in rows {
419                if let Some(n) = row.get(key).and_then(|v| v.as_u64()) {
420                    return Some(n);
421                }
422            }
423        }
424    }
425    None
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::types::target::TargetStatus;
432
433    fn spec(name: &str, ty: &str) -> TargetColumnSpec {
434        TargetColumnSpec {
435            column_name: name.to_string(),
436            target_type: ty.to_string(),
437            autoload_type: String::new(),
438            status: TargetStatus::Ok,
439            note: None,
440            cast_sql: None,
441        }
442    }
443
444    #[test]
445    fn copy_files_clause_lists_the_selected_files_relative_to_the_stage() {
446        // PRODUCTION REALITY: the stage URL is `gcs://` (Snowflake's scheme) while
447        // the driver hands `gs://` uris. FILES must still come out stage-RELATIVE
448        // across the scheme gap — a scheme-blind strip_prefix leaks the FULL uri,
449        // which Snowflake resolves relative to the stage → a doubled
450        // `gcs://…/gs://…` path ("file not found", caught live). This test now
451        // mixes the schemes so it reproduces that bug.
452        let clause = copy_files_clause(
453            "gcs://bucket/exports/orders/",
454            &[
455                "gs://bucket/exports/orders/part-0.parquet".to_string(),
456                "gs://bucket/exports/orders/snapshot/part-1.parquet".to_string(),
457            ],
458        )
459        .unwrap();
460        assert_eq!(
461            clause, "FILES=('part-0.parquet', 'snapshot/part-1.parquet')",
462            "each uri stripped to a stage-relative path across schemes; no PATTERN"
463        );
464        assert!(!clause.contains("PATTERN"));
465        assert!(
466            !clause.contains("gs://") && !clause.contains("gcs://"),
467            "no absolute URI may leak into FILES=() — that doubles the stage prefix"
468        );
469        // A `gcs://` stage URL without a trailing slash still strips a `gs://` uri.
470        assert_eq!(
471            copy_files_clause("gcs://b/p", &["gs://b/p/f.parquet".to_string()]).unwrap(),
472            "FILES=('f.parquet')"
473        );
474        // Empty selection and the 1000-file cap both bail (never a silent
475        // whole-prefix fallback).
476        assert!(copy_files_clause("gcs://b/p/", &[]).is_err());
477        let many: Vec<String> = (0..1001)
478            .map(|i| format!("gs://b/p/f{i}.parquet"))
479            .collect();
480        assert!(copy_files_clause("gcs://b/p/", &many).is_err());
481    }
482
483    #[test]
484    fn variant_columns_are_parsed_scalars_pass_through() {
485        let specs = [
486            spec("id", "NUMBER(38,0)"),
487            spec("meta", "VARIANT"),
488            spec("created", "DATE"),
489        ];
490        let sel = SnowflakeLoader::build_copy_select(&specs);
491        assert_eq!(sel, "$1:id, PARSE_JSON($1:meta), $1:created");
492    }
493
494    #[test]
495    fn schema_ddl_and_column_list_are_unquoted() {
496        let specs = [spec("id", "NUMBER(38,0)"), spec("meta", "VARIANT")];
497        assert_eq!(
498            SnowflakeLoader::build_schema_ddl(&specs),
499            "  id NUMBER(38,0),\n  meta VARIANT"
500        );
501        assert_eq!(SnowflakeLoader::build_column_list(&specs), "id, meta");
502    }
503
504    #[test]
505    fn cluster_clause_wraps_key_in_parens_and_is_empty_when_unset() {
506        assert_eq!(SnowflakeLoader::cluster_clause(&[]), "");
507        assert_eq!(
508            SnowflakeLoader::cluster_clause(&["created".to_string(), "customer".to_string()]),
509            " CLUSTER BY (created, customer)"
510        );
511    }
512
513    /// An existing change log must be reconciled by ADDING what it lacks, never
514    /// by a replace. `CREATE TABLE IF NOT EXISTS` is a no-op on a table that
515    /// already exists, so without this the `COPY INTO … (<declared columns>)`
516    /// names a column the table does not have and Snowflake fails the load with
517    /// `invalid identifier` — after the extract was already paid for. This is
518    /// the Snowflake twin of bigquery.rs's `build_alter_add_columns_sql`; the
519    /// reconciliation lives in each adapter, so one adapter having it proves
520    /// nothing about the other (it shipped on BigQuery alone).
521    #[test]
522    fn alter_add_columns_reconciles_an_existing_log_and_never_replaces_it() {
523        let specs = vec![
524            spec("__op", "VARCHAR"),
525            spec(crate::enrich::COL_ROW_HASH, "NUMBER(38,0)"),
526        ];
527        let sql = build_alter_add_columns_sql("DB.SC.t__changes", &specs);
528        assert!(
529            sql.starts_with("ALTER TABLE DB.SC.t__changes"),
530            "must ALTER the log in place; got: {sql}"
531        );
532        assert!(
533            sql.contains("ADD COLUMN IF NOT EXISTS __op VARCHAR")
534                && sql.contains("ADD COLUMN IF NOT EXISTS _rivet_row_hash NUMBER(38,0)"),
535            "every declared column is added idempotently; got: {sql}"
536        );
537        assert!(
538            !sql.to_uppercase().contains("REPLACE") && !sql.to_uppercase().contains("DROP"),
539            "reconciliation must never replace or drop — the log holds history \
540             rivet does not own; got: {sql}"
541        );
542        // Bare identifiers: the loader creates its columns unquoted, so a quoted
543        // name would add a SECOND case-sensitive column the COPY never names.
544        assert!(!sql.contains('"'), "identifiers stay bare; got: {sql}");
545        // Empty spec list ⇒ no statement at all, not a bare `ALTER TABLE t ;`.
546        assert_eq!(build_alter_add_columns_sql("DB.SC.t", &[]), "");
547    }
548
549    /// The reconciliation must actually be IN the append script — a correct
550    /// builder that no caller invokes is the bug this release shipped on
551    /// Snowflake. Asserted on the emitted SQL's ORDER: the ALTER has to sit
552    /// after the CREATE (which is the no-op on an existing table) and before
553    /// the COPY that names the columns.
554    #[test]
555    fn the_append_script_alters_between_the_create_and_the_copy() {
556        let specs = vec![spec("id", "NUMBER"), spec("__op", "VARCHAR")];
557        let mut l = SnowflakeLoader::new("c");
558        l.database = "DB".into();
559        l.schema = "SC".into();
560        let sql = l
561            .build_append_changelog_sql(
562                "t",
563                &specs,
564                &["gs://b/p/part-0.parquet".to_string()],
565                &["id".to_string()],
566            )
567            .unwrap();
568        let create = sql
569            .find("CREATE TABLE IF NOT EXISTS")
570            .expect("create present");
571        let alter = sql.find("ALTER TABLE DB.SC.t__changes").expect(
572            "the append script must reconcile an existing log — without this the COPY \
573             names columns the table lacks and the load fails after the extract",
574        );
575        let copy = sql.find("COPY INTO").expect("copy present");
576        assert!(
577            create < alter && alter < copy,
578            "order must be CREATE → ALTER → COPY; got:\n{sql}"
579        );
580    }
581
582    #[test]
583    fn is_meta_column_matches_only_cdc_meta() {
584        assert!(is_meta_column("__op"));
585        assert!(is_meta_column("__pos"));
586        assert!(is_meta_column("__seq"));
587        assert!(!is_meta_column("id"));
588        assert!(!is_meta_column("__other"));
589    }
590
591    #[test]
592    fn fqtn_qualifies_database_schema_table() {
593        let mut l = SnowflakeLoader::new("c");
594        l.database = "DB".into();
595        l.schema = "SC".into();
596        assert_eq!(l.fqtn("orders"), "DB.SC.orders");
597    }
598
599    #[test]
600    fn query_tag_carries_run_id_when_set_and_omits_it_otherwise() {
601        let mut l = SnowflakeLoader::new("c");
602        assert_eq!(
603            l.query_tag("Orders"),
604            r#"{"managed_by":"rivet","rivet_op":"load","rivet_table":"Orders"}"#
605        );
606        // Non-alphanumerics in the id are coerced to `_` so QUERY_TAG stays
607        // valid JSON. The generated run id is pure hex, so this only bites a
608        // user-supplied `--run-id` with punctuation.
609        l.run_id = Some("r-7".to_string());
610        assert_eq!(
611            l.query_tag("Orders"),
612            r#"{"managed_by":"rivet","rivet_op":"load","rivet_table":"Orders","rivet_run":"r_7"}"#
613        );
614    }
615
616    #[test]
617    fn count_is_extracted_from_snow_json() {
618        let v = serde_json::json!([
619            [{"status": "ok"}],
620            [{"ROWS_": 50}]
621        ]);
622        assert_eq!(extract_count(&v), Some(50));
623    }
624
625    #[test]
626    fn cdc_before_and_after_counts_are_extracted_by_name() {
627        // The CDC script emits BEFORE_ and AFTER_ counts in separate blocks.
628        let v = serde_json::json!([
629            [{"status": "Statement executed successfully."}],
630            [{"BEFORE_": 10}],
631            [{"status": "rows loaded"}],
632            [{"AFTER_": 35}]
633        ]);
634        assert_eq!(extract_named(&v, "BEFORE_"), Some(10));
635        assert_eq!(extract_named(&v, "AFTER_"), Some(35));
636        assert_eq!(extract_named(&v, "MISSING_"), None);
637    }
638}