1use crate::config::BigQuerySourceConfig;
12use crate::convert::row_to_json;
13use async_trait::async_trait;
14use faucet_common_bigquery::build_client;
15use faucet_core::util::substitute_context_bind_params;
16use faucet_core::{FaucetError, Stream, StreamPage};
17use gcp_bigquery_client::Client;
18use gcp_bigquery_client::model::get_query_results_parameters::GetQueryResultsParameters;
19use gcp_bigquery_client::model::query_parameter::QueryParameter;
20use gcp_bigquery_client::model::query_parameter_type::QueryParameterType;
21use gcp_bigquery_client::model::query_parameter_value::QueryParameterValue;
22use gcp_bigquery_client::model::query_request::QueryRequest;
23use gcp_bigquery_client::model::query_response::QueryResponse;
24use gcp_bigquery_client::model::table_field_schema::TableFieldSchema;
25use gcp_bigquery_client::model::table_row::TableRow;
26use serde_json::Value;
27use std::collections::HashMap;
28use std::pin::Pin;
29use std::time::Duration;
30
31pub struct BigQuerySource {
33 config: BigQuerySourceConfig,
34 client: Client,
35}
36
37impl BigQuerySource {
38 pub async fn new(config: BigQuerySourceConfig) -> Result<Self, FaucetError> {
44 faucet_core::validate_batch_size(config.batch_size)?;
45 let client = build_client(&config.auth).await?;
46 Ok(Self { config, client })
47 }
48
49 #[doc(hidden)]
57 pub fn from_parts(config: BigQuerySourceConfig, client: Client) -> Self {
58 Self { config, client }
59 }
60
61 fn resolve_query(&self, context: &HashMap<String, Value>) -> (String, Vec<Value>) {
64 let mut bindings = self.config.params.clone();
65 let (rewritten, context_values) = if context.is_empty() {
66 (self.config.query.clone(), Vec::new())
67 } else {
68 substitute_context_bind_params(&self.config.query, context, bindings.len() + 1, |_| {
69 "?".to_string()
70 })
71 };
72 bindings.extend(context_values);
73 (rewritten, bindings)
74 }
75
76 fn build_query_request(&self, query: String, bindings: &[Value]) -> QueryRequest {
77 build_query_request(&self.config, query, bindings)
78 }
79}
80
81fn build_query_request(
85 cfg: &BigQuerySourceConfig,
86 query: String,
87 bindings: &[Value],
88) -> QueryRequest {
89 let mut req = QueryRequest::new(query);
90 req.use_legacy_sql = cfg.use_legacy_sql;
91 req.timeout_ms = Some(clamp_timeout_ms(cfg.statement_timeout));
92 req.max_results = Some(cfg.max_results_per_page);
93 if let Some(location) = &cfg.location {
94 req.location = Some(location.clone());
95 }
96
97 if !bindings.is_empty() {
98 req.parameter_mode = Some("POSITIONAL".to_string());
99 req.query_parameters = Some(
100 bindings
101 .iter()
102 .map(|v| QueryParameter {
103 name: None,
104 parameter_type: Some(QueryParameterType {
105 r#type: bq_param_type(v).to_string(),
106 array_type: None,
107 struct_types: None,
108 }),
109 parameter_value: Some(QueryParameterValue {
110 value: match v {
114 Value::Null => None,
115 other => Some(stringify_param(other)),
116 },
117 array_values: None,
118 struct_values: None,
119 }),
120 })
121 .collect(),
122 );
123 }
124
125 req
126}
127
128fn bq_param_type(v: &Value) -> &'static str {
133 match v {
134 Value::Bool(_) => "BOOL",
135 Value::Number(n) => {
136 if n.is_i64() || n.is_u64() {
137 "INT64"
138 } else {
139 "FLOAT64"
140 }
141 }
142 _ => "STRING",
143 }
144}
145
146fn stringify_param(v: &Value) -> String {
147 match v {
148 Value::String(s) => s.clone(),
149 other => other.to_string(),
150 }
151}
152
153fn clamp_timeout_ms(timeout: Duration) -> i32 {
154 let ms = timeout.as_millis();
155 if ms > i32::MAX as u128 {
156 i32::MAX
157 } else {
158 ms as i32
159 }
160}
161
162fn schema_fields(qr: &QueryResponse) -> Vec<TableFieldSchema> {
163 qr.schema
164 .as_ref()
165 .and_then(|s| s.fields.clone())
166 .unwrap_or_default()
167}
168
169fn job_reference(qr: &QueryResponse) -> Result<(String, Option<String>), FaucetError> {
170 let r = qr.job_reference.as_ref().ok_or_else(|| {
171 FaucetError::Source("BigQuery query response missing jobReference".into())
172 })?;
173 let job_id = r
174 .job_id
175 .clone()
176 .ok_or_else(|| FaucetError::Source("BigQuery jobReference missing jobId".into()))?;
177 Ok((job_id, r.location.clone()))
178}
179
180#[async_trait]
181impl faucet_core::Source for BigQuerySource {
182 fn connector_name(&self) -> &'static str {
183 "bigquery"
184 }
185
186 fn config_schema(&self) -> Value {
187 serde_json::to_value(faucet_core::schema_for!(BigQuerySourceConfig))
188 .expect("schema serialization")
189 }
190
191 fn dataset_uri(&self) -> String {
192 format!(
193 "bigquery://{}?query={}",
194 self.config.project_id, self.config.query
195 )
196 }
197
198 async fn check(
204 &self,
205 ctx: &faucet_core::check::CheckContext,
206 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
207 use faucet_core::check::{CheckReport, Probe};
208 let start = std::time::Instant::now();
209 let mut req =
210 build_query_request(&self.config, self.config.query.clone(), &self.config.params);
211 req.dry_run = Some(true);
212
213 let probe = async {
214 match self.client.job().query(&self.config.project_id, req).await {
215 Ok(_) => Ok::<Probe, Probe>(Probe::pass("query", start.elapsed())),
216 Err(e) => Err(Probe::fail_hint(
217 "query",
218 start.elapsed(),
219 format!("BigQuery dry-run failed: {e}"),
220 "verify credentials, project_id, dataset/table access, and the SQL",
221 )),
222 }
223 };
224 let probe = match tokio::time::timeout(ctx.timeout, probe).await {
225 Ok(Ok(p)) | Ok(Err(p)) => p,
226 Err(_elapsed) => Probe::fail_hint(
227 "query",
228 start.elapsed(),
229 "BigQuery dry-run timed out",
230 "BigQuery did not respond within the check timeout",
231 ),
232 };
233 Ok(CheckReport::single(probe))
234 }
235
236 async fn fetch_with_context(
237 &self,
238 context: &HashMap<String, Value>,
239 ) -> Result<Vec<Value>, FaucetError> {
240 let (query, bindings) = self.resolve_query(context);
241 let req = self.build_query_request(query, &bindings);
242
243 let initial = self
244 .client
245 .job()
246 .query(&self.config.project_id, req)
247 .await
248 .map_err(|e| FaucetError::Source(format!("BigQuery jobs.query failed: {e}")))?;
249
250 let fields = schema_fields(&initial);
251 let mut all_rows: Vec<Value> = rows_from_response(&initial, &fields);
252 let mut page_token = initial.page_token.clone();
253 let mut job_complete = initial.job_complete.unwrap_or(false);
254 let (job_id, job_location) = job_reference(&initial)?;
255 let mut fields = fields;
256 let poll_timeout = self.config.poll_timeout;
257 let poll_started = std::time::Instant::now();
258
259 while !job_complete || page_token.is_some() {
263 let params = GetQueryResultsParameters {
264 page_token: page_token.clone(),
265 max_results: Some(self.config.max_results_per_page),
266 location: job_location.clone(),
267 ..Default::default()
268 };
269
270 let resp = self
271 .client
272 .job()
273 .get_query_results(&self.config.project_id, &job_id, params)
274 .await
275 .map_err(|e| {
276 FaucetError::Source(format!("BigQuery jobs.getQueryResults failed: {e}"))
277 })?;
278
279 job_complete = resp.job_complete.unwrap_or(false);
280 if !job_complete {
281 if !poll_timeout.is_zero() && poll_started.elapsed() >= poll_timeout {
283 return Err(FaucetError::Source(format!(
284 "BigQuery job '{job_id}' did not complete within poll_timeout ({}s)",
285 poll_timeout.as_secs()
286 )));
287 }
288 tokio::time::sleep(Duration::from_millis(200)).await;
289 continue;
290 }
291
292 if fields.is_empty()
296 && let Some(s) = resp.schema.as_ref()
297 && let Some(f) = s.fields.as_ref()
298 {
299 fields = f.clone();
300 }
301
302 for row in resp.rows.unwrap_or_default() {
303 all_rows.push(row_to_json(&row, &fields));
304 }
305 page_token = resp.page_token;
306 if page_token.is_none() {
307 break;
308 }
309 }
310
311 tracing::info!(
312 rows = all_rows.len(),
313 query = %self.config.query,
314 "BigQuery source fetch complete",
315 );
316 Ok(all_rows)
317 }
318
319 fn stream_pages<'a>(
332 &'a self,
333 context: &'a HashMap<String, Value>,
334 _batch_size: usize,
335 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
336 let batch_size = self.config.batch_size;
337
338 Box::pin(async_stream::try_stream! {
339 let (query, bindings) = self.resolve_query(context);
340 let req = self.build_query_request(query, &bindings);
341
342 let initial = self
343 .client
344 .job()
345 .query(&self.config.project_id, req)
346 .await
347 .map_err(|e| FaucetError::Source(format!("BigQuery jobs.query failed: {e}")))?;
348
349 let mut fields = schema_fields(&initial);
350 let mut buffer: Vec<Value> = if batch_size == 0 {
351 Vec::with_capacity(1024)
352 } else {
353 Vec::with_capacity(batch_size)
354 };
355 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
356
357 for row in rows_from_response_owned(&initial, &fields) {
358 buffer.push(row);
359 if buffer.len() >= chunk {
360 let page = std::mem::replace(&mut buffer, Vec::with_capacity(chunk));
361 yield StreamPage { records: page, bookmark: None };
362 }
363 }
364
365 let mut job_complete = initial.job_complete.unwrap_or(false);
366 let mut page_token = initial.page_token.clone();
367
368 let (job_id, job_location) = job_reference(&initial)?;
372 let poll_timeout = self.config.poll_timeout;
373 let poll_started = std::time::Instant::now();
374
375 while !job_complete || page_token.is_some() {
376 let params = GetQueryResultsParameters {
377 page_token: page_token.clone(),
378 max_results: Some(self.config.max_results_per_page),
379 location: job_location.clone(),
380 ..Default::default()
381 };
382
383 let resp = self
384 .client
385 .job()
386 .get_query_results(&self.config.project_id, &job_id, params)
387 .await
388 .map_err(|e| {
389 FaucetError::Source(format!("BigQuery jobs.getQueryResults failed: {e}"))
390 })?;
391
392 job_complete = resp.job_complete.unwrap_or(false);
393 if !job_complete {
394 if !poll_timeout.is_zero() && poll_started.elapsed() >= poll_timeout {
396 Err(FaucetError::Source(format!(
397 "BigQuery job '{job_id}' did not complete within poll_timeout ({}s)",
398 poll_timeout.as_secs()
399 )))?;
400 }
401 tokio::time::sleep(Duration::from_millis(200)).await;
402 continue;
403 }
404
405 if fields.is_empty()
406 && let Some(s) = resp.schema.as_ref()
407 && let Some(f) = s.fields.as_ref()
408 {
409 fields = f.clone();
410 }
411
412 for row in resp.rows.unwrap_or_default() {
413 buffer.push(row_to_json(&row, &fields));
414 if buffer.len() >= chunk {
415 let page = std::mem::replace(&mut buffer, Vec::with_capacity(chunk));
416 yield StreamPage { records: page, bookmark: None };
417 }
418 }
419 page_token = resp.page_token;
420 if page_token.is_none() {
421 break;
422 }
423 }
424
425 if !buffer.is_empty() {
426 yield StreamPage { records: buffer, bookmark: None };
427 }
428
429 tracing::info!(
430 batch_size,
431 query = %self.config.query,
432 "BigQuery source stream complete",
433 );
434 })
435 }
436}
437
438fn rows_from_response(resp: &QueryResponse, fields: &[TableFieldSchema]) -> Vec<Value> {
441 resp.rows
442 .as_ref()
443 .map(|rows| rows.iter().map(|r| row_to_json(r, fields)).collect())
444 .unwrap_or_default()
445}
446
447fn rows_from_response_owned(resp: &QueryResponse, fields: &[TableFieldSchema]) -> Vec<Value> {
450 let rows: &Vec<TableRow> = match resp.rows.as_ref() {
451 Some(r) => r,
452 None => return Vec::new(),
453 };
454 rows.iter().map(|r| row_to_json(r, fields)).collect()
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::config::BigQueryCredentials;
461 use serde_json::json;
462
463 fn cfg() -> BigQuerySourceConfig {
464 BigQuerySourceConfig::new(
465 "my-project",
466 BigQueryCredentials::ApplicationDefault,
467 "SELECT id FROM events",
468 )
469 }
470
471 #[test]
472 fn dataset_uri_returns_project_and_query() {
473 let c = cfg();
476 let uri = format!("bigquery://{}?query={}", c.project_id, c.query);
477 assert_eq!(uri, "bigquery://my-project?query=SELECT id FROM events");
478 }
479
480 #[test]
481 fn stringify_param_passes_strings_unquoted() {
482 assert_eq!(stringify_param(&json!("us-east")), "us-east");
483 assert_eq!(stringify_param(&json!(42)), "42");
484 assert_eq!(stringify_param(&json!(true)), "true");
485 }
486
487 #[test]
488 fn clamp_timeout_ms_handles_overflow() {
489 assert_eq!(clamp_timeout_ms(Duration::from_secs(1)), 1000);
490 assert_eq!(clamp_timeout_ms(Duration::from_secs(u64::MAX)), i32::MAX);
491 }
492
493 #[test]
494 fn build_request_no_params_omits_query_parameters() {
495 let c = cfg();
496 let req = build_query_request(&c, "SELECT id".to_string(), &[]);
497 assert_eq!(req.query, "SELECT id");
498 assert!(req.query_parameters.is_none());
499 assert!(req.parameter_mode.is_none());
500 assert!(!req.use_legacy_sql);
501 assert_eq!(req.max_results, Some(1000));
502 }
503
504 #[test]
505 fn doctor_probe_request_is_dry_run() {
506 let c = cfg();
510 let mut req = build_query_request(&c, "SELECT 1".to_string(), &[]);
511 req.dry_run = Some(true);
512 assert_eq!(
513 req.dry_run,
514 Some(true),
515 "doctor probe must dry-run (no billing)"
516 );
517 }
518
519 #[test]
520 fn build_request_with_params_uses_positional_string_binds() {
521 let c = cfg().with_params(vec![json!("us-east"), json!(42)]);
522 let req = build_query_request(&c, "SELECT * WHERE r = ? AND n > ?".to_string(), &c.params);
523 assert_eq!(req.parameter_mode.as_deref(), Some("POSITIONAL"));
524 let params = req.query_parameters.as_ref().unwrap();
525 assert_eq!(params.len(), 2);
526 assert_eq!(params[0].parameter_type.as_ref().unwrap().r#type, "STRING");
527 assert_eq!(
528 params[0].parameter_value.as_ref().unwrap().value.as_deref(),
529 Some("us-east")
530 );
531 assert_eq!(
532 params[1].parameter_value.as_ref().unwrap().value.as_deref(),
533 Some("42")
534 );
535 }
536
537 #[test]
538 fn build_request_propagates_location_and_legacy_flag() {
539 let c = cfg()
540 .with_location("EU")
541 .with_use_legacy_sql(true)
542 .with_max_results_per_page(250);
543 let req = build_query_request(&c, "SELECT 1".to_string(), &[]);
544 assert!(req.use_legacy_sql);
545 assert_eq!(req.location.as_deref(), Some("EU"));
546 assert_eq!(req.max_results, Some(250));
547 }
548
549 #[tokio::test]
550 async fn new_rejects_out_of_range_batch_size() {
551 let mut config = BigQuerySourceConfig::new(
552 "my-project",
553 BigQueryCredentials::ApplicationDefault,
554 "SELECT id FROM events",
555 );
556 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
557 match BigQuerySource::new(config).await {
558 Err(faucet_core::FaucetError::Config(m)) => {
559 assert!(m.contains("batch_size"), "got: {m}")
560 }
561 _ => panic!("expected a batch_size Config error"),
562 }
563 }
564
565 #[test]
566 fn resolve_query_substitutes_context_with_positional_markers() {
567 let c = cfg();
569 let mut bindings = c.params.clone();
570 let mut ctx = HashMap::new();
571 ctx.insert("parent.id".to_string(), json!(7));
572 let (rewritten, extra) = substitute_context_bind_params(
573 "SELECT * FROM t WHERE id = {parent.id}",
574 &ctx,
575 bindings.len() + 1,
576 |_| "?".to_string(),
577 );
578 bindings.extend(extra);
579 assert_eq!(rewritten, "SELECT * FROM t WHERE id = ?");
580 assert_eq!(bindings, vec![json!(7)]);
581 }
582}