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