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::cursor::{CursorBookmark, CursorTracker};
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_googleapis::spanner::v1::TypeCode;
20use gcloud_googleapis::spanner::v1::struct_type::Field;
21use gcloud_spanner::client::Client;
22use gcloud_spanner::statement::Statement;
23use gcloud_spanner::transaction::QueryOptions;
24use gcloud_spanner::transaction_ro::ReadOnlyTransaction;
25use gcloud_spanner::value::TimestampBound;
26use serde_json::Value;
27
28use crate::config::{BOOKMARK_TOKEN, SpannerReplication, SpannerSourceConfig};
29
30use faucet_core::CallExt as _;
31
32pub struct SpannerSource {
34 config: SpannerSourceConfig,
35 client: Client,
36 start_bookmark: Mutex<Option<CursorBookmark>>,
39 applied_shard: Mutex<Option<PkShardBounds>>,
42 calls: faucet_core::CallSlot,
45}
46
47impl SpannerSource {
48 pub async fn new(config: SpannerSourceConfig) -> Result<Self, FaucetError> {
50 config.validate()?;
51 let client = config.connection.connect().await?;
52 Ok(Self {
53 config,
54 client,
55 start_bookmark: Mutex::new(None),
56 applied_shard: Mutex::new(None),
57 calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
58 })
59 }
60
61 fn current_start(&self) -> Option<CursorBookmark> {
62 self.start_bookmark
63 .lock()
64 .expect("start_bookmark mutex poisoned")
65 .clone()
66 }
67
68 fn shard_wrap(&self, query: String) -> String {
72 match &*self.applied_shard.lock().expect("shard mutex poisoned") {
73 Some(bounds) => bounds.wrap(&query, quote_ident_spanner),
74 None => query,
75 }
76 }
77
78 async fn read_txn(&self) -> Result<ReadOnlyTransaction, FaucetError> {
81 let begin = async {
82 match self.config.exact_staleness_secs {
83 Some(secs) => {
84 self.client
85 .single_with_timestamp_bound(TimestampBound::exact_staleness(
86 Duration::from_secs(secs),
87 ))
88 .await
89 }
90 None => self.client.single().await,
91 }
92 };
93 begin
94 .within(&self.calls, "begin")
95 .await?
96 .map_err(|e| FaucetError::Source(format!("spanner: transaction begin failed: {e}")))
97 }
98
99 async fn cursor_type(
102 &self,
103 query: &str,
104 params: &[(String, Value)],
105 column: &str,
106 ) -> Result<&'static str, FaucetError> {
107 let probe = format!(
108 "SELECT q.{} FROM ({query}\n) AS q LIMIT 0",
109 quote_ident_spanner(column)
110 );
111 let map_err = |e: String| {
112 FaucetError::Source(format!(
113 "spanner: cannot read the type of the incremental cursor column {column:?} \
114 (it must be a column of the query's output): {e}"
115 ))
116 };
117 let stmt = build_statement(&probe, params)?;
118 let mut tx = self.read_txn().await?;
119 let mut iter = tx
120 .query(stmt)
121 .within(&self.calls, "query")
122 .await?
123 .map_err(|e| map_err(e.to_string()))?;
124 iter.next()
125 .within(&self.calls, "read")
126 .await?
127 .map_err(|e| map_err(e.to_string()))?;
128 cursor_cast_type(iter.columns_metadata(), column)
129 }
130}
131
132#[derive(Debug, Clone, PartialEq)]
134struct IncrementalCtx {
135 column: String,
136 start: CursorBookmark,
137}
138
139fn cursor_cast_type(fields: &[Field], column: &str) -> Result<&'static str, FaucetError> {
142 let code = fields
143 .first()
144 .and_then(|f| f.r#type.as_ref())
145 .and_then(|t| TypeCode::try_from(t.code).ok())
146 .unwrap_or(TypeCode::Unspecified);
147 Ok(match code {
148 TypeCode::Int64 => "INT64",
149 TypeCode::Float64 => "FLOAT64",
150 TypeCode::Float32 => "FLOAT32",
151 TypeCode::Numeric => "NUMERIC",
152 TypeCode::String => "STRING",
153 TypeCode::Bool => "BOOL",
154 TypeCode::Date => "DATE",
155 TypeCode::Timestamp => "TIMESTAMP",
156 TypeCode::Bytes => "BYTES",
157 other => {
158 return Err(FaucetError::Config(format!(
159 "spanner: the incremental cursor column {column:?} has type {}, which \
160 cannot order a cursor",
161 other.as_str_name()
162 )));
163 }
164 })
165}
166
167fn cursor_wrap(query: &str, column: &str, ty: &str) -> String {
172 let c = quote_ident_spanner(column);
173 let bookmark = if ty == "BYTES" {
174 format!("FROM_BASE64(@{CURSOR_PARAM})")
175 } else {
176 format!("CAST(@{CURSOR_PARAM} AS {ty})")
177 };
178 let inner = query.trim_end_matches(|ch: char| ch.is_whitespace() || ch == ';');
179 format!("SELECT * FROM ({inner}\n) AS q WHERE q.{c} >= {bookmark} ORDER BY q.{c}")
180}
181
182const CURSOR_PARAM: &str = "_faucet_bookmark";
184
185fn build_query_and_params(
194 config: &SpannerSourceConfig,
195 context: &HashMap<String, Value>,
196 start_bookmark: Option<&CursorBookmark>,
197) -> (String, Vec<(String, Value)>, Option<IncrementalCtx>) {
198 let mut params: Vec<(String, Value)> = config
199 .params
200 .iter()
201 .map(|(k, v)| (k.clone(), v.clone()))
202 .collect();
203 params.sort_by(|a, b| a.0.cmp(&b.0));
205
206 let query = if context.is_empty() {
207 config.query.clone()
208 } else {
209 let (q, ctx_values) =
210 faucet_core::util::substitute_context_bind_params(&config.query, context, 1, |i| {
211 format!("@_faucet_ctx_{i}")
212 });
213 for (i, v) in ctx_values.into_iter().enumerate() {
214 params.push((format!("_faucet_ctx_{}", i + 1), v));
215 }
216 q
217 };
218
219 let incremental = match &config.replication {
220 SpannerReplication::Full => None,
221 SpannerReplication::Incremental {
222 column,
223 initial_value,
224 } => {
225 let start = start_bookmark
226 .cloned()
227 .unwrap_or_else(|| CursorBookmark::initial(initial_value.clone()));
228 if query.contains(BOOKMARK_TOKEN) {
229 params.push(("bookmark".to_string(), start.value.clone()));
230 }
231 Some(IncrementalCtx {
232 column: column.clone(),
233 start,
234 })
235 }
236 };
237
238 (query, params, incremental)
239}
240
241fn bind_json_param(stmt: &mut Statement, name: &str, value: &Value) -> Result<(), FaucetError> {
247 match value {
248 Value::String(s) => stmt.add_param(name, s),
249 Value::Bool(b) => stmt.add_param(name, b),
250 Value::Number(n) => {
251 if let Some(i) = n.as_i64() {
252 stmt.add_param(name, &i);
253 } else if let Some(u) = n.as_u64() {
254 let i = i64::try_from(u).map_err(|_| {
255 FaucetError::Config(format!(
256 "spanner: parameter `{name}` ({u}) overflows INT64"
257 ))
258 })?;
259 stmt.add_param(name, &i);
260 } else {
261 stmt.add_param(name, &n.as_f64().unwrap_or(0.0));
263 }
264 }
265 other => {
266 return Err(FaucetError::Config(format!(
267 "spanner: parameter `{name}` must be a scalar, got {other}"
268 )));
269 }
270 }
271 Ok(())
272}
273
274fn build_statement(query: &str, params: &[(String, Value)]) -> Result<Statement, FaucetError> {
276 let mut stmt = Statement::new(query);
277 for (name, value) in params {
278 bind_json_param(&mut stmt, name, value)?;
279 }
280 Ok(stmt)
281}
282
283type CatalogRow = (String, String, String, bool);
286
287fn descriptors_from_catalog(rows: Vec<CatalogRow>) -> Vec<faucet_core::DatasetDescriptor> {
292 type PendingTable = (String, Vec<(String, Value)>);
294
295 let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
296 let mut current: Option<PendingTable> = None;
297
298 let flush = |cur: Option<PendingTable>, out: &mut Vec<faucet_core::DatasetDescriptor>| {
299 if let Some((table, cols)) = cur {
300 let query = format!("SELECT * FROM {}", quote_ident_spanner(&table));
301 out.push(
302 faucet_core::DatasetDescriptor::new(
303 table,
304 "table",
305 serde_json::json!({ "query": query }),
306 )
307 .with_schema(faucet_core::columns_to_schema(cols)),
308 );
309 }
310 };
311
312 for (table, column, spanner_type, is_nullable) in rows {
313 let same = current.as_ref().is_some_and(|(t, _)| *t == table);
314 if !same {
315 flush(current.take(), &mut out);
316 current = Some((table, Vec::new()));
317 }
318 let fragment = spanner_type_to_json_schema(&parse_spanner_type(&spanner_type), is_nullable);
319 if let Some((_, cols)) = current.as_mut() {
320 cols.push((column, fragment));
321 }
322 }
323 flush(current, &mut out);
324 out
325}
326
327fn default_state_key(config: &SpannerSourceConfig) -> String {
330 let fingerprint = faucet_core::shard::shard_hash(&config.query);
332 let path: String = format!(
335 "{}.{}.{}",
336 config.connection.project_id, config.connection.instance, config.connection.database
337 )
338 .chars()
339 .map(|c| {
340 if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
341 c
342 } else {
343 '_'
344 }
345 })
346 .collect();
347 format!("spanner:{path}:{fingerprint:016x}")
348}
349
350#[async_trait]
351impl Source for SpannerSource {
352 async fn fetch_with_context(
353 &self,
354 context: &HashMap<String, Value>,
355 ) -> Result<Vec<Value>, FaucetError> {
356 Ok(self.collect_all(context).await?.0)
357 }
358
359 async fn fetch_with_context_incremental(
360 &self,
361 context: &HashMap<String, Value>,
362 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
363 self.collect_all(context).await
364 }
365
366 fn stream_pages<'a>(
367 &'a self,
368 context: &'a HashMap<String, Value>,
369 _batch_size: usize,
370 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
371 let batch_size = self.config.batch_size;
372 let chunk = if batch_size == 0 {
373 usize::MAX
374 } else {
375 batch_size
376 };
377 let cap = if batch_size == 0 { 1024 } else { batch_size };
378 let start = self.current_start();
379 let (query, params, incr) = build_query_and_params(&self.config, context, start.as_ref());
380 let query = self.shard_wrap(query);
381
382 Box::pin(async_stream::try_stream! {
383 let (query, params) = match &incr {
384 None => (query, params),
385 Some(ic) => {
386 let ty = self.cursor_type(&query, ¶ms, &ic.column).await?;
387 let mut params = params;
388 params.push((CURSOR_PARAM.to_string(), ic.start.value.clone()));
389 (cursor_wrap(&query, &ic.column, ty), params)
390 }
391 };
392 let mut tracker = incr
393 .as_ref()
394 .map(|ic| CursorTracker::new(ic.column.clone(), ic.start.clone()));
395 let stmt = build_statement(&query, ¶ms)?;
396 let mut tx = self.read_txn().await?;
397 let opts = QueryOptions {
402 enable_resume: false,
403 ..Default::default()
404 };
405 let mut iter = tx
406 .query_with_option(stmt, opts)
407 .within(&self.calls, "query")
408 .await?
409 .map_err(|e| FaucetError::Source(format!("spanner: query failed: {e}")))?;
410
411 let mut fields: Option<std::sync::Arc<Vec<_>>> = None;
412 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
413 let mut total = 0usize;
414 let reads = self.calls.get().with_roundtrip_metering(false);
415
416 loop {
417 let rows = reads
420 .call_once("read", async {
421 let mut rows = Vec::new();
422 while rows.len() < chunk {
423 match iter.next().await.map_err(|e| {
424 FaucetError::Source(format!("spanner: row stream failed: {e}"))
425 })? {
426 Some(row) => rows.push(row),
427 None => break,
428 }
429 }
430 Ok(rows)
431 })
432 .await?;
433 let exhausted = rows.len() < chunk;
434 for row in rows {
435 let fields = fields.get_or_insert_with(|| iter.columns_metadata().clone());
436 let record = row_to_json(&row, fields)
437 .map_err(|e| FaucetError::Source(format!("spanner: row decode failed: {e}")))?;
438 if tracker.as_mut().is_none_or(|t| t.admit(&record)) {
439 buffer.push(record);
440 }
441 if buffer.len() >= chunk {
442 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
443 total += page.len();
444 let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
445 yield StreamPage { records: page, bookmark };
446 }
447 }
448 if exhausted {
449 break;
450 }
451 }
452
453 total += buffer.len();
456 if !buffer.is_empty() || tracker.is_some() {
457 let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
458 yield StreamPage { records: buffer, bookmark };
459 }
460 if let Some(missing) = tracker.as_ref().map(CursorTracker::missing).filter(|n| *n > 0) {
461 tracing::warn!(
462 missing,
463 "spanner incremental read: rows without a cursor value were emitted \
464 without moving the bookmark"
465 );
466 }
467
468 tracing::info!(rows = total, query = %self.config.query, "spanner source stream complete");
469 })
470 }
471
472 fn config_schema(&self) -> Value {
473 serde_json::to_value(faucet_core::schema_for!(SpannerSourceConfig))
474 .expect("schema serialization")
475 }
476
477 fn connector_name(&self) -> &'static str {
478 "spanner"
479 }
480
481 fn set_call_context(&self, ctx: faucet_core::io::CallContext) {
482 self.calls.install(ctx);
483 }
484
485 fn set_roundtrip_recorder(
486 &self,
487 recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
488 ) {
489 self.calls.install_recorder(recorder);
490 }
491
492 fn dataset_uri(&self) -> String {
493 format!(
494 "spanner://{}/{}/{}?query={}",
495 self.config.connection.project_id,
496 self.config.connection.instance,
497 self.config.connection.database,
498 self.config.query
499 )
500 }
501
502 fn state_key(&self) -> Option<String> {
503 match &self.config.replication {
504 SpannerReplication::Full => None,
505 SpannerReplication::Incremental { .. } => Some(
506 self.config
507 .state_key
508 .clone()
509 .unwrap_or_else(|| default_state_key(&self.config)),
510 ),
511 }
512 }
513
514 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
515 let start = CursorBookmark::from_state(bookmark)?;
516 *self
517 .start_bookmark
518 .lock()
519 .expect("start_bookmark mutex poisoned") = Some(start);
520 Ok(())
521 }
522
523 fn supports_discover(&self) -> bool {
524 true
525 }
526
527 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
534 const CATALOG_SQL: &str = "SELECT c.TABLE_NAME, c.COLUMN_NAME, c.SPANNER_TYPE, \
535 c.IS_NULLABLE \
536 FROM INFORMATION_SCHEMA.COLUMNS AS c \
537 JOIN INFORMATION_SCHEMA.TABLES AS t \
538 ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME \
539 WHERE t.TABLE_TYPE = 'BASE TABLE' AND t.TABLE_SCHEMA = '' \
540 ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION";
541
542 let map_err =
543 |e: String| FaucetError::Source(format!("spanner: catalog discovery failed: {e}"));
544
545 let mut tx = self.read_txn().await?;
546 let mut iter = tx
547 .query(Statement::new(CATALOG_SQL))
548 .within(&self.calls, "query")
549 .await?
550 .map_err(|e| map_err(e.to_string()))?;
551
552 let mut catalog: Vec<CatalogRow> = Vec::new();
553 while let Some(row) = iter
554 .next()
555 .within(&self.calls, "read")
556 .await?
557 .map_err(|e| map_err(e.to_string()))?
558 {
559 let table: String = row
560 .column_by_name("TABLE_NAME")
561 .map_err(|e| map_err(e.to_string()))?;
562 let column: String = row
563 .column_by_name("COLUMN_NAME")
564 .map_err(|e| map_err(e.to_string()))?;
565 let spanner_type: String = row
566 .column_by_name("SPANNER_TYPE")
567 .map_err(|e| map_err(e.to_string()))?;
568 let nullable: String = row
570 .column_by_name("IS_NULLABLE")
571 .unwrap_or_else(|_| "YES".to_string());
572 catalog.push((
573 table,
574 column,
575 spanner_type,
576 nullable.eq_ignore_ascii_case("YES"),
577 ));
578 }
579
580 Ok(descriptors_from_catalog(catalog))
581 }
582
583 fn is_shardable(&self) -> bool {
585 self.config.shard.is_some()
586 }
587
588 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
598 let Some(shard_cfg) = &self.config.shard else {
599 return Ok(vec![ShardSpec::whole()]);
600 };
601
602 let start = self.current_start();
603 let (inner, params, _incr) =
604 build_query_and_params(&self.config, &HashMap::new(), start.as_ref());
605 let key = quote_ident_spanner(&shard_cfg.key);
606 let bounds_sql = pk_bounds_query(&inner, &key, "INT64");
607
608 let map_err = |e: String| {
609 FaucetError::Source(format!(
610 "spanner: failed to compute shard bounds for key {:?} \
611 (it must be an INT64-typed column present in the query's output): {e}",
612 shard_cfg.key
613 ))
614 };
615
616 let stmt = build_statement(&bounds_sql, ¶ms)?;
617 let mut tx = self.read_txn().await?;
618 let mut iter = tx
619 .query(stmt)
620 .within(&self.calls, "query")
621 .await?
622 .map_err(|e| map_err(e.to_string()))?;
623 let row = iter
624 .next()
625 .within(&self.calls, "read")
626 .await?
627 .map_err(|e| map_err(e.to_string()))?;
628 let Some(row) = row else {
629 return Ok(vec![ShardSpec::whole()]);
630 };
631 let lo: Option<i64> = row
632 .column_by_name("lo")
633 .map_err(|e| map_err(e.to_string()))?;
634 let hi: Option<i64> = row
635 .column_by_name("hi")
636 .map_err(|e| map_err(e.to_string()))?;
637 Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
638 }
639
640 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
643 let bounds = parse_pk_shard(shard, "spanner")?;
644 *self.applied_shard.lock().expect("shard mutex poisoned") = bounds;
645 Ok(())
646 }
647}
648
649impl SpannerSource {
650 async fn collect_all(
653 &self,
654 context: &HashMap<String, Value>,
655 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
656 use futures::TryStreamExt;
657 let mut records: Vec<Value> = Vec::new();
658 let mut bookmark: Option<Value> = None;
659 let mut stream = self.stream_pages(context, self.config.batch_size);
660 while let Some(page) = stream.try_next().await? {
661 records.extend(page.records);
662 if page.bookmark.is_some() {
663 bookmark = page.bookmark;
664 }
665 }
666 Ok((records, bookmark))
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use faucet_core::shard::plan_pk_shards;
674 use serde_json::json;
675
676 fn base() -> SpannerSourceConfig {
677 SpannerSourceConfig::new("proj", "inst", "db", "SELECT * FROM t")
678 }
679
680 #[test]
681 fn build_full_returns_query_and_sorted_params() {
682 let mut cfg = base();
683 cfg.params.insert("zeta".into(), json!(1));
684 cfg.params.insert("alpha".into(), json!("x"));
685 let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
686 assert_eq!(q, "SELECT * FROM t");
687 assert_eq!(
688 p,
689 vec![
690 ("alpha".to_string(), json!("x")),
691 ("zeta".to_string(), json!(1))
692 ],
693 "params bind in deterministic (sorted) order"
694 );
695 assert!(incr.is_none());
696 }
697
698 fn field(code: TypeCode) -> Field {
699 Field {
700 name: "c".into(),
701 r#type: Some(gcloud_googleapis::spanner::v1::Type {
702 code: code as i32,
703 ..Default::default()
704 }),
705 }
706 }
707
708 #[test]
709 fn cursor_types_cast_the_bookmark() {
710 for (code, ty) in [
711 (TypeCode::Int64, "INT64"),
712 (TypeCode::Float64, "FLOAT64"),
713 (TypeCode::Float32, "FLOAT32"),
714 (TypeCode::Numeric, "NUMERIC"),
715 (TypeCode::String, "STRING"),
716 (TypeCode::Bool, "BOOL"),
717 (TypeCode::Date, "DATE"),
718 (TypeCode::Timestamp, "TIMESTAMP"),
719 (TypeCode::Bytes, "BYTES"),
720 ] {
721 assert_eq!(cursor_cast_type(&[field(code)], "c").unwrap(), ty);
722 }
723 for code in [TypeCode::Json, TypeCode::Array, TypeCode::Struct] {
724 let err = cursor_cast_type(&[field(code)], "c")
725 .unwrap_err()
726 .to_string();
727 assert!(err.contains("cannot order a cursor"), "{err}");
728 }
729 assert!(cursor_cast_type(&[], "c").is_err());
730 }
731
732 #[test]
733 fn cursor_wrap_filters_and_orders_on_the_cast_bookmark() {
734 assert_eq!(
735 cursor_wrap(
736 "SELECT * FROM t WHERE ts >= TIMESTAMP(@bookmark);\n",
737 "ts",
738 "TIMESTAMP"
739 ),
740 "SELECT * FROM (SELECT * FROM t WHERE ts >= TIMESTAMP(@bookmark)\n) AS q \
741 WHERE q.`ts` >= CAST(@_faucet_bookmark AS TIMESTAMP) ORDER BY q.`ts`"
742 );
743 assert_eq!(
744 cursor_wrap("SELECT * FROM t", "we`ird", "BYTES"),
745 "SELECT * FROM (SELECT * FROM t\n) AS q WHERE q.`we\\`ird` >= \
746 FROM_BASE64(@_faucet_bookmark) ORDER BY q.`we\\`ird`"
747 );
748 }
749
750 #[test]
751 fn build_incremental_binds_bookmark_param() {
752 let mut cfg = base();
753 cfg.query = "SELECT * FROM t WHERE updated_at >= @bookmark".into();
754 cfg.replication = SpannerReplication::Incremental {
755 column: "updated_at".into(),
756 initial_value: json!("1970-01-01"),
757 };
758 let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
759 assert_eq!(q, "SELECT * FROM t WHERE updated_at >= @bookmark");
760 assert_eq!(p, vec![("bookmark".to_string(), json!("1970-01-01"))]);
761 assert_eq!(
762 incr,
763 Some(IncrementalCtx {
764 column: "updated_at".into(),
765 start: CursorBookmark::initial(json!("1970-01-01"))
766 })
767 );
768 }
769
770 #[test]
771 fn build_incremental_uses_stored_bookmark_over_initial() {
772 let mut cfg = base();
773 cfg.query = "SELECT * FROM t WHERE c >= @bookmark".into();
774 cfg.replication = SpannerReplication::Incremental {
775 column: "c".into(),
776 initial_value: json!(0),
777 };
778 let stored = CursorBookmark::from_state(json!({"value": 500, "boundary": ["x"]})).unwrap();
779 let (_q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), Some(&stored));
780 assert_eq!(p, vec![("bookmark".to_string(), json!(500))]);
781 assert_eq!(incr.unwrap().start, stored);
782 }
783
784 #[test]
785 fn build_incremental_without_token_still_returns_filter_ctx() {
786 let mut cfg = base();
787 cfg.replication = SpannerReplication::Incremental {
788 column: "c".into(),
789 initial_value: json!(0),
790 };
791 let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
792 assert_eq!(q, "SELECT * FROM t");
793 assert!(p.is_empty());
794 assert!(incr.is_some(), "the outer cursor filter still applies");
795 }
796
797 #[test]
798 fn build_context_binds_generated_named_params() {
799 let mut cfg = base();
800 cfg.query = "SELECT * FROM t WHERE tenant = {parent.id}".into();
801 let mut ctx = HashMap::new();
802 ctx.insert("parent.id".to_string(), json!(7));
803 let (q, p, _incr) = build_query_and_params(&cfg, &ctx, None);
804 assert_eq!(q, "SELECT * FROM t WHERE tenant = @_faucet_ctx_1");
805 assert_eq!(p, vec![("_faucet_ctx_1".to_string(), json!(7))]);
806 }
807
808 #[test]
809 fn build_statement_binds_scalars_and_rejects_containers() {
810 let stmt = build_statement(
811 "SELECT 1",
812 &[
813 ("s".to_string(), json!("x")),
814 ("i".to_string(), json!(7)),
815 ("f".to_string(), json!(1.5)),
816 ("b".to_string(), json!(true)),
817 ],
818 );
819 assert!(stmt.is_ok());
820
821 let err = build_statement("SELECT 1", &[("o".to_string(), json!({"a": 1}))]);
822 assert!(matches!(err, Err(FaucetError::Config(_))));
823 let err = build_statement("SELECT 1", &[("n".to_string(), Value::Null)]);
824 assert!(matches!(err, Err(FaucetError::Config(_))));
825 let err = build_statement("SELECT 1", &[("u".to_string(), json!(u64::MAX))]);
826 assert!(matches!(err, Err(FaucetError::Config(_))));
827 }
828
829 #[test]
830 fn default_state_key_is_stable_and_valid() {
831 let cfg = base();
832 let k1 = default_state_key(&cfg);
833 let k2 = default_state_key(&cfg);
834 assert_eq!(k1, k2);
835 assert!(k1.starts_with("spanner:proj.inst.db:"));
836 assert!(k1.contains(&format!("{:x}", faucet_core::shard::shard_hash(&cfg.query))));
837 faucet_core::state::validate_state_key(&k1).expect("derived key must be valid");
838 }
839
840 #[test]
841 fn dataset_uri_shape() {
842 let cfg = base();
843 let uri = format!(
844 "spanner://{}/{}/{}?query={}",
845 cfg.connection.project_id, cfg.connection.instance, cfg.connection.database, cfg.query
846 );
847 assert_eq!(uri, "spanner://proj/inst/db?query=SELECT * FROM t");
848 }
849
850 #[test]
853 fn shard_wrap_uses_backtick_quoting() {
854 let spec = faucet_core::ShardSpec::new(
855 "1",
856 json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
857 );
858 let bounds = PkShardBounds::from_spec(&spec).unwrap();
859 let sql = bounds.wrap("SELECT * FROM t", quote_ident_spanner);
860 assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"), "{sql}");
861 assert!(sql.contains("`id` >= 100"), "backtick-quoted key: {sql}");
862 assert!(sql.contains("`id` < 200"), "half-open upper bound: {sql}");
863 }
864
865 #[test]
866 fn last_shard_wrap_covers_null_keys() {
867 let shards = plan_pk_shards("id", 0, 99, 3);
868 let last = PkShardBounds::from_spec(shards.last().unwrap()).unwrap();
869 let sql = last.wrap("SELECT * FROM t", quote_ident_spanner);
870 assert!(
871 sql.contains("`id` IS NULL"),
872 "last shard must match NULL keys: {sql}"
873 );
874 }
875
876 #[test]
877 fn shard_wrap_preserves_named_bind_params() {
878 let mut cfg = base();
881 cfg.query = "SELECT * FROM t WHERE c >= @bookmark".into();
882 cfg.replication = SpannerReplication::Incremental {
883 column: "c".into(),
884 initial_value: json!(0),
885 };
886 let (q, p, _incr) = build_query_and_params(&cfg, &HashMap::new(), None);
887 let spec = faucet_core::ShardSpec::new(
888 "0",
889 json!({"key": "id", "lo": 0, "hi": 10, "lo_unbounded": false, "hi_unbounded": false}),
890 );
891 let wrapped = PkShardBounds::from_spec(&spec)
892 .unwrap()
893 .wrap(&q, quote_ident_spanner);
894 assert!(
895 wrapped.contains("@bookmark"),
896 "bind param survives: {wrapped}"
897 );
898 assert_eq!(p.len(), 1, "one bound value for the bookmark");
899 }
900
901 #[test]
902 fn bounds_query_uses_int64_cast() {
903 let sql = pk_bounds_query("SELECT * FROM t", "`id`", "INT64");
904 assert!(sql.contains("CAST(MIN(`id`) AS INT64) AS lo"), "{sql}");
905 assert!(
906 sql.contains("FROM (SELECT * FROM t) AS _faucet_bounds"),
907 "{sql}"
908 );
909 }
910
911 fn cat_row(t: &str, c: &str, ty: &str, nullable: bool) -> CatalogRow {
914 (t.into(), c.into(), ty.into(), nullable)
915 }
916
917 #[test]
918 fn descriptors_group_catalog_rows_per_table() {
919 let rows = vec![
920 cat_row("orders", "id", "INT64", false),
921 cat_row("orders", "note", "STRING(MAX)", true),
922 cat_row("users", "score", "FLOAT64", false),
923 ];
924 let ds = descriptors_from_catalog(rows);
925 assert_eq!(ds.len(), 2);
926
927 assert_eq!(ds[0].name, "orders");
928 assert_eq!(ds[0].kind, "table");
929 assert_eq!(ds[0].estimated_rows, None, "spanner has no cheap estimate");
930 assert_eq!(ds[0].config_patch["query"], "SELECT * FROM `orders`");
931 let schema = ds[0].schema.as_ref().unwrap();
932 assert_eq!(schema["type"], "object");
933 assert_eq!(schema["properties"]["id"]["type"], "integer");
934 assert_eq!(
935 schema["properties"]["note"]["type"],
936 json!(["string", "null"]),
937 "nullable column"
938 );
939
940 assert_eq!(ds[1].name, "users");
941 assert_eq!(
942 ds[1].schema.as_ref().unwrap()["properties"]["score"]["type"],
943 "number"
944 );
945 }
946
947 #[test]
948 fn descriptors_quote_hostile_identifiers() {
949 let rows = vec![cat_row("we`ird", "id", "INT64", false)];
950 let ds = descriptors_from_catalog(rows);
951 let q = ds[0].config_patch["query"].as_str().unwrap();
952 assert_eq!(
953 q, "SELECT * FROM `we\\`ird`",
954 "interior backtick must be backslash-escaped"
955 );
956 }
957
958 #[test]
959 fn descriptors_empty_catalog_is_empty() {
960 assert!(descriptors_from_catalog(Vec::new()).is_empty());
961 }
962}