1use crate::config::BigQuerySourceConfig;
12use crate::convert::row_to_json;
13use async_trait::async_trait;
14use faucet_common_bigquery::build_client;
15use faucet_core::util::substitute_context_bind_params;
16use faucet_core::{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
34const MAX_DISCOVER_TABLES: usize = 500;
39
40const MAX_DISCOVER_SCHEMA_FETCHES: usize = 100;
45
46pub struct BigQuerySource {
48 config: BigQuerySourceConfig,
49 client: Client,
50}
51
52impl BigQuerySource {
53 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 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 #[cfg(feature = "arrow")]
95 pub(crate) fn config(&self) -> &BigQuerySourceConfig {
96 &self.config
97 }
98
99 #[doc(hidden)]
107 pub fn from_parts(config: BigQuerySourceConfig, client: Client) -> Self {
108 Self { config, client }
109 }
110
111 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 #[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 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 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 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 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
250fn 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",
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 _ => faucet_core::nullable_type(json!({ "type": base })),
273 }
274}
275
276fn 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
284fn 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 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
315fn 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 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
362fn 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 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 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 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 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 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 #[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 #[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 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 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
713fn 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
722fn 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 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 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 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 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 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 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 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 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 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 assert_eq!(d.schema.as_ref().unwrap()["type"], "object");
967 }
968
969 #[test]
970 fn resolve_query_substitutes_context_with_positional_markers() {
971 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}