1use super::TargetLoader;
20use crate::types::target::TargetColumnSpec;
21use anyhow::{Context, Result, bail};
22use std::process::Command;
23
24#[derive(Debug, Default, Clone)]
26pub struct SnowflakeLoader {
27 pub connection: String,
29 pub warehouse: String,
30 pub database: String,
31 pub schema: String,
32 pub storage_integration: String,
35 pub gcs_url: String,
38 pub cluster_by: Vec<String>,
42 pub private_key_path: Option<String>,
46 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 fn fqtn(&self, table: &str) -> String {
66 format!("{}.{}.{}", self.database, self.schema, table)
67 }
68
69 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 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 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 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 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 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 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 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 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 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 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 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 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 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
301fn is_meta_column(name: &str) -> bool {
303 crate::load::cdc::is_meta_column(name)
304}
305
306fn 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 .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
344fn needs_parse_json(spec: &TargetColumnSpec) -> bool {
348 spec.target_type.eq_ignore_ascii_case("VARIANT")
349}
350
351fn sanitize_tag(s: &str) -> String {
353 s.chars()
354 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
355 .collect()
356}
357
358fn strip_gcs_scheme(s: &str) -> &str {
362 s.strip_prefix("gcs://")
363 .or_else(|| s.strip_prefix("gs://"))
364 .unwrap_or(s)
365}
366
367fn 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 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 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
406fn extract_count(value: &serde_json::Value) -> Option<u64> {
408 extract_named(value, "ROWS_")
409}
410
411fn 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 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 assert_eq!(
471 copy_files_clause("gcs://b/p", &["gs://b/p/f.parquet".to_string()]).unwrap(),
472 "FILES=('f.parquet')"
473 );
474 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 #[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 assert!(!sql.contains('"'), "identifiers stay bare; got: {sql}");
545 assert_eq!(build_alter_add_columns_sql("DB.SC.t", &[]), "");
547 }
548
549 #[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 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 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}