1use faucet_core::{AuthSpec, DEFAULT_BATCH_SIZE};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::time::Duration;
8
9pub use faucet_common_snowflake::SnowflakeAuth;
12
13#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15pub struct SnowflakeSinkConfig {
16 pub account: String,
18 pub warehouse: String,
20 pub database: String,
22 pub schema: String,
24 pub table: String,
26 pub auth: AuthSpec<SnowflakeAuth>,
31 #[serde(default = "default_batch_size")]
41 pub batch_size: usize,
42 #[serde(
51 default = "default_poll_timeout",
52 with = "faucet_core::config::duration_secs"
53 )]
54 #[schemars(with = "u64")]
55 pub poll_timeout: Duration,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub bulk_load: Option<SnowflakeStageConfig>,
69}
70
71#[derive(Clone, Serialize, Deserialize, JsonSchema)]
77pub struct SnowflakeStageConfig {
78 pub stage: String,
83 pub url: String,
88 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
93 pub storage_options: HashMap<String, String>,
94 #[serde(default = "default_match_by_column_name")]
98 pub match_by_column_name: String,
99 #[serde(default)]
102 pub purge: bool,
103}
104
105impl std::fmt::Debug for SnowflakeStageConfig {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("SnowflakeStageConfig")
110 .field("stage", &self.stage)
111 .field("url", &self.url)
112 .field(
113 "storage_options",
114 &self.storage_options.keys().collect::<Vec<_>>(),
115 )
116 .field("match_by_column_name", &self.match_by_column_name)
117 .field("purge", &self.purge)
118 .finish()
119 }
120}
121
122fn default_batch_size() -> usize {
123 DEFAULT_BATCH_SIZE
124}
125
126fn default_match_by_column_name() -> String {
127 "CASE_INSENSITIVE".to_string()
128}
129
130fn default_poll_timeout() -> Duration {
131 Duration::from_secs(300)
132}
133
134impl SnowflakeSinkConfig {
135 pub fn new(
137 account: impl Into<String>,
138 warehouse: impl Into<String>,
139 database: impl Into<String>,
140 schema: impl Into<String>,
141 table: impl Into<String>,
142 auth: SnowflakeAuth,
143 ) -> Self {
144 Self {
145 account: account.into(),
146 warehouse: warehouse.into(),
147 database: database.into(),
148 schema: schema.into(),
149 table: table.into(),
150 auth: AuthSpec::Inline(auth),
151 batch_size: DEFAULT_BATCH_SIZE,
152 poll_timeout: default_poll_timeout(),
153 bulk_load: None,
154 }
155 }
156
157 pub fn with_bulk_load(mut self, stage: SnowflakeStageConfig) -> Self {
159 self.bulk_load = Some(stage);
160 self
161 }
162
163 pub fn with_poll_timeout(mut self, timeout: Duration) -> Self {
166 self.poll_timeout = timeout;
167 self
168 }
169
170 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
176 self.batch_size = batch_size;
177 self
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 fn sample_auth() -> SnowflakeAuth {
186 SnowflakeAuth::OAuth {
187 token: "tok".into(),
188 }
189 }
190
191 fn sample_config() -> SnowflakeSinkConfig {
192 SnowflakeSinkConfig::new(
193 "xy12345",
194 "COMPUTE_WH",
195 "MY_DB",
196 "PUBLIC",
197 "events",
198 sample_auth(),
199 )
200 }
201
202 #[test]
203 fn default_config() {
204 let config = sample_config();
205 assert_eq!(config.account, "xy12345");
206 assert_eq!(config.warehouse, "COMPUTE_WH");
207 assert_eq!(config.database, "MY_DB");
208 assert_eq!(config.schema, "PUBLIC");
209 assert_eq!(config.table, "events");
210 assert_eq!(config.poll_timeout, Duration::from_secs(300));
211 }
212
213 #[test]
214 fn batch_size_defaults_to_default_batch_size() {
215 let config = sample_config();
216 assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
217 }
218
219 #[test]
220 fn with_batch_size_overrides_default() {
221 let config = sample_config().with_batch_size(250);
222 assert_eq!(config.batch_size, 250);
223 }
224
225 #[test]
226 fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
227 let config = sample_config().with_batch_size(0);
228 assert_eq!(config.batch_size, 0);
229 assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
230 }
231
232 #[test]
233 fn batch_size_above_max_is_rejected_by_validate_batch_size() {
234 let config = sample_config().with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
235 assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
236 }
237
238 #[test]
239 fn with_bulk_load_sets_stage_and_defaults_deserialize() {
240 let cfg = sample_config().with_bulk_load(SnowflakeStageConfig {
241 stage: "STG".into(),
242 url: "s3://b/p/".into(),
243 storage_options: std::collections::HashMap::new(),
244 match_by_column_name: default_match_by_column_name(),
245 purge: false,
246 });
247 let stage = cfg.bulk_load.expect("bulk_load set");
248 assert_eq!(stage.stage, "STG");
249 assert_eq!(stage.match_by_column_name, "CASE_INSENSITIVE");
250 assert!(!stage.purge);
251
252 let json = r#"{ "stage": "S", "url": "gs://b/" }"#;
254 let s: SnowflakeStageConfig = serde_json::from_str(json).unwrap();
255 assert_eq!(s.match_by_column_name, "CASE_INSENSITIVE");
256 assert!(!s.purge);
257 assert!(s.storage_options.is_empty());
258 }
259
260 #[test]
261 fn stage_debug_masks_storage_option_values() {
262 let mut opts = std::collections::HashMap::new();
263 opts.insert(
264 "aws_secret_access_key".to_string(),
265 "SUPER_SECRET".to_string(),
266 );
267 let stage = SnowflakeStageConfig {
268 stage: "STG".into(),
269 url: "s3://b/".into(),
270 storage_options: opts,
271 match_by_column_name: default_match_by_column_name(),
272 purge: true,
273 };
274 let dbg = format!("{stage:?}");
275 assert!(!dbg.contains("SUPER_SECRET"), "secret leaked: {dbg}");
276 assert!(
277 dbg.contains("aws_secret_access_key"),
278 "key name shown: {dbg}"
279 );
280 assert!(dbg.contains("purge: true"), "{dbg}");
281 }
282
283 #[test]
284 fn batch_size_deserializes_from_json() {
285 let json = r#"{
286 "account": "xy12345",
287 "warehouse": "COMPUTE_WH",
288 "database": "MY_DB",
289 "schema": "PUBLIC",
290 "table": "events",
291 "auth": {"type": "oauth", "config": {"token": "tok"}},
292 "batch_size": 250
293 }"#;
294 let config: SnowflakeSinkConfig = serde_json::from_str(json).unwrap();
295 assert_eq!(config.batch_size, 250);
296 }
297
298 #[test]
299 fn batch_size_defaults_when_absent_from_json() {
300 let json = r#"{
301 "account": "xy12345",
302 "warehouse": "COMPUTE_WH",
303 "database": "MY_DB",
304 "schema": "PUBLIC",
305 "table": "events",
306 "auth": {"type": "oauth", "config": {"token": "tok"}}
307 }"#;
308 let config: SnowflakeSinkConfig = serde_json::from_str(json).unwrap();
309 assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
310 }
311}