Skip to main content

faucet_source_postgres/
stream.rs

1//! PostgreSQL source implementation.
2
3use crate::config::PostgresSourceConfig;
4use async_trait::async_trait;
5use faucet_core::shard::{
6    PkShardBounds, ShardSpec, parse_pk_shard, pk_bounds_query, pk_shards_from_bounds,
7};
8use faucet_core::util::quote_ident;
9use faucet_core::{FaucetError, Stream, StreamPage};
10use futures::TryStreamExt;
11use serde_json::Value;
12use sqlx::postgres::PgPoolOptions;
13use sqlx::{Column, PgPool, Row};
14use std::pin::Pin;
15use std::sync::Mutex;
16
17/// A source that executes a SQL query against PostgreSQL and returns rows as JSON.
18pub struct PostgresSource {
19    config: PostgresSourceConfig,
20    pool: PgPool,
21    /// Shard applied by the cluster coordinator (Mode B), if any. `None` (or the
22    /// whole-dataset shard) means the full query is streamed. Stored behind a
23    /// `Mutex` so `apply_shard(&self, …)` can record it before streaming.
24    applied_shard: Mutex<Option<PkShardBounds>>,
25}
26
27impl PostgresSource {
28    /// Create a new PostgreSQL source. Establishes a connection pool.
29    pub async fn new(config: PostgresSourceConfig) -> Result<Self, FaucetError> {
30        faucet_core::validate_batch_size(config.batch_size)?;
31
32        let pool = PgPoolOptions::new()
33            .max_connections(config.max_connections)
34            .connect(&config.connection_url)
35            .await
36            .map_err(|e| FaucetError::Config(format!("PostgreSQL connection failed: {e}")))?;
37
38        Ok(Self {
39            config,
40            pool,
41            applied_shard: Mutex::new(None),
42        })
43    }
44
45    /// Apply the currently-set shard (if any) to a resolved query string.
46    fn shard_wrap(&self, query: String) -> String {
47        match &*self.applied_shard.lock().expect("shard mutex poisoned") {
48            Some(bounds) => bounds.wrap(&query, quote_ident),
49            None => query,
50        }
51    }
52}
53
54/// Convert a raw sqlx column value to a `serde_json::Value`.
55///
56/// Uses `try_get_raw` to inspect the type info and convert accordingly.
57/// Falls back to `Value::Null` for unsupported or null columns.
58fn pg_value_to_json(row: &sqlx::postgres::PgRow, col_name: &str) -> Value {
59    // Try JSON/JSONB first — this is the most flexible
60    if let Ok(v) = row.try_get::<Value, _>(col_name) {
61        return v;
62    }
63
64    // Try common scalar types
65    if let Ok(v) = row.try_get::<String, _>(col_name) {
66        return Value::String(v);
67    }
68    if let Ok(v) = row.try_get::<i64, _>(col_name) {
69        return Value::Number(v.into());
70    }
71    if let Ok(v) = row.try_get::<i32, _>(col_name) {
72        return Value::Number(v.into());
73    }
74    if let Ok(v) = row.try_get::<i16, _>(col_name) {
75        return Value::Number(v.into());
76    }
77    if let Ok(v) = row.try_get::<f64, _>(col_name) {
78        return serde_json::Number::from_f64(v)
79            .map(Value::Number)
80            .unwrap_or(Value::Null);
81    }
82    if let Ok(v) = row.try_get::<f32, _>(col_name) {
83        return serde_json::Number::from_f64(v as f64)
84            .map(Value::Number)
85            .unwrap_or(Value::Null);
86    }
87    if let Ok(v) = row.try_get::<bool, _>(col_name) {
88        return Value::Bool(v);
89    }
90
91    // Richer types that would otherwise silently decode to Null (#78/#43).
92    // Timestamps → RFC3339 / ISO-8601 strings.
93    if let Ok(v) =
94        row.try_get::<sqlx::types::chrono::DateTime<sqlx::types::chrono::Utc>, _>(col_name)
95    {
96        return Value::String(v.to_rfc3339());
97    }
98    if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveDateTime, _>(col_name) {
99        return Value::String(v.to_string());
100    }
101    if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveDate, _>(col_name) {
102        return Value::String(v.to_string());
103    }
104    if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveTime, _>(col_name) {
105        return Value::String(v.to_string());
106    }
107    // UUID → canonical hyphenated string.
108    if let Ok(v) = row.try_get::<sqlx::types::Uuid, _>(col_name) {
109        return Value::String(v.to_string());
110    }
111    // NUMERIC / DECIMAL → string, preserving exact precision.
112    if let Ok(v) = row.try_get::<sqlx::types::BigDecimal, _>(col_name) {
113        return Value::String(v.to_string());
114    }
115    // BYTEA → base64 (so binary survives the JSON round-trip).
116    if let Ok(v) = row.try_get::<Vec<u8>, _>(col_name) {
117        use base64::Engine as _;
118        return Value::String(base64::engine::general_purpose::STANDARD.encode(v));
119    }
120
121    Value::Null
122}
123
124/// Build the effective SQL query and ordered context-bind values for a given
125/// parent context. Returns the literal query when there is no context.
126fn resolve_query(
127    config: &PostgresSourceConfig,
128    context: &std::collections::HashMap<String, Value>,
129) -> (String, Vec<Value>) {
130    if context.is_empty() {
131        (config.query.clone(), Vec::new())
132    } else {
133        faucet_core::util::substitute_context_bind_params(
134            &config.query,
135            context,
136            config.params.len() + 1,
137            |i| format!("${i}"),
138        )
139    }
140}
141
142/// How a numeric bind value should be bound onto a sqlx query.
143///
144/// Classifying *before* binding keeps the integer/float decision in one pure,
145/// unit-testable place and — critically — binds any integer in
146/// `[i64::MIN, i64::MAX]` as an exact `i64` rather than an `f64`. Binding an
147/// integer above `2^53` as `f64` silently rounds it (audit F38), so a large
148/// 64-bit id threaded into `WHERE id = $1` would compare against the *wrong*
149/// value and return wrong rows.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum NumberBind {
152    /// Exact `i64` — covers every integer in `[i64::MIN, i64::MAX]`.
153    I64,
154    /// Value above `i64::MAX`; bind the `u64` reinterpreted as `i64` (two's
155    /// complement) so the bytes round-trip into an `int8`/`bigint` column.
156    U64,
157    /// Genuine floating-point value — bind as `f64`.
158    F64,
159}
160
161/// Classify a JSON number into the bind category to use.
162///
163/// `is_i64()` losslessly covers `[i64::MIN, i64::MAX]` (including the
164/// `(2^53, i64::MAX]` range that `f64` would round); `is_u64()` covers values
165/// above `i64::MAX`; everything else is a real float.
166fn classify_number(n: &serde_json::Number) -> NumberBind {
167    if n.is_i64() {
168        NumberBind::I64
169    } else if n.is_u64() {
170        NumberBind::U64
171    } else {
172        NumberBind::F64
173    }
174}
175
176/// Apply configured params followed by context-derived bind values onto a
177/// sqlx query.
178fn bind_params<'q>(
179    mut query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
180    config_params: &'q [Value],
181    bind_values: &'q [Value],
182) -> Result<sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>, FaucetError> {
183    // Bind the static config params and the per-context values as native
184    // scalar types, in positional order ($1, $2, …). Binding a raw
185    // `serde_json::Value` encodes it as `jsonb` (sqlx), which breaks comparisons
186    // against typed columns — e.g. `WHERE id = $1` against an integer column
187    // fails with "operator does not exist: integer = jsonb". config_params
188    // previously bound the raw Value and hit exactly this (audit #146 H12).
189    for (i, value) in config_params.iter().chain(bind_values).enumerate() {
190        query = match value {
191            Value::String(s) => query.bind(s.clone()),
192            Value::Number(n) => match classify_number(n) {
193                // `unwrap()` is sound: the classifier proves the predicate.
194                NumberBind::I64 => query.bind(n.as_i64().unwrap()),
195                // Above `i64::MAX`. Postgres has no unsigned integer type, and
196                // `as i64` would *wrap* — writing a large id as a large negative
197                // number, or (when this binds an incremental bookmark) comparing
198                // against a negative bound and re-reading or skipping rows. The
199                // original intent here was to avoid an `f64` cast's precision
200                // loss, which is right; bit-reinterpretation is not the way to
201                // get it. Refuse instead (#462).
202                NumberBind::U64 => query.bind(faucet_core::util::u64_to_signed(
203                    n.as_u64().unwrap(),
204                    &format!("bind parameter ${}", i + 1),
205                )?),
206                NumberBind::F64 => query.bind(n.as_f64().unwrap_or(0.0)),
207            },
208            Value::Bool(b) => query.bind(*b),
209            Value::Null => query.bind(None::<String>),
210            _ => query.bind(value.to_string()),
211        };
212    }
213    Ok(query)
214}
215
216/// One flattened `information_schema.columns` row used by [`discover`].
217///
218/// (schema, table, column, data_type, is_nullable, estimated_rows)
219type CatalogRow = (String, String, String, String, bool, Option<i64>);
220
221/// A table mid-accumulation while grouping catalog rows:
222/// (schema, table, estimated_rows, columns).
223type PendingTable = (String, String, Option<i64>, Vec<(String, Value)>);
224
225/// Group flattened catalog rows (ordered by schema, table, ordinal position)
226/// into one [`DatasetDescriptor`] per table. Pure — unit-testable without a
227/// live server. `quote` is the dialect's identifier quoter.
228fn descriptors_from_catalog(
229    rows: Vec<CatalogRow>,
230    quote: fn(&str) -> String,
231) -> Vec<faucet_core::DatasetDescriptor> {
232    let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
233    let mut current: Option<PendingTable> = None;
234
235    let flush = |cur: Option<PendingTable>, out: &mut Vec<faucet_core::DatasetDescriptor>| {
236        if let Some((schema, table, est, cols)) = cur {
237            let query = format!("SELECT * FROM {}.{}", quote(&schema), quote(&table));
238            let mut d = faucet_core::DatasetDescriptor::new(
239                format!("{schema}.{table}"),
240                "table",
241                serde_json::json!({ "query": query }),
242            )
243            .with_schema(faucet_core::columns_to_schema(cols));
244            // reltuples is -1 for a never-analyzed table — no estimate.
245            if let Some(n) = est
246                && n >= 0
247            {
248                d = d.with_estimated_rows(n as u64);
249            }
250            out.push(d);
251        }
252    };
253
254    for (schema, table, column, data_type, is_nullable, est) in rows {
255        let same = current
256            .as_ref()
257            .is_some_and(|(s, t, _, _)| *s == schema && *t == table);
258        if !same {
259            flush(current.take(), &mut out);
260            current = Some((schema, table, est, Vec::new()));
261        }
262        let mut fragment = faucet_core::sql_type_to_json_schema(&data_type);
263        if is_nullable {
264            fragment = faucet_core::nullable_type(fragment);
265        }
266        if let Some((_, _, _, cols)) = current.as_mut() {
267            cols.push((column, fragment));
268        }
269    }
270    flush(current, &mut out);
271    out
272}
273
274/// Convert a single `PgRow` into a JSON object whose keys are the row's
275/// column names.
276fn row_to_json(row: &sqlx::postgres::PgRow) -> Value {
277    let mut map = serde_json::Map::new();
278    for col in row.columns() {
279        let name = col.name().to_string();
280        let value = pg_value_to_json(row, &name);
281        map.insert(name, value);
282    }
283    Value::Object(map)
284}
285
286#[async_trait]
287impl faucet_core::Source for PostgresSource {
288    async fn fetch_with_context(
289        &self,
290        context: &std::collections::HashMap<String, serde_json::Value>,
291    ) -> Result<Vec<Value>, FaucetError> {
292        let (query_str, bind_values) = resolve_query(&self.config, context);
293        let query_str = self.shard_wrap(query_str);
294        let query = bind_params(sqlx::query(&query_str), &self.config.params, &bind_values)?;
295
296        let rows = query
297            .fetch_all(&self.pool)
298            .await
299            .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?;
300
301        let records: Vec<Value> = rows.iter().map(row_to_json).collect();
302        tracing::info!(rows = records.len(), query = %self.config.query, "PostgreSQL source fetch complete");
303        Ok(records)
304    }
305
306    /// Stream rows from the underlying sqlx cursor without buffering the full
307    /// result set. Each emitted [`StreamPage`] holds up to
308    /// [`PostgresSourceConfig::batch_size`] rows.
309    ///
310    /// The trait-level `batch_size` argument is ignored in favour of the
311    /// config field — the config is the user-facing knob the README
312    /// documents, and routing the pipeline-supplied hint through it would
313    /// silently override an explicit config value.
314    ///
315    /// `batch_size = 0` drains the entire cursor into a single page. The
316    /// postgres query source has no incremental-replication mode today, so
317    /// every emitted page carries `bookmark: None`.
318    fn stream_pages<'a>(
319        &'a self,
320        context: &'a std::collections::HashMap<String, Value>,
321        _batch_size: usize,
322    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
323        let batch_size = self.config.batch_size;
324
325        Box::pin(async_stream::try_stream! {
326            let (query_str, bind_values) = resolve_query(&self.config, context);
327            let query_str = self.shard_wrap(query_str);
328            let query = bind_params(
329                sqlx::query(&query_str),
330                &self.config.params,
331                &bind_values,
332            )?;
333
334            let mut rows = query.fetch(&self.pool);
335            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
336            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
337            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
338            let mut total = 0usize;
339
340            while let Some(row) = rows
341                .try_next()
342                .await
343                .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?
344            {
345                buffer.push(row_to_json(&row));
346                if buffer.len() >= chunk {
347                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
348                    total += page.len();
349                    yield StreamPage { records: page, bookmark: None };
350                }
351            }
352            if !buffer.is_empty() {
353                total += buffer.len();
354                yield StreamPage { records: buffer, bookmark: None };
355            }
356
357            tracing::info!(
358                rows = total,
359                batch_size,
360                query = %self.config.query,
361                "PostgreSQL source stream complete",
362            );
363        })
364    }
365
366    fn connector_name(&self) -> &'static str {
367        "postgres"
368    }
369
370    fn config_schema(&self) -> serde_json::Value {
371        serde_json::to_value(faucet_core::schema_for!(PostgresSourceConfig))
372            .expect("schema serialization")
373    }
374
375    fn dataset_uri(&self) -> String {
376        format!(
377            "{}?query={}",
378            faucet_core::redact_uri_credentials(&self.config.connection_url),
379            self.config.query
380        )
381    }
382
383    fn supports_discover(&self) -> bool {
384        true
385    }
386
387    /// Enumerate every base table outside `pg_catalog` / `information_schema`,
388    /// with column types from `information_schema.columns` and a row estimate
389    /// from `pg_class.reltuples` (catalog metadata only — no data scan).
390    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
391        let sql = r#"
392            SELECT c.table_schema, c.table_name, c.column_name, c.data_type,
393                   (c.is_nullable = 'YES') AS is_nullable,
394                   (SELECT pc.reltuples::bigint
395                      FROM pg_class pc
396                      JOIN pg_namespace pn ON pn.oid = pc.relnamespace
397                     WHERE pn.nspname = c.table_schema
398                       AND pc.relname = c.table_name) AS estimated_rows
399              FROM information_schema.columns c
400              JOIN information_schema.tables t
401                ON t.table_schema = c.table_schema AND t.table_name = c.table_name
402             WHERE t.table_type = 'BASE TABLE'
403               AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
404             ORDER BY c.table_schema, c.table_name, c.ordinal_position"#;
405        let rows = sqlx::query(sql)
406            .fetch_all(&self.pool)
407            .await
408            .map_err(|e| FaucetError::Source(format!("postgres: catalog discovery failed: {e}")))?;
409
410        let catalog: Vec<CatalogRow> = rows
411            .iter()
412            .map(|row| -> Result<CatalogRow, FaucetError> {
413                let decode = |col: &str| -> Result<String, FaucetError> {
414                    row.try_get::<String, _>(col).map_err(|e| {
415                        FaucetError::Source(format!("postgres: catalog decode failed ({col}): {e}"))
416                    })
417                };
418                Ok((
419                    decode("table_schema")?,
420                    decode("table_name")?,
421                    decode("column_name")?,
422                    decode("data_type")?,
423                    row.try_get::<bool, _>("is_nullable").unwrap_or(true),
424                    row.try_get::<i64, _>("estimated_rows").ok(),
425                ))
426            })
427            .collect::<Result<_, _>>()?;
428
429        Ok(descriptors_from_catalog(catalog, quote_ident))
430    }
431
432    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
433    fn is_shardable(&self) -> bool {
434        self.config.shard.is_some()
435    }
436
437    /// Enumerate contiguous primary-key range shards by computing the `key`
438    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting that
439    /// range into ~`target` slices. Returns a single whole-dataset shard when no
440    /// `shard` config is set or the result set is empty.
441    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
442        let Some(shard_cfg) = &self.config.shard else {
443            return Ok(vec![ShardSpec::whole()]);
444        };
445
446        let bounds_sql =
447            pk_bounds_query(&self.config.query, &quote_ident(&shard_cfg.key), "BIGINT");
448        let row = bind_params(sqlx::query(&bounds_sql), &self.config.params, &[])?
449            .fetch_one(&self.pool)
450            .await
451            .map_err(|e| {
452                FaucetError::Source(format!(
453                    "postgres: failed to compute shard bounds for key {:?} \
454                     (it must be an integer-typed column): {e}",
455                    shard_cfg.key
456                ))
457            })?;
458
459        let lo: Option<i64> = row.try_get("lo").map_err(|e| {
460            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
461        })?;
462        let hi: Option<i64> = row.try_get("hi").map_err(|e| {
463            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
464        })?;
465        Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
466    }
467
468    /// Narrow this source to a single PK-range shard. The whole-dataset shard
469    /// clears any applied range (streams the full query).
470    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
471        *self.applied_shard.lock().expect("shard mutex poisoned") =
472            parse_pk_shard(shard, "postgres")?;
473        Ok(())
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use faucet_core::shard::plan_pk_shards;
481
482    /// The shard-bounds type moved to `faucet_core::shard` (#262) so the
483    /// PK-range logic is shared across the SQL sources; alias it so the
484    /// long-standing tests below keep pinning postgres's behavior unchanged.
485    type ShardBounds = PkShardBounds;
486
487    #[tokio::test]
488    async fn new_rejects_out_of_range_batch_size() {
489        let mut config = PostgresSourceConfig::new("postgres://localhost/test", "SELECT 1");
490        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
491        match PostgresSource::new(config).await {
492            Err(faucet_core::FaucetError::Config(m)) => {
493                assert!(m.contains("batch_size"), "got: {m}")
494            }
495            _ => panic!("expected a batch_size Config error"),
496        }
497    }
498
499    // ── F38: numeric bind classification (precision-safe) ───────────────────
500
501    fn num(v: serde_json::Value) -> serde_json::Number {
502        match v {
503            serde_json::Value::Number(n) => n,
504            _ => panic!("not a number"),
505        }
506    }
507
508    #[test]
509    fn classify_small_int_is_i64() {
510        assert_eq!(
511            classify_number(&num(serde_json::json!(42))),
512            NumberBind::I64
513        );
514        assert_eq!(
515            classify_number(&num(serde_json::json!(-7))),
516            NumberBind::I64
517        );
518        assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
519    }
520
521    #[test]
522    fn classify_above_2_pow_53_stays_i64_not_f64() {
523        // The key precision bug: 2^53 + 1 must NOT be bound as f64 (which would
524        // round it). It is a valid i64, so it must classify as I64.
525        let v = 9_007_199_254_740_993i64; // 2^53 + 1
526        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
527    }
528
529    #[test]
530    fn classify_i64_max_is_i64() {
531        assert_eq!(
532            classify_number(&num(serde_json::json!(i64::MAX))),
533            NumberBind::I64
534        );
535        assert_eq!(
536            classify_number(&num(serde_json::json!(i64::MIN))),
537            NumberBind::I64
538        );
539    }
540
541    #[test]
542    fn classify_above_i64_max_is_u64() {
543        // i64::MAX + 1 has no i64 representation but fits u64.
544        let v: u64 = i64::MAX as u64 + 1;
545        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
546        assert_eq!(
547            classify_number(&num(serde_json::json!(u64::MAX))),
548            NumberBind::U64
549        );
550    }
551
552    #[test]
553    fn classify_float_is_f64() {
554        assert_eq!(
555            classify_number(&num(serde_json::json!(3.5))),
556            NumberBind::F64
557        );
558        assert_eq!(
559            classify_number(&num(serde_json::json!(-0.5))),
560            NumberBind::F64
561        );
562    }
563
564    // ── PK-range sharding (pure logic) ──────────────────────────────────────
565
566    #[test]
567    fn plan_pk_shards_covers_full_range_without_gaps_or_overlap() {
568        let shards = plan_pk_shards("id", 0, 99, 4);
569        assert_eq!(shards.len(), 4);
570        // Contiguous half-open interior cuts; boundary shards are open-ended.
571        let mut expected_lo = 0i64;
572        for (i, s) in shards.iter().enumerate() {
573            let d = &s.descriptor;
574            assert_eq!(d["key"], "id");
575            assert_eq!(d["lo"].as_i64().unwrap(), expected_lo);
576            let hi = d["hi"].as_i64().unwrap();
577            let first = i == 0;
578            let last = i == shards.len() - 1;
579            assert_eq!(d["lo_unbounded"].as_bool().unwrap(), first);
580            assert_eq!(d["hi_unbounded"].as_bool().unwrap(), last);
581            expected_lo = hi; // next shard starts where this half-open one ended
582        }
583    }
584
585    #[test]
586    fn plan_pk_shards_never_more_shards_than_values() {
587        // Range [5, 7] has 3 values; asking for 10 shards yields at most 3.
588        let shards = plan_pk_shards("pk", 5, 7, 10);
589        assert!(shards.len() <= 3, "got {} shards", shards.len());
590        assert!(
591            shards[0].descriptor["lo_unbounded"].as_bool().unwrap(),
592            "first shard is unbounded below"
593        );
594        assert!(
595            shards.last().unwrap().descriptor["hi_unbounded"]
596                .as_bool()
597                .unwrap(),
598            "last shard is unbounded above"
599        );
600    }
601
602    #[test]
603    fn plan_pk_shards_single_value_one_shard() {
604        let shards = plan_pk_shards("id", 42, 42, 8);
605        assert_eq!(shards.len(), 1);
606        // A lone shard is open-ended on both sides → the whole dataset.
607        assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
608        assert!(shards[0].descriptor["hi_unbounded"].as_bool().unwrap());
609    }
610
611    #[test]
612    fn plan_pk_shards_target_zero_treated_as_one() {
613        let shards = plan_pk_shards("id", 0, 9, 0);
614        assert_eq!(shards.len(), 1);
615        assert_eq!(shards[0].descriptor["hi"].as_i64().unwrap(), 9);
616    }
617
618    #[test]
619    fn shard_bounds_wrap_builds_half_open_predicate() {
620        // An interior shard (bounded both sides) is half-open `[lo, hi)`.
621        let spec = ShardSpec::new(
622            "1",
623            serde_json::json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
624        );
625        let b = ShardBounds::from_spec(&spec).unwrap();
626        let sql = b.wrap("SELECT * FROM t", quote_ident);
627        assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"));
628        assert!(sql.contains(r#""id" >= 100"#), "got: {sql}");
629        assert!(
630            sql.contains(r#""id" < 200"#),
631            "half-open upper bound: {sql}"
632        );
633    }
634
635    #[test]
636    fn shard_bounds_wrap_first_shard_has_no_lower_bound() {
637        // F54: the first shard omits the `>= lo` floor so keys below the
638        // enumerated MIN are still read.
639        let spec = ShardSpec::new(
640            "0",
641            serde_json::json!({"key": "id", "lo": 0, "hi": 100, "lo_unbounded": true, "hi_unbounded": false}),
642        );
643        let b = ShardBounds::from_spec(&spec).unwrap();
644        let sql = b.wrap("SELECT * FROM t", quote_ident);
645        assert!(sql.contains(r#""id" < 100"#), "upper bound present: {sql}");
646        assert!(!sql.contains(">="), "first shard has no lower floor: {sql}");
647    }
648
649    #[test]
650    fn shard_bounds_wrap_last_shard_has_no_upper_bound() {
651        // F55: the last shard omits the upper bound so keys above the
652        // enumerated MAX are still read.
653        let spec = ShardSpec::new(
654            "2",
655            serde_json::json!({"key": "id", "lo": 200, "hi": 300, "lo_unbounded": false, "hi_unbounded": true}),
656        );
657        let b = ShardBounds::from_spec(&spec).unwrap();
658        let sql = b.wrap("SELECT * FROM t", quote_ident);
659        assert!(sql.contains(r#""id" >= 200"#), "lower bound present: {sql}");
660        assert!(
661            !sql.contains(" < ") && !sql.contains("<="),
662            "last shard has no upper bound: {sql}"
663        );
664    }
665
666    #[test]
667    fn shard_bounds_quotes_key_against_injection() {
668        let spec = ShardSpec::new(
669            "0",
670            serde_json::json!({"key": "weird\"; DROP", "lo": 0, "hi": 1, "lo_unbounded": false, "hi_unbounded": false}),
671        );
672        let b = ShardBounds::from_spec(&spec).unwrap();
673        let sql = b.wrap("SELECT 1", quote_ident);
674        // The doubled quote escaping proves the identifier was quoted, not raw.
675        assert!(
676            sql.contains(r#""weird""; DROP""#),
677            "key must be quoted: {sql}"
678        );
679    }
680
681    #[test]
682    fn shard_bounds_from_spec_rejects_malformed_descriptor() {
683        let spec = ShardSpec::new("0", serde_json::json!({"key": "id"})); // no lo/hi
684        assert!(ShardBounds::from_spec(&spec).is_none());
685        assert!(ShardBounds::from_spec(&ShardSpec::whole()).is_none());
686    }
687
688    // ── F37: NULL-key shard coverage ────────────────────────────────────────
689
690    #[test]
691    fn exactly_one_shard_includes_null() {
692        let shards = plan_pk_shards("id", 0, 99, 5);
693        let null_owners: Vec<usize> = shards
694            .iter()
695            .enumerate()
696            .filter(|(_, s)| s.descriptor["include_null"].as_bool().unwrap_or(false))
697            .map(|(i, _)| i)
698            .collect();
699        assert_eq!(
700            null_owners,
701            vec![shards.len() - 1],
702            "exactly the last shard owns NULL keys"
703        );
704    }
705
706    #[test]
707    fn single_shard_plan_still_owns_null() {
708        // A single value yields one shard; it must still cover NULL keys.
709        let shards = plan_pk_shards("id", 7, 7, 4);
710        assert_eq!(shards.len(), 1);
711        assert!(shards[0].descriptor["include_null"].as_bool().unwrap());
712    }
713
714    #[test]
715    fn last_shard_wrap_emits_is_null_clause() {
716        let shards = plan_pk_shards("id", 0, 99, 3);
717        let last = ShardBounds::from_spec(shards.last().unwrap()).unwrap();
718        let sql = last.wrap("SELECT * FROM t", quote_ident);
719        assert!(
720            sql.contains(r#""id" IS NULL"#),
721            "last shard must match NULL keys: {sql}"
722        );
723        assert!(sql.contains(" OR "), "NULL clause OR'd with range: {sql}");
724    }
725
726    #[test]
727    fn non_last_shard_wrap_omits_is_null_clause() {
728        let shards = plan_pk_shards("id", 0, 99, 3);
729        // First shard is not the last → no NULL clause.
730        let first = ShardBounds::from_spec(&shards[0]).unwrap();
731        let sql = first.wrap("SELECT * FROM t", quote_ident);
732        assert!(
733            !sql.contains("IS NULL"),
734            "non-last shard must not match NULL keys: {sql}"
735        );
736    }
737
738    /// Property check on the generated predicates: OR-ing every shard's WHERE
739    /// predicate must cover (a) every non-NULL key — including values *outside*
740    /// the enumerated `[min, max]` (F54/F55) — exactly once and (b) NULL keys
741    /// exactly once.
742    #[test]
743    fn predicate_coverage_complete_and_non_overlapping() {
744        let (min, max, target) = (0i64, 19i64, 4usize);
745        let bounds: Vec<ShardBounds> = plan_pk_shards("k", min, max, target)
746            .iter()
747            .map(|s| ShardBounds::from_spec(s).unwrap())
748            .collect();
749
750        // The boundary shards model SQL membership: open below for the first
751        // shard, open above for the last.
752        let matches_key = |b: &ShardBounds, key: i64| -> bool {
753            let lower = b.lo_unbounded || key >= b.lo;
754            let upper = b.hi_unbounded || key < b.hi;
755            lower && upper
756        };
757
758        // (a) Every non-NULL key — well below min, in range, and well above max
759        // — matches exactly one shard. Keys outside [min, max] model rows
760        // inserted/backfilled during the coordinate→execute window.
761        for key in (min - 50)..=(max + 50) {
762            let matches = bounds.iter().filter(|b| matches_key(b, key)).count();
763            assert_eq!(matches, 1, "key {key} matched {matches} shards (want 1)");
764        }
765
766        // (b) NULL keys match exactly one shard (the one with include_null).
767        let null_matches = bounds.iter().filter(|b| b.include_null).count();
768        assert_eq!(null_matches, 1, "NULL keys must match exactly one shard");
769    }
770
771    #[test]
772    fn single_shard_wrap_selects_whole_dataset_including_null() {
773        // A lone open-ended shard must select every row, NULL keys included.
774        let shards = plan_pk_shards("id", 7, 7, 1);
775        assert_eq!(shards.len(), 1);
776        let b = ShardBounds::from_spec(&shards[0]).unwrap();
777        let sql = b.wrap("SELECT * FROM t", quote_ident);
778        assert!(sql.contains("WHERE TRUE"), "whole-dataset predicate: {sql}");
779        assert!(!sql.contains(">="), "no bounds on a lone shard: {sql}");
780    }
781
782    // ── discover: pure catalog-row grouping ─────────────────────────────────
783
784    #[test]
785    fn descriptors_group_catalog_rows_per_table() {
786        let rows = vec![
787            (
788                "public".to_string(),
789                "orders".to_string(),
790                "id".to_string(),
791                "integer".to_string(),
792                false,
793                Some(120i64),
794            ),
795            (
796                "public".to_string(),
797                "orders".to_string(),
798                "note".to_string(),
799                "text".to_string(),
800                true,
801                Some(120i64),
802            ),
803            (
804                "sales".to_string(),
805                "orders".to_string(),
806                "total".to_string(),
807                "numeric".to_string(),
808                false,
809                None,
810            ),
811        ];
812        let ds = descriptors_from_catalog(rows, quote_ident);
813        assert_eq!(ds.len(), 2, "same table name in two schemas = two datasets");
814
815        assert_eq!(ds[0].name, "public.orders");
816        assert_eq!(ds[0].kind, "table");
817        assert_eq!(ds[0].estimated_rows, Some(120));
818        assert_eq!(
819            ds[0].config_patch["query"],
820            r#"SELECT * FROM "public"."orders""#
821        );
822        let schema = ds[0].schema.as_ref().unwrap();
823        assert_eq!(schema["properties"]["id"]["type"], "integer");
824        assert_eq!(
825            schema["properties"]["note"]["type"],
826            serde_json::json!(["string", "null"])
827        );
828
829        assert_eq!(ds[1].name, "sales.orders");
830        assert_eq!(ds[1].estimated_rows, None);
831        assert_eq!(schema["type"], "object");
832    }
833
834    #[test]
835    fn descriptors_negative_reltuples_means_no_estimate() {
836        let rows = vec![(
837            "public".to_string(),
838            "fresh".to_string(),
839            "id".to_string(),
840            "bigint".to_string(),
841            false,
842            Some(-1i64),
843        )];
844        let ds = descriptors_from_catalog(rows, quote_ident);
845        assert_eq!(ds.len(), 1);
846        assert_eq!(ds[0].estimated_rows, None, "-1 = never analyzed");
847    }
848
849    #[test]
850    fn descriptors_quote_hostile_identifiers() {
851        let rows = vec![(
852            "public".to_string(),
853            "weird\"; DROP".to_string(),
854            "id".to_string(),
855            "integer".to_string(),
856            false,
857            None,
858        )];
859        let ds = descriptors_from_catalog(rows, quote_ident);
860        let q = ds[0].config_patch["query"].as_str().unwrap();
861        assert!(q.contains(r#""weird""; DROP""#), "quoted identifier: {q}");
862    }
863
864    #[test]
865    fn descriptors_empty_catalog_is_empty() {
866        assert!(descriptors_from_catalog(Vec::new(), quote_ident).is_empty());
867    }
868
869    #[tokio::test]
870    async fn source_advertises_discover() {
871        use faucet_core::Source as _;
872        let config = PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT 1");
873        let source = lazy_source(config);
874        assert!(source.supports_discover());
875        // Against an unreachable server the catalog query surfaces the typed
876        // discovery error (exercises the error path without Docker).
877        let err = source.discover().await.unwrap_err();
878        assert!(
879            err.to_string().contains("catalog discovery failed"),
880            "typed error: {err}"
881        );
882    }
883
884    // dataset_uri is a pure-config method; the source requires a live DB to
885    // construct so we test it via a config-derived assertion instead.
886    #[test]
887    fn dataset_uri_strips_credentials() {
888        // We cannot construct PostgresSource offline, so we verify the
889        // credential-stripping logic used by dataset_uri() directly.
890        let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
891        let uri = format!("{}?query={}", redacted, "SELECT 1");
892        assert_eq!(uri, "postgres://h:5432/db?query=SELECT 1");
893    }
894
895    /// Build a source over a lazy pool (no server needed) so the shard glue —
896    /// `apply_shard`, `shard_wrap`, and `enumerate_shards`' error path — is
897    /// testable without Docker.
898    fn lazy_source(config: PostgresSourceConfig) -> PostgresSource {
899        let pool = PgPoolOptions::new()
900            // Fail fast at first checkout — these tests never reach a server.
901            .acquire_timeout(std::time::Duration::from_millis(200))
902            .connect_lazy(&config.connection_url)
903            .expect("lazy pool");
904        PostgresSource {
905            config,
906            pool,
907            applied_shard: Mutex::new(None),
908        }
909    }
910
911    #[tokio::test]
912    async fn apply_shard_then_shard_wrap_narrows_query() {
913        use faucet_core::Source as _;
914        let mut config =
915            PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT * FROM t");
916        config.shard = Some(crate::config::ShardConfig { key: "id".into() });
917        let source = lazy_source(config);
918        assert!(source.is_shardable());
919
920        // No shard applied / whole shard applied → query passes through.
921        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
922        source
923            .apply_shard(&faucet_core::ShardSpec::whole())
924            .await
925            .unwrap();
926        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
927
928        // A real shard narrows with ANSI double-quote quoting.
929        let spec = &plan_pk_shards("id", 0, 99, 2)[0];
930        source.apply_shard(spec).await.unwrap();
931        let wrapped = source.shard_wrap("SELECT * FROM t".into());
932        assert!(wrapped.contains(r#""id""#), "got: {wrapped}");
933        assert!(wrapped.contains("_faucet_shard"), "got: {wrapped}");
934
935        // Enumeration against the unreachable server surfaces the bounds-probe
936        // error path.
937        let err = source.enumerate_shards(4).await.unwrap_err();
938        assert!(
939            err.to_string().contains("shard bounds"),
940            "expected bounds-probe error, got: {err}"
941        );
942    }
943}
944
945#[cfg(test)]
946mod bind_overflow_tests {
947    use super::*;
948    use serde_json::json;
949
950    /// #462: above `i64::MAX` Postgres has no type that fits, and `as i64` would
951    /// wrap to a negative. Refuse loudly instead — silently binding a negative
952    /// bookmark would make `WHERE key > $1` re-read or skip rows.
953    #[test]
954    fn u64_above_i64_max_is_refused_not_wrapped() {
955        let err = match bind_params(sqlx::query("SELECT 1"), &[json!(u64::MAX)], &[]) {
956            Err(e) => e.to_string(),
957            Ok(_) => panic!("u64::MAX must not bind"),
958        };
959        assert!(err.contains(&u64::MAX.to_string()), "{err}");
960        assert!(
961            !err.contains("-9223372036854775808"),
962            "must not show the wrap: {err}"
963        );
964    }
965
966    #[test]
967    fn values_a_signed_column_can_hold_still_bind() {
968        for v in [json!(0), json!(-1), json!(i64::MAX), json!(i64::MAX as u64)] {
969            assert!(
970                bind_params(sqlx::query("SELECT 1"), std::slice::from_ref(&v), &[]).is_ok(),
971                "{v} must still bind"
972            );
973        }
974    }
975}