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