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    /// Preflight probe for `faucet doctor`. Overrides the default (which pulls a
192    /// page via `stream_pages` and would run the configured query — a **billed**
193    /// execution). Instead submits the same query with `dryRun: true`, which
194    /// validates auth, SQL syntax, and table/permission access **without
195    /// executing or billing** it.
196    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        // Either keep polling until jobComplete, or keep paging until
253        // pageToken vanishes. The two reasons we'd loop again share one
254        // condition: we are not done.
255        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                // `poll_timeout == 0` disables the cap (poll forever).
275                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            // Fill in the schema from the first complete page if `jobs.query`
286            // returned 200 without one (happens when the statement timeout
287            // fires before completion).
288            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    /// Stream rows page-by-page via `jobs.getQueryResults` without
313    /// buffering the full result set.
314    ///
315    /// The trait-level `batch_size` argument is ignored in favour of the
316    /// config field — the config is the user-facing knob the README
317    /// documents, and routing the pipeline-supplied hint through it would
318    /// silently override an explicit config value.
319    ///
320    /// `batch_size = 0` is the "no batching" sentinel: all rows from all
321    /// pages are concatenated and emitted as a single page. The source has
322    /// no incremental-replication mode today, so every emitted page carries
323    /// `bookmark: None`.
324    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            // If the first response was incomplete, we have to know the job id
362            // to keep polling. If it was complete but had no further token,
363            // we're done after emitting the first batch.
364            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                    // `poll_timeout == 0` disables the cap (poll forever).
388                    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
431/// Borrow-based row extraction (used by `fetch_with_context`, which collects
432/// into a `Vec` anyway).
433fn 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
440/// Owned-iteration variant — clones each row out of the response so the
441/// streaming loop above doesn't have to keep a borrow open across yields.
442fn 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        // The `faucet doctor` `check()` probe submits the configured query with
491        // dryRun=true so it validates auth/SQL/permissions without a billed
492        // execution — mirror that construction here to guard the field name.
493        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        // Test resolve_query without needing a Client by mimicking its core.
552        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}