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 async fn check(
197 &self,
198 ctx: &faucet_core::check::CheckContext,
199 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
200 use faucet_core::check::{CheckReport, Probe};
201 let start = std::time::Instant::now();
202 let mut req =
203 build_query_request(&self.config, self.config.query.clone(), &self.config.params);
204 req.dry_run = Some(true);
205
206 let probe = async {
207 match self.client.job().query(&self.config.project_id, req).await {
208 Ok(_) => Ok::<Probe, Probe>(Probe::pass("query", start.elapsed())),
209 Err(e) => Err(Probe::fail_hint(
210 "query",
211 start.elapsed(),
212 format!("BigQuery dry-run failed: {e}"),
213 "verify credentials, project_id, dataset/table access, and the SQL",
214 )),
215 }
216 };
217 let probe = match tokio::time::timeout(ctx.timeout, probe).await {
218 Ok(Ok(p)) | Ok(Err(p)) => p,
219 Err(_elapsed) => Probe::fail_hint(
220 "query",
221 start.elapsed(),
222 "BigQuery dry-run timed out",
223 "BigQuery did not respond within the check timeout",
224 ),
225 };
226 Ok(CheckReport::single(probe))
227 }
228
229 async fn fetch_with_context(
230 &self,
231 context: &HashMap<String, Value>,
232 ) -> Result<Vec<Value>, FaucetError> {
233 let (query, bindings) = self.resolve_query(context);
234 let req = self.build_query_request(query, &bindings);
235
236 let initial = self
237 .client
238 .job()
239 .query(&self.config.project_id, req)
240 .await
241 .map_err(|e| FaucetError::Source(format!("BigQuery jobs.query failed: {e}")))?;
242
243 let fields = schema_fields(&initial);
244 let mut all_rows: Vec<Value> = rows_from_response(&initial, &fields);
245 let mut page_token = initial.page_token.clone();
246 let mut job_complete = initial.job_complete.unwrap_or(false);
247 let (job_id, job_location) = job_reference(&initial)?;
248 let mut fields = fields;
249 let poll_timeout = self.config.poll_timeout;
250 let poll_started = std::time::Instant::now();
251
252 while !job_complete || page_token.is_some() {
256 let params = GetQueryResultsParameters {
257 page_token: page_token.clone(),
258 max_results: Some(self.config.max_results_per_page),
259 location: job_location.clone(),
260 ..Default::default()
261 };
262
263 let resp = self
264 .client
265 .job()
266 .get_query_results(&self.config.project_id, &job_id, params)
267 .await
268 .map_err(|e| {
269 FaucetError::Source(format!("BigQuery jobs.getQueryResults failed: {e}"))
270 })?;
271
272 job_complete = resp.job_complete.unwrap_or(false);
273 if !job_complete {
274 if !poll_timeout.is_zero() && poll_started.elapsed() >= poll_timeout {
276 return Err(FaucetError::Source(format!(
277 "BigQuery job '{job_id}' did not complete within poll_timeout ({}s)",
278 poll_timeout.as_secs()
279 )));
280 }
281 tokio::time::sleep(Duration::from_millis(200)).await;
282 continue;
283 }
284
285 if fields.is_empty()
289 && let Some(s) = resp.schema.as_ref()
290 && let Some(f) = s.fields.as_ref()
291 {
292 fields = f.clone();
293 }
294
295 for row in resp.rows.unwrap_or_default() {
296 all_rows.push(row_to_json(&row, &fields));
297 }
298 page_token = resp.page_token;
299 if page_token.is_none() {
300 break;
301 }
302 }
303
304 tracing::info!(
305 rows = all_rows.len(),
306 query = %self.config.query,
307 "BigQuery source fetch complete",
308 );
309 Ok(all_rows)
310 }
311
312 fn stream_pages<'a>(
325 &'a self,
326 context: &'a HashMap<String, Value>,
327 _batch_size: usize,
328 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
329 let batch_size = self.config.batch_size;
330
331 Box::pin(async_stream::try_stream! {
332 let (query, bindings) = self.resolve_query(context);
333 let req = self.build_query_request(query, &bindings);
334
335 let initial = self
336 .client
337 .job()
338 .query(&self.config.project_id, req)
339 .await
340 .map_err(|e| FaucetError::Source(format!("BigQuery jobs.query failed: {e}")))?;
341
342 let mut fields = schema_fields(&initial);
343 let mut buffer: Vec<Value> = if batch_size == 0 {
344 Vec::with_capacity(1024)
345 } else {
346 Vec::with_capacity(batch_size)
347 };
348 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
349
350 for row in rows_from_response_owned(&initial, &fields) {
351 buffer.push(row);
352 if buffer.len() >= chunk {
353 let page = std::mem::replace(&mut buffer, Vec::with_capacity(chunk));
354 yield StreamPage { records: page, bookmark: None };
355 }
356 }
357
358 let mut job_complete = initial.job_complete.unwrap_or(false);
359 let mut page_token = initial.page_token.clone();
360
361 let (job_id, job_location) = job_reference(&initial)?;
365 let poll_timeout = self.config.poll_timeout;
366 let poll_started = std::time::Instant::now();
367
368 while !job_complete || page_token.is_some() {
369 let params = GetQueryResultsParameters {
370 page_token: page_token.clone(),
371 max_results: Some(self.config.max_results_per_page),
372 location: job_location.clone(),
373 ..Default::default()
374 };
375
376 let resp = self
377 .client
378 .job()
379 .get_query_results(&self.config.project_id, &job_id, params)
380 .await
381 .map_err(|e| {
382 FaucetError::Source(format!("BigQuery jobs.getQueryResults failed: {e}"))
383 })?;
384
385 job_complete = resp.job_complete.unwrap_or(false);
386 if !job_complete {
387 if !poll_timeout.is_zero() && poll_started.elapsed() >= poll_timeout {
389 Err(FaucetError::Source(format!(
390 "BigQuery job '{job_id}' did not complete within poll_timeout ({}s)",
391 poll_timeout.as_secs()
392 )))?;
393 }
394 tokio::time::sleep(Duration::from_millis(200)).await;
395 continue;
396 }
397
398 if fields.is_empty()
399 && let Some(s) = resp.schema.as_ref()
400 && let Some(f) = s.fields.as_ref()
401 {
402 fields = f.clone();
403 }
404
405 for row in resp.rows.unwrap_or_default() {
406 buffer.push(row_to_json(&row, &fields));
407 if buffer.len() >= chunk {
408 let page = std::mem::replace(&mut buffer, Vec::with_capacity(chunk));
409 yield StreamPage { records: page, bookmark: None };
410 }
411 }
412 page_token = resp.page_token;
413 if page_token.is_none() {
414 break;
415 }
416 }
417
418 if !buffer.is_empty() {
419 yield StreamPage { records: buffer, bookmark: None };
420 }
421
422 tracing::info!(
423 batch_size,
424 query = %self.config.query,
425 "BigQuery source stream complete",
426 );
427 })
428 }
429}
430
431fn rows_from_response(resp: &QueryResponse, fields: &[TableFieldSchema]) -> Vec<Value> {
434 resp.rows
435 .as_ref()
436 .map(|rows| rows.iter().map(|r| row_to_json(r, fields)).collect())
437 .unwrap_or_default()
438}
439
440fn rows_from_response_owned(resp: &QueryResponse, fields: &[TableFieldSchema]) -> Vec<Value> {
443 let rows: &Vec<TableRow> = match resp.rows.as_ref() {
444 Some(r) => r,
445 None => return Vec::new(),
446 };
447 rows.iter().map(|r| row_to_json(r, fields)).collect()
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::config::BigQueryCredentials;
454 use serde_json::json;
455
456 fn cfg() -> BigQuerySourceConfig {
457 BigQuerySourceConfig::new(
458 "my-project",
459 BigQueryCredentials::ApplicationDefault,
460 "SELECT id FROM events",
461 )
462 }
463
464 #[test]
465 fn stringify_param_passes_strings_unquoted() {
466 assert_eq!(stringify_param(&json!("us-east")), "us-east");
467 assert_eq!(stringify_param(&json!(42)), "42");
468 assert_eq!(stringify_param(&json!(true)), "true");
469 }
470
471 #[test]
472 fn clamp_timeout_ms_handles_overflow() {
473 assert_eq!(clamp_timeout_ms(Duration::from_secs(1)), 1000);
474 assert_eq!(clamp_timeout_ms(Duration::from_secs(u64::MAX)), i32::MAX);
475 }
476
477 #[test]
478 fn build_request_no_params_omits_query_parameters() {
479 let c = cfg();
480 let req = build_query_request(&c, "SELECT id".to_string(), &[]);
481 assert_eq!(req.query, "SELECT id");
482 assert!(req.query_parameters.is_none());
483 assert!(req.parameter_mode.is_none());
484 assert!(!req.use_legacy_sql);
485 assert_eq!(req.max_results, Some(1000));
486 }
487
488 #[test]
489 fn doctor_probe_request_is_dry_run() {
490 let c = cfg();
494 let mut req = build_query_request(&c, "SELECT 1".to_string(), &[]);
495 req.dry_run = Some(true);
496 assert_eq!(
497 req.dry_run,
498 Some(true),
499 "doctor probe must dry-run (no billing)"
500 );
501 }
502
503 #[test]
504 fn build_request_with_params_uses_positional_string_binds() {
505 let c = cfg().with_params(vec![json!("us-east"), json!(42)]);
506 let req = build_query_request(&c, "SELECT * WHERE r = ? AND n > ?".to_string(), &c.params);
507 assert_eq!(req.parameter_mode.as_deref(), Some("POSITIONAL"));
508 let params = req.query_parameters.as_ref().unwrap();
509 assert_eq!(params.len(), 2);
510 assert_eq!(params[0].parameter_type.as_ref().unwrap().r#type, "STRING");
511 assert_eq!(
512 params[0].parameter_value.as_ref().unwrap().value.as_deref(),
513 Some("us-east")
514 );
515 assert_eq!(
516 params[1].parameter_value.as_ref().unwrap().value.as_deref(),
517 Some("42")
518 );
519 }
520
521 #[test]
522 fn build_request_propagates_location_and_legacy_flag() {
523 let c = cfg()
524 .with_location("EU")
525 .with_use_legacy_sql(true)
526 .with_max_results_per_page(250);
527 let req = build_query_request(&c, "SELECT 1".to_string(), &[]);
528 assert!(req.use_legacy_sql);
529 assert_eq!(req.location.as_deref(), Some("EU"));
530 assert_eq!(req.max_results, Some(250));
531 }
532
533 #[tokio::test]
534 async fn new_rejects_out_of_range_batch_size() {
535 let mut config = BigQuerySourceConfig::new(
536 "my-project",
537 BigQueryCredentials::ApplicationDefault,
538 "SELECT id FROM events",
539 );
540 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
541 match BigQuerySource::new(config).await {
542 Err(faucet_core::FaucetError::Config(m)) => {
543 assert!(m.contains("batch_size"), "got: {m}")
544 }
545 _ => panic!("expected a batch_size Config error"),
546 }
547 }
548
549 #[test]
550 fn resolve_query_substitutes_context_with_positional_markers() {
551 let c = cfg();
553 let mut bindings = c.params.clone();
554 let mut ctx = HashMap::new();
555 ctx.insert("parent.id".to_string(), json!(7));
556 let (rewritten, extra) = substitute_context_bind_params(
557 "SELECT * FROM t WHERE id = {parent.id}",
558 &ctx,
559 bindings.len() + 1,
560 |_| "?".to_string(),
561 );
562 bindings.extend(extra);
563 assert_eq!(rewritten, "SELECT * FROM t WHERE id = ?");
564 assert_eq!(bindings, vec![json!(7)]);
565 }
566}