use faucet_core::{AuthSpec, DEFAULT_BATCH_SIZE};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
pub use faucet_common_snowflake::SnowflakeAuth;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SnowflakeSinkConfig {
pub account: String,
pub warehouse: String,
pub database: String,
pub schema: String,
pub table: String,
pub auth: AuthSpec<SnowflakeAuth>,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(
default = "default_poll_timeout",
with = "faucet_core::config::duration_secs"
)]
#[schemars(with = "u64")]
pub poll_timeout: Duration,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bulk_load: Option<SnowflakeStageConfig>,
}
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub struct SnowflakeStageConfig {
pub stage: String,
pub url: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub storage_options: HashMap<String, String>,
#[serde(default = "default_match_by_column_name")]
pub match_by_column_name: String,
#[serde(default)]
pub purge: bool,
}
impl std::fmt::Debug for SnowflakeStageConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SnowflakeStageConfig")
.field("stage", &self.stage)
.field("url", &self.url)
.field(
"storage_options",
&self.storage_options.keys().collect::<Vec<_>>(),
)
.field("match_by_column_name", &self.match_by_column_name)
.field("purge", &self.purge)
.finish()
}
}
fn default_batch_size() -> usize {
DEFAULT_BATCH_SIZE
}
fn default_match_by_column_name() -> String {
"CASE_INSENSITIVE".to_string()
}
fn default_poll_timeout() -> Duration {
Duration::from_secs(300)
}
impl SnowflakeSinkConfig {
pub fn new(
account: impl Into<String>,
warehouse: impl Into<String>,
database: impl Into<String>,
schema: impl Into<String>,
table: impl Into<String>,
auth: SnowflakeAuth,
) -> Self {
Self {
account: account.into(),
warehouse: warehouse.into(),
database: database.into(),
schema: schema.into(),
table: table.into(),
auth: AuthSpec::Inline(auth),
batch_size: DEFAULT_BATCH_SIZE,
poll_timeout: default_poll_timeout(),
bulk_load: None,
}
}
pub fn with_bulk_load(mut self, stage: SnowflakeStageConfig) -> Self {
self.bulk_load = Some(stage);
self
}
pub fn with_poll_timeout(mut self, timeout: Duration) -> Self {
self.poll_timeout = timeout;
self
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_auth() -> SnowflakeAuth {
SnowflakeAuth::OAuth {
token: "tok".into(),
}
}
fn sample_config() -> SnowflakeSinkConfig {
SnowflakeSinkConfig::new(
"xy12345",
"COMPUTE_WH",
"MY_DB",
"PUBLIC",
"events",
sample_auth(),
)
}
#[test]
fn default_config() {
let config = sample_config();
assert_eq!(config.account, "xy12345");
assert_eq!(config.warehouse, "COMPUTE_WH");
assert_eq!(config.database, "MY_DB");
assert_eq!(config.schema, "PUBLIC");
assert_eq!(config.table, "events");
assert_eq!(config.poll_timeout, Duration::from_secs(300));
}
#[test]
fn batch_size_defaults_to_default_batch_size() {
let config = sample_config();
assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
}
#[test]
fn with_batch_size_overrides_default() {
let config = sample_config().with_batch_size(250);
assert_eq!(config.batch_size, 250);
}
#[test]
fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
let config = sample_config().with_batch_size(0);
assert_eq!(config.batch_size, 0);
assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
}
#[test]
fn batch_size_above_max_is_rejected_by_validate_batch_size() {
let config = sample_config().with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
}
#[test]
fn with_bulk_load_sets_stage_and_defaults_deserialize() {
let cfg = sample_config().with_bulk_load(SnowflakeStageConfig {
stage: "STG".into(),
url: "s3://b/p/".into(),
storage_options: std::collections::HashMap::new(),
match_by_column_name: default_match_by_column_name(),
purge: false,
});
let stage = cfg.bulk_load.expect("bulk_load set");
assert_eq!(stage.stage, "STG");
assert_eq!(stage.match_by_column_name, "CASE_INSENSITIVE");
assert!(!stage.purge);
let json = r#"{ "stage": "S", "url": "gs://b/" }"#;
let s: SnowflakeStageConfig = serde_json::from_str(json).unwrap();
assert_eq!(s.match_by_column_name, "CASE_INSENSITIVE");
assert!(!s.purge);
assert!(s.storage_options.is_empty());
}
#[test]
fn stage_debug_masks_storage_option_values() {
let mut opts = std::collections::HashMap::new();
opts.insert(
"aws_secret_access_key".to_string(),
"SUPER_SECRET".to_string(),
);
let stage = SnowflakeStageConfig {
stage: "STG".into(),
url: "s3://b/".into(),
storage_options: opts,
match_by_column_name: default_match_by_column_name(),
purge: true,
};
let dbg = format!("{stage:?}");
assert!(!dbg.contains("SUPER_SECRET"), "secret leaked: {dbg}");
assert!(
dbg.contains("aws_secret_access_key"),
"key name shown: {dbg}"
);
assert!(dbg.contains("purge: true"), "{dbg}");
}
#[test]
fn batch_size_deserializes_from_json() {
let json = r#"{
"account": "xy12345",
"warehouse": "COMPUTE_WH",
"database": "MY_DB",
"schema": "PUBLIC",
"table": "events",
"auth": {"type": "oauth", "config": {"token": "tok"}},
"batch_size": 250
}"#;
let config: SnowflakeSinkConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.batch_size, 250);
}
#[test]
fn batch_size_defaults_when_absent_from_json() {
let json = r#"{
"account": "xy12345",
"warehouse": "COMPUTE_WH",
"database": "MY_DB",
"schema": "PUBLIC",
"table": "events",
"auth": {"type": "oauth", "config": {"token": "tok"}}
}"#;
let config: SnowflakeSinkConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
}
}