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