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/// Convert a single `PgRow` into a JSON object whose keys are the row's
210/// column names.
211fn row_to_json(row: &sqlx::postgres::PgRow) -> Value {
212    let mut map = serde_json::Map::new();
213    for col in row.columns() {
214        let name = col.name().to_string();
215        let value = pg_value_to_json(row, &name);
216        map.insert(name, value);
217    }
218    Value::Object(map)
219}
220
221#[async_trait]
222impl faucet_core::Source for PostgresSource {
223    async fn fetch_with_context(
224        &self,
225        context: &std::collections::HashMap<String, serde_json::Value>,
226    ) -> Result<Vec<Value>, FaucetError> {
227        let (query_str, bind_values) = resolve_query(&self.config, context);
228        let query_str = self.shard_wrap(query_str);
229        let query = bind_params(sqlx::query(&query_str), &self.config.params, &bind_values);
230
231        let rows = query
232            .fetch_all(&self.pool)
233            .await
234            .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?;
235
236        let records: Vec<Value> = rows.iter().map(row_to_json).collect();
237        tracing::info!(rows = records.len(), query = %self.config.query, "PostgreSQL source fetch complete");
238        Ok(records)
239    }
240
241    /// Stream rows from the underlying sqlx cursor without buffering the full
242    /// result set. Each emitted [`StreamPage`] holds up to
243    /// [`PostgresSourceConfig::batch_size`] rows.
244    ///
245    /// The trait-level `batch_size` argument is ignored in favour of the
246    /// config field — the config is the user-facing knob the README
247    /// documents, and routing the pipeline-supplied hint through it would
248    /// silently override an explicit config value.
249    ///
250    /// `batch_size = 0` drains the entire cursor into a single page. The
251    /// postgres query source has no incremental-replication mode today, so
252    /// every emitted page carries `bookmark: None`.
253    fn stream_pages<'a>(
254        &'a self,
255        context: &'a std::collections::HashMap<String, Value>,
256        _batch_size: usize,
257    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
258        let batch_size = self.config.batch_size;
259
260        Box::pin(async_stream::try_stream! {
261            let (query_str, bind_values) = resolve_query(&self.config, context);
262            let query_str = self.shard_wrap(query_str);
263            let query = bind_params(
264                sqlx::query(&query_str),
265                &self.config.params,
266                &bind_values,
267            );
268
269            let mut rows = query.fetch(&self.pool);
270            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
271            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
272            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
273            let mut total = 0usize;
274
275            while let Some(row) = rows
276                .try_next()
277                .await
278                .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?
279            {
280                buffer.push(row_to_json(&row));
281                if buffer.len() >= chunk {
282                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
283                    total += page.len();
284                    yield StreamPage { records: page, bookmark: None };
285                }
286            }
287            if !buffer.is_empty() {
288                total += buffer.len();
289                yield StreamPage { records: buffer, bookmark: None };
290            }
291
292            tracing::info!(
293                rows = total,
294                batch_size,
295                query = %self.config.query,
296                "PostgreSQL source stream complete",
297            );
298        })
299    }
300
301    fn config_schema(&self) -> serde_json::Value {
302        serde_json::to_value(faucet_core::schema_for!(PostgresSourceConfig))
303            .expect("schema serialization")
304    }
305
306    fn dataset_uri(&self) -> String {
307        format!(
308            "{}?query={}",
309            faucet_core::redact_uri_credentials(&self.config.connection_url),
310            self.config.query
311        )
312    }
313
314    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
315    fn is_shardable(&self) -> bool {
316        self.config.shard.is_some()
317    }
318
319    /// Enumerate contiguous primary-key range shards by computing the `key`
320    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting that
321    /// range into ~`target` slices. Returns a single whole-dataset shard when no
322    /// `shard` config is set or the result set is empty.
323    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
324        let Some(shard_cfg) = &self.config.shard else {
325            return Ok(vec![ShardSpec::whole()]);
326        };
327
328        let bounds_sql =
329            pk_bounds_query(&self.config.query, &quote_ident(&shard_cfg.key), "BIGINT");
330        let row = bind_params(sqlx::query(&bounds_sql), &self.config.params, &[])
331            .fetch_one(&self.pool)
332            .await
333            .map_err(|e| {
334                FaucetError::Source(format!(
335                    "postgres: failed to compute shard bounds for key {:?} \
336                     (it must be an integer-typed column): {e}",
337                    shard_cfg.key
338                ))
339            })?;
340
341        let lo: Option<i64> = row.try_get("lo").map_err(|e| {
342            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
343        })?;
344        let hi: Option<i64> = row.try_get("hi").map_err(|e| {
345            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
346        })?;
347        Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
348    }
349
350    /// Narrow this source to a single PK-range shard. The whole-dataset shard
351    /// clears any applied range (streams the full query).
352    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
353        *self.applied_shard.lock().expect("shard mutex poisoned") =
354            parse_pk_shard(shard, "postgres")?;
355        Ok(())
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use faucet_core::shard::plan_pk_shards;
363
364    /// The shard-bounds type moved to `faucet_core::shard` (#262) so the
365    /// PK-range logic is shared across the SQL sources; alias it so the
366    /// long-standing tests below keep pinning postgres's behavior unchanged.
367    type ShardBounds = PkShardBounds;
368
369    #[tokio::test]
370    async fn new_rejects_out_of_range_batch_size() {
371        let mut config = PostgresSourceConfig::new("postgres://localhost/test", "SELECT 1");
372        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
373        match PostgresSource::new(config).await {
374            Err(faucet_core::FaucetError::Config(m)) => {
375                assert!(m.contains("batch_size"), "got: {m}")
376            }
377            _ => panic!("expected a batch_size Config error"),
378        }
379    }
380
381    // ── F38: numeric bind classification (precision-safe) ───────────────────
382
383    fn num(v: serde_json::Value) -> serde_json::Number {
384        match v {
385            serde_json::Value::Number(n) => n,
386            _ => panic!("not a number"),
387        }
388    }
389
390    #[test]
391    fn classify_small_int_is_i64() {
392        assert_eq!(
393            classify_number(&num(serde_json::json!(42))),
394            NumberBind::I64
395        );
396        assert_eq!(
397            classify_number(&num(serde_json::json!(-7))),
398            NumberBind::I64
399        );
400        assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
401    }
402
403    #[test]
404    fn classify_above_2_pow_53_stays_i64_not_f64() {
405        // The key precision bug: 2^53 + 1 must NOT be bound as f64 (which would
406        // round it). It is a valid i64, so it must classify as I64.
407        let v = 9_007_199_254_740_993i64; // 2^53 + 1
408        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
409    }
410
411    #[test]
412    fn classify_i64_max_is_i64() {
413        assert_eq!(
414            classify_number(&num(serde_json::json!(i64::MAX))),
415            NumberBind::I64
416        );
417        assert_eq!(
418            classify_number(&num(serde_json::json!(i64::MIN))),
419            NumberBind::I64
420        );
421    }
422
423    #[test]
424    fn classify_above_i64_max_is_u64() {
425        // i64::MAX + 1 has no i64 representation but fits u64.
426        let v: u64 = i64::MAX as u64 + 1;
427        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
428        assert_eq!(
429            classify_number(&num(serde_json::json!(u64::MAX))),
430            NumberBind::U64
431        );
432    }
433
434    #[test]
435    fn classify_float_is_f64() {
436        assert_eq!(
437            classify_number(&num(serde_json::json!(3.5))),
438            NumberBind::F64
439        );
440        assert_eq!(
441            classify_number(&num(serde_json::json!(-0.5))),
442            NumberBind::F64
443        );
444    }
445
446    // ── PK-range sharding (pure logic) ──────────────────────────────────────
447
448    #[test]
449    fn plan_pk_shards_covers_full_range_without_gaps_or_overlap() {
450        let shards = plan_pk_shards("id", 0, 99, 4);
451        assert_eq!(shards.len(), 4);
452        // Contiguous half-open interior cuts; boundary shards are open-ended.
453        let mut expected_lo = 0i64;
454        for (i, s) in shards.iter().enumerate() {
455            let d = &s.descriptor;
456            assert_eq!(d["key"], "id");
457            assert_eq!(d["lo"].as_i64().unwrap(), expected_lo);
458            let hi = d["hi"].as_i64().unwrap();
459            let first = i == 0;
460            let last = i == shards.len() - 1;
461            assert_eq!(d["lo_unbounded"].as_bool().unwrap(), first);
462            assert_eq!(d["hi_unbounded"].as_bool().unwrap(), last);
463            expected_lo = hi; // next shard starts where this half-open one ended
464        }
465    }
466
467    #[test]
468    fn plan_pk_shards_never_more_shards_than_values() {
469        // Range [5, 7] has 3 values; asking for 10 shards yields at most 3.
470        let shards = plan_pk_shards("pk", 5, 7, 10);
471        assert!(shards.len() <= 3, "got {} shards", shards.len());
472        assert!(
473            shards[0].descriptor["lo_unbounded"].as_bool().unwrap(),
474            "first shard is unbounded below"
475        );
476        assert!(
477            shards.last().unwrap().descriptor["hi_unbounded"]
478                .as_bool()
479                .unwrap(),
480            "last shard is unbounded above"
481        );
482    }
483
484    #[test]
485    fn plan_pk_shards_single_value_one_shard() {
486        let shards = plan_pk_shards("id", 42, 42, 8);
487        assert_eq!(shards.len(), 1);
488        // A lone shard is open-ended on both sides → the whole dataset.
489        assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
490        assert!(shards[0].descriptor["hi_unbounded"].as_bool().unwrap());
491    }
492
493    #[test]
494    fn plan_pk_shards_target_zero_treated_as_one() {
495        let shards = plan_pk_shards("id", 0, 9, 0);
496        assert_eq!(shards.len(), 1);
497        assert_eq!(shards[0].descriptor["hi"].as_i64().unwrap(), 9);
498    }
499
500    #[test]
501    fn shard_bounds_wrap_builds_half_open_predicate() {
502        // An interior shard (bounded both sides) is half-open `[lo, hi)`.
503        let spec = ShardSpec::new(
504            "1",
505            serde_json::json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
506        );
507        let b = ShardBounds::from_spec(&spec).unwrap();
508        let sql = b.wrap("SELECT * FROM t", quote_ident);
509        assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"));
510        assert!(sql.contains(r#""id" >= 100"#), "got: {sql}");
511        assert!(
512            sql.contains(r#""id" < 200"#),
513            "half-open upper bound: {sql}"
514        );
515    }
516
517    #[test]
518    fn shard_bounds_wrap_first_shard_has_no_lower_bound() {
519        // F54: the first shard omits the `>= lo` floor so keys below the
520        // enumerated MIN are still read.
521        let spec = ShardSpec::new(
522            "0",
523            serde_json::json!({"key": "id", "lo": 0, "hi": 100, "lo_unbounded": true, "hi_unbounded": false}),
524        );
525        let b = ShardBounds::from_spec(&spec).unwrap();
526        let sql = b.wrap("SELECT * FROM t", quote_ident);
527        assert!(sql.contains(r#""id" < 100"#), "upper bound present: {sql}");
528        assert!(!sql.contains(">="), "first shard has no lower floor: {sql}");
529    }
530
531    #[test]
532    fn shard_bounds_wrap_last_shard_has_no_upper_bound() {
533        // F55: the last shard omits the upper bound so keys above the
534        // enumerated MAX are still read.
535        let spec = ShardSpec::new(
536            "2",
537            serde_json::json!({"key": "id", "lo": 200, "hi": 300, "lo_unbounded": false, "hi_unbounded": true}),
538        );
539        let b = ShardBounds::from_spec(&spec).unwrap();
540        let sql = b.wrap("SELECT * FROM t", quote_ident);
541        assert!(sql.contains(r#""id" >= 200"#), "lower bound present: {sql}");
542        assert!(
543            !sql.contains(" < ") && !sql.contains("<="),
544            "last shard has no upper bound: {sql}"
545        );
546    }
547
548    #[test]
549    fn shard_bounds_quotes_key_against_injection() {
550        let spec = ShardSpec::new(
551            "0",
552            serde_json::json!({"key": "weird\"; DROP", "lo": 0, "hi": 1, "lo_unbounded": false, "hi_unbounded": false}),
553        );
554        let b = ShardBounds::from_spec(&spec).unwrap();
555        let sql = b.wrap("SELECT 1", quote_ident);
556        // The doubled quote escaping proves the identifier was quoted, not raw.
557        assert!(
558            sql.contains(r#""weird""; DROP""#),
559            "key must be quoted: {sql}"
560        );
561    }
562
563    #[test]
564    fn shard_bounds_from_spec_rejects_malformed_descriptor() {
565        let spec = ShardSpec::new("0", serde_json::json!({"key": "id"})); // no lo/hi
566        assert!(ShardBounds::from_spec(&spec).is_none());
567        assert!(ShardBounds::from_spec(&ShardSpec::whole()).is_none());
568    }
569
570    // ── F37: NULL-key shard coverage ────────────────────────────────────────
571
572    #[test]
573    fn exactly_one_shard_includes_null() {
574        let shards = plan_pk_shards("id", 0, 99, 5);
575        let null_owners: Vec<usize> = shards
576            .iter()
577            .enumerate()
578            .filter(|(_, s)| s.descriptor["include_null"].as_bool().unwrap_or(false))
579            .map(|(i, _)| i)
580            .collect();
581        assert_eq!(
582            null_owners,
583            vec![shards.len() - 1],
584            "exactly the last shard owns NULL keys"
585        );
586    }
587
588    #[test]
589    fn single_shard_plan_still_owns_null() {
590        // A single value yields one shard; it must still cover NULL keys.
591        let shards = plan_pk_shards("id", 7, 7, 4);
592        assert_eq!(shards.len(), 1);
593        assert!(shards[0].descriptor["include_null"].as_bool().unwrap());
594    }
595
596    #[test]
597    fn last_shard_wrap_emits_is_null_clause() {
598        let shards = plan_pk_shards("id", 0, 99, 3);
599        let last = ShardBounds::from_spec(shards.last().unwrap()).unwrap();
600        let sql = last.wrap("SELECT * FROM t", quote_ident);
601        assert!(
602            sql.contains(r#""id" IS NULL"#),
603            "last shard must match NULL keys: {sql}"
604        );
605        assert!(sql.contains(" OR "), "NULL clause OR'd with range: {sql}");
606    }
607
608    #[test]
609    fn non_last_shard_wrap_omits_is_null_clause() {
610        let shards = plan_pk_shards("id", 0, 99, 3);
611        // First shard is not the last → no NULL clause.
612        let first = ShardBounds::from_spec(&shards[0]).unwrap();
613        let sql = first.wrap("SELECT * FROM t", quote_ident);
614        assert!(
615            !sql.contains("IS NULL"),
616            "non-last shard must not match NULL keys: {sql}"
617        );
618    }
619
620    /// Property check on the generated predicates: OR-ing every shard's WHERE
621    /// predicate must cover (a) every non-NULL key — including values *outside*
622    /// the enumerated `[min, max]` (F54/F55) — exactly once and (b) NULL keys
623    /// exactly once.
624    #[test]
625    fn predicate_coverage_complete_and_non_overlapping() {
626        let (min, max, target) = (0i64, 19i64, 4usize);
627        let bounds: Vec<ShardBounds> = plan_pk_shards("k", min, max, target)
628            .iter()
629            .map(|s| ShardBounds::from_spec(s).unwrap())
630            .collect();
631
632        // The boundary shards model SQL membership: open below for the first
633        // shard, open above for the last.
634        let matches_key = |b: &ShardBounds, key: i64| -> bool {
635            let lower = b.lo_unbounded || key >= b.lo;
636            let upper = b.hi_unbounded || key < b.hi;
637            lower && upper
638        };
639
640        // (a) Every non-NULL key — well below min, in range, and well above max
641        // — matches exactly one shard. Keys outside [min, max] model rows
642        // inserted/backfilled during the coordinate→execute window.
643        for key in (min - 50)..=(max + 50) {
644            let matches = bounds.iter().filter(|b| matches_key(b, key)).count();
645            assert_eq!(matches, 1, "key {key} matched {matches} shards (want 1)");
646        }
647
648        // (b) NULL keys match exactly one shard (the one with include_null).
649        let null_matches = bounds.iter().filter(|b| b.include_null).count();
650        assert_eq!(null_matches, 1, "NULL keys must match exactly one shard");
651    }
652
653    #[test]
654    fn single_shard_wrap_selects_whole_dataset_including_null() {
655        // A lone open-ended shard must select every row, NULL keys included.
656        let shards = plan_pk_shards("id", 7, 7, 1);
657        assert_eq!(shards.len(), 1);
658        let b = ShardBounds::from_spec(&shards[0]).unwrap();
659        let sql = b.wrap("SELECT * FROM t", quote_ident);
660        assert!(sql.contains("WHERE TRUE"), "whole-dataset predicate: {sql}");
661        assert!(!sql.contains(">="), "no bounds on a lone shard: {sql}");
662    }
663
664    // dataset_uri is a pure-config method; the source requires a live DB to
665    // construct so we test it via a config-derived assertion instead.
666    #[test]
667    fn dataset_uri_strips_credentials() {
668        // We cannot construct PostgresSource offline, so we verify the
669        // credential-stripping logic used by dataset_uri() directly.
670        let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
671        let uri = format!("{}?query={}", redacted, "SELECT 1");
672        assert_eq!(uri, "postgres://h:5432/db?query=SELECT 1");
673    }
674
675    /// Build a source over a lazy pool (no server needed) so the shard glue —
676    /// `apply_shard`, `shard_wrap`, and `enumerate_shards`' error path — is
677    /// testable without Docker.
678    fn lazy_source(config: PostgresSourceConfig) -> PostgresSource {
679        let pool = PgPoolOptions::new()
680            // Fail fast at first checkout — these tests never reach a server.
681            .acquire_timeout(std::time::Duration::from_millis(200))
682            .connect_lazy(&config.connection_url)
683            .expect("lazy pool");
684        PostgresSource {
685            config,
686            pool,
687            applied_shard: Mutex::new(None),
688        }
689    }
690
691    #[tokio::test]
692    async fn apply_shard_then_shard_wrap_narrows_query() {
693        use faucet_core::Source as _;
694        let mut config =
695            PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT * FROM t");
696        config.shard = Some(crate::config::ShardConfig { key: "id".into() });
697        let source = lazy_source(config);
698        assert!(source.is_shardable());
699
700        // No shard applied / whole shard applied → query passes through.
701        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
702        source
703            .apply_shard(&faucet_core::ShardSpec::whole())
704            .await
705            .unwrap();
706        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
707
708        // A real shard narrows with ANSI double-quote quoting.
709        let spec = &plan_pk_shards("id", 0, 99, 2)[0];
710        source.apply_shard(spec).await.unwrap();
711        let wrapped = source.shard_wrap("SELECT * FROM t".into());
712        assert!(wrapped.contains(r#""id""#), "got: {wrapped}");
713        assert!(wrapped.contains("_faucet_shard"), "got: {wrapped}");
714
715        // Enumeration against the unreachable server surfaces the bounds-probe
716        // error path.
717        let err = source.enumerate_shards(4).await.unwrap_err();
718        assert!(
719            err.to_string().contains("shard bounds"),
720            "expected bounds-probe error, got: {err}"
721        );
722    }
723}