Skip to main content

faucet_source_spanner/
stream.rs

1//! The Cloud Spanner [`Source`] implementation — client construction, query
2//! execution, streaming, and incremental-replication bookkeeping.
3
4use 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
32/// Google Cloud Spanner query source.
33pub struct SpannerSource {
34    config: SpannerSourceConfig,
35    client: Client,
36    /// Bookmark loaded via [`Source::apply_start_bookmark`]; overrides the
37    /// configured `initial_value` for incremental runs.
38    start_bookmark: Mutex<Option<CursorBookmark>>,
39    /// Shard applied by the cluster coordinator (Mode B), if any. `None` (or
40    /// the whole-dataset shard) means the full query is streamed.
41    applied_shard: Mutex<Option<PkShardBounds>>,
42    /// Backend-call context (#880): the transaction begin, the query and
43    /// every page of rows run through it.
44    calls: faucet_core::CallSlot,
45}
46
47impl SpannerSource {
48    /// Validate the config and build the Spanner client (session pool).
49    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    /// Apply the currently-set shard (if any) to a resolved query string. The
69    /// `@name` bind parameters inside the wrapped subquery are unaffected
70    /// (they bind by name, not by position in the text).
71    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    /// Open the single-use read-only transaction this run reads through,
79    /// honoring `exact_staleness_secs`.
80    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    /// The Spanner type of the cursor `column` in the query's output, read
100    /// from the result metadata of a `LIMIT 0` probe.
101    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/// Incremental-replication context resolved for one run.
133#[derive(Debug, Clone, PartialEq)]
134struct IncrementalCtx {
135    column: String,
136    start: CursorBookmark,
137}
138
139/// The type a bookmark parameter is cast to before it is compared with the
140/// cursor column (Spanner does not coerce parameter types).
141fn 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
167/// Wrap `query` so it reads only rows at or past the bookmark, in cursor
168/// order: `SELECT * FROM (<query>) AS q WHERE q.<column> >= <bookmark> ORDER
169/// BY q.<column>`. The bookmark binds as `@_faucet_bookmark`, cast to `ty`
170/// (a BYTES bookmark is base64 text).
171fn 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
182/// The parameter the outer cursor filter binds the bookmark as.
183const CURSOR_PARAM: &str = "_faucet_bookmark";
184
185/// Build the final query string, the named bind values, and (for incremental
186/// runs) the client-side filter context.
187///
188/// Pure function (no client) so it is unit-testable. Named parameters:
189/// `config.params` keep their user-chosen names, parent-context placeholders
190/// become `@_faucet_ctx_N`, and the incremental cursor binds as `@bookmark`
191/// (only when the query mentions it). The outer cursor filter is added by
192/// [`cursor_wrap`].
193fn 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    // Deterministic bind order (HashMap iteration order is random).
204    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
241/// Bind one JSON scalar as a typed Spanner parameter. Spanner parameters are
242/// typed, so each JSON scalar maps to the concrete Rust type whose
243/// `ToKind::get_type` matches: string→STRING, integer→INT64, float→FLOAT64,
244/// bool→BOOL. Non-scalar values are rejected (config validation catches
245/// user params at load time; this also guards bookmark values at runtime).
246fn 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                // Finite f64 (serde_json rejects NaN/Inf at parse time).
262                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
274/// Build a [`Statement`] from a resolved query and named values.
275fn 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
283/// One flattened `INFORMATION_SCHEMA.COLUMNS` row used by
284/// [`Source::discover`]: `(table, column, spanner_type, is_nullable)`.
285type CatalogRow = (String, String, String, bool);
286
287/// Group flattened catalog rows (ordered by table, ordinal position) into one
288/// [`DatasetDescriptor`](faucet_core::DatasetDescriptor) per table. Pure —
289/// unit-testable without a live database. Spanner has no cheap row estimate
290/// (no `reltuples` analogue), so descriptors carry none.
291fn descriptors_from_catalog(rows: Vec<CatalogRow>) -> Vec<faucet_core::DatasetDescriptor> {
292    /// In-progress per-table accumulator: `(table, columns)`.
293    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
327/// Derive a default state-store key from the database path + a query
328/// fingerprint, stable across runs.
329fn default_state_key(config: &SpannerSourceConfig) -> String {
330    // Fixed FNV-1a: the key is durable and `DefaultHasher` is not stable across Rust releases.
331    let fingerprint = faucet_core::shard::shard_hash(&config.query);
332    // Project/instance/database ids are [a-z0-9-] by Spanner's naming rules;
333    // sanitise defensively anyway (`:` is the key-segment separator).
334    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, &params, &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, &params)?;
396            let mut tx = self.read_txn().await?;
397            // `enable_resume: false`: the resume path buffers up to 128 MiB
398            // between resume tokens and makes `next()` non-cancel-safe — the
399            // pipeline `select!`s pages against its cancel token, so
400            // cancel-safety wins over transparent stream resumption here.
401            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            // A page of rows per call: the run's cancel and deadline end a
418            // stalled stream without a per-row call.
419            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            // In incremental mode every page carries the position after its
454            // last row (the final page carries one even when empty).
455            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    /// Enumerate every base table in the database's default schema with
528    /// column types from `INFORMATION_SCHEMA.COLUMNS` (catalog metadata only
529    /// — no data scan). System schemas (`INFORMATION_SCHEMA`, `SPANNER_SYS`)
530    /// carry a non-empty `TABLE_SCHEMA`, so filtering on the default (empty)
531    /// schema excludes them. Spanner exposes no cheap row estimate, so
532    /// descriptors carry none.
533    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            // Absent/odd IS_NULLABLE over-approximates to nullable.
569            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    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
584    fn is_shardable(&self) -> bool {
585        self.config.shard.is_some()
586    }
587
588    /// Enumerate contiguous primary-key range shards by computing the `key`
589    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting
590    /// that range into ~`target` slices. Returns a single whole-dataset shard
591    /// when no `shard` config is set or the result set is empty.
592    ///
593    /// The base query is resolved through the same query builder as a normal
594    /// fetch first, so a `@bookmark` parameter (incremental replication) is
595    /// bound rather than left dangling — bounds are then computed over the
596    /// not-yet-synced slice, which is exactly the data the shards will read.
597    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, &params)?;
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    /// Narrow this source to a single PK-range shard. The whole-dataset shard
641    /// clears any applied range (streams the full query).
642    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    /// Run the query and return all decoded rows plus (for incremental) the
651    /// new bookmark. Used by the non-streaming convenience methods.
652    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    // ── PK-range sharding (Mode B) ──────────────────────────────────────────
851
852    #[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        // The @name parameters of a resolved incremental query survive the
879        // wrap — Spanner binds them by name, not by textual position.
880        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    // ── discover: pure catalog-row grouping ─────────────────────────────────
912
913    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}