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) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
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 value in config_params.iter().chain(bind_values) {
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                // `u64::MAX` has no `i64` representation; reinterpret the bits
196                // so the value round-trips into an `int8`/`bigint` column
197                // without the precision loss an `f64` cast would introduce.
198                NumberBind::U64 => query.bind(n.as_u64().unwrap() as i64),
199                NumberBind::F64 => query.bind(n.as_f64().unwrap_or(0.0)),
200            },
201            Value::Bool(b) => query.bind(*b),
202            Value::Null => query.bind(None::<String>),
203            _ => query.bind(value.to_string()),
204        };
205    }
206    query
207}
208
209/// One flattened `information_schema.columns` row used by [`discover`].
210///
211/// (schema, table, column, data_type, is_nullable, estimated_rows)
212type CatalogRow = (String, String, String, String, bool, Option<i64>);
213
214/// A table mid-accumulation while grouping catalog rows:
215/// (schema, table, estimated_rows, columns).
216type PendingTable = (String, String, Option<i64>, Vec<(String, Value)>);
217
218/// Group flattened catalog rows (ordered by schema, table, ordinal position)
219/// into one [`DatasetDescriptor`] per table. Pure — unit-testable without a
220/// live server. `quote` is the dialect's identifier quoter.
221fn descriptors_from_catalog(
222    rows: Vec<CatalogRow>,
223    quote: fn(&str) -> String,
224) -> Vec<faucet_core::DatasetDescriptor> {
225    let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
226    let mut current: Option<PendingTable> = None;
227
228    let flush = |cur: Option<PendingTable>, out: &mut Vec<faucet_core::DatasetDescriptor>| {
229        if let Some((schema, table, est, cols)) = cur {
230            let query = format!("SELECT * FROM {}.{}", quote(&schema), quote(&table));
231            let mut d = faucet_core::DatasetDescriptor::new(
232                format!("{schema}.{table}"),
233                "table",
234                serde_json::json!({ "query": query }),
235            )
236            .with_schema(faucet_core::columns_to_schema(cols));
237            // reltuples is -1 for a never-analyzed table — no estimate.
238            if let Some(n) = est
239                && n >= 0
240            {
241                d = d.with_estimated_rows(n as u64);
242            }
243            out.push(d);
244        }
245    };
246
247    for (schema, table, column, data_type, is_nullable, est) in rows {
248        let same = current
249            .as_ref()
250            .is_some_and(|(s, t, _, _)| *s == schema && *t == table);
251        if !same {
252            flush(current.take(), &mut out);
253            current = Some((schema, table, est, Vec::new()));
254        }
255        let mut fragment = faucet_core::sql_type_to_json_schema(&data_type);
256        if is_nullable {
257            fragment = faucet_core::nullable_type(fragment);
258        }
259        if let Some((_, _, _, cols)) = current.as_mut() {
260            cols.push((column, fragment));
261        }
262    }
263    flush(current, &mut out);
264    out
265}
266
267/// Convert a single `PgRow` into a JSON object whose keys are the row's
268/// column names.
269fn row_to_json(row: &sqlx::postgres::PgRow) -> Value {
270    let mut map = serde_json::Map::new();
271    for col in row.columns() {
272        let name = col.name().to_string();
273        let value = pg_value_to_json(row, &name);
274        map.insert(name, value);
275    }
276    Value::Object(map)
277}
278
279#[async_trait]
280impl faucet_core::Source for PostgresSource {
281    async fn fetch_with_context(
282        &self,
283        context: &std::collections::HashMap<String, serde_json::Value>,
284    ) -> Result<Vec<Value>, FaucetError> {
285        let (query_str, bind_values) = resolve_query(&self.config, context);
286        let query_str = self.shard_wrap(query_str);
287        let query = bind_params(sqlx::query(&query_str), &self.config.params, &bind_values);
288
289        let rows = query
290            .fetch_all(&self.pool)
291            .await
292            .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?;
293
294        let records: Vec<Value> = rows.iter().map(row_to_json).collect();
295        tracing::info!(rows = records.len(), query = %self.config.query, "PostgreSQL source fetch complete");
296        Ok(records)
297    }
298
299    /// Stream rows from the underlying sqlx cursor without buffering the full
300    /// result set. Each emitted [`StreamPage`] holds up to
301    /// [`PostgresSourceConfig::batch_size`] rows.
302    ///
303    /// The trait-level `batch_size` argument is ignored in favour of the
304    /// config field — the config is the user-facing knob the README
305    /// documents, and routing the pipeline-supplied hint through it would
306    /// silently override an explicit config value.
307    ///
308    /// `batch_size = 0` drains the entire cursor into a single page. The
309    /// postgres query source has no incremental-replication mode today, so
310    /// every emitted page carries `bookmark: None`.
311    fn stream_pages<'a>(
312        &'a self,
313        context: &'a std::collections::HashMap<String, Value>,
314        _batch_size: usize,
315    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
316        let batch_size = self.config.batch_size;
317
318        Box::pin(async_stream::try_stream! {
319            let (query_str, bind_values) = resolve_query(&self.config, context);
320            let query_str = self.shard_wrap(query_str);
321            let query = bind_params(
322                sqlx::query(&query_str),
323                &self.config.params,
324                &bind_values,
325            );
326
327            let mut rows = query.fetch(&self.pool);
328            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
329            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
330            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
331            let mut total = 0usize;
332
333            while let Some(row) = rows
334                .try_next()
335                .await
336                .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?
337            {
338                buffer.push(row_to_json(&row));
339                if buffer.len() >= chunk {
340                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
341                    total += page.len();
342                    yield StreamPage { records: page, bookmark: None };
343                }
344            }
345            if !buffer.is_empty() {
346                total += buffer.len();
347                yield StreamPage { records: buffer, bookmark: None };
348            }
349
350            tracing::info!(
351                rows = total,
352                batch_size,
353                query = %self.config.query,
354                "PostgreSQL source stream complete",
355            );
356        })
357    }
358
359    fn config_schema(&self) -> serde_json::Value {
360        serde_json::to_value(faucet_core::schema_for!(PostgresSourceConfig))
361            .expect("schema serialization")
362    }
363
364    fn dataset_uri(&self) -> String {
365        format!(
366            "{}?query={}",
367            faucet_core::redact_uri_credentials(&self.config.connection_url),
368            self.config.query
369        )
370    }
371
372    fn supports_discover(&self) -> bool {
373        true
374    }
375
376    /// Enumerate every base table outside `pg_catalog` / `information_schema`,
377    /// with column types from `information_schema.columns` and a row estimate
378    /// from `pg_class.reltuples` (catalog metadata only — no data scan).
379    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
380        let sql = r#"
381            SELECT c.table_schema, c.table_name, c.column_name, c.data_type,
382                   (c.is_nullable = 'YES') AS is_nullable,
383                   (SELECT pc.reltuples::bigint
384                      FROM pg_class pc
385                      JOIN pg_namespace pn ON pn.oid = pc.relnamespace
386                     WHERE pn.nspname = c.table_schema
387                       AND pc.relname = c.table_name) AS estimated_rows
388              FROM information_schema.columns c
389              JOIN information_schema.tables t
390                ON t.table_schema = c.table_schema AND t.table_name = c.table_name
391             WHERE t.table_type = 'BASE TABLE'
392               AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
393             ORDER BY c.table_schema, c.table_name, c.ordinal_position"#;
394        let rows = sqlx::query(sql)
395            .fetch_all(&self.pool)
396            .await
397            .map_err(|e| FaucetError::Source(format!("postgres: catalog discovery failed: {e}")))?;
398
399        let catalog: Vec<CatalogRow> = rows
400            .iter()
401            .map(|row| -> Result<CatalogRow, FaucetError> {
402                let decode = |col: &str| -> Result<String, FaucetError> {
403                    row.try_get::<String, _>(col).map_err(|e| {
404                        FaucetError::Source(format!("postgres: catalog decode failed ({col}): {e}"))
405                    })
406                };
407                Ok((
408                    decode("table_schema")?,
409                    decode("table_name")?,
410                    decode("column_name")?,
411                    decode("data_type")?,
412                    row.try_get::<bool, _>("is_nullable").unwrap_or(true),
413                    row.try_get::<i64, _>("estimated_rows").ok(),
414                ))
415            })
416            .collect::<Result<_, _>>()?;
417
418        Ok(descriptors_from_catalog(catalog, quote_ident))
419    }
420
421    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
422    fn is_shardable(&self) -> bool {
423        self.config.shard.is_some()
424    }
425
426    /// Enumerate contiguous primary-key range shards by computing the `key`
427    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting that
428    /// range into ~`target` slices. Returns a single whole-dataset shard when no
429    /// `shard` config is set or the result set is empty.
430    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
431        let Some(shard_cfg) = &self.config.shard else {
432            return Ok(vec![ShardSpec::whole()]);
433        };
434
435        let bounds_sql =
436            pk_bounds_query(&self.config.query, &quote_ident(&shard_cfg.key), "BIGINT");
437        let row = bind_params(sqlx::query(&bounds_sql), &self.config.params, &[])
438            .fetch_one(&self.pool)
439            .await
440            .map_err(|e| {
441                FaucetError::Source(format!(
442                    "postgres: failed to compute shard bounds for key {:?} \
443                     (it must be an integer-typed column): {e}",
444                    shard_cfg.key
445                ))
446            })?;
447
448        let lo: Option<i64> = row.try_get("lo").map_err(|e| {
449            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
450        })?;
451        let hi: Option<i64> = row.try_get("hi").map_err(|e| {
452            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
453        })?;
454        Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
455    }
456
457    /// Narrow this source to a single PK-range shard. The whole-dataset shard
458    /// clears any applied range (streams the full query).
459    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
460        *self.applied_shard.lock().expect("shard mutex poisoned") =
461            parse_pk_shard(shard, "postgres")?;
462        Ok(())
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use faucet_core::shard::plan_pk_shards;
470
471    /// The shard-bounds type moved to `faucet_core::shard` (#262) so the
472    /// PK-range logic is shared across the SQL sources; alias it so the
473    /// long-standing tests below keep pinning postgres's behavior unchanged.
474    type ShardBounds = PkShardBounds;
475
476    #[tokio::test]
477    async fn new_rejects_out_of_range_batch_size() {
478        let mut config = PostgresSourceConfig::new("postgres://localhost/test", "SELECT 1");
479        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
480        match PostgresSource::new(config).await {
481            Err(faucet_core::FaucetError::Config(m)) => {
482                assert!(m.contains("batch_size"), "got: {m}")
483            }
484            _ => panic!("expected a batch_size Config error"),
485        }
486    }
487
488    // ── F38: numeric bind classification (precision-safe) ───────────────────
489
490    fn num(v: serde_json::Value) -> serde_json::Number {
491        match v {
492            serde_json::Value::Number(n) => n,
493            _ => panic!("not a number"),
494        }
495    }
496
497    #[test]
498    fn classify_small_int_is_i64() {
499        assert_eq!(
500            classify_number(&num(serde_json::json!(42))),
501            NumberBind::I64
502        );
503        assert_eq!(
504            classify_number(&num(serde_json::json!(-7))),
505            NumberBind::I64
506        );
507        assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
508    }
509
510    #[test]
511    fn classify_above_2_pow_53_stays_i64_not_f64() {
512        // The key precision bug: 2^53 + 1 must NOT be bound as f64 (which would
513        // round it). It is a valid i64, so it must classify as I64.
514        let v = 9_007_199_254_740_993i64; // 2^53 + 1
515        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
516    }
517
518    #[test]
519    fn classify_i64_max_is_i64() {
520        assert_eq!(
521            classify_number(&num(serde_json::json!(i64::MAX))),
522            NumberBind::I64
523        );
524        assert_eq!(
525            classify_number(&num(serde_json::json!(i64::MIN))),
526            NumberBind::I64
527        );
528    }
529
530    #[test]
531    fn classify_above_i64_max_is_u64() {
532        // i64::MAX + 1 has no i64 representation but fits u64.
533        let v: u64 = i64::MAX as u64 + 1;
534        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
535        assert_eq!(
536            classify_number(&num(serde_json::json!(u64::MAX))),
537            NumberBind::U64
538        );
539    }
540
541    #[test]
542    fn classify_float_is_f64() {
543        assert_eq!(
544            classify_number(&num(serde_json::json!(3.5))),
545            NumberBind::F64
546        );
547        assert_eq!(
548            classify_number(&num(serde_json::json!(-0.5))),
549            NumberBind::F64
550        );
551    }
552
553    // ── PK-range sharding (pure logic) ──────────────────────────────────────
554
555    #[test]
556    fn plan_pk_shards_covers_full_range_without_gaps_or_overlap() {
557        let shards = plan_pk_shards("id", 0, 99, 4);
558        assert_eq!(shards.len(), 4);
559        // Contiguous half-open interior cuts; boundary shards are open-ended.
560        let mut expected_lo = 0i64;
561        for (i, s) in shards.iter().enumerate() {
562            let d = &s.descriptor;
563            assert_eq!(d["key"], "id");
564            assert_eq!(d["lo"].as_i64().unwrap(), expected_lo);
565            let hi = d["hi"].as_i64().unwrap();
566            let first = i == 0;
567            let last = i == shards.len() - 1;
568            assert_eq!(d["lo_unbounded"].as_bool().unwrap(), first);
569            assert_eq!(d["hi_unbounded"].as_bool().unwrap(), last);
570            expected_lo = hi; // next shard starts where this half-open one ended
571        }
572    }
573
574    #[test]
575    fn plan_pk_shards_never_more_shards_than_values() {
576        // Range [5, 7] has 3 values; asking for 10 shards yields at most 3.
577        let shards = plan_pk_shards("pk", 5, 7, 10);
578        assert!(shards.len() <= 3, "got {} shards", shards.len());
579        assert!(
580            shards[0].descriptor["lo_unbounded"].as_bool().unwrap(),
581            "first shard is unbounded below"
582        );
583        assert!(
584            shards.last().unwrap().descriptor["hi_unbounded"]
585                .as_bool()
586                .unwrap(),
587            "last shard is unbounded above"
588        );
589    }
590
591    #[test]
592    fn plan_pk_shards_single_value_one_shard() {
593        let shards = plan_pk_shards("id", 42, 42, 8);
594        assert_eq!(shards.len(), 1);
595        // A lone shard is open-ended on both sides → the whole dataset.
596        assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
597        assert!(shards[0].descriptor["hi_unbounded"].as_bool().unwrap());
598    }
599
600    #[test]
601    fn plan_pk_shards_target_zero_treated_as_one() {
602        let shards = plan_pk_shards("id", 0, 9, 0);
603        assert_eq!(shards.len(), 1);
604        assert_eq!(shards[0].descriptor["hi"].as_i64().unwrap(), 9);
605    }
606
607    #[test]
608    fn shard_bounds_wrap_builds_half_open_predicate() {
609        // An interior shard (bounded both sides) is half-open `[lo, hi)`.
610        let spec = ShardSpec::new(
611            "1",
612            serde_json::json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
613        );
614        let b = ShardBounds::from_spec(&spec).unwrap();
615        let sql = b.wrap("SELECT * FROM t", quote_ident);
616        assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"));
617        assert!(sql.contains(r#""id" >= 100"#), "got: {sql}");
618        assert!(
619            sql.contains(r#""id" < 200"#),
620            "half-open upper bound: {sql}"
621        );
622    }
623
624    #[test]
625    fn shard_bounds_wrap_first_shard_has_no_lower_bound() {
626        // F54: the first shard omits the `>= lo` floor so keys below the
627        // enumerated MIN are still read.
628        let spec = ShardSpec::new(
629            "0",
630            serde_json::json!({"key": "id", "lo": 0, "hi": 100, "lo_unbounded": true, "hi_unbounded": false}),
631        );
632        let b = ShardBounds::from_spec(&spec).unwrap();
633        let sql = b.wrap("SELECT * FROM t", quote_ident);
634        assert!(sql.contains(r#""id" < 100"#), "upper bound present: {sql}");
635        assert!(!sql.contains(">="), "first shard has no lower floor: {sql}");
636    }
637
638    #[test]
639    fn shard_bounds_wrap_last_shard_has_no_upper_bound() {
640        // F55: the last shard omits the upper bound so keys above the
641        // enumerated MAX are still read.
642        let spec = ShardSpec::new(
643            "2",
644            serde_json::json!({"key": "id", "lo": 200, "hi": 300, "lo_unbounded": false, "hi_unbounded": true}),
645        );
646        let b = ShardBounds::from_spec(&spec).unwrap();
647        let sql = b.wrap("SELECT * FROM t", quote_ident);
648        assert!(sql.contains(r#""id" >= 200"#), "lower bound present: {sql}");
649        assert!(
650            !sql.contains(" < ") && !sql.contains("<="),
651            "last shard has no upper bound: {sql}"
652        );
653    }
654
655    #[test]
656    fn shard_bounds_quotes_key_against_injection() {
657        let spec = ShardSpec::new(
658            "0",
659            serde_json::json!({"key": "weird\"; DROP", "lo": 0, "hi": 1, "lo_unbounded": false, "hi_unbounded": false}),
660        );
661        let b = ShardBounds::from_spec(&spec).unwrap();
662        let sql = b.wrap("SELECT 1", quote_ident);
663        // The doubled quote escaping proves the identifier was quoted, not raw.
664        assert!(
665            sql.contains(r#""weird""; DROP""#),
666            "key must be quoted: {sql}"
667        );
668    }
669
670    #[test]
671    fn shard_bounds_from_spec_rejects_malformed_descriptor() {
672        let spec = ShardSpec::new("0", serde_json::json!({"key": "id"})); // no lo/hi
673        assert!(ShardBounds::from_spec(&spec).is_none());
674        assert!(ShardBounds::from_spec(&ShardSpec::whole()).is_none());
675    }
676
677    // ── F37: NULL-key shard coverage ────────────────────────────────────────
678
679    #[test]
680    fn exactly_one_shard_includes_null() {
681        let shards = plan_pk_shards("id", 0, 99, 5);
682        let null_owners: Vec<usize> = shards
683            .iter()
684            .enumerate()
685            .filter(|(_, s)| s.descriptor["include_null"].as_bool().unwrap_or(false))
686            .map(|(i, _)| i)
687            .collect();
688        assert_eq!(
689            null_owners,
690            vec![shards.len() - 1],
691            "exactly the last shard owns NULL keys"
692        );
693    }
694
695    #[test]
696    fn single_shard_plan_still_owns_null() {
697        // A single value yields one shard; it must still cover NULL keys.
698        let shards = plan_pk_shards("id", 7, 7, 4);
699        assert_eq!(shards.len(), 1);
700        assert!(shards[0].descriptor["include_null"].as_bool().unwrap());
701    }
702
703    #[test]
704    fn last_shard_wrap_emits_is_null_clause() {
705        let shards = plan_pk_shards("id", 0, 99, 3);
706        let last = ShardBounds::from_spec(shards.last().unwrap()).unwrap();
707        let sql = last.wrap("SELECT * FROM t", quote_ident);
708        assert!(
709            sql.contains(r#""id" IS NULL"#),
710            "last shard must match NULL keys: {sql}"
711        );
712        assert!(sql.contains(" OR "), "NULL clause OR'd with range: {sql}");
713    }
714
715    #[test]
716    fn non_last_shard_wrap_omits_is_null_clause() {
717        let shards = plan_pk_shards("id", 0, 99, 3);
718        // First shard is not the last → no NULL clause.
719        let first = ShardBounds::from_spec(&shards[0]).unwrap();
720        let sql = first.wrap("SELECT * FROM t", quote_ident);
721        assert!(
722            !sql.contains("IS NULL"),
723            "non-last shard must not match NULL keys: {sql}"
724        );
725    }
726
727    /// Property check on the generated predicates: OR-ing every shard's WHERE
728    /// predicate must cover (a) every non-NULL key — including values *outside*
729    /// the enumerated `[min, max]` (F54/F55) — exactly once and (b) NULL keys
730    /// exactly once.
731    #[test]
732    fn predicate_coverage_complete_and_non_overlapping() {
733        let (min, max, target) = (0i64, 19i64, 4usize);
734        let bounds: Vec<ShardBounds> = plan_pk_shards("k", min, max, target)
735            .iter()
736            .map(|s| ShardBounds::from_spec(s).unwrap())
737            .collect();
738
739        // The boundary shards model SQL membership: open below for the first
740        // shard, open above for the last.
741        let matches_key = |b: &ShardBounds, key: i64| -> bool {
742            let lower = b.lo_unbounded || key >= b.lo;
743            let upper = b.hi_unbounded || key < b.hi;
744            lower && upper
745        };
746
747        // (a) Every non-NULL key — well below min, in range, and well above max
748        // — matches exactly one shard. Keys outside [min, max] model rows
749        // inserted/backfilled during the coordinate→execute window.
750        for key in (min - 50)..=(max + 50) {
751            let matches = bounds.iter().filter(|b| matches_key(b, key)).count();
752            assert_eq!(matches, 1, "key {key} matched {matches} shards (want 1)");
753        }
754
755        // (b) NULL keys match exactly one shard (the one with include_null).
756        let null_matches = bounds.iter().filter(|b| b.include_null).count();
757        assert_eq!(null_matches, 1, "NULL keys must match exactly one shard");
758    }
759
760    #[test]
761    fn single_shard_wrap_selects_whole_dataset_including_null() {
762        // A lone open-ended shard must select every row, NULL keys included.
763        let shards = plan_pk_shards("id", 7, 7, 1);
764        assert_eq!(shards.len(), 1);
765        let b = ShardBounds::from_spec(&shards[0]).unwrap();
766        let sql = b.wrap("SELECT * FROM t", quote_ident);
767        assert!(sql.contains("WHERE TRUE"), "whole-dataset predicate: {sql}");
768        assert!(!sql.contains(">="), "no bounds on a lone shard: {sql}");
769    }
770
771    // ── discover: pure catalog-row grouping ─────────────────────────────────
772
773    #[test]
774    fn descriptors_group_catalog_rows_per_table() {
775        let rows = vec![
776            (
777                "public".to_string(),
778                "orders".to_string(),
779                "id".to_string(),
780                "integer".to_string(),
781                false,
782                Some(120i64),
783            ),
784            (
785                "public".to_string(),
786                "orders".to_string(),
787                "note".to_string(),
788                "text".to_string(),
789                true,
790                Some(120i64),
791            ),
792            (
793                "sales".to_string(),
794                "orders".to_string(),
795                "total".to_string(),
796                "numeric".to_string(),
797                false,
798                None,
799            ),
800        ];
801        let ds = descriptors_from_catalog(rows, quote_ident);
802        assert_eq!(ds.len(), 2, "same table name in two schemas = two datasets");
803
804        assert_eq!(ds[0].name, "public.orders");
805        assert_eq!(ds[0].kind, "table");
806        assert_eq!(ds[0].estimated_rows, Some(120));
807        assert_eq!(
808            ds[0].config_patch["query"],
809            r#"SELECT * FROM "public"."orders""#
810        );
811        let schema = ds[0].schema.as_ref().unwrap();
812        assert_eq!(schema["properties"]["id"]["type"], "integer");
813        assert_eq!(
814            schema["properties"]["note"]["type"],
815            serde_json::json!(["string", "null"])
816        );
817
818        assert_eq!(ds[1].name, "sales.orders");
819        assert_eq!(ds[1].estimated_rows, None);
820        assert_eq!(schema["type"], "object");
821    }
822
823    #[test]
824    fn descriptors_negative_reltuples_means_no_estimate() {
825        let rows = vec![(
826            "public".to_string(),
827            "fresh".to_string(),
828            "id".to_string(),
829            "bigint".to_string(),
830            false,
831            Some(-1i64),
832        )];
833        let ds = descriptors_from_catalog(rows, quote_ident);
834        assert_eq!(ds.len(), 1);
835        assert_eq!(ds[0].estimated_rows, None, "-1 = never analyzed");
836    }
837
838    #[test]
839    fn descriptors_quote_hostile_identifiers() {
840        let rows = vec![(
841            "public".to_string(),
842            "weird\"; DROP".to_string(),
843            "id".to_string(),
844            "integer".to_string(),
845            false,
846            None,
847        )];
848        let ds = descriptors_from_catalog(rows, quote_ident);
849        let q = ds[0].config_patch["query"].as_str().unwrap();
850        assert!(q.contains(r#""weird""; DROP""#), "quoted identifier: {q}");
851    }
852
853    #[test]
854    fn descriptors_empty_catalog_is_empty() {
855        assert!(descriptors_from_catalog(Vec::new(), quote_ident).is_empty());
856    }
857
858    #[tokio::test]
859    async fn source_advertises_discover() {
860        use faucet_core::Source as _;
861        let config = PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT 1");
862        let source = lazy_source(config);
863        assert!(source.supports_discover());
864        // Against an unreachable server the catalog query surfaces the typed
865        // discovery error (exercises the error path without Docker).
866        let err = source.discover().await.unwrap_err();
867        assert!(
868            err.to_string().contains("catalog discovery failed"),
869            "typed error: {err}"
870        );
871    }
872
873    // dataset_uri is a pure-config method; the source requires a live DB to
874    // construct so we test it via a config-derived assertion instead.
875    #[test]
876    fn dataset_uri_strips_credentials() {
877        // We cannot construct PostgresSource offline, so we verify the
878        // credential-stripping logic used by dataset_uri() directly.
879        let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
880        let uri = format!("{}?query={}", redacted, "SELECT 1");
881        assert_eq!(uri, "postgres://h:5432/db?query=SELECT 1");
882    }
883
884    /// Build a source over a lazy pool (no server needed) so the shard glue —
885    /// `apply_shard`, `shard_wrap`, and `enumerate_shards`' error path — is
886    /// testable without Docker.
887    fn lazy_source(config: PostgresSourceConfig) -> PostgresSource {
888        let pool = PgPoolOptions::new()
889            // Fail fast at first checkout — these tests never reach a server.
890            .acquire_timeout(std::time::Duration::from_millis(200))
891            .connect_lazy(&config.connection_url)
892            .expect("lazy pool");
893        PostgresSource {
894            config,
895            pool,
896            applied_shard: Mutex::new(None),
897        }
898    }
899
900    #[tokio::test]
901    async fn apply_shard_then_shard_wrap_narrows_query() {
902        use faucet_core::Source as _;
903        let mut config =
904            PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT * FROM t");
905        config.shard = Some(crate::config::ShardConfig { key: "id".into() });
906        let source = lazy_source(config);
907        assert!(source.is_shardable());
908
909        // No shard applied / whole shard applied → query passes through.
910        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
911        source
912            .apply_shard(&faucet_core::ShardSpec::whole())
913            .await
914            .unwrap();
915        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
916
917        // A real shard narrows with ANSI double-quote quoting.
918        let spec = &plan_pk_shards("id", 0, 99, 2)[0];
919        source.apply_shard(spec).await.unwrap();
920        let wrapped = source.shard_wrap("SELECT * FROM t".into());
921        assert!(wrapped.contains(r#""id""#), "got: {wrapped}");
922        assert!(wrapped.contains("_faucet_shard"), "got: {wrapped}");
923
924        // Enumeration against the unreachable server surfaces the bounds-probe
925        // error path.
926        let err = source.enumerate_shards(4).await.unwrap_err();
927        assert!(
928            err.to_string().contains("shard bounds"),
929            "expected bounds-probe error, got: {err}"
930        );
931    }
932}