1use std::collections::HashMap;
5use std::hash::{Hash, Hasher};
6use std::pin::Pin;
7use std::sync::Mutex;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use faucet_common_spanner::decode::row_to_json;
12use faucet_common_spanner::quote_ident_spanner;
13use faucet_common_spanner::types::{parse_spanner_type, spanner_type_to_json_schema};
14use faucet_core::replication::{filter_incremental, max_replication_value, max_value};
15use faucet_core::shard::{
16 PkShardBounds, ShardSpec, parse_pk_shard, pk_bounds_query, pk_shards_from_bounds,
17};
18use faucet_core::{FaucetError, Source, StreamPage};
19use futures::Stream;
20use gcloud_spanner::client::Client;
21use gcloud_spanner::statement::Statement;
22use gcloud_spanner::transaction::QueryOptions;
23use gcloud_spanner::transaction_ro::ReadOnlyTransaction;
24use gcloud_spanner::value::TimestampBound;
25use serde_json::Value;
26
27use crate::config::{SpannerReplication, SpannerSourceConfig};
28
29pub struct SpannerSource {
31 config: SpannerSourceConfig,
32 client: Client,
33 start_bookmark: Mutex<Option<Value>>,
36 applied_shard: Mutex<Option<PkShardBounds>>,
39}
40
41impl SpannerSource {
42 pub async fn new(config: SpannerSourceConfig) -> Result<Self, FaucetError> {
44 config.validate()?;
45 let client = config.connection.connect().await?;
46 Ok(Self {
47 config,
48 client,
49 start_bookmark: Mutex::new(None),
50 applied_shard: Mutex::new(None),
51 })
52 }
53
54 fn current_start(&self) -> Option<Value> {
55 self.start_bookmark
56 .lock()
57 .expect("start_bookmark mutex poisoned")
58 .clone()
59 }
60
61 fn shard_wrap(&self, query: String) -> String {
65 match &*self.applied_shard.lock().expect("shard mutex poisoned") {
66 Some(bounds) => bounds.wrap(&query, quote_ident_spanner),
67 None => query,
68 }
69 }
70
71 async fn read_txn(&self) -> Result<ReadOnlyTransaction, FaucetError> {
74 let result = match self.config.exact_staleness_secs {
75 Some(secs) => {
76 self.client
77 .single_with_timestamp_bound(TimestampBound::exact_staleness(
78 Duration::from_secs(secs),
79 ))
80 .await
81 }
82 None => self.client.single().await,
83 };
84 result.map_err(|e| FaucetError::Source(format!("spanner: transaction begin failed: {e}")))
85 }
86}
87
88#[derive(Debug, Clone, PartialEq)]
90struct IncrementalCtx {
91 column: String,
92 start: Value,
93}
94
95fn build_query_and_params(
103 config: &SpannerSourceConfig,
104 context: &HashMap<String, Value>,
105 start_bookmark: Option<&Value>,
106) -> (String, Vec<(String, Value)>, Option<IncrementalCtx>) {
107 let mut params: Vec<(String, Value)> = config
108 .params
109 .iter()
110 .map(|(k, v)| (k.clone(), v.clone()))
111 .collect();
112 params.sort_by(|a, b| a.0.cmp(&b.0));
114
115 let query = if context.is_empty() {
116 config.query.clone()
117 } else {
118 let (q, ctx_values) =
119 faucet_core::util::substitute_context_bind_params(&config.query, context, 1, |i| {
120 format!("@_faucet_ctx_{i}")
121 });
122 for (i, v) in ctx_values.into_iter().enumerate() {
123 params.push((format!("_faucet_ctx_{}", i + 1), v));
124 }
125 q
126 };
127
128 let incremental = match &config.replication {
129 SpannerReplication::Full => None,
130 SpannerReplication::Incremental {
131 column,
132 initial_value,
133 } => {
134 let start = start_bookmark
135 .cloned()
136 .unwrap_or_else(|| initial_value.clone());
137 if query.contains("@bookmark") {
140 params.push(("bookmark".to_string(), start.clone()));
141 }
142 Some(IncrementalCtx {
143 column: column.clone(),
144 start,
145 })
146 }
147 };
148
149 (query, params, incremental)
150}
151
152fn bind_json_param(stmt: &mut Statement, name: &str, value: &Value) -> Result<(), FaucetError> {
158 match value {
159 Value::String(s) => stmt.add_param(name, s),
160 Value::Bool(b) => stmt.add_param(name, b),
161 Value::Number(n) => {
162 if let Some(i) = n.as_i64() {
163 stmt.add_param(name, &i);
164 } else if let Some(u) = n.as_u64() {
165 let i = i64::try_from(u).map_err(|_| {
166 FaucetError::Config(format!(
167 "spanner: parameter `{name}` ({u}) overflows INT64"
168 ))
169 })?;
170 stmt.add_param(name, &i);
171 } else {
172 stmt.add_param(name, &n.as_f64().unwrap_or(0.0));
174 }
175 }
176 other => {
177 return Err(FaucetError::Config(format!(
178 "spanner: parameter `{name}` must be a scalar, got {other}"
179 )));
180 }
181 }
182 Ok(())
183}
184
185fn build_statement(query: &str, params: &[(String, Value)]) -> Result<Statement, FaucetError> {
187 let mut stmt = Statement::new(query);
188 for (name, value) in params {
189 bind_json_param(&mut stmt, name, value)?;
190 }
191 Ok(stmt)
192}
193
194type CatalogRow = (String, String, String, bool);
197
198fn descriptors_from_catalog(rows: Vec<CatalogRow>) -> Vec<faucet_core::DatasetDescriptor> {
203 type PendingTable = (String, Vec<(String, Value)>);
205
206 let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
207 let mut current: Option<PendingTable> = None;
208
209 let flush = |cur: Option<PendingTable>, out: &mut Vec<faucet_core::DatasetDescriptor>| {
210 if let Some((table, cols)) = cur {
211 let query = format!("SELECT * FROM {}", quote_ident_spanner(&table));
212 out.push(
213 faucet_core::DatasetDescriptor::new(
214 table,
215 "table",
216 serde_json::json!({ "query": query }),
217 )
218 .with_schema(faucet_core::columns_to_schema(cols)),
219 );
220 }
221 };
222
223 for (table, column, spanner_type, is_nullable) in rows {
224 let same = current.as_ref().is_some_and(|(t, _)| *t == table);
225 if !same {
226 flush(current.take(), &mut out);
227 current = Some((table, Vec::new()));
228 }
229 let fragment = spanner_type_to_json_schema(&parse_spanner_type(&spanner_type), is_nullable);
230 if let Some((_, cols)) = current.as_mut() {
231 cols.push((column, fragment));
232 }
233 }
234 flush(current, &mut out);
235 out
236}
237
238fn default_state_key(config: &SpannerSourceConfig) -> String {
241 let mut hasher = std::collections::hash_map::DefaultHasher::new();
242 config.query.hash(&mut hasher);
243 let fingerprint = hasher.finish();
244 let path: String = format!(
247 "{}.{}.{}",
248 config.connection.project_id, config.connection.instance, config.connection.database
249 )
250 .chars()
251 .map(|c| {
252 if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
253 c
254 } else {
255 '_'
256 }
257 })
258 .collect();
259 format!("spanner:{path}:{fingerprint:016x}")
260}
261
262#[async_trait]
263impl Source for SpannerSource {
264 async fn fetch_with_context(
265 &self,
266 context: &HashMap<String, Value>,
267 ) -> Result<Vec<Value>, FaucetError> {
268 Ok(self.collect_all(context).await?.0)
269 }
270
271 async fn fetch_with_context_incremental(
272 &self,
273 context: &HashMap<String, Value>,
274 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
275 self.collect_all(context).await
276 }
277
278 fn stream_pages<'a>(
279 &'a self,
280 context: &'a HashMap<String, Value>,
281 _batch_size: usize,
282 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
283 let batch_size = self.config.batch_size;
284 let chunk = if batch_size == 0 {
285 usize::MAX
286 } else {
287 batch_size
288 };
289 let cap = if batch_size == 0 { 1024 } else { batch_size };
290 let start = self.current_start();
291 let (query, params, incr) = build_query_and_params(&self.config, context, start.as_ref());
292 let query = self.shard_wrap(query);
293
294 Box::pin(async_stream::try_stream! {
295 let stmt = build_statement(&query, ¶ms)?;
296 let mut tx = self.read_txn().await?;
297 let opts = QueryOptions {
302 enable_resume: false,
303 ..Default::default()
304 };
305 let mut iter = tx
306 .query_with_option(stmt, opts)
307 .await
308 .map_err(|e| FaucetError::Source(format!("spanner: query failed: {e}")))?;
309
310 let mut fields: Option<std::sync::Arc<Vec<_>>> = None;
311 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
312 let mut running_max: Option<Value> = None;
313 let mut total = 0usize;
314
315 while let Some(row) = iter
316 .next()
317 .await
318 .map_err(|e| FaucetError::Source(format!("spanner: row stream failed: {e}")))?
319 {
320 let fields = fields.get_or_insert_with(|| iter.columns_metadata().clone());
321 let record = row_to_json(&row, fields)
322 .map_err(|e| FaucetError::Source(format!("spanner: row decode failed: {e}")))?;
323 buffer.push(record);
324 if buffer.len() >= chunk {
325 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
326 let kept = apply_incremental(page, incr.as_ref(), &mut running_max);
327 total += kept.len();
328 if !kept.is_empty() {
329 yield StreamPage { records: kept, bookmark: None };
330 }
331 }
332 }
333
334 let kept = apply_incremental(buffer, incr.as_ref(), &mut running_max);
337 total += kept.len();
338 let bookmark = if incr.is_some() { running_max.clone() } else { None };
339 if !kept.is_empty() || bookmark.is_some() {
340 yield StreamPage { records: kept, bookmark };
341 }
342
343 tracing::info!(rows = total, query = %self.config.query, "spanner source stream complete");
344 })
345 }
346
347 fn config_schema(&self) -> Value {
348 serde_json::to_value(faucet_core::schema_for!(SpannerSourceConfig))
349 .expect("schema serialization")
350 }
351
352 fn connector_name(&self) -> &'static str {
353 "spanner"
354 }
355
356 fn dataset_uri(&self) -> String {
357 format!(
358 "spanner://{}/{}/{}?query={}",
359 self.config.connection.project_id,
360 self.config.connection.instance,
361 self.config.connection.database,
362 self.config.query
363 )
364 }
365
366 fn state_key(&self) -> Option<String> {
367 match &self.config.replication {
368 SpannerReplication::Full => None,
369 SpannerReplication::Incremental { .. } => Some(
370 self.config
371 .state_key
372 .clone()
373 .unwrap_or_else(|| default_state_key(&self.config)),
374 ),
375 }
376 }
377
378 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
379 *self
380 .start_bookmark
381 .lock()
382 .expect("start_bookmark mutex poisoned") = Some(bookmark);
383 Ok(())
384 }
385
386 fn supports_discover(&self) -> bool {
387 true
388 }
389
390 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
397 const CATALOG_SQL: &str = "SELECT c.TABLE_NAME, c.COLUMN_NAME, c.SPANNER_TYPE, \
398 c.IS_NULLABLE \
399 FROM INFORMATION_SCHEMA.COLUMNS AS c \
400 JOIN INFORMATION_SCHEMA.TABLES AS t \
401 ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME \
402 WHERE t.TABLE_TYPE = 'BASE TABLE' AND t.TABLE_SCHEMA = '' \
403 ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION";
404
405 let map_err =
406 |e: String| FaucetError::Source(format!("spanner: catalog discovery failed: {e}"));
407
408 let mut tx = self.read_txn().await?;
409 let mut iter = tx
410 .query(Statement::new(CATALOG_SQL))
411 .await
412 .map_err(|e| map_err(e.to_string()))?;
413
414 let mut catalog: Vec<CatalogRow> = Vec::new();
415 while let Some(row) = iter.next().await.map_err(|e| map_err(e.to_string()))? {
416 let table: String = row
417 .column_by_name("TABLE_NAME")
418 .map_err(|e| map_err(e.to_string()))?;
419 let column: String = row
420 .column_by_name("COLUMN_NAME")
421 .map_err(|e| map_err(e.to_string()))?;
422 let spanner_type: String = row
423 .column_by_name("SPANNER_TYPE")
424 .map_err(|e| map_err(e.to_string()))?;
425 let nullable: String = row
427 .column_by_name("IS_NULLABLE")
428 .unwrap_or_else(|_| "YES".to_string());
429 catalog.push((
430 table,
431 column,
432 spanner_type,
433 nullable.eq_ignore_ascii_case("YES"),
434 ));
435 }
436
437 Ok(descriptors_from_catalog(catalog))
438 }
439
440 fn is_shardable(&self) -> bool {
442 self.config.shard.is_some()
443 }
444
445 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
455 let Some(shard_cfg) = &self.config.shard else {
456 return Ok(vec![ShardSpec::whole()]);
457 };
458
459 let start = self.current_start();
460 let (inner, params, _incr) =
461 build_query_and_params(&self.config, &HashMap::new(), start.as_ref());
462 let key = quote_ident_spanner(&shard_cfg.key);
463 let bounds_sql = pk_bounds_query(&inner, &key, "INT64");
464
465 let map_err = |e: String| {
466 FaucetError::Source(format!(
467 "spanner: failed to compute shard bounds for key {:?} \
468 (it must be an INT64-typed column present in the query's output): {e}",
469 shard_cfg.key
470 ))
471 };
472
473 let stmt = build_statement(&bounds_sql, ¶ms)?;
474 let mut tx = self.read_txn().await?;
475 let mut iter = tx.query(stmt).await.map_err(|e| map_err(e.to_string()))?;
476 let row = iter.next().await.map_err(|e| map_err(e.to_string()))?;
477 let Some(row) = row else {
478 return Ok(vec![ShardSpec::whole()]);
479 };
480 let lo: Option<i64> = row
481 .column_by_name("lo")
482 .map_err(|e| map_err(e.to_string()))?;
483 let hi: Option<i64> = row
484 .column_by_name("hi")
485 .map_err(|e| map_err(e.to_string()))?;
486 Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
487 }
488
489 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
492 let bounds = parse_pk_shard(shard, "spanner")?;
493 *self.applied_shard.lock().expect("shard mutex poisoned") = bounds;
494 Ok(())
495 }
496}
497
498impl SpannerSource {
499 async fn collect_all(
502 &self,
503 context: &HashMap<String, Value>,
504 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
505 use futures::TryStreamExt;
506 let mut records: Vec<Value> = Vec::new();
507 let mut bookmark: Option<Value> = None;
508 let mut stream = self.stream_pages(context, self.config.batch_size);
509 while let Some(page) = stream.try_next().await? {
510 records.extend(page.records);
511 if page.bookmark.is_some() {
512 bookmark = page.bookmark;
513 }
514 }
515 Ok((records, bookmark))
516 }
517}
518
519fn apply_incremental(
522 page: Vec<Value>,
523 incr: Option<&IncrementalCtx>,
524 running_max: &mut Option<Value>,
525) -> Vec<Value> {
526 match incr {
527 None => page,
528 Some(ctx) => {
529 let kept = filter_incremental(page, &ctx.column, &ctx.start);
530 if let Some(m) = max_replication_value(&kept, &ctx.column) {
531 let m = m.clone();
532 *running_max = Some(match running_max.take() {
533 Some(prev) => max_value(prev, m),
534 None => m,
535 });
536 }
537 kept
538 }
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use faucet_core::shard::plan_pk_shards;
546 use serde_json::json;
547
548 fn base() -> SpannerSourceConfig {
549 SpannerSourceConfig::new("proj", "inst", "db", "SELECT * FROM t")
550 }
551
552 #[test]
553 fn build_full_returns_query_and_sorted_params() {
554 let mut cfg = base();
555 cfg.params.insert("zeta".into(), json!(1));
556 cfg.params.insert("alpha".into(), json!("x"));
557 let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
558 assert_eq!(q, "SELECT * FROM t");
559 assert_eq!(
560 p,
561 vec![
562 ("alpha".to_string(), json!("x")),
563 ("zeta".to_string(), json!(1))
564 ],
565 "params bind in deterministic (sorted) order"
566 );
567 assert!(incr.is_none());
568 }
569
570 #[test]
571 fn build_incremental_binds_bookmark_param() {
572 let mut cfg = base();
573 cfg.query = "SELECT * FROM t WHERE updated_at > @bookmark".into();
574 cfg.replication = SpannerReplication::Incremental {
575 column: "updated_at".into(),
576 initial_value: json!("1970-01-01"),
577 };
578 let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
579 assert_eq!(q, "SELECT * FROM t WHERE updated_at > @bookmark");
580 assert_eq!(p, vec![("bookmark".to_string(), json!("1970-01-01"))]);
581 assert_eq!(
582 incr,
583 Some(IncrementalCtx {
584 column: "updated_at".into(),
585 start: json!("1970-01-01")
586 })
587 );
588 }
589
590 #[test]
591 fn build_incremental_uses_stored_bookmark_over_initial() {
592 let mut cfg = base();
593 cfg.query = "SELECT * FROM t WHERE c > @bookmark".into();
594 cfg.replication = SpannerReplication::Incremental {
595 column: "c".into(),
596 initial_value: json!(0),
597 };
598 let stored = json!(500);
599 let (_q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), Some(&stored));
600 assert_eq!(p, vec![("bookmark".to_string(), json!(500))]);
601 assert_eq!(incr.unwrap().start, json!(500));
602 }
603
604 #[test]
605 fn build_incremental_without_token_still_returns_filter_ctx() {
606 let mut cfg = base();
607 cfg.replication = SpannerReplication::Incremental {
608 column: "c".into(),
609 initial_value: json!(0),
610 };
611 let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
612 assert_eq!(q, "SELECT * FROM t");
613 assert!(p.is_empty());
614 assert!(incr.is_some(), "client-side filter must still run");
615 }
616
617 #[test]
618 fn build_context_binds_generated_named_params() {
619 let mut cfg = base();
620 cfg.query = "SELECT * FROM t WHERE tenant = {parent.id}".into();
621 let mut ctx = HashMap::new();
622 ctx.insert("parent.id".to_string(), json!(7));
623 let (q, p, _incr) = build_query_and_params(&cfg, &ctx, None);
624 assert_eq!(q, "SELECT * FROM t WHERE tenant = @_faucet_ctx_1");
625 assert_eq!(p, vec![("_faucet_ctx_1".to_string(), json!(7))]);
626 }
627
628 #[test]
629 fn build_statement_binds_scalars_and_rejects_containers() {
630 let stmt = build_statement(
631 "SELECT 1",
632 &[
633 ("s".to_string(), json!("x")),
634 ("i".to_string(), json!(7)),
635 ("f".to_string(), json!(1.5)),
636 ("b".to_string(), json!(true)),
637 ],
638 );
639 assert!(stmt.is_ok());
640
641 let err = build_statement("SELECT 1", &[("o".to_string(), json!({"a": 1}))]);
642 assert!(matches!(err, Err(FaucetError::Config(_))));
643 let err = build_statement("SELECT 1", &[("n".to_string(), Value::Null)]);
644 assert!(matches!(err, Err(FaucetError::Config(_))));
645 let err = build_statement("SELECT 1", &[("u".to_string(), json!(u64::MAX))]);
646 assert!(matches!(err, Err(FaucetError::Config(_))));
647 }
648
649 #[test]
650 fn apply_incremental_filters_and_tracks_max() {
651 let ctx = IncrementalCtx {
652 column: "c".into(),
653 start: json!(10),
654 };
655 let mut running = None;
656 let page = vec![json!({"c": 5}), json!({"c": 15}), json!({"c": 20})];
657 let kept = apply_incremental(page, Some(&ctx), &mut running);
658 assert_eq!(kept.len(), 2);
659 assert_eq!(running, Some(json!(20)));
660 }
661
662 #[test]
663 fn apply_incremental_full_passes_through() {
664 let mut running = None;
665 let page = vec![json!({"c": 1}), json!({"c": 2})];
666 let kept = apply_incremental(page, None, &mut running);
667 assert_eq!(kept.len(), 2);
668 assert_eq!(running, None);
669 }
670
671 #[test]
672 fn default_state_key_is_stable_and_valid() {
673 let cfg = base();
674 let k1 = default_state_key(&cfg);
675 let k2 = default_state_key(&cfg);
676 assert_eq!(k1, k2);
677 assert!(k1.starts_with("spanner:proj.inst.db:"));
678 faucet_core::state::validate_state_key(&k1).expect("derived key must be valid");
679 }
680
681 #[test]
682 fn dataset_uri_shape() {
683 let cfg = base();
684 let uri = format!(
685 "spanner://{}/{}/{}?query={}",
686 cfg.connection.project_id, cfg.connection.instance, cfg.connection.database, cfg.query
687 );
688 assert_eq!(uri, "spanner://proj/inst/db?query=SELECT * FROM t");
689 }
690
691 #[test]
694 fn shard_wrap_uses_backtick_quoting() {
695 let spec = faucet_core::ShardSpec::new(
696 "1",
697 json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
698 );
699 let bounds = PkShardBounds::from_spec(&spec).unwrap();
700 let sql = bounds.wrap("SELECT * FROM t", quote_ident_spanner);
701 assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"), "{sql}");
702 assert!(sql.contains("`id` >= 100"), "backtick-quoted key: {sql}");
703 assert!(sql.contains("`id` < 200"), "half-open upper bound: {sql}");
704 }
705
706 #[test]
707 fn last_shard_wrap_covers_null_keys() {
708 let shards = plan_pk_shards("id", 0, 99, 3);
709 let last = PkShardBounds::from_spec(shards.last().unwrap()).unwrap();
710 let sql = last.wrap("SELECT * FROM t", quote_ident_spanner);
711 assert!(
712 sql.contains("`id` IS NULL"),
713 "last shard must match NULL keys: {sql}"
714 );
715 }
716
717 #[test]
718 fn shard_wrap_preserves_named_bind_params() {
719 let mut cfg = base();
722 cfg.query = "SELECT * FROM t WHERE c > @bookmark".into();
723 cfg.replication = SpannerReplication::Incremental {
724 column: "c".into(),
725 initial_value: json!(0),
726 };
727 let (q, p, _incr) = build_query_and_params(&cfg, &HashMap::new(), None);
728 let spec = faucet_core::ShardSpec::new(
729 "0",
730 json!({"key": "id", "lo": 0, "hi": 10, "lo_unbounded": false, "hi_unbounded": false}),
731 );
732 let wrapped = PkShardBounds::from_spec(&spec)
733 .unwrap()
734 .wrap(&q, quote_ident_spanner);
735 assert!(
736 wrapped.contains("@bookmark"),
737 "bind param survives: {wrapped}"
738 );
739 assert_eq!(p.len(), 1, "one bound value for the bookmark");
740 }
741
742 #[test]
743 fn bounds_query_uses_int64_cast() {
744 let sql = pk_bounds_query("SELECT * FROM t", "`id`", "INT64");
745 assert!(sql.contains("CAST(MIN(`id`) AS INT64) AS lo"), "{sql}");
746 assert!(
747 sql.contains("FROM (SELECT * FROM t) AS _faucet_bounds"),
748 "{sql}"
749 );
750 }
751
752 fn cat_row(t: &str, c: &str, ty: &str, nullable: bool) -> CatalogRow {
755 (t.into(), c.into(), ty.into(), nullable)
756 }
757
758 #[test]
759 fn descriptors_group_catalog_rows_per_table() {
760 let rows = vec![
761 cat_row("orders", "id", "INT64", false),
762 cat_row("orders", "note", "STRING(MAX)", true),
763 cat_row("users", "score", "FLOAT64", false),
764 ];
765 let ds = descriptors_from_catalog(rows);
766 assert_eq!(ds.len(), 2);
767
768 assert_eq!(ds[0].name, "orders");
769 assert_eq!(ds[0].kind, "table");
770 assert_eq!(ds[0].estimated_rows, None, "spanner has no cheap estimate");
771 assert_eq!(ds[0].config_patch["query"], "SELECT * FROM `orders`");
772 let schema = ds[0].schema.as_ref().unwrap();
773 assert_eq!(schema["type"], "object");
774 assert_eq!(schema["properties"]["id"]["type"], "integer");
775 assert_eq!(
776 schema["properties"]["note"]["type"],
777 json!(["string", "null"]),
778 "nullable column"
779 );
780
781 assert_eq!(ds[1].name, "users");
782 assert_eq!(
783 ds[1].schema.as_ref().unwrap()["properties"]["score"]["type"],
784 "number"
785 );
786 }
787
788 #[test]
789 fn descriptors_quote_hostile_identifiers() {
790 let rows = vec![cat_row("we`ird", "id", "INT64", false)];
791 let ds = descriptors_from_catalog(rows);
792 let q = ds[0].config_patch["query"].as_str().unwrap();
793 assert_eq!(
794 q, "SELECT * FROM `we``ird`",
795 "interior backtick must be doubled"
796 );
797 }
798
799 #[test]
800 fn descriptors_empty_catalog_is_empty() {
801 assert!(descriptors_from_catalog(Vec::new()).is_empty());
802 }
803}