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::{column_is_numeric, 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 first_page = fields.is_none();
321 let fields = fields.get_or_insert_with(|| iter.columns_metadata().clone());
322 if first_page
327 && let Some(ctx) = incr.as_ref()
328 && column_is_numeric(fields, &ctx.column)
329 {
330 Err(FaucetError::Config(format!(
331 "spanner: incremental cursor column `{}` is NUMERIC, which decodes to a \
332 string and orders lexicographically (\"9\" > \"10\"), producing an \
333 incorrect bookmark that skips or re-reads rows. Use an INT64 or \
334 TIMESTAMP/DATE cursor column instead.",
335 ctx.column
336 )))?;
337 }
338 let record = row_to_json(&row, fields)
339 .map_err(|e| FaucetError::Source(format!("spanner: row decode failed: {e}")))?;
340 buffer.push(record);
341 if buffer.len() >= chunk {
342 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
343 let kept = apply_incremental(page, incr.as_ref(), &mut running_max);
344 total += kept.len();
345 if !kept.is_empty() {
346 yield StreamPage { records: kept, bookmark: None };
347 }
348 }
349 }
350
351 let kept = apply_incremental(buffer, incr.as_ref(), &mut running_max);
354 total += kept.len();
355 let bookmark = if incr.is_some() { running_max.clone() } else { None };
356 if !kept.is_empty() || bookmark.is_some() {
357 yield StreamPage { records: kept, bookmark };
358 }
359
360 tracing::info!(rows = total, query = %self.config.query, "spanner source stream complete");
361 })
362 }
363
364 fn config_schema(&self) -> Value {
365 serde_json::to_value(faucet_core::schema_for!(SpannerSourceConfig))
366 .expect("schema serialization")
367 }
368
369 fn connector_name(&self) -> &'static str {
370 "spanner"
371 }
372
373 fn dataset_uri(&self) -> String {
374 format!(
375 "spanner://{}/{}/{}?query={}",
376 self.config.connection.project_id,
377 self.config.connection.instance,
378 self.config.connection.database,
379 self.config.query
380 )
381 }
382
383 fn state_key(&self) -> Option<String> {
384 match &self.config.replication {
385 SpannerReplication::Full => None,
386 SpannerReplication::Incremental { .. } => Some(
387 self.config
388 .state_key
389 .clone()
390 .unwrap_or_else(|| default_state_key(&self.config)),
391 ),
392 }
393 }
394
395 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
396 *self
397 .start_bookmark
398 .lock()
399 .expect("start_bookmark mutex poisoned") = Some(bookmark);
400 Ok(())
401 }
402
403 fn supports_discover(&self) -> bool {
404 true
405 }
406
407 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
414 const CATALOG_SQL: &str = "SELECT c.TABLE_NAME, c.COLUMN_NAME, c.SPANNER_TYPE, \
415 c.IS_NULLABLE \
416 FROM INFORMATION_SCHEMA.COLUMNS AS c \
417 JOIN INFORMATION_SCHEMA.TABLES AS t \
418 ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME \
419 WHERE t.TABLE_TYPE = 'BASE TABLE' AND t.TABLE_SCHEMA = '' \
420 ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION";
421
422 let map_err =
423 |e: String| FaucetError::Source(format!("spanner: catalog discovery failed: {e}"));
424
425 let mut tx = self.read_txn().await?;
426 let mut iter = tx
427 .query(Statement::new(CATALOG_SQL))
428 .await
429 .map_err(|e| map_err(e.to_string()))?;
430
431 let mut catalog: Vec<CatalogRow> = Vec::new();
432 while let Some(row) = iter.next().await.map_err(|e| map_err(e.to_string()))? {
433 let table: String = row
434 .column_by_name("TABLE_NAME")
435 .map_err(|e| map_err(e.to_string()))?;
436 let column: String = row
437 .column_by_name("COLUMN_NAME")
438 .map_err(|e| map_err(e.to_string()))?;
439 let spanner_type: String = row
440 .column_by_name("SPANNER_TYPE")
441 .map_err(|e| map_err(e.to_string()))?;
442 let nullable: String = row
444 .column_by_name("IS_NULLABLE")
445 .unwrap_or_else(|_| "YES".to_string());
446 catalog.push((
447 table,
448 column,
449 spanner_type,
450 nullable.eq_ignore_ascii_case("YES"),
451 ));
452 }
453
454 Ok(descriptors_from_catalog(catalog))
455 }
456
457 fn is_shardable(&self) -> bool {
459 self.config.shard.is_some()
460 }
461
462 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
472 let Some(shard_cfg) = &self.config.shard else {
473 return Ok(vec![ShardSpec::whole()]);
474 };
475
476 let start = self.current_start();
477 let (inner, params, _incr) =
478 build_query_and_params(&self.config, &HashMap::new(), start.as_ref());
479 let key = quote_ident_spanner(&shard_cfg.key);
480 let bounds_sql = pk_bounds_query(&inner, &key, "INT64");
481
482 let map_err = |e: String| {
483 FaucetError::Source(format!(
484 "spanner: failed to compute shard bounds for key {:?} \
485 (it must be an INT64-typed column present in the query's output): {e}",
486 shard_cfg.key
487 ))
488 };
489
490 let stmt = build_statement(&bounds_sql, ¶ms)?;
491 let mut tx = self.read_txn().await?;
492 let mut iter = tx.query(stmt).await.map_err(|e| map_err(e.to_string()))?;
493 let row = iter.next().await.map_err(|e| map_err(e.to_string()))?;
494 let Some(row) = row else {
495 return Ok(vec![ShardSpec::whole()]);
496 };
497 let lo: Option<i64> = row
498 .column_by_name("lo")
499 .map_err(|e| map_err(e.to_string()))?;
500 let hi: Option<i64> = row
501 .column_by_name("hi")
502 .map_err(|e| map_err(e.to_string()))?;
503 Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
504 }
505
506 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
509 let bounds = parse_pk_shard(shard, "spanner")?;
510 *self.applied_shard.lock().expect("shard mutex poisoned") = bounds;
511 Ok(())
512 }
513}
514
515impl SpannerSource {
516 async fn collect_all(
519 &self,
520 context: &HashMap<String, Value>,
521 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
522 use futures::TryStreamExt;
523 let mut records: Vec<Value> = Vec::new();
524 let mut bookmark: Option<Value> = None;
525 let mut stream = self.stream_pages(context, self.config.batch_size);
526 while let Some(page) = stream.try_next().await? {
527 records.extend(page.records);
528 if page.bookmark.is_some() {
529 bookmark = page.bookmark;
530 }
531 }
532 Ok((records, bookmark))
533 }
534}
535
536fn apply_incremental(
539 page: Vec<Value>,
540 incr: Option<&IncrementalCtx>,
541 running_max: &mut Option<Value>,
542) -> Vec<Value> {
543 match incr {
544 None => page,
545 Some(ctx) => {
546 let kept = filter_incremental(page, &ctx.column, &ctx.start);
547 if let Some(m) = max_replication_value(&kept, &ctx.column) {
548 let m = m.clone();
549 *running_max = Some(match running_max.take() {
550 Some(prev) => max_value(prev, m),
551 None => m,
552 });
553 }
554 kept
555 }
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use faucet_core::shard::plan_pk_shards;
563 use serde_json::json;
564
565 fn base() -> SpannerSourceConfig {
566 SpannerSourceConfig::new("proj", "inst", "db", "SELECT * FROM t")
567 }
568
569 #[test]
570 fn build_full_returns_query_and_sorted_params() {
571 let mut cfg = base();
572 cfg.params.insert("zeta".into(), json!(1));
573 cfg.params.insert("alpha".into(), json!("x"));
574 let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
575 assert_eq!(q, "SELECT * FROM t");
576 assert_eq!(
577 p,
578 vec![
579 ("alpha".to_string(), json!("x")),
580 ("zeta".to_string(), json!(1))
581 ],
582 "params bind in deterministic (sorted) order"
583 );
584 assert!(incr.is_none());
585 }
586
587 #[test]
588 fn build_incremental_binds_bookmark_param() {
589 let mut cfg = base();
590 cfg.query = "SELECT * FROM t WHERE updated_at > @bookmark".into();
591 cfg.replication = SpannerReplication::Incremental {
592 column: "updated_at".into(),
593 initial_value: json!("1970-01-01"),
594 };
595 let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
596 assert_eq!(q, "SELECT * FROM t WHERE updated_at > @bookmark");
597 assert_eq!(p, vec![("bookmark".to_string(), json!("1970-01-01"))]);
598 assert_eq!(
599 incr,
600 Some(IncrementalCtx {
601 column: "updated_at".into(),
602 start: json!("1970-01-01")
603 })
604 );
605 }
606
607 #[test]
608 fn build_incremental_uses_stored_bookmark_over_initial() {
609 let mut cfg = base();
610 cfg.query = "SELECT * FROM t WHERE c > @bookmark".into();
611 cfg.replication = SpannerReplication::Incremental {
612 column: "c".into(),
613 initial_value: json!(0),
614 };
615 let stored = json!(500);
616 let (_q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), Some(&stored));
617 assert_eq!(p, vec![("bookmark".to_string(), json!(500))]);
618 assert_eq!(incr.unwrap().start, json!(500));
619 }
620
621 #[test]
622 fn build_incremental_without_token_still_returns_filter_ctx() {
623 let mut cfg = base();
624 cfg.replication = SpannerReplication::Incremental {
625 column: "c".into(),
626 initial_value: json!(0),
627 };
628 let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
629 assert_eq!(q, "SELECT * FROM t");
630 assert!(p.is_empty());
631 assert!(incr.is_some(), "client-side filter must still run");
632 }
633
634 #[test]
635 fn build_context_binds_generated_named_params() {
636 let mut cfg = base();
637 cfg.query = "SELECT * FROM t WHERE tenant = {parent.id}".into();
638 let mut ctx = HashMap::new();
639 ctx.insert("parent.id".to_string(), json!(7));
640 let (q, p, _incr) = build_query_and_params(&cfg, &ctx, None);
641 assert_eq!(q, "SELECT * FROM t WHERE tenant = @_faucet_ctx_1");
642 assert_eq!(p, vec![("_faucet_ctx_1".to_string(), json!(7))]);
643 }
644
645 #[test]
646 fn build_statement_binds_scalars_and_rejects_containers() {
647 let stmt = build_statement(
648 "SELECT 1",
649 &[
650 ("s".to_string(), json!("x")),
651 ("i".to_string(), json!(7)),
652 ("f".to_string(), json!(1.5)),
653 ("b".to_string(), json!(true)),
654 ],
655 );
656 assert!(stmt.is_ok());
657
658 let err = build_statement("SELECT 1", &[("o".to_string(), json!({"a": 1}))]);
659 assert!(matches!(err, Err(FaucetError::Config(_))));
660 let err = build_statement("SELECT 1", &[("n".to_string(), Value::Null)]);
661 assert!(matches!(err, Err(FaucetError::Config(_))));
662 let err = build_statement("SELECT 1", &[("u".to_string(), json!(u64::MAX))]);
663 assert!(matches!(err, Err(FaucetError::Config(_))));
664 }
665
666 #[test]
667 fn apply_incremental_filters_and_tracks_max() {
668 let ctx = IncrementalCtx {
669 column: "c".into(),
670 start: json!(10),
671 };
672 let mut running = None;
673 let page = vec![json!({"c": 5}), json!({"c": 15}), json!({"c": 20})];
674 let kept = apply_incremental(page, Some(&ctx), &mut running);
675 assert_eq!(kept.len(), 2);
676 assert_eq!(running, Some(json!(20)));
677 }
678
679 #[test]
680 fn apply_incremental_full_passes_through() {
681 let mut running = None;
682 let page = vec![json!({"c": 1}), json!({"c": 2})];
683 let kept = apply_incremental(page, None, &mut running);
684 assert_eq!(kept.len(), 2);
685 assert_eq!(running, None);
686 }
687
688 #[test]
689 fn default_state_key_is_stable_and_valid() {
690 let cfg = base();
691 let k1 = default_state_key(&cfg);
692 let k2 = default_state_key(&cfg);
693 assert_eq!(k1, k2);
694 assert!(k1.starts_with("spanner:proj.inst.db:"));
695 faucet_core::state::validate_state_key(&k1).expect("derived key must be valid");
696 }
697
698 #[test]
699 fn dataset_uri_shape() {
700 let cfg = base();
701 let uri = format!(
702 "spanner://{}/{}/{}?query={}",
703 cfg.connection.project_id, cfg.connection.instance, cfg.connection.database, cfg.query
704 );
705 assert_eq!(uri, "spanner://proj/inst/db?query=SELECT * FROM t");
706 }
707
708 #[test]
711 fn shard_wrap_uses_backtick_quoting() {
712 let spec = faucet_core::ShardSpec::new(
713 "1",
714 json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
715 );
716 let bounds = PkShardBounds::from_spec(&spec).unwrap();
717 let sql = bounds.wrap("SELECT * FROM t", quote_ident_spanner);
718 assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"), "{sql}");
719 assert!(sql.contains("`id` >= 100"), "backtick-quoted key: {sql}");
720 assert!(sql.contains("`id` < 200"), "half-open upper bound: {sql}");
721 }
722
723 #[test]
724 fn last_shard_wrap_covers_null_keys() {
725 let shards = plan_pk_shards("id", 0, 99, 3);
726 let last = PkShardBounds::from_spec(shards.last().unwrap()).unwrap();
727 let sql = last.wrap("SELECT * FROM t", quote_ident_spanner);
728 assert!(
729 sql.contains("`id` IS NULL"),
730 "last shard must match NULL keys: {sql}"
731 );
732 }
733
734 #[test]
735 fn shard_wrap_preserves_named_bind_params() {
736 let mut cfg = base();
739 cfg.query = "SELECT * FROM t WHERE c > @bookmark".into();
740 cfg.replication = SpannerReplication::Incremental {
741 column: "c".into(),
742 initial_value: json!(0),
743 };
744 let (q, p, _incr) = build_query_and_params(&cfg, &HashMap::new(), None);
745 let spec = faucet_core::ShardSpec::new(
746 "0",
747 json!({"key": "id", "lo": 0, "hi": 10, "lo_unbounded": false, "hi_unbounded": false}),
748 );
749 let wrapped = PkShardBounds::from_spec(&spec)
750 .unwrap()
751 .wrap(&q, quote_ident_spanner);
752 assert!(
753 wrapped.contains("@bookmark"),
754 "bind param survives: {wrapped}"
755 );
756 assert_eq!(p.len(), 1, "one bound value for the bookmark");
757 }
758
759 #[test]
760 fn bounds_query_uses_int64_cast() {
761 let sql = pk_bounds_query("SELECT * FROM t", "`id`", "INT64");
762 assert!(sql.contains("CAST(MIN(`id`) AS INT64) AS lo"), "{sql}");
763 assert!(
764 sql.contains("FROM (SELECT * FROM t) AS _faucet_bounds"),
765 "{sql}"
766 );
767 }
768
769 fn cat_row(t: &str, c: &str, ty: &str, nullable: bool) -> CatalogRow {
772 (t.into(), c.into(), ty.into(), nullable)
773 }
774
775 #[test]
776 fn descriptors_group_catalog_rows_per_table() {
777 let rows = vec![
778 cat_row("orders", "id", "INT64", false),
779 cat_row("orders", "note", "STRING(MAX)", true),
780 cat_row("users", "score", "FLOAT64", false),
781 ];
782 let ds = descriptors_from_catalog(rows);
783 assert_eq!(ds.len(), 2);
784
785 assert_eq!(ds[0].name, "orders");
786 assert_eq!(ds[0].kind, "table");
787 assert_eq!(ds[0].estimated_rows, None, "spanner has no cheap estimate");
788 assert_eq!(ds[0].config_patch["query"], "SELECT * FROM `orders`");
789 let schema = ds[0].schema.as_ref().unwrap();
790 assert_eq!(schema["type"], "object");
791 assert_eq!(schema["properties"]["id"]["type"], "integer");
792 assert_eq!(
793 schema["properties"]["note"]["type"],
794 json!(["string", "null"]),
795 "nullable column"
796 );
797
798 assert_eq!(ds[1].name, "users");
799 assert_eq!(
800 ds[1].schema.as_ref().unwrap()["properties"]["score"]["type"],
801 "number"
802 );
803 }
804
805 #[test]
806 fn descriptors_quote_hostile_identifiers() {
807 let rows = vec![cat_row("we`ird", "id", "INT64", false)];
808 let ds = descriptors_from_catalog(rows);
809 let q = ds[0].config_patch["query"].as_str().unwrap();
810 assert_eq!(
811 q, "SELECT * FROM `we``ird`",
812 "interior backtick must be doubled"
813 );
814 }
815
816 #[test]
817 fn descriptors_empty_catalog_is_empty() {
818 assert!(descriptors_from_catalog(Vec::new()).is_empty());
819 }
820}