use super::TargetLoader;
use crate::types::target::TargetColumnSpec;
use anyhow::{Context, Result, bail};
use std::process::Command;
#[derive(Debug, Default, Clone)]
pub struct SnowflakeLoader {
pub connection: String,
pub warehouse: String,
pub database: String,
pub schema: String,
pub storage_integration: String,
pub gcs_url: String,
pub cluster_by: Vec<String>,
pub private_key_path: Option<String>,
pub run_id: Option<String>,
}
impl SnowflakeLoader {
pub fn new(connection: impl Into<String>) -> Self {
Self {
connection: connection.into(),
..Default::default()
}
}
fn fqtn(&self, table: &str) -> String {
format!("{}.{}.{}", self.database, self.schema, table)
}
fn build_schema_ddl(specs: &[TargetColumnSpec]) -> String {
specs
.iter()
.map(|s| format!(" {} {}", s.column_name, s.target_type))
.collect::<Vec<_>>()
.join(",\n")
}
fn build_copy_select(specs: &[TargetColumnSpec]) -> String {
specs
.iter()
.map(|s| {
let path = format!("$1:{}", s.column_name);
if needs_parse_json(s) {
format!("PARSE_JSON({path})")
} else {
path
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn build_column_list(specs: &[TargetColumnSpec]) -> String {
specs
.iter()
.map(|s| s.column_name.clone())
.collect::<Vec<_>>()
.join(", ")
}
fn cluster_clause(cluster_by: &[String]) -> String {
if cluster_by.is_empty() {
String::new()
} else {
format!(" CLUSTER BY ({})", cluster_by.join(", "))
}
}
fn query_tag(&self, table: &str) -> String {
let run = self
.run_id
.as_deref()
.map(|r| format!(r#","rivet_run":"{}""#, sanitize_tag(r)))
.unwrap_or_default();
format!(
r#"{{"managed_by":"rivet","rivet_op":"load","rivet_table":"{}"{run}}}"#,
sanitize_tag(table)
)
}
fn run_snow(&self, sql: &str) -> Result<serde_json::Value> {
let mut cmd = Command::new("snow");
cmd.args(["sql", "-c", &self.connection, "--format", "json", "-q", sql]);
if let Some(key) = &self.private_key_path {
let env_key = format!(
"SNOWFLAKE_CONNECTIONS_{}_PRIVATE_KEY_PATH",
self.connection.to_uppercase()
);
cmd.env(env_key, key);
}
let out = cmd
.output()
.context("running `snow sql` — is the Snowflake CLI installed?")?;
if !out.status.success() {
bail!(
"snow sql failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
serde_json::from_slice(&out.stdout).with_context(|| {
format!(
"parsing snow sql JSON output: {}",
String::from_utf8_lossy(&out.stdout)
)
})
}
}
impl TargetLoader for SnowflakeLoader {
fn fqtn(&self, table: &str) -> String {
format!("{}.{}.{}", self.database, self.schema, table)
}
fn materialize(&self, table: &str, specs: &[TargetColumnSpec], uris: &[String]) -> Result<u64> {
let fqtn = self.fqtn(table);
let ddl = Self::build_schema_ddl(specs);
let select = Self::build_copy_select(specs);
let columns = Self::build_column_list(specs);
let stage = format!("rivet_stage_{}", sanitize_tag(table));
let files = copy_files_clause(&self.gcs_url, uris)?;
let cluster = Self::cluster_clause(&self.cluster_by);
let sql = format!(
"ALTER SESSION SET QUERY_TAG = '{tag}';\n\
ALTER SESSION SET TIMEZONE = 'UTC';\n\
USE WAREHOUSE {wh};\n\
USE SCHEMA {db}.{sc};\n\
CREATE FILE FORMAT IF NOT EXISTS rivet_pq TYPE=PARQUET BINARY_AS_TEXT=FALSE;\n\
CREATE OR REPLACE STAGE {stage} URL='{url}' STORAGE_INTEGRATION={si} FILE_FORMAT=rivet_pq;\n\
CREATE OR REPLACE TABLE {fqtn} (\n{ddl}\n){cluster};\n\
COPY INTO {fqtn} ({columns})\n\
\x20 FROM (SELECT {select} FROM @{stage})\n\
\x20 FILE_FORMAT=(FORMAT_NAME=rivet_pq) {files};\n\
SELECT COUNT(*) AS ROWS_ FROM {fqtn};",
tag = self.query_tag(table),
wh = self.warehouse,
db = self.database,
sc = self.schema,
si = self.storage_integration,
url = self.gcs_url,
);
let result = self.run_snow(&sql)?;
extract_count(&result)
.context("COPY ran but the row count could not be read from snow output")
}
fn append_changelog(
&self,
table: &str,
specs: &[TargetColumnSpec],
uris: &[String],
pk: &[String],
) -> Result<u64> {
use crate::load::cdc::Warehouse;
let mut full = crate::load::cdc::meta_column_specs(Warehouse::Snowflake);
full.extend(
specs
.iter()
.filter(|s| !is_meta_column(&s.column_name))
.cloned(),
);
let changes = format!("{table}__changes");
let changes_fqtn = self.fqtn(&changes);
let ddl = Self::build_schema_ddl(&full);
let select = Self::build_copy_select(&full);
let columns = Self::build_column_list(&full);
let cluster = Self::cluster_clause(pk);
let stage = format!("rivet_stage_{}", sanitize_tag(&changes));
let files = copy_files_clause(&self.gcs_url, uris)?;
let sql = format!(
"ALTER SESSION SET QUERY_TAG = '{tag}';\n\
ALTER SESSION SET TIMEZONE = 'UTC';\n\
USE WAREHOUSE {wh};\n\
USE SCHEMA {db}.{sc};\n\
CREATE FILE FORMAT IF NOT EXISTS rivet_pq TYPE=PARQUET BINARY_AS_TEXT=FALSE;\n\
CREATE OR REPLACE STAGE {stage} URL='{url}' STORAGE_INTEGRATION={si} FILE_FORMAT=rivet_pq;\n\
CREATE TABLE IF NOT EXISTS {changes_fqtn} (\n{ddl}\n){cluster};\n\
SELECT COUNT(*) AS BEFORE_ FROM {changes_fqtn};\n\
COPY INTO {changes_fqtn} ({columns})\n\
\x20 FROM (SELECT {select} FROM @{stage})\n\
\x20 FILE_FORMAT=(FORMAT_NAME=rivet_pq) {files};\n\
SELECT COUNT(*) AS AFTER_ FROM {changes_fqtn};",
tag = self.query_tag(&changes),
wh = self.warehouse,
db = self.database,
sc = self.schema,
si = self.storage_integration,
url = self.gcs_url,
);
let result = self.run_snow(&sql)?;
let before = extract_named(&result, "BEFORE_")
.context("CDC load ran but the pre-append count (BEFORE_) could not be read")?;
let after = extract_named(&result, "AFTER_")
.context("CDC load ran but the post-append count (AFTER_) could not be read")?;
Ok(after.saturating_sub(before))
}
fn warehouse(&self) -> crate::load::cdc::Warehouse {
crate::load::cdc::Warehouse::Snowflake
}
fn create_view(&self, table: &str, view_sql: &str) -> Result<()> {
let sql = format!(
"ALTER SESSION SET QUERY_TAG = '{tag}';\n{view_sql}",
tag = self.query_tag(table),
);
self.run_snow(&sql)?;
Ok(())
}
}
fn is_meta_column(name: &str) -> bool {
matches!(name, "__op" | "__pos" | "__seq")
}
fn needs_parse_json(spec: &TargetColumnSpec) -> bool {
spec.target_type.eq_ignore_ascii_case("VARIANT")
}
fn sanitize_tag(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn strip_gcs_scheme(s: &str) -> &str {
s.strip_prefix("gcs://")
.or_else(|| s.strip_prefix("gs://"))
.unwrap_or(s)
}
fn copy_files_clause(gcs_url: &str, uris: &[String]) -> Result<String> {
if uris.is_empty() {
bail!("Snowflake COPY: no Parquet URIs selected to load");
}
if uris.len() > 1000 {
bail!(
"Snowflake COPY FILES=() caps at 1000 files, got {} — batch the load or reduce parallelism",
uris.len()
);
}
let base = strip_gcs_scheme(gcs_url).trim_end_matches('/');
let files = uris
.iter()
.map(|u| {
let stripped = strip_gcs_scheme(u);
let rel = stripped
.strip_prefix(base)
.unwrap_or(stripped)
.trim_start_matches('/');
format!("'{rel}'")
})
.collect::<Vec<_>>()
.join(", ");
Ok(format!("FILES=({files})"))
}
fn extract_count(value: &serde_json::Value) -> Option<u64> {
extract_named(value, "ROWS_")
}
fn extract_named(value: &serde_json::Value, key: &str) -> Option<u64> {
let blocks = value.as_array()?;
for block in blocks {
if let Some(rows) = block.as_array() {
for row in rows {
if let Some(n) = row.get(key).and_then(|v| v.as_u64()) {
return Some(n);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::target::TargetStatus;
fn spec(name: &str, ty: &str) -> TargetColumnSpec {
TargetColumnSpec {
column_name: name.to_string(),
target_type: ty.to_string(),
autoload_type: String::new(),
status: TargetStatus::Ok,
note: None,
cast_sql: None,
}
}
#[test]
fn copy_files_clause_lists_the_selected_files_relative_to_the_stage() {
let clause = copy_files_clause(
"gcs://bucket/exports/orders/",
&[
"gs://bucket/exports/orders/part-0.parquet".to_string(),
"gs://bucket/exports/orders/snapshot/part-1.parquet".to_string(),
],
)
.unwrap();
assert_eq!(
clause, "FILES=('part-0.parquet', 'snapshot/part-1.parquet')",
"each uri stripped to a stage-relative path across schemes; no PATTERN"
);
assert!(!clause.contains("PATTERN"));
assert!(
!clause.contains("gs://") && !clause.contains("gcs://"),
"no absolute URI may leak into FILES=() — that doubles the stage prefix"
);
assert_eq!(
copy_files_clause("gcs://b/p", &["gs://b/p/f.parquet".to_string()]).unwrap(),
"FILES=('f.parquet')"
);
assert!(copy_files_clause("gcs://b/p/", &[]).is_err());
let many: Vec<String> = (0..1001)
.map(|i| format!("gs://b/p/f{i}.parquet"))
.collect();
assert!(copy_files_clause("gcs://b/p/", &many).is_err());
}
#[test]
fn variant_columns_are_parsed_scalars_pass_through() {
let specs = [
spec("id", "NUMBER(38,0)"),
spec("meta", "VARIANT"),
spec("created", "DATE"),
];
let sel = SnowflakeLoader::build_copy_select(&specs);
assert_eq!(sel, "$1:id, PARSE_JSON($1:meta), $1:created");
}
#[test]
fn schema_ddl_and_column_list_are_unquoted() {
let specs = [spec("id", "NUMBER(38,0)"), spec("meta", "VARIANT")];
assert_eq!(
SnowflakeLoader::build_schema_ddl(&specs),
" id NUMBER(38,0),\n meta VARIANT"
);
assert_eq!(SnowflakeLoader::build_column_list(&specs), "id, meta");
}
#[test]
fn cluster_clause_wraps_key_in_parens_and_is_empty_when_unset() {
assert_eq!(SnowflakeLoader::cluster_clause(&[]), "");
assert_eq!(
SnowflakeLoader::cluster_clause(&["created".to_string(), "customer".to_string()]),
" CLUSTER BY (created, customer)"
);
}
#[test]
fn is_meta_column_matches_only_cdc_meta() {
assert!(is_meta_column("__op"));
assert!(is_meta_column("__pos"));
assert!(is_meta_column("__seq"));
assert!(!is_meta_column("id"));
assert!(!is_meta_column("__other"));
}
#[test]
fn fqtn_qualifies_database_schema_table() {
let mut l = SnowflakeLoader::new("c");
l.database = "DB".into();
l.schema = "SC".into();
assert_eq!(l.fqtn("orders"), "DB.SC.orders");
}
#[test]
fn query_tag_carries_run_id_when_set_and_omits_it_otherwise() {
let mut l = SnowflakeLoader::new("c");
assert_eq!(
l.query_tag("Orders"),
r#"{"managed_by":"rivet","rivet_op":"load","rivet_table":"Orders"}"#
);
l.run_id = Some("r-7".to_string());
assert_eq!(
l.query_tag("Orders"),
r#"{"managed_by":"rivet","rivet_op":"load","rivet_table":"Orders","rivet_run":"r_7"}"#
);
}
#[test]
fn count_is_extracted_from_snow_json() {
let v = serde_json::json!([
[{"status": "ok"}],
[{"ROWS_": 50}]
]);
assert_eq!(extract_count(&v), Some(50));
}
#[test]
fn cdc_before_and_after_counts_are_extracted_by_name() {
let v = serde_json::json!([
[{"status": "Statement executed successfully."}],
[{"BEFORE_": 10}],
[{"status": "rows loaded"}],
[{"AFTER_": 35}]
]);
assert_eq!(extract_named(&v, "BEFORE_"), Some(10));
assert_eq!(extract_named(&v, "AFTER_"), Some(35));
assert_eq!(extract_named(&v, "MISSING_"), None);
}
}