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