1use faucet_core::DEFAULT_BATCH_SIZE;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7pub use faucet_common_bigquery::BigQueryCredentials;
10
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct BigQuerySinkConfig {
14 pub project_id: String,
16 pub dataset_id: String,
18 pub table_id: String,
20 pub auth: BigQueryCredentials,
23 #[serde(default = "default_batch_size")]
38 pub batch_size: usize,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub insert_id_field: Option<String>,
48 #[serde(flatten)]
55 pub write: faucet_core::WriteSpec,
56 #[cfg(feature = "arrow")]
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub bulk_load: Option<BigQueryLoadConfig>,
66}
67
68#[cfg(feature = "arrow")]
70#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
71pub struct BigQueryLoadConfig {
72 pub staging_bucket: String,
75 #[serde(default = "default_staging_prefix")]
78 pub staging_prefix: String,
79 #[serde(default)]
83 pub gcs_auth: faucet_common_gcs::GcsCredentials,
84 #[serde(default = "default_write_disposition")]
87 pub write_disposition: String,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub storage_host: Option<String>,
92}
93
94#[cfg(feature = "arrow")]
95fn default_staging_prefix() -> String {
96 "faucet-bq-load/".to_string()
97}
98
99#[cfg(feature = "arrow")]
100fn default_write_disposition() -> String {
101 "WRITE_APPEND".to_string()
102}
103
104fn default_batch_size() -> usize {
105 DEFAULT_BATCH_SIZE
106}
107
108impl BigQuerySinkConfig {
109 pub fn new(
111 project_id: impl Into<String>,
112 dataset_id: impl Into<String>,
113 table_id: impl Into<String>,
114 credentials: BigQueryCredentials,
115 ) -> Self {
116 Self {
117 project_id: project_id.into(),
118 dataset_id: dataset_id.into(),
119 table_id: table_id.into(),
120 auth: credentials,
121 batch_size: DEFAULT_BATCH_SIZE,
122 insert_id_field: None,
123 write: faucet_core::WriteSpec::default(),
124 #[cfg(feature = "arrow")]
125 bulk_load: None,
126 }
127 }
128
129 #[cfg(feature = "arrow")]
131 pub fn with_bulk_load(mut self, load: BigQueryLoadConfig) -> Self {
132 self.bulk_load = Some(load);
133 self
134 }
135
136 pub fn with_insert_id_field(mut self, field: impl Into<String>) -> Self {
139 self.insert_id_field = Some(field.into());
140 self
141 }
142
143 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
149 self.batch_size = batch_size;
150 self
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn batch_size_defaults_to_default_batch_size() {
160 let config = BigQuerySinkConfig::new(
161 "my-project",
162 "my_dataset",
163 "my_table",
164 BigQueryCredentials::ApplicationDefault,
165 );
166 assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
167 }
168
169 #[test]
170 fn with_batch_size_overrides_default() {
171 let config =
172 BigQuerySinkConfig::new("proj", "ds", "tbl", BigQueryCredentials::ApplicationDefault)
173 .with_batch_size(500);
174 assert_eq!(config.batch_size, 500);
175 }
176
177 #[test]
178 fn config_stores_all_fields() {
179 let config = BigQuerySinkConfig::new(
180 "my-project",
181 "my_dataset",
182 "my_table",
183 BigQueryCredentials::ServiceAccountKeyPath {
184 path: "/path/to/key.json".into(),
185 },
186 );
187 assert_eq!(config.project_id, "my-project");
188 assert_eq!(config.dataset_id, "my_dataset");
189 assert_eq!(config.table_id, "my_table");
190 assert!(matches!(
191 config.auth,
192 BigQueryCredentials::ServiceAccountKeyPath { .. }
193 ));
194 }
195
196 #[test]
197 fn config_with_inline_key() {
198 let config = BigQuerySinkConfig::new(
199 "proj",
200 "ds",
201 "tbl",
202 BigQueryCredentials::ServiceAccountKey {
203 json: r#"{"type":"service_account"}"#.into(),
204 },
205 );
206 if let BigQueryCredentials::ServiceAccountKey { json } = &config.auth {
207 assert!(json.contains("service_account"));
208 } else {
209 panic!("expected ServiceAccountKey");
210 }
211 }
212
213 #[test]
214 fn config_builder_chaining() {
215 let config =
216 BigQuerySinkConfig::new("p", "d", "t", BigQueryCredentials::ApplicationDefault)
217 .with_batch_size(100)
218 .with_batch_size(250);
219 assert_eq!(config.batch_size, 250);
220 }
221
222 #[test]
223 fn config_clone() {
224 let config =
225 BigQuerySinkConfig::new("proj", "ds", "tbl", BigQueryCredentials::ApplicationDefault)
226 .with_batch_size(42);
227 let cloned = config.clone();
228 assert_eq!(cloned.project_id, "proj");
229 assert_eq!(cloned.batch_size, 42);
230 }
231
232 #[test]
233 fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
234 let config =
235 BigQuerySinkConfig::new("p", "d", "t", BigQueryCredentials::ApplicationDefault)
236 .with_batch_size(0);
237 assert_eq!(config.batch_size, 0);
238 assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
239 }
240
241 #[test]
242 fn batch_size_above_max_is_rejected_by_validate_batch_size() {
243 let config =
244 BigQuerySinkConfig::new("p", "d", "t", BigQueryCredentials::ApplicationDefault)
245 .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
246 assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
247 }
248
249 #[test]
250 fn insert_id_field_defaults_none_and_builder_sets_it() {
251 let config =
252 BigQuerySinkConfig::new("p", "d", "t", BigQueryCredentials::ApplicationDefault);
253 assert!(config.insert_id_field.is_none());
254 let config = config.with_insert_id_field("event_id");
255 assert_eq!(config.insert_id_field.as_deref(), Some("event_id"));
256 }
257
258 #[test]
259 fn insert_id_field_deserializes_from_json() {
260 let json = r#"{
261 "project_id": "p",
262 "dataset_id": "d",
263 "table_id": "t",
264 "auth": {"type": "application_default"},
265 "insert_id_field": "id"
266 }"#;
267 let config: BigQuerySinkConfig = serde_json::from_str(json).unwrap();
268 assert_eq!(config.insert_id_field.as_deref(), Some("id"));
269 }
270
271 #[test]
272 fn batch_size_deserializes_from_json() {
273 let json = r#"{
274 "project_id": "p",
275 "dataset_id": "d",
276 "table_id": "t",
277 "auth": {"type": "application_default"},
278 "batch_size": 250
279 }"#;
280 let config: BigQuerySinkConfig = serde_json::from_str(json).unwrap();
281 assert_eq!(config.batch_size, 250);
282 }
283
284 #[test]
285 fn batch_size_defaults_when_absent_in_json() {
286 let json = r#"{
287 "project_id": "p",
288 "dataset_id": "d",
289 "table_id": "t",
290 "auth": {"type": "application_default"}
291 }"#;
292 let config: BigQuerySinkConfig = serde_json::from_str(json).unwrap();
293 assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
294 }
295
296 #[cfg(feature = "arrow")]
297 #[test]
298 fn bulk_load_builder_and_defaults() {
299 let cfg = BigQuerySinkConfig::new("p", "d", "t", BigQueryCredentials::ApplicationDefault)
300 .with_bulk_load(BigQueryLoadConfig {
301 staging_bucket: "b".into(),
302 staging_prefix: default_staging_prefix(),
303 gcs_auth: Default::default(),
304 write_disposition: default_write_disposition(),
305 storage_host: None,
306 });
307 let load = cfg.bulk_load.expect("bulk_load set");
308 assert_eq!(load.staging_bucket, "b");
309 assert_eq!(load.staging_prefix, "faucet-bq-load/");
310 assert_eq!(load.write_disposition, "WRITE_APPEND");
311
312 let json = r#"{ "staging_bucket": "bk" }"#;
314 let l: BigQueryLoadConfig = serde_json::from_str(json).unwrap();
315 assert_eq!(l.staging_prefix, "faucet-bq-load/");
316 assert_eq!(l.write_disposition, "WRITE_APPEND");
317 assert!(l.storage_host.is_none());
318 }
319
320 #[test]
321 fn write_mode_defaults_to_append() {
322 let config =
323 BigQuerySinkConfig::new("p", "d", "t", BigQueryCredentials::ApplicationDefault);
324 assert_eq!(config.write.write_mode, faucet_core::WriteMode::Append);
325 assert!(config.write.key.is_empty());
326 }
327
328 #[test]
329 fn write_spec_deserializes_flattened() {
330 let json = r#"{
331 "project_id": "p",
332 "dataset_id": "d",
333 "table_id": "t",
334 "auth": {"type": "application_default"},
335 "write_mode": "upsert",
336 "key": ["id"],
337 "delete_marker": {"field": "__op", "values": ["d"]}
338 }"#;
339 let config: BigQuerySinkConfig = serde_json::from_str(json).unwrap();
340 assert_eq!(config.write.write_mode, faucet_core::WriteMode::Upsert);
341 assert_eq!(config.write.key, vec!["id".to_string()]);
342 let dm = config.write.delete_marker.expect("delete_marker");
343 assert_eq!(dm.field, "__op");
344 assert_eq!(dm.values, vec!["d".to_string()]);
345 }
346}