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 use crate::load::cdc::Warehouse;
217 let mut full = crate::load::cdc::meta_column_specs(Warehouse::Snowflake);
220 full.extend(
221 specs
222 .iter()
223 .filter(|s| !is_meta_column(&s.column_name))
224 .cloned(),
225 );
226
227 let changes = format!("{table}__changes");
228 let changes_fqtn = self.fqtn(&changes);
229 let ddl = Self::build_schema_ddl(&full);
230 let select = Self::build_copy_select(&full);
231 let columns = Self::build_column_list(&full);
232 let cluster = Self::cluster_clause(pk);
233 let stage = format!("rivet_stage_{}", sanitize_tag(&changes));
234 let files = copy_files_clause(&self.gcs_url, uris)?;
235
236 let sql = format!(
239 "ALTER SESSION SET QUERY_TAG = '{tag}';\n\
240 ALTER SESSION SET TIMEZONE = 'UTC';\n\
241 USE WAREHOUSE {wh};\n\
242 USE SCHEMA {db}.{sc};\n\
243 CREATE FILE FORMAT IF NOT EXISTS rivet_pq TYPE=PARQUET BINARY_AS_TEXT=FALSE;\n\
244 CREATE OR REPLACE STAGE {stage} URL='{url}' STORAGE_INTEGRATION={si} FILE_FORMAT=rivet_pq;\n\
245 CREATE TABLE IF NOT EXISTS {changes_fqtn} (\n{ddl}\n){cluster};\n\
246 SELECT COUNT(*) AS BEFORE_ FROM {changes_fqtn};\n\
247 COPY INTO {changes_fqtn} ({columns})\n\
248 \x20 FROM (SELECT {select} FROM @{stage})\n\
249 \x20 FILE_FORMAT=(FORMAT_NAME=rivet_pq) {files};\n\
250 SELECT COUNT(*) AS AFTER_ FROM {changes_fqtn};",
251 tag = self.query_tag(&changes),
252 wh = self.warehouse,
253 db = self.database,
254 sc = self.schema,
255 si = self.storage_integration,
256 url = self.gcs_url,
257 );
258
259 let result = self.run_snow(&sql)?;
260 let before = extract_named(&result, "BEFORE_")
261 .context("CDC load ran but the pre-append count (BEFORE_) could not be read")?;
262 let after = extract_named(&result, "AFTER_")
263 .context("CDC load ran but the post-append count (AFTER_) could not be read")?;
264 Ok(after.saturating_sub(before))
265 }
266
267 fn warehouse(&self) -> crate::load::cdc::Warehouse {
268 crate::load::cdc::Warehouse::Snowflake
269 }
270
271 fn create_view(&self, table: &str, view_sql: &str) -> Result<()> {
272 let sql = format!(
275 "ALTER SESSION SET QUERY_TAG = '{tag}';\n{view_sql}",
276 tag = self.query_tag(table),
277 );
278 self.run_snow(&sql)?;
279 Ok(())
280 }
281}
282
283fn is_meta_column(name: &str) -> bool {
285 matches!(name, "__op" | "__pos" | "__seq")
286}
287
288fn needs_parse_json(spec: &TargetColumnSpec) -> bool {
292 spec.target_type.eq_ignore_ascii_case("VARIANT")
293}
294
295fn sanitize_tag(s: &str) -> String {
297 s.chars()
298 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
299 .collect()
300}
301
302fn strip_gcs_scheme(s: &str) -> &str {
306 s.strip_prefix("gcs://")
307 .or_else(|| s.strip_prefix("gs://"))
308 .unwrap_or(s)
309}
310
311fn copy_files_clause(gcs_url: &str, uris: &[String]) -> Result<String> {
317 if uris.is_empty() {
318 bail!("Snowflake COPY: no Parquet URIs selected to load");
319 }
320 if uris.len() > 1000 {
324 bail!(
325 "Snowflake COPY FILES=() caps at 1000 files, got {} — batch the load or reduce parallelism",
326 uris.len()
327 );
328 }
329 let base = strip_gcs_scheme(gcs_url).trim_end_matches('/');
335 let files = uris
336 .iter()
337 .map(|u| {
338 let stripped = strip_gcs_scheme(u);
339 let rel = stripped
340 .strip_prefix(base)
341 .unwrap_or(stripped)
342 .trim_start_matches('/');
343 format!("'{rel}'")
344 })
345 .collect::<Vec<_>>()
346 .join(", ");
347 Ok(format!("FILES=({files})"))
348}
349
350fn extract_count(value: &serde_json::Value) -> Option<u64> {
352 extract_named(value, "ROWS_")
353}
354
355fn extract_named(value: &serde_json::Value, key: &str) -> Option<u64> {
359 let blocks = value.as_array()?;
360 for block in blocks {
361 if let Some(rows) = block.as_array() {
362 for row in rows {
363 if let Some(n) = row.get(key).and_then(|v| v.as_u64()) {
364 return Some(n);
365 }
366 }
367 }
368 }
369 None
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::types::target::TargetStatus;
376
377 fn spec(name: &str, ty: &str) -> TargetColumnSpec {
378 TargetColumnSpec {
379 column_name: name.to_string(),
380 target_type: ty.to_string(),
381 autoload_type: String::new(),
382 status: TargetStatus::Ok,
383 note: None,
384 cast_sql: None,
385 }
386 }
387
388 #[test]
389 fn copy_files_clause_lists_the_selected_files_relative_to_the_stage() {
390 let clause = copy_files_clause(
397 "gcs://bucket/exports/orders/",
398 &[
399 "gs://bucket/exports/orders/part-0.parquet".to_string(),
400 "gs://bucket/exports/orders/snapshot/part-1.parquet".to_string(),
401 ],
402 )
403 .unwrap();
404 assert_eq!(
405 clause, "FILES=('part-0.parquet', 'snapshot/part-1.parquet')",
406 "each uri stripped to a stage-relative path across schemes; no PATTERN"
407 );
408 assert!(!clause.contains("PATTERN"));
409 assert!(
410 !clause.contains("gs://") && !clause.contains("gcs://"),
411 "no absolute URI may leak into FILES=() — that doubles the stage prefix"
412 );
413 assert_eq!(
415 copy_files_clause("gcs://b/p", &["gs://b/p/f.parquet".to_string()]).unwrap(),
416 "FILES=('f.parquet')"
417 );
418 assert!(copy_files_clause("gcs://b/p/", &[]).is_err());
421 let many: Vec<String> = (0..1001)
422 .map(|i| format!("gs://b/p/f{i}.parquet"))
423 .collect();
424 assert!(copy_files_clause("gcs://b/p/", &many).is_err());
425 }
426
427 #[test]
428 fn variant_columns_are_parsed_scalars_pass_through() {
429 let specs = [
430 spec("id", "NUMBER(38,0)"),
431 spec("meta", "VARIANT"),
432 spec("created", "DATE"),
433 ];
434 let sel = SnowflakeLoader::build_copy_select(&specs);
435 assert_eq!(sel, "$1:id, PARSE_JSON($1:meta), $1:created");
436 }
437
438 #[test]
439 fn schema_ddl_and_column_list_are_unquoted() {
440 let specs = [spec("id", "NUMBER(38,0)"), spec("meta", "VARIANT")];
441 assert_eq!(
442 SnowflakeLoader::build_schema_ddl(&specs),
443 " id NUMBER(38,0),\n meta VARIANT"
444 );
445 assert_eq!(SnowflakeLoader::build_column_list(&specs), "id, meta");
446 }
447
448 #[test]
449 fn cluster_clause_wraps_key_in_parens_and_is_empty_when_unset() {
450 assert_eq!(SnowflakeLoader::cluster_clause(&[]), "");
451 assert_eq!(
452 SnowflakeLoader::cluster_clause(&["created".to_string(), "customer".to_string()]),
453 " CLUSTER BY (created, customer)"
454 );
455 }
456
457 #[test]
458 fn is_meta_column_matches_only_cdc_meta() {
459 assert!(is_meta_column("__op"));
460 assert!(is_meta_column("__pos"));
461 assert!(is_meta_column("__seq"));
462 assert!(!is_meta_column("id"));
463 assert!(!is_meta_column("__other"));
464 }
465
466 #[test]
467 fn fqtn_qualifies_database_schema_table() {
468 let mut l = SnowflakeLoader::new("c");
469 l.database = "DB".into();
470 l.schema = "SC".into();
471 assert_eq!(l.fqtn("orders"), "DB.SC.orders");
472 }
473
474 #[test]
475 fn query_tag_carries_run_id_when_set_and_omits_it_otherwise() {
476 let mut l = SnowflakeLoader::new("c");
477 assert_eq!(
478 l.query_tag("Orders"),
479 r#"{"managed_by":"rivet","rivet_op":"load","rivet_table":"Orders"}"#
480 );
481 l.run_id = Some("r-7".to_string());
485 assert_eq!(
486 l.query_tag("Orders"),
487 r#"{"managed_by":"rivet","rivet_op":"load","rivet_table":"Orders","rivet_run":"r_7"}"#
488 );
489 }
490
491 #[test]
492 fn count_is_extracted_from_snow_json() {
493 let v = serde_json::json!([
494 [{"status": "ok"}],
495 [{"ROWS_": 50}]
496 ]);
497 assert_eq!(extract_count(&v), Some(50));
498 }
499
500 #[test]
501 fn cdc_before_and_after_counts_are_extracted_by_name() {
502 let v = serde_json::json!([
504 [{"status": "Statement executed successfully."}],
505 [{"BEFORE_": 10}],
506 [{"status": "rows loaded"}],
507 [{"AFTER_": 35}]
508 ]);
509 assert_eq!(extract_named(&v, "BEFORE_"), Some(10));
510 assert_eq!(extract_named(&v, "AFTER_"), Some(35));
511 assert_eq!(extract_named(&v, "MISSING_"), None);
512 }
513}