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::{DatasetDescriptor, FaucetError, Stream, StreamPage};
17use gcp_bigquery_client::Client;
18use gcp_bigquery_client::dataset::ListOptions as DatasetListOptions;
19use gcp_bigquery_client::model::field_type::FieldType;
20use gcp_bigquery_client::model::get_query_results_parameters::GetQueryResultsParameters;
21use gcp_bigquery_client::model::query_parameter::QueryParameter;
22use gcp_bigquery_client::model::query_parameter_type::QueryParameterType;
23use gcp_bigquery_client::model::query_parameter_value::QueryParameterValue;
24use gcp_bigquery_client::model::query_request::QueryRequest;
25use gcp_bigquery_client::model::query_response::QueryResponse;
26use gcp_bigquery_client::model::table_field_schema::TableFieldSchema;
27use gcp_bigquery_client::model::table_row::TableRow;
28use gcp_bigquery_client::table::ListOptions as TableListOptions;
29use serde_json::{Value, json};
30use std::collections::HashMap;
31use std::pin::Pin;
32use std::time::Duration;
33
34/// Hard cap on the total number of tables [`Source::discover`] enumerates
35/// across all datasets in the project. Discovery is a preflight convenience —
36/// a project with thousands of tables should not turn it into an API storm.
37/// When the cap is hit, enumeration stops and a warning names the cap.
38const MAX_DISCOVER_TABLES: usize = 500;
39
40/// Hard cap on per-table `tables.get` schema/row-count fetches during
41/// [`Source::discover`]. Tables beyond this cap are still emitted (name +
42/// `config_patch`), just without a `schema` / `estimated_rows`, and a warning
43/// names the cap.
44const MAX_DISCOVER_SCHEMA_FETCHES: usize = 100;
45
46/// A source that runs a SQL query against BigQuery and yields rows as JSON.
47pub struct BigQuerySource {
48    config: BigQuerySourceConfig,
49    client: Client,
50}
51
52impl BigQuerySource {
53    /// Create a new BigQuery source from the given configuration.
54    ///
55    /// Initialises the underlying BigQuery client and exchanges credentials
56    /// for an OAuth token. Returns [`FaucetError::Auth`] on credential
57    /// failures.
58    pub async fn new(config: BigQuerySourceConfig) -> Result<Self, FaucetError> {
59        faucet_core::validate_batch_size(config.batch_size)?;
60        Self::validate_read_api(&config)?;
61        let client = build_client(&config.auth).await?;
62        Ok(Self { config, client })
63    }
64
65    /// Validate `read_api` mode: it needs the `arrow` feature and a
66    /// `read_table`. Rejecting here makes a misconfiguration loud at load time
67    /// rather than silently falling back to the (empty) query path.
68    fn validate_read_api(config: &BigQuerySourceConfig) -> Result<(), FaucetError> {
69        if !config.read_api {
70            return Ok(());
71        }
72        #[cfg(not(feature = "arrow"))]
73        {
74            Err(FaucetError::Config(
75                "BigQuery `read_api` requires a binary built with the `arrow` feature \
76                 (e.g. `cargo install faucet-cli --features arrow`)"
77                    .into(),
78            ))
79        }
80        #[cfg(feature = "arrow")]
81        {
82            if config.read_table.as_deref().unwrap_or("").is_empty() {
83                return Err(FaucetError::Config(
84                    "BigQuery `read_api` requires `read_table` (dataset.table or \
85                     project.dataset.table)"
86                        .into(),
87                ));
88            }
89            Ok(())
90        }
91    }
92
93    /// Accessor for the Arrow Storage Read path (`storage_read.rs`).
94    #[cfg(feature = "arrow")]
95    pub(crate) fn config(&self) -> &BigQuerySourceConfig {
96        &self.config
97    }
98
99    /// Construct a source from a pre-built BigQuery client.
100    ///
101    /// Low-level escape hatch for callers that build their own
102    /// [`gcp_bigquery_client::Client`] — for example to target the
103    /// [`bigquery-emulator`](https://github.com/goccy/bigquery-emulator) or
104    /// drive a wiremock-backed test fixture. Production code should prefer
105    /// [`BigQuerySource::new`], which handles credential loading.
106    #[doc(hidden)]
107    pub fn from_parts(config: BigQuerySourceConfig, client: Client) -> Self {
108        Self { config, client }
109    }
110
111    /// Resolve the final SQL statement and ordered bind values for a given
112    /// parent-record context.
113    fn resolve_query(&self, context: &HashMap<String, Value>) -> (String, Vec<Value>) {
114        let mut bindings = self.config.params.clone();
115        let (rewritten, context_values) = if context.is_empty() {
116            (self.config.query.clone(), Vec::new())
117        } else {
118            substitute_context_bind_params(&self.config.query, context, bindings.len() + 1, |_| {
119                "?".to_string()
120            })
121        };
122        bindings.extend(context_values);
123        (rewritten, bindings)
124    }
125
126    fn build_query_request(&self, query: String, bindings: &[Value]) -> QueryRequest {
127        build_query_request(&self.config, query, bindings)
128    }
129
130    /// [`Source::discover`] with explicit caps — split out so tests can
131    /// exercise the truncation branches without mocking hundreds of tables.
132    /// Production code goes through [`Source::discover`], which applies
133    /// `MAX_DISCOVER_TABLES` / `MAX_DISCOVER_SCHEMA_FETCHES`.
134    #[doc(hidden)]
135    pub async fn discover_with_caps(
136        &self,
137        max_tables: usize,
138        max_schema_fetches: usize,
139    ) -> Result<Vec<DatasetDescriptor>, FaucetError> {
140        let project = &self.config.project_id;
141        let discovery_err = |e: gcp_bigquery_client::error::BQError| -> FaucetError {
142            FaucetError::Source(format!("bigquery: catalog discovery failed: {e}"))
143        };
144
145        // 1. Enumerate every dataset in the project (paged).
146        let mut dataset_ids: Vec<String> = Vec::new();
147        let mut page_token: Option<String> = None;
148        loop {
149            let mut opts = DatasetListOptions::default();
150            if let Some(t) = page_token.take() {
151                opts = opts.page_token(t);
152            }
153            let resp = self
154                .client
155                .dataset()
156                .list(project, opts)
157                .await
158                .map_err(discovery_err)?;
159            dataset_ids.extend(
160                resp.datasets
161                    .iter()
162                    .map(|d| d.dataset_reference.dataset_id.clone()),
163            );
164            page_token = resp.next_page_token;
165            if page_token.is_none() {
166                break;
167            }
168        }
169
170        // 2. Enumerate physical tables per dataset (paged), capped in total.
171        let mut refs: Vec<(String, String)> = Vec::new();
172        let mut truncated = false;
173        'datasets: for dataset_id in &dataset_ids {
174            let mut page_token: Option<String> = None;
175            loop {
176                let mut opts = TableListOptions::default();
177                if let Some(t) = page_token.take() {
178                    opts = opts.page_token(t);
179                }
180                let resp = self
181                    .client
182                    .table()
183                    .list(project, dataset_id, opts)
184                    .await
185                    .map_err(discovery_err)?;
186                for table in resp.tables.unwrap_or_default() {
187                    // Physical tables only — views / materialized views /
188                    // external tables are not `SELECT *`-scannable datasets in
189                    // the same cheap sense. A missing `type` is treated as a
190                    // table (BigQuery always sets it in practice).
191                    if let Some(kind) = table.r#type.as_deref()
192                        && !kind.eq_ignore_ascii_case("TABLE")
193                    {
194                        continue;
195                    }
196                    if refs.len() >= max_tables {
197                        truncated = true;
198                        break 'datasets;
199                    }
200                    refs.push((dataset_id.clone(), table.table_reference.table_id));
201                }
202                page_token = resp.next_page_token;
203                if page_token.is_none() {
204                    break;
205                }
206            }
207        }
208        if truncated {
209            tracing::warn!(
210                cap = max_tables,
211                "BigQuery discovery hit the {max_tables}-table cap; remaining tables were not enumerated",
212            );
213        }
214        if refs.len() > max_schema_fetches {
215            tracing::warn!(
216                cap = max_schema_fetches,
217                total = refs.len(),
218                "BigQuery discovery found more than {max_schema_fetches} tables; \
219                 only the first {max_schema_fetches} get a schema / row estimate",
220            );
221        }
222
223        // 3. Fetch schema + row count for the first `max_schema_fetches`
224        //    tables; the rest are emitted name-only.
225        let mut out = Vec::with_capacity(refs.len());
226        for (i, (dataset_id, table_id)) in refs.iter().enumerate() {
227            if i < max_schema_fetches {
228                let table = self
229                    .client
230                    .table()
231                    .get(project, dataset_id, table_id, None)
232                    .await
233                    .map_err(discovery_err)?;
234                let fields = table.schema.fields.unwrap_or_default();
235                out.push(table_descriptor(
236                    project,
237                    dataset_id,
238                    table_id,
239                    Some(&fields),
240                    table.num_rows.as_deref(),
241                ));
242            } else {
243                out.push(table_descriptor(project, dataset_id, table_id, None, None));
244            }
245        }
246        Ok(out)
247    }
248}
249
250/// Map one BigQuery table-schema field to a JSON-Schema type fragment
251/// matching the shape [`faucet_core::schema::infer_schema`] produces.
252///
253/// `mode: REPEATED` → `array`; `mode: NULLABLE` (BigQuery's default when the
254/// mode is omitted) wraps the base type as `["T", "null"]`; `mode: REQUIRED`
255/// keeps the bare base type.
256fn bq_field_to_json_schema(field: &TableFieldSchema) -> Value {
257    let base = match field.r#type {
258        FieldType::Integer | FieldType::Int64 => "integer",
259        FieldType::Float | FieldType::Float64 | FieldType::Numeric | FieldType::Bignumeric => {
260            "number"
261        }
262        FieldType::Boolean | FieldType::Bool => "boolean",
263        FieldType::Record | FieldType::Struct | FieldType::Json => "object",
264        // STRING, BYTES, DATE, DATETIME, TIME, TIMESTAMP, GEOGRAPHY,
265        // INTERVAL — all serialized as JSON strings by this source.
266        _ => "string",
267    };
268    match field.mode.as_deref() {
269        Some(mode) if mode.eq_ignore_ascii_case("REPEATED") => json!({ "type": "array" }),
270        Some(mode) if mode.eq_ignore_ascii_case("REQUIRED") => json!({ "type": base }),
271        // NULLABLE, or absent (NULLABLE is the BigQuery default).
272        _ => faucet_core::nullable_type(json!({ "type": base })),
273    }
274}
275
276/// Backtick-quote a fully-qualified `project.dataset.table` path for Standard
277/// SQL. Backslashes and backticks inside an identifier are escaped (`\\` /
278/// `` \` ``) so a hostile identifier cannot break out of the quoted path.
279fn bq_quote_path(project: &str, dataset: &str, table: &str) -> String {
280    let esc = |s: &str| s.replace('\\', r"\\").replace('`', r"\`");
281    format!("`{}.{}.{}`", esc(project), esc(dataset), esc(table))
282}
283
284/// Build one [`DatasetDescriptor`] for a BigQuery table. Pure —
285/// unit-testable without a live client. `fields`/`num_rows` are `None` for
286/// tables past the schema-fetch cap (emitted name-only).
287fn table_descriptor(
288    project: &str,
289    dataset: &str,
290    table: &str,
291    fields: Option<&[TableFieldSchema]>,
292    num_rows: Option<&str>,
293) -> DatasetDescriptor {
294    let query = format!("SELECT * FROM {}", bq_quote_path(project, dataset, table));
295    let mut descriptor = DatasetDescriptor::new(
296        format!("{dataset}.{table}"),
297        "table",
298        json!({ "query": query }),
299    );
300    if let Some(fields) = fields {
301        descriptor = descriptor.with_schema(faucet_core::columns_to_schema(
302            fields
303                .iter()
304                .map(|f| (f.name.clone(), bq_field_to_json_schema(f))),
305        ));
306    }
307    // `numRows` arrives as a decimal string; a missing/unparseable value
308    // simply means no estimate.
309    if let Some(n) = num_rows.and_then(|s| s.trim().parse::<u64>().ok()) {
310        descriptor = descriptor.with_estimated_rows(n);
311    }
312    descriptor
313}
314
315/// Free-standing version of [`BigQuerySource::build_query_request`] — kept
316/// separate so unit tests can exercise it without spinning up a real
317/// `gcp_bigquery_client::Client`.
318fn build_query_request(
319    cfg: &BigQuerySourceConfig,
320    query: String,
321    bindings: &[Value],
322) -> QueryRequest {
323    let mut req = QueryRequest::new(query);
324    req.use_legacy_sql = cfg.use_legacy_sql;
325    req.timeout_ms = Some(clamp_timeout_ms(cfg.statement_timeout));
326    req.max_results = Some(cfg.max_results_per_page);
327    if let Some(location) = &cfg.location {
328        req.location = Some(location.clone());
329    }
330
331    if !bindings.is_empty() {
332        req.parameter_mode = Some("POSITIONAL".to_string());
333        req.query_parameters = Some(
334            bindings
335                .iter()
336                .map(|v| QueryParameter {
337                    name: None,
338                    parameter_type: Some(QueryParameterType {
339                        r#type: bq_param_type(v).to_string(),
340                        array_type: None,
341                        struct_types: None,
342                    }),
343                    parameter_value: Some(QueryParameterValue {
344                        // BigQuery REST always carries the value as a string;
345                        // the parameter_type tells the engine how to parse it.
346                        // A JSON null becomes a typed NULL (value omitted).
347                        value: match v {
348                            Value::Null => None,
349                            other => Some(stringify_param(other)),
350                        },
351                        array_values: None,
352                        struct_values: None,
353                    }),
354                })
355                .collect(),
356        );
357    }
358
359    req
360}
361
362/// Infer the BigQuery positional-parameter type from the JSON value, so a
363/// numeric or boolean bind compares correctly against a numeric/bool column
364/// instead of being forced to STRING (#78/#34). Arrays / objects / null fall
365/// back to STRING (stringified JSON).
366fn bq_param_type(v: &Value) -> &'static str {
367    match v {
368        Value::Bool(_) => "BOOL",
369        Value::Number(n) => {
370            if n.is_i64() || n.is_u64() {
371                "INT64"
372            } else {
373                "FLOAT64"
374            }
375        }
376        _ => "STRING",
377    }
378}
379
380fn stringify_param(v: &Value) -> String {
381    match v {
382        Value::String(s) => s.clone(),
383        other => other.to_string(),
384    }
385}
386
387fn clamp_timeout_ms(timeout: Duration) -> i32 {
388    let ms = timeout.as_millis();
389    if ms > i32::MAX as u128 {
390        i32::MAX
391    } else {
392        ms as i32
393    }
394}
395
396fn schema_fields(qr: &QueryResponse) -> Vec<TableFieldSchema> {
397    qr.schema
398        .as_ref()
399        .and_then(|s| s.fields.clone())
400        .unwrap_or_default()
401}
402
403fn job_reference(qr: &QueryResponse) -> Result<(String, Option<String>), FaucetError> {
404    let r = qr.job_reference.as_ref().ok_or_else(|| {
405        FaucetError::Source("BigQuery query response missing jobReference".into())
406    })?;
407    let job_id = r
408        .job_id
409        .clone()
410        .ok_or_else(|| FaucetError::Source("BigQuery jobReference missing jobId".into()))?;
411    Ok((job_id, r.location.clone()))
412}
413
414#[async_trait]
415impl faucet_core::Source for BigQuerySource {
416    fn connector_name(&self) -> &'static str {
417        "bigquery"
418    }
419
420    fn config_schema(&self) -> Value {
421        serde_json::to_value(faucet_core::schema_for!(BigQuerySourceConfig))
422            .expect("schema serialization")
423    }
424
425    fn dataset_uri(&self) -> String {
426        format!(
427            "bigquery://{}?query={}",
428            self.config.project_id, self.config.query
429        )
430    }
431
432    fn supports_discover(&self) -> bool {
433        true
434    }
435
436    /// Enumerate every physical table in the project, with column types from
437    /// `tables.get` schemas and a row estimate from `numRows` (catalog
438    /// metadata only — no data scan, no billed query). Datasets are listed
439    /// via `datasets.list`, tables per dataset via `tables.list` (both
440    /// paged); enumeration is capped at `MAX_DISCOVER_TABLES` tables and
441    /// per-table schema fetches at `MAX_DISCOVER_SCHEMA_FETCHES` (tables
442    /// past that cap are emitted without a schema / estimate).
443    async fn discover(&self) -> Result<Vec<DatasetDescriptor>, FaucetError> {
444        self.discover_with_caps(MAX_DISCOVER_TABLES, MAX_DISCOVER_SCHEMA_FETCHES)
445            .await
446    }
447
448    /// Preflight probe for `faucet doctor`. Overrides the default (which pulls a
449    /// page via `stream_pages` and would run the configured query — a **billed**
450    /// execution). Instead submits the same query with `dryRun: true`, which
451    /// validates auth, SQL syntax, and table/permission access **without
452    /// executing or billing** it.
453    async fn check(
454        &self,
455        ctx: &faucet_core::check::CheckContext,
456    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
457        use faucet_core::check::{CheckReport, Probe};
458        let start = std::time::Instant::now();
459        let mut req =
460            build_query_request(&self.config, self.config.query.clone(), &self.config.params);
461        req.dry_run = Some(true);
462
463        let probe = async {
464            match self.client.job().query(&self.config.project_id, req).await {
465                Ok(_) => Ok::<Probe, Probe>(Probe::pass("query", start.elapsed())),
466                Err(e) => Err(Probe::fail_hint(
467                    "query",
468                    start.elapsed(),
469                    format!("BigQuery dry-run failed: {e}"),
470                    "verify credentials, project_id, dataset/table access, and the SQL",
471                )),
472            }
473        };
474        let probe = match tokio::time::timeout(ctx.timeout, probe).await {
475            Ok(Ok(p)) | Ok(Err(p)) => p,
476            Err(_elapsed) => Probe::fail_hint(
477                "query",
478                start.elapsed(),
479                "BigQuery dry-run timed out",
480                "BigQuery did not respond within the check timeout",
481            ),
482        };
483        Ok(CheckReport::single(probe))
484    }
485
486    async fn fetch_with_context(
487        &self,
488        context: &HashMap<String, Value>,
489    ) -> Result<Vec<Value>, FaucetError> {
490        let (query, bindings) = self.resolve_query(context);
491        let req = self.build_query_request(query, &bindings);
492
493        let initial = self
494            .client
495            .job()
496            .query(&self.config.project_id, req)
497            .await
498            .map_err(|e| FaucetError::Source(format!("BigQuery jobs.query failed: {e}")))?;
499
500        let fields = schema_fields(&initial);
501        let mut all_rows: Vec<Value> = rows_from_response(&initial, &fields);
502        let mut page_token = initial.page_token.clone();
503        let mut job_complete = initial.job_complete.unwrap_or(false);
504        let (job_id, job_location) = job_reference(&initial)?;
505        let mut fields = fields;
506        let poll_timeout = self.config.poll_timeout;
507        let poll_started = std::time::Instant::now();
508
509        // Either keep polling until jobComplete, or keep paging until
510        // pageToken vanishes. The two reasons we'd loop again share one
511        // condition: we are not done.
512        while !job_complete || page_token.is_some() {
513            let params = GetQueryResultsParameters {
514                page_token: page_token.clone(),
515                max_results: Some(self.config.max_results_per_page),
516                location: job_location.clone(),
517                ..Default::default()
518            };
519
520            let resp = self
521                .client
522                .job()
523                .get_query_results(&self.config.project_id, &job_id, params)
524                .await
525                .map_err(|e| {
526                    FaucetError::Source(format!("BigQuery jobs.getQueryResults failed: {e}"))
527                })?;
528
529            job_complete = resp.job_complete.unwrap_or(false);
530            if !job_complete {
531                // `poll_timeout == 0` disables the cap (poll forever).
532                if !poll_timeout.is_zero() && poll_started.elapsed() >= poll_timeout {
533                    return Err(FaucetError::Source(format!(
534                        "BigQuery job '{job_id}' did not complete within poll_timeout ({}s)",
535                        poll_timeout.as_secs()
536                    )));
537                }
538                tokio::time::sleep(Duration::from_millis(200)).await;
539                continue;
540            }
541
542            // Fill in the schema from the first complete page if `jobs.query`
543            // returned 200 without one (happens when the statement timeout
544            // fires before completion).
545            if fields.is_empty()
546                && let Some(s) = resp.schema.as_ref()
547                && let Some(f) = s.fields.as_ref()
548            {
549                fields = f.clone();
550            }
551
552            for row in resp.rows.unwrap_or_default() {
553                all_rows.push(row_to_json(&row, &fields));
554            }
555            page_token = resp.page_token;
556            if page_token.is_none() {
557                break;
558            }
559        }
560
561        tracing::info!(
562            rows = all_rows.len(),
563            query = %self.config.query,
564            "BigQuery source fetch complete",
565        );
566        Ok(all_rows)
567    }
568
569    /// Stream rows page-by-page via `jobs.getQueryResults` without
570    /// buffering the full result set.
571    ///
572    /// The trait-level `batch_size` argument is ignored in favour of the
573    /// config field — the config is the user-facing knob the README
574    /// documents, and routing the pipeline-supplied hint through it would
575    /// silently override an explicit config value.
576    ///
577    /// `batch_size = 0` is the "no batching" sentinel: all rows from all
578    /// pages are concatenated and emitted as a single page. The source has
579    /// no incremental-replication mode today, so every emitted page carries
580    /// `bookmark: None`.
581    #[cfg(feature = "arrow")]
582    fn supports_columnar(&self) -> bool {
583        self.config.read_api
584    }
585
586    #[cfg(feature = "arrow")]
587    fn stream_batches<'a>(
588        &'a self,
589        _context: &'a HashMap<String, Value>,
590        _batch_size: usize,
591    ) -> Pin<
592        Box<
593            dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
594        >,
595    > {
596        crate::storage_read::stream_batches_arrow(self)
597    }
598
599    fn stream_pages<'a>(
600        &'a self,
601        context: &'a HashMap<String, Value>,
602        _batch_size: usize,
603    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
604        // `read_api` mode reads the table via the Storage Read API (Arrow →
605        // JSON on this row path); the `query` path below is skipped entirely.
606        #[cfg(feature = "arrow")]
607        if self.config.read_api {
608            return crate::storage_read::stream_pages_arrow(self);
609        }
610
611        let batch_size = self.config.batch_size;
612
613        Box::pin(async_stream::try_stream! {
614            let (query, bindings) = self.resolve_query(context);
615            let req = self.build_query_request(query, &bindings);
616
617            let initial = self
618                .client
619                .job()
620                .query(&self.config.project_id, req)
621                .await
622                .map_err(|e| FaucetError::Source(format!("BigQuery jobs.query failed: {e}")))?;
623
624            let mut fields = schema_fields(&initial);
625            let mut buffer: Vec<Value> = if batch_size == 0 {
626                Vec::with_capacity(1024)
627            } else {
628                Vec::with_capacity(batch_size)
629            };
630            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
631
632            for row in rows_from_response_owned(&initial, &fields) {
633                buffer.push(row);
634                if buffer.len() >= chunk {
635                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(chunk));
636                    yield StreamPage { records: page, bookmark: None };
637                }
638            }
639
640            let mut job_complete = initial.job_complete.unwrap_or(false);
641            let mut page_token = initial.page_token.clone();
642
643            // If the first response was incomplete, we have to know the job id
644            // to keep polling. If it was complete but had no further token,
645            // we're done after emitting the first batch.
646            let (job_id, job_location) = job_reference(&initial)?;
647            let poll_timeout = self.config.poll_timeout;
648            let poll_started = std::time::Instant::now();
649
650            while !job_complete || page_token.is_some() {
651                let params = GetQueryResultsParameters {
652                    page_token: page_token.clone(),
653                    max_results: Some(self.config.max_results_per_page),
654                    location: job_location.clone(),
655                    ..Default::default()
656                };
657
658                let resp = self
659                    .client
660                    .job()
661                    .get_query_results(&self.config.project_id, &job_id, params)
662                    .await
663                    .map_err(|e| {
664                        FaucetError::Source(format!("BigQuery jobs.getQueryResults failed: {e}"))
665                    })?;
666
667                job_complete = resp.job_complete.unwrap_or(false);
668                if !job_complete {
669                    // `poll_timeout == 0` disables the cap (poll forever).
670                    if !poll_timeout.is_zero() && poll_started.elapsed() >= poll_timeout {
671                        Err(FaucetError::Source(format!(
672                            "BigQuery job '{job_id}' did not complete within poll_timeout ({}s)",
673                            poll_timeout.as_secs()
674                        )))?;
675                    }
676                    tokio::time::sleep(Duration::from_millis(200)).await;
677                    continue;
678                }
679
680                if fields.is_empty()
681                    && let Some(s) = resp.schema.as_ref()
682                    && let Some(f) = s.fields.as_ref()
683                {
684                    fields = f.clone();
685                }
686
687                for row in resp.rows.unwrap_or_default() {
688                    buffer.push(row_to_json(&row, &fields));
689                    if buffer.len() >= chunk {
690                        let page = std::mem::replace(&mut buffer, Vec::with_capacity(chunk));
691                        yield StreamPage { records: page, bookmark: None };
692                    }
693                }
694                page_token = resp.page_token;
695                if page_token.is_none() {
696                    break;
697                }
698            }
699
700            if !buffer.is_empty() {
701                yield StreamPage { records: buffer, bookmark: None };
702            }
703
704            tracing::info!(
705                batch_size,
706                query = %self.config.query,
707                "BigQuery source stream complete",
708            );
709        })
710    }
711}
712
713/// Borrow-based row extraction (used by `fetch_with_context`, which collects
714/// into a `Vec` anyway).
715fn rows_from_response(resp: &QueryResponse, fields: &[TableFieldSchema]) -> Vec<Value> {
716    resp.rows
717        .as_ref()
718        .map(|rows| rows.iter().map(|r| row_to_json(r, fields)).collect())
719        .unwrap_or_default()
720}
721
722/// Owned-iteration variant — clones each row out of the response so the
723/// streaming loop above doesn't have to keep a borrow open across yields.
724fn rows_from_response_owned(resp: &QueryResponse, fields: &[TableFieldSchema]) -> Vec<Value> {
725    let rows: &Vec<TableRow> = match resp.rows.as_ref() {
726        Some(r) => r,
727        None => return Vec::new(),
728    };
729    rows.iter().map(|r| row_to_json(r, fields)).collect()
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use crate::config::BigQueryCredentials;
736    use serde_json::json;
737
738    #[test]
739    fn validate_read_api_rules() {
740        // read_api off → always ok.
741        let mut c =
742            BigQuerySourceConfig::new("p", BigQueryCredentials::ApplicationDefault, "SELECT 1");
743        assert!(BigQuerySource::validate_read_api(&c).is_ok());
744
745        c.read_api = true;
746        #[cfg(feature = "arrow")]
747        {
748            // read_api on but no table → error.
749            assert!(BigQuerySource::validate_read_api(&c).is_err());
750            c.read_table = Some("ds.events".into());
751            assert!(BigQuerySource::validate_read_api(&c).is_ok());
752        }
753        #[cfg(not(feature = "arrow"))]
754        {
755            // read_api on without the arrow feature → error.
756            assert!(BigQuerySource::validate_read_api(&c).is_err());
757        }
758    }
759
760    fn cfg() -> BigQuerySourceConfig {
761        BigQuerySourceConfig::new(
762            "my-project",
763            BigQueryCredentials::ApplicationDefault,
764            "SELECT id FROM events",
765        )
766    }
767
768    #[test]
769    fn dataset_uri_returns_project_and_query() {
770        // Inline logic test — BigQuerySource::new requires a live client, so
771        // we replicate the dataset_uri() computation directly from config fields.
772        let c = cfg();
773        let uri = format!("bigquery://{}?query={}", c.project_id, c.query);
774        assert_eq!(uri, "bigquery://my-project?query=SELECT id FROM events");
775    }
776
777    #[test]
778    fn stringify_param_passes_strings_unquoted() {
779        assert_eq!(stringify_param(&json!("us-east")), "us-east");
780        assert_eq!(stringify_param(&json!(42)), "42");
781        assert_eq!(stringify_param(&json!(true)), "true");
782    }
783
784    #[test]
785    fn clamp_timeout_ms_handles_overflow() {
786        assert_eq!(clamp_timeout_ms(Duration::from_secs(1)), 1000);
787        assert_eq!(clamp_timeout_ms(Duration::from_secs(u64::MAX)), i32::MAX);
788    }
789
790    #[test]
791    fn build_request_no_params_omits_query_parameters() {
792        let c = cfg();
793        let req = build_query_request(&c, "SELECT id".to_string(), &[]);
794        assert_eq!(req.query, "SELECT id");
795        assert!(req.query_parameters.is_none());
796        assert!(req.parameter_mode.is_none());
797        assert!(!req.use_legacy_sql);
798        assert_eq!(req.max_results, Some(1000));
799    }
800
801    #[test]
802    fn doctor_probe_request_is_dry_run() {
803        // The `faucet doctor` `check()` probe submits the configured query with
804        // dryRun=true so it validates auth/SQL/permissions without a billed
805        // execution — mirror that construction here to guard the field name.
806        let c = cfg();
807        let mut req = build_query_request(&c, "SELECT 1".to_string(), &[]);
808        req.dry_run = Some(true);
809        assert_eq!(
810            req.dry_run,
811            Some(true),
812            "doctor probe must dry-run (no billing)"
813        );
814    }
815
816    #[test]
817    fn build_request_with_params_uses_positional_string_binds() {
818        let c = cfg().with_params(vec![json!("us-east"), json!(42)]);
819        let req = build_query_request(&c, "SELECT * WHERE r = ? AND n > ?".to_string(), &c.params);
820        assert_eq!(req.parameter_mode.as_deref(), Some("POSITIONAL"));
821        let params = req.query_parameters.as_ref().unwrap();
822        assert_eq!(params.len(), 2);
823        assert_eq!(params[0].parameter_type.as_ref().unwrap().r#type, "STRING");
824        assert_eq!(
825            params[0].parameter_value.as_ref().unwrap().value.as_deref(),
826            Some("us-east")
827        );
828        assert_eq!(
829            params[1].parameter_value.as_ref().unwrap().value.as_deref(),
830            Some("42")
831        );
832    }
833
834    #[test]
835    fn build_request_propagates_location_and_legacy_flag() {
836        let c = cfg()
837            .with_location("EU")
838            .with_use_legacy_sql(true)
839            .with_max_results_per_page(250);
840        let req = build_query_request(&c, "SELECT 1".to_string(), &[]);
841        assert!(req.use_legacy_sql);
842        assert_eq!(req.location.as_deref(), Some("EU"));
843        assert_eq!(req.max_results, Some(250));
844    }
845
846    #[tokio::test]
847    async fn new_rejects_out_of_range_batch_size() {
848        let mut config = BigQuerySourceConfig::new(
849            "my-project",
850            BigQueryCredentials::ApplicationDefault,
851            "SELECT id FROM events",
852        );
853        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
854        match BigQuerySource::new(config).await {
855            Err(faucet_core::FaucetError::Config(m)) => {
856                assert!(m.contains("batch_size"), "got: {m}")
857            }
858            _ => panic!("expected a batch_size Config error"),
859        }
860    }
861
862    // ── discover: pure descriptor-building helpers ───────────────────────────
863
864    fn field(name: &str, ty: FieldType, mode: Option<&str>) -> TableFieldSchema {
865        let mut f = TableFieldSchema::new(name, ty);
866        f.mode = mode.map(str::to_owned);
867        f
868    }
869
870    #[test]
871    fn bq_field_types_map_to_json_types() {
872        for (ty, want) in [
873            (FieldType::Integer, "integer"),
874            (FieldType::Int64, "integer"),
875            (FieldType::Float, "number"),
876            (FieldType::Float64, "number"),
877            (FieldType::Numeric, "number"),
878            (FieldType::Bignumeric, "number"),
879            (FieldType::Boolean, "boolean"),
880            (FieldType::Bool, "boolean"),
881            (FieldType::Record, "object"),
882            (FieldType::Struct, "object"),
883            (FieldType::Json, "object"),
884            (FieldType::String, "string"),
885            (FieldType::Bytes, "string"),
886            (FieldType::Date, "string"),
887            (FieldType::Datetime, "string"),
888            (FieldType::Time, "string"),
889            (FieldType::Timestamp, "string"),
890            (FieldType::Geography, "string"),
891            (FieldType::Interval, "string"),
892        ] {
893            let f = field("c", ty.clone(), Some("REQUIRED"));
894            assert_eq!(
895                bq_field_to_json_schema(&f),
896                json!({ "type": want }),
897                "for BigQuery type {ty:?}"
898            );
899        }
900    }
901
902    #[test]
903    fn bq_field_mode_nullable_and_absent_wrap_as_nullable() {
904        // Explicit NULLABLE and an absent mode (BigQuery's default) both wrap.
905        for mode in [Some("NULLABLE"), None] {
906            let f = field("c", FieldType::Integer, mode);
907            assert_eq!(
908                bq_field_to_json_schema(&f),
909                json!({ "type": ["integer", "null"] }),
910                "for mode {mode:?}"
911            );
912        }
913    }
914
915    #[test]
916    fn bq_field_mode_repeated_maps_to_array() {
917        let f = field("tags", FieldType::String, Some("REPEATED"));
918        assert_eq!(bq_field_to_json_schema(&f), json!({ "type": "array" }));
919    }
920
921    #[test]
922    fn bq_quote_path_backtick_quotes_and_escapes() {
923        assert_eq!(
924            bq_quote_path("proj", "sales", "orders"),
925            "`proj.sales.orders`"
926        );
927        // A hostile identifier cannot break out of the quoted path.
928        assert_eq!(bq_quote_path("p", "d", r"we`ird\x"), r"`p.d.we\`ird\\x`");
929    }
930
931    #[test]
932    fn table_descriptor_carries_schema_estimate_and_patch() {
933        let fields = vec![
934            field("id", FieldType::Integer, Some("REQUIRED")),
935            field("note", FieldType::String, Some("NULLABLE")),
936        ];
937        let d = table_descriptor("proj", "sales", "orders", Some(&fields), Some("120"));
938        assert_eq!(d.name, "sales.orders");
939        assert_eq!(d.kind, "table");
940        assert_eq!(d.estimated_rows, Some(120));
941        assert_eq!(d.config_patch["query"], "SELECT * FROM `proj.sales.orders`");
942        let schema = d.schema.as_ref().unwrap();
943        assert_eq!(schema["type"], "object");
944        assert_eq!(schema["properties"]["id"]["type"], "integer");
945        assert_eq!(
946            schema["properties"]["note"]["type"],
947            json!(["string", "null"])
948        );
949    }
950
951    #[test]
952    fn table_descriptor_without_schema_fetch_is_name_only() {
953        // Past the schema-fetch cap: still a full config_patch, no schema/rows.
954        let d = table_descriptor("proj", "ops", "events", None, None);
955        assert_eq!(d.name, "ops.events");
956        assert!(d.schema.is_none());
957        assert_eq!(d.estimated_rows, None);
958        assert_eq!(d.config_patch["query"], "SELECT * FROM `proj.ops.events`");
959    }
960
961    #[test]
962    fn table_descriptor_unparseable_num_rows_means_no_estimate() {
963        let d = table_descriptor("p", "d", "t", Some(&[]), Some("not-a-number"));
964        assert_eq!(d.estimated_rows, None);
965        // An empty schema fetch still yields an (empty) object schema.
966        assert_eq!(d.schema.as_ref().unwrap()["type"], "object");
967    }
968
969    #[test]
970    fn resolve_query_substitutes_context_with_positional_markers() {
971        // Test resolve_query without needing a Client by mimicking its core.
972        let c = cfg();
973        let mut bindings = c.params.clone();
974        let mut ctx = HashMap::new();
975        ctx.insert("parent.id".to_string(), json!(7));
976        let (rewritten, extra) = substitute_context_bind_params(
977            "SELECT * FROM t WHERE id = {parent.id}",
978            &ctx,
979            bindings.len() + 1,
980            |_| "?".to_string(),
981        );
982        bindings.extend(extra);
983        assert_eq!(rewritten, "SELECT * FROM t WHERE id = ?");
984        assert_eq!(bindings, vec![json!(7)]);
985    }
986}