Skip to main content

faucet_source_bigquery/
stream.rs

1//! BigQuery query source.
2//!
3//! Submits the configured SQL statement via `jobs.query` and pages through
4//! the result set via `jobs.getQueryResults`. The first response may carry
5//! `jobComplete=false` (statement still running on the server side); the
6//! source polls `getQueryResults` until BigQuery flips that flag, exactly
7//! mirroring the behaviour of `gcp_bigquery_client::Client::job().query_all`
8//! without giving up the row-level access we need for incremental
9//! [`StreamPage`]s.
10
11use 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
31/// A source that runs a SQL query against BigQuery and yields rows as JSON.
32pub struct BigQuerySource {
33    config: BigQuerySourceConfig,
34    client: Client,
35}
36
37impl BigQuerySource {
38    /// Create a new BigQuery source from the given configuration.
39    ///
40    /// Initialises the underlying BigQuery client and exchanges credentials
41    /// for an OAuth token. Returns [`FaucetError::Auth`] on credential
42    /// failures.
43    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    /// Construct a source from a pre-built BigQuery client.
50    ///
51    /// Low-level escape hatch for callers that build their own
52    /// [`gcp_bigquery_client::Client`] — for example to target the
53    /// [`bigquery-emulator`](https://github.com/goccy/bigquery-emulator) or
54    /// drive a wiremock-backed test fixture. Production code should prefer
55    /// [`BigQuerySource::new`], which handles credential loading.
56    #[doc(hidden)]
57    pub fn from_parts(config: BigQuerySourceConfig, client: Client) -> Self {
58        Self { config, client }
59    }
60
61    /// Resolve the final SQL statement and ordered bind values for a given
62    /// parent-record context.
63    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
81/// Free-standing version of [`BigQuerySource::build_query_request`] — kept
82/// separate so unit tests can exercise it without spinning up a real
83/// `gcp_bigquery_client::Client`.
84fn 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                        // BigQuery REST always carries the value as a string;
111                        // the parameter_type tells the engine how to parse it.
112                        // A JSON null becomes a typed NULL (value omitted).
113                        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
128/// Infer the BigQuery positional-parameter type from the JSON value, so a
129/// numeric or boolean bind compares correctly against a numeric/bool column
130/// instead of being forced to STRING (#78/#34). Arrays / objects / null fall
131/// back to STRING (stringified JSON).
132fn 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    /// Preflight probe for `faucet doctor`. Overrides the default (which pulls a
199    /// page via `stream_pages` and would run the configured query — a **billed**
200    /// execution). Instead submits the same query with `dryRun: true`, which
201    /// validates auth, SQL syntax, and table/permission access **without
202    /// executing or billing** it.
203    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        // Either keep polling until jobComplete, or keep paging until
260        // pageToken vanishes. The two reasons we'd loop again share one
261        // condition: we are not done.
262        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                // `poll_timeout == 0` disables the cap (poll forever).
282                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            // Fill in the schema from the first complete page if `jobs.query`
293            // returned 200 without one (happens when the statement timeout
294            // fires before completion).
295            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    /// Stream rows page-by-page via `jobs.getQueryResults` without
320    /// buffering the full result set.
321    ///
322    /// The trait-level `batch_size` argument is ignored in favour of the
323    /// config field — the config is the user-facing knob the README
324    /// documents, and routing the pipeline-supplied hint through it would
325    /// silently override an explicit config value.
326    ///
327    /// `batch_size = 0` is the "no batching" sentinel: all rows from all
328    /// pages are concatenated and emitted as a single page. The source has
329    /// no incremental-replication mode today, so every emitted page carries
330    /// `bookmark: None`.
331    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            // If the first response was incomplete, we have to know the job id
369            // to keep polling. If it was complete but had no further token,
370            // we're done after emitting the first batch.
371            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                    // `poll_timeout == 0` disables the cap (poll forever).
395                    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
438/// Borrow-based row extraction (used by `fetch_with_context`, which collects
439/// into a `Vec` anyway).
440fn 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
447/// Owned-iteration variant — clones each row out of the response so the
448/// streaming loop above doesn't have to keep a borrow open across yields.
449fn 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        // Inline logic test — BigQuerySource::new requires a live client, so
474        // we replicate the dataset_uri() computation directly from config fields.
475        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        // The `faucet doctor` `check()` probe submits the configured query with
507        // dryRun=true so it validates auth/SQL/permissions without a billed
508        // execution — mirror that construction here to guard the field name.
509        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        // Test resolve_query without needing a Client by mimicking its core.
568        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}