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