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 config_schema(&self) -> serde_json::Value {
367        serde_json::to_value(faucet_core::schema_for!(PostgresSourceConfig))
368            .expect("schema serialization")
369    }
370
371    fn dataset_uri(&self) -> String {
372        format!(
373            "{}?query={}",
374            faucet_core::redact_uri_credentials(&self.config.connection_url),
375            self.config.query
376        )
377    }
378
379    fn supports_discover(&self) -> bool {
380        true
381    }
382
383    /// Enumerate every base table outside `pg_catalog` / `information_schema`,
384    /// with column types from `information_schema.columns` and a row estimate
385    /// from `pg_class.reltuples` (catalog metadata only — no data scan).
386    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
387        let sql = r#"
388            SELECT c.table_schema, c.table_name, c.column_name, c.data_type,
389                   (c.is_nullable = 'YES') AS is_nullable,
390                   (SELECT pc.reltuples::bigint
391                      FROM pg_class pc
392                      JOIN pg_namespace pn ON pn.oid = pc.relnamespace
393                     WHERE pn.nspname = c.table_schema
394                       AND pc.relname = c.table_name) AS estimated_rows
395              FROM information_schema.columns c
396              JOIN information_schema.tables t
397                ON t.table_schema = c.table_schema AND t.table_name = c.table_name
398             WHERE t.table_type = 'BASE TABLE'
399               AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
400             ORDER BY c.table_schema, c.table_name, c.ordinal_position"#;
401        let rows = sqlx::query(sql)
402            .fetch_all(&self.pool)
403            .await
404            .map_err(|e| FaucetError::Source(format!("postgres: catalog discovery failed: {e}")))?;
405
406        let catalog: Vec<CatalogRow> = rows
407            .iter()
408            .map(|row| -> Result<CatalogRow, FaucetError> {
409                let decode = |col: &str| -> Result<String, FaucetError> {
410                    row.try_get::<String, _>(col).map_err(|e| {
411                        FaucetError::Source(format!("postgres: catalog decode failed ({col}): {e}"))
412                    })
413                };
414                Ok((
415                    decode("table_schema")?,
416                    decode("table_name")?,
417                    decode("column_name")?,
418                    decode("data_type")?,
419                    row.try_get::<bool, _>("is_nullable").unwrap_or(true),
420                    row.try_get::<i64, _>("estimated_rows").ok(),
421                ))
422            })
423            .collect::<Result<_, _>>()?;
424
425        Ok(descriptors_from_catalog(catalog, quote_ident))
426    }
427
428    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
429    fn is_shardable(&self) -> bool {
430        self.config.shard.is_some()
431    }
432
433    /// Enumerate contiguous primary-key range shards by computing the `key`
434    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting that
435    /// range into ~`target` slices. Returns a single whole-dataset shard when no
436    /// `shard` config is set or the result set is empty.
437    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
438        let Some(shard_cfg) = &self.config.shard else {
439            return Ok(vec![ShardSpec::whole()]);
440        };
441
442        let bounds_sql =
443            pk_bounds_query(&self.config.query, &quote_ident(&shard_cfg.key), "BIGINT");
444        let row = bind_params(sqlx::query(&bounds_sql), &self.config.params, &[])?
445            .fetch_one(&self.pool)
446            .await
447            .map_err(|e| {
448                FaucetError::Source(format!(
449                    "postgres: failed to compute shard bounds for key {:?} \
450                     (it must be an integer-typed column): {e}",
451                    shard_cfg.key
452                ))
453            })?;
454
455        let lo: Option<i64> = row.try_get("lo").map_err(|e| {
456            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
457        })?;
458        let hi: Option<i64> = row.try_get("hi").map_err(|e| {
459            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
460        })?;
461        Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
462    }
463
464    /// Narrow this source to a single PK-range shard. The whole-dataset shard
465    /// clears any applied range (streams the full query).
466    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
467        *self.applied_shard.lock().expect("shard mutex poisoned") =
468            parse_pk_shard(shard, "postgres")?;
469        Ok(())
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use faucet_core::shard::plan_pk_shards;
477
478    /// The shard-bounds type moved to `faucet_core::shard` (#262) so the
479    /// PK-range logic is shared across the SQL sources; alias it so the
480    /// long-standing tests below keep pinning postgres's behavior unchanged.
481    type ShardBounds = PkShardBounds;
482
483    #[tokio::test]
484    async fn new_rejects_out_of_range_batch_size() {
485        let mut config = PostgresSourceConfig::new("postgres://localhost/test", "SELECT 1");
486        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
487        match PostgresSource::new(config).await {
488            Err(faucet_core::FaucetError::Config(m)) => {
489                assert!(m.contains("batch_size"), "got: {m}")
490            }
491            _ => panic!("expected a batch_size Config error"),
492        }
493    }
494
495    // ── F38: numeric bind classification (precision-safe) ───────────────────
496
497    fn num(v: serde_json::Value) -> serde_json::Number {
498        match v {
499            serde_json::Value::Number(n) => n,
500            _ => panic!("not a number"),
501        }
502    }
503
504    #[test]
505    fn classify_small_int_is_i64() {
506        assert_eq!(
507            classify_number(&num(serde_json::json!(42))),
508            NumberBind::I64
509        );
510        assert_eq!(
511            classify_number(&num(serde_json::json!(-7))),
512            NumberBind::I64
513        );
514        assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
515    }
516
517    #[test]
518    fn classify_above_2_pow_53_stays_i64_not_f64() {
519        // The key precision bug: 2^53 + 1 must NOT be bound as f64 (which would
520        // round it). It is a valid i64, so it must classify as I64.
521        let v = 9_007_199_254_740_993i64; // 2^53 + 1
522        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
523    }
524
525    #[test]
526    fn classify_i64_max_is_i64() {
527        assert_eq!(
528            classify_number(&num(serde_json::json!(i64::MAX))),
529            NumberBind::I64
530        );
531        assert_eq!(
532            classify_number(&num(serde_json::json!(i64::MIN))),
533            NumberBind::I64
534        );
535    }
536
537    #[test]
538    fn classify_above_i64_max_is_u64() {
539        // i64::MAX + 1 has no i64 representation but fits u64.
540        let v: u64 = i64::MAX as u64 + 1;
541        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
542        assert_eq!(
543            classify_number(&num(serde_json::json!(u64::MAX))),
544            NumberBind::U64
545        );
546    }
547
548    #[test]
549    fn classify_float_is_f64() {
550        assert_eq!(
551            classify_number(&num(serde_json::json!(3.5))),
552            NumberBind::F64
553        );
554        assert_eq!(
555            classify_number(&num(serde_json::json!(-0.5))),
556            NumberBind::F64
557        );
558    }
559
560    // ── PK-range sharding (pure logic) ──────────────────────────────────────
561
562    #[test]
563    fn plan_pk_shards_covers_full_range_without_gaps_or_overlap() {
564        let shards = plan_pk_shards("id", 0, 99, 4);
565        assert_eq!(shards.len(), 4);
566        // Contiguous half-open interior cuts; boundary shards are open-ended.
567        let mut expected_lo = 0i64;
568        for (i, s) in shards.iter().enumerate() {
569            let d = &s.descriptor;
570            assert_eq!(d["key"], "id");
571            assert_eq!(d["lo"].as_i64().unwrap(), expected_lo);
572            let hi = d["hi"].as_i64().unwrap();
573            let first = i == 0;
574            let last = i == shards.len() - 1;
575            assert_eq!(d["lo_unbounded"].as_bool().unwrap(), first);
576            assert_eq!(d["hi_unbounded"].as_bool().unwrap(), last);
577            expected_lo = hi; // next shard starts where this half-open one ended
578        }
579    }
580
581    #[test]
582    fn plan_pk_shards_never_more_shards_than_values() {
583        // Range [5, 7] has 3 values; asking for 10 shards yields at most 3.
584        let shards = plan_pk_shards("pk", 5, 7, 10);
585        assert!(shards.len() <= 3, "got {} shards", shards.len());
586        assert!(
587            shards[0].descriptor["lo_unbounded"].as_bool().unwrap(),
588            "first shard is unbounded below"
589        );
590        assert!(
591            shards.last().unwrap().descriptor["hi_unbounded"]
592                .as_bool()
593                .unwrap(),
594            "last shard is unbounded above"
595        );
596    }
597
598    #[test]
599    fn plan_pk_shards_single_value_one_shard() {
600        let shards = plan_pk_shards("id", 42, 42, 8);
601        assert_eq!(shards.len(), 1);
602        // A lone shard is open-ended on both sides → the whole dataset.
603        assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
604        assert!(shards[0].descriptor["hi_unbounded"].as_bool().unwrap());
605    }
606
607    #[test]
608    fn plan_pk_shards_target_zero_treated_as_one() {
609        let shards = plan_pk_shards("id", 0, 9, 0);
610        assert_eq!(shards.len(), 1);
611        assert_eq!(shards[0].descriptor["hi"].as_i64().unwrap(), 9);
612    }
613
614    #[test]
615    fn shard_bounds_wrap_builds_half_open_predicate() {
616        // An interior shard (bounded both sides) is half-open `[lo, hi)`.
617        let spec = ShardSpec::new(
618            "1",
619            serde_json::json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
620        );
621        let b = ShardBounds::from_spec(&spec).unwrap();
622        let sql = b.wrap("SELECT * FROM t", quote_ident);
623        assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"));
624        assert!(sql.contains(r#""id" >= 100"#), "got: {sql}");
625        assert!(
626            sql.contains(r#""id" < 200"#),
627            "half-open upper bound: {sql}"
628        );
629    }
630
631    #[test]
632    fn shard_bounds_wrap_first_shard_has_no_lower_bound() {
633        // F54: the first shard omits the `>= lo` floor so keys below the
634        // enumerated MIN are still read.
635        let spec = ShardSpec::new(
636            "0",
637            serde_json::json!({"key": "id", "lo": 0, "hi": 100, "lo_unbounded": true, "hi_unbounded": false}),
638        );
639        let b = ShardBounds::from_spec(&spec).unwrap();
640        let sql = b.wrap("SELECT * FROM t", quote_ident);
641        assert!(sql.contains(r#""id" < 100"#), "upper bound present: {sql}");
642        assert!(!sql.contains(">="), "first shard has no lower floor: {sql}");
643    }
644
645    #[test]
646    fn shard_bounds_wrap_last_shard_has_no_upper_bound() {
647        // F55: the last shard omits the upper bound so keys above the
648        // enumerated MAX are still read.
649        let spec = ShardSpec::new(
650            "2",
651            serde_json::json!({"key": "id", "lo": 200, "hi": 300, "lo_unbounded": false, "hi_unbounded": true}),
652        );
653        let b = ShardBounds::from_spec(&spec).unwrap();
654        let sql = b.wrap("SELECT * FROM t", quote_ident);
655        assert!(sql.contains(r#""id" >= 200"#), "lower bound present: {sql}");
656        assert!(
657            !sql.contains(" < ") && !sql.contains("<="),
658            "last shard has no upper bound: {sql}"
659        );
660    }
661
662    #[test]
663    fn shard_bounds_quotes_key_against_injection() {
664        let spec = ShardSpec::new(
665            "0",
666            serde_json::json!({"key": "weird\"; DROP", "lo": 0, "hi": 1, "lo_unbounded": false, "hi_unbounded": false}),
667        );
668        let b = ShardBounds::from_spec(&spec).unwrap();
669        let sql = b.wrap("SELECT 1", quote_ident);
670        // The doubled quote escaping proves the identifier was quoted, not raw.
671        assert!(
672            sql.contains(r#""weird""; DROP""#),
673            "key must be quoted: {sql}"
674        );
675    }
676
677    #[test]
678    fn shard_bounds_from_spec_rejects_malformed_descriptor() {
679        let spec = ShardSpec::new("0", serde_json::json!({"key": "id"})); // no lo/hi
680        assert!(ShardBounds::from_spec(&spec).is_none());
681        assert!(ShardBounds::from_spec(&ShardSpec::whole()).is_none());
682    }
683
684    // ── F37: NULL-key shard coverage ────────────────────────────────────────
685
686    #[test]
687    fn exactly_one_shard_includes_null() {
688        let shards = plan_pk_shards("id", 0, 99, 5);
689        let null_owners: Vec<usize> = shards
690            .iter()
691            .enumerate()
692            .filter(|(_, s)| s.descriptor["include_null"].as_bool().unwrap_or(false))
693            .map(|(i, _)| i)
694            .collect();
695        assert_eq!(
696            null_owners,
697            vec![shards.len() - 1],
698            "exactly the last shard owns NULL keys"
699        );
700    }
701
702    #[test]
703    fn single_shard_plan_still_owns_null() {
704        // A single value yields one shard; it must still cover NULL keys.
705        let shards = plan_pk_shards("id", 7, 7, 4);
706        assert_eq!(shards.len(), 1);
707        assert!(shards[0].descriptor["include_null"].as_bool().unwrap());
708    }
709
710    #[test]
711    fn last_shard_wrap_emits_is_null_clause() {
712        let shards = plan_pk_shards("id", 0, 99, 3);
713        let last = ShardBounds::from_spec(shards.last().unwrap()).unwrap();
714        let sql = last.wrap("SELECT * FROM t", quote_ident);
715        assert!(
716            sql.contains(r#""id" IS NULL"#),
717            "last shard must match NULL keys: {sql}"
718        );
719        assert!(sql.contains(" OR "), "NULL clause OR'd with range: {sql}");
720    }
721
722    #[test]
723    fn non_last_shard_wrap_omits_is_null_clause() {
724        let shards = plan_pk_shards("id", 0, 99, 3);
725        // First shard is not the last → no NULL clause.
726        let first = ShardBounds::from_spec(&shards[0]).unwrap();
727        let sql = first.wrap("SELECT * FROM t", quote_ident);
728        assert!(
729            !sql.contains("IS NULL"),
730            "non-last shard must not match NULL keys: {sql}"
731        );
732    }
733
734    /// Property check on the generated predicates: OR-ing every shard's WHERE
735    /// predicate must cover (a) every non-NULL key — including values *outside*
736    /// the enumerated `[min, max]` (F54/F55) — exactly once and (b) NULL keys
737    /// exactly once.
738    #[test]
739    fn predicate_coverage_complete_and_non_overlapping() {
740        let (min, max, target) = (0i64, 19i64, 4usize);
741        let bounds: Vec<ShardBounds> = plan_pk_shards("k", min, max, target)
742            .iter()
743            .map(|s| ShardBounds::from_spec(s).unwrap())
744            .collect();
745
746        // The boundary shards model SQL membership: open below for the first
747        // shard, open above for the last.
748        let matches_key = |b: &ShardBounds, key: i64| -> bool {
749            let lower = b.lo_unbounded || key >= b.lo;
750            let upper = b.hi_unbounded || key < b.hi;
751            lower && upper
752        };
753
754        // (a) Every non-NULL key — well below min, in range, and well above max
755        // — matches exactly one shard. Keys outside [min, max] model rows
756        // inserted/backfilled during the coordinate→execute window.
757        for key in (min - 50)..=(max + 50) {
758            let matches = bounds.iter().filter(|b| matches_key(b, key)).count();
759            assert_eq!(matches, 1, "key {key} matched {matches} shards (want 1)");
760        }
761
762        // (b) NULL keys match exactly one shard (the one with include_null).
763        let null_matches = bounds.iter().filter(|b| b.include_null).count();
764        assert_eq!(null_matches, 1, "NULL keys must match exactly one shard");
765    }
766
767    #[test]
768    fn single_shard_wrap_selects_whole_dataset_including_null() {
769        // A lone open-ended shard must select every row, NULL keys included.
770        let shards = plan_pk_shards("id", 7, 7, 1);
771        assert_eq!(shards.len(), 1);
772        let b = ShardBounds::from_spec(&shards[0]).unwrap();
773        let sql = b.wrap("SELECT * FROM t", quote_ident);
774        assert!(sql.contains("WHERE TRUE"), "whole-dataset predicate: {sql}");
775        assert!(!sql.contains(">="), "no bounds on a lone shard: {sql}");
776    }
777
778    // ── discover: pure catalog-row grouping ─────────────────────────────────
779
780    #[test]
781    fn descriptors_group_catalog_rows_per_table() {
782        let rows = vec![
783            (
784                "public".to_string(),
785                "orders".to_string(),
786                "id".to_string(),
787                "integer".to_string(),
788                false,
789                Some(120i64),
790            ),
791            (
792                "public".to_string(),
793                "orders".to_string(),
794                "note".to_string(),
795                "text".to_string(),
796                true,
797                Some(120i64),
798            ),
799            (
800                "sales".to_string(),
801                "orders".to_string(),
802                "total".to_string(),
803                "numeric".to_string(),
804                false,
805                None,
806            ),
807        ];
808        let ds = descriptors_from_catalog(rows, quote_ident);
809        assert_eq!(ds.len(), 2, "same table name in two schemas = two datasets");
810
811        assert_eq!(ds[0].name, "public.orders");
812        assert_eq!(ds[0].kind, "table");
813        assert_eq!(ds[0].estimated_rows, Some(120));
814        assert_eq!(
815            ds[0].config_patch["query"],
816            r#"SELECT * FROM "public"."orders""#
817        );
818        let schema = ds[0].schema.as_ref().unwrap();
819        assert_eq!(schema["properties"]["id"]["type"], "integer");
820        assert_eq!(
821            schema["properties"]["note"]["type"],
822            serde_json::json!(["string", "null"])
823        );
824
825        assert_eq!(ds[1].name, "sales.orders");
826        assert_eq!(ds[1].estimated_rows, None);
827        assert_eq!(schema["type"], "object");
828    }
829
830    #[test]
831    fn descriptors_negative_reltuples_means_no_estimate() {
832        let rows = vec![(
833            "public".to_string(),
834            "fresh".to_string(),
835            "id".to_string(),
836            "bigint".to_string(),
837            false,
838            Some(-1i64),
839        )];
840        let ds = descriptors_from_catalog(rows, quote_ident);
841        assert_eq!(ds.len(), 1);
842        assert_eq!(ds[0].estimated_rows, None, "-1 = never analyzed");
843    }
844
845    #[test]
846    fn descriptors_quote_hostile_identifiers() {
847        let rows = vec![(
848            "public".to_string(),
849            "weird\"; DROP".to_string(),
850            "id".to_string(),
851            "integer".to_string(),
852            false,
853            None,
854        )];
855        let ds = descriptors_from_catalog(rows, quote_ident);
856        let q = ds[0].config_patch["query"].as_str().unwrap();
857        assert!(q.contains(r#""weird""; DROP""#), "quoted identifier: {q}");
858    }
859
860    #[test]
861    fn descriptors_empty_catalog_is_empty() {
862        assert!(descriptors_from_catalog(Vec::new(), quote_ident).is_empty());
863    }
864
865    #[tokio::test]
866    async fn source_advertises_discover() {
867        use faucet_core::Source as _;
868        let config = PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT 1");
869        let source = lazy_source(config);
870        assert!(source.supports_discover());
871        // Against an unreachable server the catalog query surfaces the typed
872        // discovery error (exercises the error path without Docker).
873        let err = source.discover().await.unwrap_err();
874        assert!(
875            err.to_string().contains("catalog discovery failed"),
876            "typed error: {err}"
877        );
878    }
879
880    // dataset_uri is a pure-config method; the source requires a live DB to
881    // construct so we test it via a config-derived assertion instead.
882    #[test]
883    fn dataset_uri_strips_credentials() {
884        // We cannot construct PostgresSource offline, so we verify the
885        // credential-stripping logic used by dataset_uri() directly.
886        let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
887        let uri = format!("{}?query={}", redacted, "SELECT 1");
888        assert_eq!(uri, "postgres://h:5432/db?query=SELECT 1");
889    }
890
891    /// Build a source over a lazy pool (no server needed) so the shard glue —
892    /// `apply_shard`, `shard_wrap`, and `enumerate_shards`' error path — is
893    /// testable without Docker.
894    fn lazy_source(config: PostgresSourceConfig) -> PostgresSource {
895        let pool = PgPoolOptions::new()
896            // Fail fast at first checkout — these tests never reach a server.
897            .acquire_timeout(std::time::Duration::from_millis(200))
898            .connect_lazy(&config.connection_url)
899            .expect("lazy pool");
900        PostgresSource {
901            config,
902            pool,
903            applied_shard: Mutex::new(None),
904        }
905    }
906
907    #[tokio::test]
908    async fn apply_shard_then_shard_wrap_narrows_query() {
909        use faucet_core::Source as _;
910        let mut config =
911            PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT * FROM t");
912        config.shard = Some(crate::config::ShardConfig { key: "id".into() });
913        let source = lazy_source(config);
914        assert!(source.is_shardable());
915
916        // No shard applied / whole shard applied → query passes through.
917        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
918        source
919            .apply_shard(&faucet_core::ShardSpec::whole())
920            .await
921            .unwrap();
922        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
923
924        // A real shard narrows with ANSI double-quote quoting.
925        let spec = &plan_pk_shards("id", 0, 99, 2)[0];
926        source.apply_shard(spec).await.unwrap();
927        let wrapped = source.shard_wrap("SELECT * FROM t".into());
928        assert!(wrapped.contains(r#""id""#), "got: {wrapped}");
929        assert!(wrapped.contains("_faucet_shard"), "got: {wrapped}");
930
931        // Enumeration against the unreachable server surfaces the bounds-probe
932        // error path.
933        let err = source.enumerate_shards(4).await.unwrap_err();
934        assert!(
935            err.to_string().contains("shard bounds"),
936            "expected bounds-probe error, got: {err}"
937        );
938    }
939}
940
941#[cfg(test)]
942mod bind_overflow_tests {
943    use super::*;
944    use serde_json::json;
945
946    /// #462: above `i64::MAX` Postgres has no type that fits, and `as i64` would
947    /// wrap to a negative. Refuse loudly instead — silently binding a negative
948    /// bookmark would make `WHERE key > $1` re-read or skip rows.
949    #[test]
950    fn u64_above_i64_max_is_refused_not_wrapped() {
951        let err = match bind_params(sqlx::query("SELECT 1"), &[json!(u64::MAX)], &[]) {
952            Err(e) => e.to_string(),
953            Ok(_) => panic!("u64::MAX must not bind"),
954        };
955        assert!(err.contains(&u64::MAX.to_string()), "{err}");
956        assert!(
957            !err.contains("-9223372036854775808"),
958            "must not show the wrap: {err}"
959        );
960    }
961
962    #[test]
963    fn values_a_signed_column_can_hold_still_bind() {
964        for v in [json!(0), json!(-1), json!(i64::MAX), json!(i64::MAX as u64)] {
965            assert!(
966                bind_params(sqlx::query("SELECT 1"), std::slice::from_ref(&v), &[]).is_ok(),
967                "{v} must still bind"
968            );
969        }
970    }
971}