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