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 let client = build_client(&config.auth).await?;
61 Ok(Self { config, client })
62 }
63
64 #[doc(hidden)]
72 pub fn from_parts(config: BigQuerySourceConfig, client: Client) -> Self {
73 Self { config, client }
74 }
75
76 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 #[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 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 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 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 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
215fn 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",
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 _ => faucet_core::nullable_type(json!({ "type": base })),
238 }
239}
240
241fn 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
249fn 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 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
280fn 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 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
327fn 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 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 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 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 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 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 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 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 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
653fn 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
662fn 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 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 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 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 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 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 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 assert_eq!(d.schema.as_ref().unwrap()["type"], "object");
885 }
886
887 #[test]
888 fn resolve_query_substitutes_context_with_positional_markers() {
889 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}