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::hash::{Hash, Hasher};
6use std::pin::Pin;
7use std::sync::Mutex;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use faucet_common_spanner::decode::row_to_json;
12use faucet_common_spanner::quote_ident_spanner;
13use faucet_common_spanner::types::{parse_spanner_type, spanner_type_to_json_schema};
14use faucet_core::replication::{filter_incremental, max_replication_value, max_value};
15use faucet_core::shard::{
16    PkShardBounds, ShardSpec, parse_pk_shard, pk_bounds_query, pk_shards_from_bounds,
17};
18use faucet_core::{FaucetError, Source, StreamPage};
19use futures::Stream;
20use gcloud_spanner::client::Client;
21use gcloud_spanner::statement::Statement;
22use gcloud_spanner::transaction::QueryOptions;
23use gcloud_spanner::transaction_ro::ReadOnlyTransaction;
24use gcloud_spanner::value::TimestampBound;
25use serde_json::Value;
26
27use crate::config::{SpannerReplication, SpannerSourceConfig};
28
29/// Google Cloud Spanner query source.
30pub struct SpannerSource {
31    config: SpannerSourceConfig,
32    client: Client,
33    /// Bookmark loaded via [`Source::apply_start_bookmark`]; overrides the
34    /// configured `initial_value` for incremental runs.
35    start_bookmark: Mutex<Option<Value>>,
36    /// Shard applied by the cluster coordinator (Mode B), if any. `None` (or
37    /// the whole-dataset shard) means the full query is streamed.
38    applied_shard: Mutex<Option<PkShardBounds>>,
39}
40
41impl SpannerSource {
42    /// Validate the config and build the Spanner client (session pool).
43    pub async fn new(config: SpannerSourceConfig) -> Result<Self, FaucetError> {
44        config.validate()?;
45        let client = config.connection.connect().await?;
46        Ok(Self {
47            config,
48            client,
49            start_bookmark: Mutex::new(None),
50            applied_shard: Mutex::new(None),
51        })
52    }
53
54    fn current_start(&self) -> Option<Value> {
55        self.start_bookmark
56            .lock()
57            .expect("start_bookmark mutex poisoned")
58            .clone()
59    }
60
61    /// Apply the currently-set shard (if any) to a resolved query string. The
62    /// `@name` bind parameters inside the wrapped subquery are unaffected
63    /// (they bind by name, not by position in the text).
64    fn shard_wrap(&self, query: String) -> String {
65        match &*self.applied_shard.lock().expect("shard mutex poisoned") {
66            Some(bounds) => bounds.wrap(&query, quote_ident_spanner),
67            None => query,
68        }
69    }
70
71    /// Open the single-use read-only transaction this run reads through,
72    /// honoring `exact_staleness_secs`.
73    async fn read_txn(&self) -> Result<ReadOnlyTransaction, FaucetError> {
74        let result = match self.config.exact_staleness_secs {
75            Some(secs) => {
76                self.client
77                    .single_with_timestamp_bound(TimestampBound::exact_staleness(
78                        Duration::from_secs(secs),
79                    ))
80                    .await
81            }
82            None => self.client.single().await,
83        };
84        result.map_err(|e| FaucetError::Source(format!("spanner: transaction begin failed: {e}")))
85    }
86}
87
88/// Incremental-replication context resolved for one run.
89#[derive(Debug, Clone, PartialEq)]
90struct IncrementalCtx {
91    column: String,
92    start: Value,
93}
94
95/// Build the final query string, the named bind values, and (for incremental
96/// runs) the client-side filter context.
97///
98/// Pure function (no client) so it is unit-testable. Named parameters:
99/// `config.params` keep their user-chosen names, parent-context placeholders
100/// become `@_faucet_ctx_N`, and the incremental cursor binds as `@bookmark`
101/// (only when the query mentions it).
102fn build_query_and_params(
103    config: &SpannerSourceConfig,
104    context: &HashMap<String, Value>,
105    start_bookmark: Option<&Value>,
106) -> (String, Vec<(String, Value)>, Option<IncrementalCtx>) {
107    let mut params: Vec<(String, Value)> = config
108        .params
109        .iter()
110        .map(|(k, v)| (k.clone(), v.clone()))
111        .collect();
112    // Deterministic bind order (HashMap iteration order is random).
113    params.sort_by(|a, b| a.0.cmp(&b.0));
114
115    let query = if context.is_empty() {
116        config.query.clone()
117    } else {
118        let (q, ctx_values) =
119            faucet_core::util::substitute_context_bind_params(&config.query, context, 1, |i| {
120                format!("@_faucet_ctx_{i}")
121            });
122        for (i, v) in ctx_values.into_iter().enumerate() {
123            params.push((format!("_faucet_ctx_{}", i + 1), v));
124        }
125        q
126    };
127
128    let incremental = match &config.replication {
129        SpannerReplication::Full => None,
130        SpannerReplication::Incremental {
131            column,
132            initial_value,
133        } => {
134            let start = start_bookmark
135                .cloned()
136                .unwrap_or_else(|| initial_value.clone());
137            // Server-side pushdown: bind the cursor where the user wrote
138            // `@bookmark`. If absent, only the client-side filter applies.
139            if query.contains("@bookmark") {
140                params.push(("bookmark".to_string(), start.clone()));
141            }
142            Some(IncrementalCtx {
143                column: column.clone(),
144                start,
145            })
146        }
147    };
148
149    (query, params, incremental)
150}
151
152/// Bind one JSON scalar as a typed Spanner parameter. Spanner parameters are
153/// typed, so each JSON scalar maps to the concrete Rust type whose
154/// `ToKind::get_type` matches: string→STRING, integer→INT64, float→FLOAT64,
155/// bool→BOOL. Non-scalar values are rejected (config validation catches
156/// user params at load time; this also guards bookmark values at runtime).
157fn bind_json_param(stmt: &mut Statement, name: &str, value: &Value) -> Result<(), FaucetError> {
158    match value {
159        Value::String(s) => stmt.add_param(name, s),
160        Value::Bool(b) => stmt.add_param(name, b),
161        Value::Number(n) => {
162            if let Some(i) = n.as_i64() {
163                stmt.add_param(name, &i);
164            } else if let Some(u) = n.as_u64() {
165                let i = i64::try_from(u).map_err(|_| {
166                    FaucetError::Config(format!(
167                        "spanner: parameter `{name}` ({u}) overflows INT64"
168                    ))
169                })?;
170                stmt.add_param(name, &i);
171            } else {
172                // Finite f64 (serde_json rejects NaN/Inf at parse time).
173                stmt.add_param(name, &n.as_f64().unwrap_or(0.0));
174            }
175        }
176        other => {
177            return Err(FaucetError::Config(format!(
178                "spanner: parameter `{name}` must be a scalar, got {other}"
179            )));
180        }
181    }
182    Ok(())
183}
184
185/// Build a [`Statement`] from a resolved query and named values.
186fn build_statement(query: &str, params: &[(String, Value)]) -> Result<Statement, FaucetError> {
187    let mut stmt = Statement::new(query);
188    for (name, value) in params {
189        bind_json_param(&mut stmt, name, value)?;
190    }
191    Ok(stmt)
192}
193
194/// One flattened `INFORMATION_SCHEMA.COLUMNS` row used by
195/// [`Source::discover`]: `(table, column, spanner_type, is_nullable)`.
196type CatalogRow = (String, String, String, bool);
197
198/// Group flattened catalog rows (ordered by table, ordinal position) into one
199/// [`DatasetDescriptor`](faucet_core::DatasetDescriptor) per table. Pure —
200/// unit-testable without a live database. Spanner has no cheap row estimate
201/// (no `reltuples` analogue), so descriptors carry none.
202fn descriptors_from_catalog(rows: Vec<CatalogRow>) -> Vec<faucet_core::DatasetDescriptor> {
203    /// In-progress per-table accumulator: `(table, columns)`.
204    type PendingTable = (String, Vec<(String, Value)>);
205
206    let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
207    let mut current: Option<PendingTable> = None;
208
209    let flush = |cur: Option<PendingTable>, out: &mut Vec<faucet_core::DatasetDescriptor>| {
210        if let Some((table, cols)) = cur {
211            let query = format!("SELECT * FROM {}", quote_ident_spanner(&table));
212            out.push(
213                faucet_core::DatasetDescriptor::new(
214                    table,
215                    "table",
216                    serde_json::json!({ "query": query }),
217                )
218                .with_schema(faucet_core::columns_to_schema(cols)),
219            );
220        }
221    };
222
223    for (table, column, spanner_type, is_nullable) in rows {
224        let same = current.as_ref().is_some_and(|(t, _)| *t == table);
225        if !same {
226            flush(current.take(), &mut out);
227            current = Some((table, Vec::new()));
228        }
229        let fragment = spanner_type_to_json_schema(&parse_spanner_type(&spanner_type), is_nullable);
230        if let Some((_, cols)) = current.as_mut() {
231            cols.push((column, fragment));
232        }
233    }
234    flush(current, &mut out);
235    out
236}
237
238/// Derive a default state-store key from the database path + a query
239/// fingerprint, stable across runs.
240fn default_state_key(config: &SpannerSourceConfig) -> String {
241    let mut hasher = std::collections::hash_map::DefaultHasher::new();
242    config.query.hash(&mut hasher);
243    let fingerprint = hasher.finish();
244    // Project/instance/database ids are [a-z0-9-] by Spanner's naming rules;
245    // sanitise defensively anyway (`:` is the key-segment separator).
246    let path: String = format!(
247        "{}.{}.{}",
248        config.connection.project_id, config.connection.instance, config.connection.database
249    )
250    .chars()
251    .map(|c| {
252        if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
253            c
254        } else {
255            '_'
256        }
257    })
258    .collect();
259    format!("spanner:{path}:{fingerprint:016x}")
260}
261
262#[async_trait]
263impl Source for SpannerSource {
264    async fn fetch_with_context(
265        &self,
266        context: &HashMap<String, Value>,
267    ) -> Result<Vec<Value>, FaucetError> {
268        Ok(self.collect_all(context).await?.0)
269    }
270
271    async fn fetch_with_context_incremental(
272        &self,
273        context: &HashMap<String, Value>,
274    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
275        self.collect_all(context).await
276    }
277
278    fn stream_pages<'a>(
279        &'a self,
280        context: &'a HashMap<String, Value>,
281        _batch_size: usize,
282    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
283        let batch_size = self.config.batch_size;
284        let chunk = if batch_size == 0 {
285            usize::MAX
286        } else {
287            batch_size
288        };
289        let cap = if batch_size == 0 { 1024 } else { batch_size };
290        let start = self.current_start();
291        let (query, params, incr) = build_query_and_params(&self.config, context, start.as_ref());
292        let query = self.shard_wrap(query);
293
294        Box::pin(async_stream::try_stream! {
295            let stmt = build_statement(&query, &params)?;
296            let mut tx = self.read_txn().await?;
297            // `enable_resume: false`: the resume path buffers up to 128 MiB
298            // between resume tokens and makes `next()` non-cancel-safe — the
299            // pipeline `select!`s pages against its cancel token, so
300            // cancel-safety wins over transparent stream resumption here.
301            let opts = QueryOptions {
302                enable_resume: false,
303                ..Default::default()
304            };
305            let mut iter = tx
306                .query_with_option(stmt, opts)
307                .await
308                .map_err(|e| FaucetError::Source(format!("spanner: query failed: {e}")))?;
309
310            let mut fields: Option<std::sync::Arc<Vec<_>>> = None;
311            let mut buffer: Vec<Value> = Vec::with_capacity(cap);
312            let mut running_max: Option<Value> = None;
313            let mut total = 0usize;
314
315            while let Some(row) = iter
316                .next()
317                .await
318                .map_err(|e| FaucetError::Source(format!("spanner: row stream failed: {e}")))?
319            {
320                let fields = fields.get_or_insert_with(|| iter.columns_metadata().clone());
321                let record = row_to_json(&row, fields)
322                    .map_err(|e| FaucetError::Source(format!("spanner: row decode failed: {e}")))?;
323                buffer.push(record);
324                if buffer.len() >= chunk {
325                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
326                    let kept = apply_incremental(page, incr.as_ref(), &mut running_max);
327                    total += kept.len();
328                    if !kept.is_empty() {
329                        yield StreamPage { records: kept, bookmark: None };
330                    }
331                }
332            }
333
334            // Final page carries the bookmark so the pipeline persists only
335            // after everything before it has been written.
336            let kept = apply_incremental(buffer, incr.as_ref(), &mut running_max);
337            total += kept.len();
338            let bookmark = if incr.is_some() { running_max.clone() } else { None };
339            if !kept.is_empty() || bookmark.is_some() {
340                yield StreamPage { records: kept, bookmark };
341            }
342
343            tracing::info!(rows = total, query = %self.config.query, "spanner source stream complete");
344        })
345    }
346
347    fn config_schema(&self) -> Value {
348        serde_json::to_value(faucet_core::schema_for!(SpannerSourceConfig))
349            .expect("schema serialization")
350    }
351
352    fn connector_name(&self) -> &'static str {
353        "spanner"
354    }
355
356    fn dataset_uri(&self) -> String {
357        format!(
358            "spanner://{}/{}/{}?query={}",
359            self.config.connection.project_id,
360            self.config.connection.instance,
361            self.config.connection.database,
362            self.config.query
363        )
364    }
365
366    fn state_key(&self) -> Option<String> {
367        match &self.config.replication {
368            SpannerReplication::Full => None,
369            SpannerReplication::Incremental { .. } => Some(
370                self.config
371                    .state_key
372                    .clone()
373                    .unwrap_or_else(|| default_state_key(&self.config)),
374            ),
375        }
376    }
377
378    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
379        *self
380            .start_bookmark
381            .lock()
382            .expect("start_bookmark mutex poisoned") = Some(bookmark);
383        Ok(())
384    }
385
386    fn supports_discover(&self) -> bool {
387        true
388    }
389
390    /// Enumerate every base table in the database's default schema with
391    /// column types from `INFORMATION_SCHEMA.COLUMNS` (catalog metadata only
392    /// — no data scan). System schemas (`INFORMATION_SCHEMA`, `SPANNER_SYS`)
393    /// carry a non-empty `TABLE_SCHEMA`, so filtering on the default (empty)
394    /// schema excludes them. Spanner exposes no cheap row estimate, so
395    /// descriptors carry none.
396    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
397        const CATALOG_SQL: &str = "SELECT c.TABLE_NAME, c.COLUMN_NAME, c.SPANNER_TYPE, \
398                c.IS_NULLABLE \
399           FROM INFORMATION_SCHEMA.COLUMNS AS c \
400           JOIN INFORMATION_SCHEMA.TABLES AS t \
401             ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME \
402          WHERE t.TABLE_TYPE = 'BASE TABLE' AND t.TABLE_SCHEMA = '' \
403          ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION";
404
405        let map_err =
406            |e: String| FaucetError::Source(format!("spanner: catalog discovery failed: {e}"));
407
408        let mut tx = self.read_txn().await?;
409        let mut iter = tx
410            .query(Statement::new(CATALOG_SQL))
411            .await
412            .map_err(|e| map_err(e.to_string()))?;
413
414        let mut catalog: Vec<CatalogRow> = Vec::new();
415        while let Some(row) = iter.next().await.map_err(|e| map_err(e.to_string()))? {
416            let table: String = row
417                .column_by_name("TABLE_NAME")
418                .map_err(|e| map_err(e.to_string()))?;
419            let column: String = row
420                .column_by_name("COLUMN_NAME")
421                .map_err(|e| map_err(e.to_string()))?;
422            let spanner_type: String = row
423                .column_by_name("SPANNER_TYPE")
424                .map_err(|e| map_err(e.to_string()))?;
425            // Absent/odd IS_NULLABLE over-approximates to nullable.
426            let nullable: String = row
427                .column_by_name("IS_NULLABLE")
428                .unwrap_or_else(|_| "YES".to_string());
429            catalog.push((
430                table,
431                column,
432                spanner_type,
433                nullable.eq_ignore_ascii_case("YES"),
434            ));
435        }
436
437        Ok(descriptors_from_catalog(catalog))
438    }
439
440    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
441    fn is_shardable(&self) -> bool {
442        self.config.shard.is_some()
443    }
444
445    /// Enumerate contiguous primary-key range shards by computing the `key`
446    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting
447    /// that range into ~`target` slices. Returns a single whole-dataset shard
448    /// when no `shard` config is set or the result set is empty.
449    ///
450    /// The base query is resolved through the same query builder as a normal
451    /// fetch first, so a `@bookmark` parameter (incremental replication) is
452    /// bound rather than left dangling — bounds are then computed over the
453    /// not-yet-synced slice, which is exactly the data the shards will read.
454    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
455        let Some(shard_cfg) = &self.config.shard else {
456            return Ok(vec![ShardSpec::whole()]);
457        };
458
459        let start = self.current_start();
460        let (inner, params, _incr) =
461            build_query_and_params(&self.config, &HashMap::new(), start.as_ref());
462        let key = quote_ident_spanner(&shard_cfg.key);
463        let bounds_sql = pk_bounds_query(&inner, &key, "INT64");
464
465        let map_err = |e: String| {
466            FaucetError::Source(format!(
467                "spanner: failed to compute shard bounds for key {:?} \
468                 (it must be an INT64-typed column present in the query's output): {e}",
469                shard_cfg.key
470            ))
471        };
472
473        let stmt = build_statement(&bounds_sql, &params)?;
474        let mut tx = self.read_txn().await?;
475        let mut iter = tx.query(stmt).await.map_err(|e| map_err(e.to_string()))?;
476        let row = iter.next().await.map_err(|e| map_err(e.to_string()))?;
477        let Some(row) = row else {
478            return Ok(vec![ShardSpec::whole()]);
479        };
480        let lo: Option<i64> = row
481            .column_by_name("lo")
482            .map_err(|e| map_err(e.to_string()))?;
483        let hi: Option<i64> = row
484            .column_by_name("hi")
485            .map_err(|e| map_err(e.to_string()))?;
486        Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
487    }
488
489    /// Narrow this source to a single PK-range shard. The whole-dataset shard
490    /// clears any applied range (streams the full query).
491    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
492        let bounds = parse_pk_shard(shard, "spanner")?;
493        *self.applied_shard.lock().expect("shard mutex poisoned") = bounds;
494        Ok(())
495    }
496}
497
498impl SpannerSource {
499    /// Run the query and return all decoded rows plus (for incremental) the
500    /// new bookmark. Used by the non-streaming convenience methods.
501    async fn collect_all(
502        &self,
503        context: &HashMap<String, Value>,
504    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
505        use futures::TryStreamExt;
506        let mut records: Vec<Value> = Vec::new();
507        let mut bookmark: Option<Value> = None;
508        let mut stream = self.stream_pages(context, self.config.batch_size);
509        while let Some(page) = stream.try_next().await? {
510            records.extend(page.records);
511            if page.bookmark.is_some() {
512                bookmark = page.bookmark;
513            }
514        }
515        Ok((records, bookmark))
516    }
517}
518
519/// Filter a page for incremental replication and advance `running_max`.
520/// For full replication the page passes through unchanged.
521fn apply_incremental(
522    page: Vec<Value>,
523    incr: Option<&IncrementalCtx>,
524    running_max: &mut Option<Value>,
525) -> Vec<Value> {
526    match incr {
527        None => page,
528        Some(ctx) => {
529            let kept = filter_incremental(page, &ctx.column, &ctx.start);
530            if let Some(m) = max_replication_value(&kept, &ctx.column) {
531                let m = m.clone();
532                *running_max = Some(match running_max.take() {
533                    Some(prev) => max_value(prev, m),
534                    None => m,
535                });
536            }
537            kept
538        }
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use faucet_core::shard::plan_pk_shards;
546    use serde_json::json;
547
548    fn base() -> SpannerSourceConfig {
549        SpannerSourceConfig::new("proj", "inst", "db", "SELECT * FROM t")
550    }
551
552    #[test]
553    fn build_full_returns_query_and_sorted_params() {
554        let mut cfg = base();
555        cfg.params.insert("zeta".into(), json!(1));
556        cfg.params.insert("alpha".into(), json!("x"));
557        let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
558        assert_eq!(q, "SELECT * FROM t");
559        assert_eq!(
560            p,
561            vec![
562                ("alpha".to_string(), json!("x")),
563                ("zeta".to_string(), json!(1))
564            ],
565            "params bind in deterministic (sorted) order"
566        );
567        assert!(incr.is_none());
568    }
569
570    #[test]
571    fn build_incremental_binds_bookmark_param() {
572        let mut cfg = base();
573        cfg.query = "SELECT * FROM t WHERE updated_at > @bookmark".into();
574        cfg.replication = SpannerReplication::Incremental {
575            column: "updated_at".into(),
576            initial_value: json!("1970-01-01"),
577        };
578        let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
579        assert_eq!(q, "SELECT * FROM t WHERE updated_at > @bookmark");
580        assert_eq!(p, vec![("bookmark".to_string(), json!("1970-01-01"))]);
581        assert_eq!(
582            incr,
583            Some(IncrementalCtx {
584                column: "updated_at".into(),
585                start: json!("1970-01-01")
586            })
587        );
588    }
589
590    #[test]
591    fn build_incremental_uses_stored_bookmark_over_initial() {
592        let mut cfg = base();
593        cfg.query = "SELECT * FROM t WHERE c > @bookmark".into();
594        cfg.replication = SpannerReplication::Incremental {
595            column: "c".into(),
596            initial_value: json!(0),
597        };
598        let stored = json!(500);
599        let (_q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), Some(&stored));
600        assert_eq!(p, vec![("bookmark".to_string(), json!(500))]);
601        assert_eq!(incr.unwrap().start, json!(500));
602    }
603
604    #[test]
605    fn build_incremental_without_token_still_returns_filter_ctx() {
606        let mut cfg = base();
607        cfg.replication = SpannerReplication::Incremental {
608            column: "c".into(),
609            initial_value: json!(0),
610        };
611        let (q, p, incr) = build_query_and_params(&cfg, &HashMap::new(), None);
612        assert_eq!(q, "SELECT * FROM t");
613        assert!(p.is_empty());
614        assert!(incr.is_some(), "client-side filter must still run");
615    }
616
617    #[test]
618    fn build_context_binds_generated_named_params() {
619        let mut cfg = base();
620        cfg.query = "SELECT * FROM t WHERE tenant = {parent.id}".into();
621        let mut ctx = HashMap::new();
622        ctx.insert("parent.id".to_string(), json!(7));
623        let (q, p, _incr) = build_query_and_params(&cfg, &ctx, None);
624        assert_eq!(q, "SELECT * FROM t WHERE tenant = @_faucet_ctx_1");
625        assert_eq!(p, vec![("_faucet_ctx_1".to_string(), json!(7))]);
626    }
627
628    #[test]
629    fn build_statement_binds_scalars_and_rejects_containers() {
630        let stmt = build_statement(
631            "SELECT 1",
632            &[
633                ("s".to_string(), json!("x")),
634                ("i".to_string(), json!(7)),
635                ("f".to_string(), json!(1.5)),
636                ("b".to_string(), json!(true)),
637            ],
638        );
639        assert!(stmt.is_ok());
640
641        let err = build_statement("SELECT 1", &[("o".to_string(), json!({"a": 1}))]);
642        assert!(matches!(err, Err(FaucetError::Config(_))));
643        let err = build_statement("SELECT 1", &[("n".to_string(), Value::Null)]);
644        assert!(matches!(err, Err(FaucetError::Config(_))));
645        let err = build_statement("SELECT 1", &[("u".to_string(), json!(u64::MAX))]);
646        assert!(matches!(err, Err(FaucetError::Config(_))));
647    }
648
649    #[test]
650    fn apply_incremental_filters_and_tracks_max() {
651        let ctx = IncrementalCtx {
652            column: "c".into(),
653            start: json!(10),
654        };
655        let mut running = None;
656        let page = vec![json!({"c": 5}), json!({"c": 15}), json!({"c": 20})];
657        let kept = apply_incremental(page, Some(&ctx), &mut running);
658        assert_eq!(kept.len(), 2);
659        assert_eq!(running, Some(json!(20)));
660    }
661
662    #[test]
663    fn apply_incremental_full_passes_through() {
664        let mut running = None;
665        let page = vec![json!({"c": 1}), json!({"c": 2})];
666        let kept = apply_incremental(page, None, &mut running);
667        assert_eq!(kept.len(), 2);
668        assert_eq!(running, None);
669    }
670
671    #[test]
672    fn default_state_key_is_stable_and_valid() {
673        let cfg = base();
674        let k1 = default_state_key(&cfg);
675        let k2 = default_state_key(&cfg);
676        assert_eq!(k1, k2);
677        assert!(k1.starts_with("spanner:proj.inst.db:"));
678        faucet_core::state::validate_state_key(&k1).expect("derived key must be valid");
679    }
680
681    #[test]
682    fn dataset_uri_shape() {
683        let cfg = base();
684        let uri = format!(
685            "spanner://{}/{}/{}?query={}",
686            cfg.connection.project_id, cfg.connection.instance, cfg.connection.database, cfg.query
687        );
688        assert_eq!(uri, "spanner://proj/inst/db?query=SELECT * FROM t");
689    }
690
691    // ── PK-range sharding (Mode B) ──────────────────────────────────────────
692
693    #[test]
694    fn shard_wrap_uses_backtick_quoting() {
695        let spec = faucet_core::ShardSpec::new(
696            "1",
697            json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
698        );
699        let bounds = PkShardBounds::from_spec(&spec).unwrap();
700        let sql = bounds.wrap("SELECT * FROM t", quote_ident_spanner);
701        assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"), "{sql}");
702        assert!(sql.contains("`id` >= 100"), "backtick-quoted key: {sql}");
703        assert!(sql.contains("`id` < 200"), "half-open upper bound: {sql}");
704    }
705
706    #[test]
707    fn last_shard_wrap_covers_null_keys() {
708        let shards = plan_pk_shards("id", 0, 99, 3);
709        let last = PkShardBounds::from_spec(shards.last().unwrap()).unwrap();
710        let sql = last.wrap("SELECT * FROM t", quote_ident_spanner);
711        assert!(
712            sql.contains("`id` IS NULL"),
713            "last shard must match NULL keys: {sql}"
714        );
715    }
716
717    #[test]
718    fn shard_wrap_preserves_named_bind_params() {
719        // The @name parameters of a resolved incremental query survive the
720        // wrap — Spanner binds them by name, not by textual position.
721        let mut cfg = base();
722        cfg.query = "SELECT * FROM t WHERE c > @bookmark".into();
723        cfg.replication = SpannerReplication::Incremental {
724            column: "c".into(),
725            initial_value: json!(0),
726        };
727        let (q, p, _incr) = build_query_and_params(&cfg, &HashMap::new(), None);
728        let spec = faucet_core::ShardSpec::new(
729            "0",
730            json!({"key": "id", "lo": 0, "hi": 10, "lo_unbounded": false, "hi_unbounded": false}),
731        );
732        let wrapped = PkShardBounds::from_spec(&spec)
733            .unwrap()
734            .wrap(&q, quote_ident_spanner);
735        assert!(
736            wrapped.contains("@bookmark"),
737            "bind param survives: {wrapped}"
738        );
739        assert_eq!(p.len(), 1, "one bound value for the bookmark");
740    }
741
742    #[test]
743    fn bounds_query_uses_int64_cast() {
744        let sql = pk_bounds_query("SELECT * FROM t", "`id`", "INT64");
745        assert!(sql.contains("CAST(MIN(`id`) AS INT64) AS lo"), "{sql}");
746        assert!(
747            sql.contains("FROM (SELECT * FROM t) AS _faucet_bounds"),
748            "{sql}"
749        );
750    }
751
752    // ── discover: pure catalog-row grouping ─────────────────────────────────
753
754    fn cat_row(t: &str, c: &str, ty: &str, nullable: bool) -> CatalogRow {
755        (t.into(), c.into(), ty.into(), nullable)
756    }
757
758    #[test]
759    fn descriptors_group_catalog_rows_per_table() {
760        let rows = vec![
761            cat_row("orders", "id", "INT64", false),
762            cat_row("orders", "note", "STRING(MAX)", true),
763            cat_row("users", "score", "FLOAT64", false),
764        ];
765        let ds = descriptors_from_catalog(rows);
766        assert_eq!(ds.len(), 2);
767
768        assert_eq!(ds[0].name, "orders");
769        assert_eq!(ds[0].kind, "table");
770        assert_eq!(ds[0].estimated_rows, None, "spanner has no cheap estimate");
771        assert_eq!(ds[0].config_patch["query"], "SELECT * FROM `orders`");
772        let schema = ds[0].schema.as_ref().unwrap();
773        assert_eq!(schema["type"], "object");
774        assert_eq!(schema["properties"]["id"]["type"], "integer");
775        assert_eq!(
776            schema["properties"]["note"]["type"],
777            json!(["string", "null"]),
778            "nullable column"
779        );
780
781        assert_eq!(ds[1].name, "users");
782        assert_eq!(
783            ds[1].schema.as_ref().unwrap()["properties"]["score"]["type"],
784            "number"
785        );
786    }
787
788    #[test]
789    fn descriptors_quote_hostile_identifiers() {
790        let rows = vec![cat_row("we`ird", "id", "INT64", false)];
791        let ds = descriptors_from_catalog(rows);
792        let q = ds[0].config_patch["query"].as_str().unwrap();
793        assert_eq!(
794            q, "SELECT * FROM `we``ird`",
795            "interior backtick must be doubled"
796        );
797    }
798
799    #[test]
800    fn descriptors_empty_catalog_is_empty() {
801        assert!(descriptors_from_catalog(Vec::new()).is_empty());
802    }
803}