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::ShardSpec;
6use faucet_core::util::quote_ident;
7use faucet_core::{FaucetError, Stream, StreamPage};
8use futures::TryStreamExt;
9use serde_json::Value;
10use sqlx::postgres::PgPoolOptions;
11use sqlx::{Column, PgPool, Row};
12use std::pin::Pin;
13use std::sync::Mutex;
14
15/// A source that executes a SQL query against PostgreSQL and returns rows as JSON.
16pub struct PostgresSource {
17    config: PostgresSourceConfig,
18    pool: PgPool,
19    /// Shard applied by the cluster coordinator (Mode B), if any. `None` (or the
20    /// whole-dataset shard) means the full query is streamed. Stored behind a
21    /// `Mutex` so `apply_shard(&self, …)` can record it before streaming.
22    applied_shard: Mutex<Option<ShardBounds>>,
23}
24
25/// Parsed integer range bounds for an applied PK-range shard.
26#[derive(Clone, Debug)]
27struct ShardBounds {
28    key: String,
29    lo: i64,
30    hi: i64,
31    /// When `true` this shard has **no lower bound** — it is the *first* shard,
32    /// so it owns every key below `hi` including any below the enumerated `MIN`.
33    /// Rows backfilled or inserted with smaller ids during the run are read by
34    /// this shard instead of being silently dropped outside `[MIN, MAX]` (F54).
35    lo_unbounded: bool,
36    /// When `true` this shard has **no upper bound** — it is the *last* shard,
37    /// so it owns every key at/above `lo` including any above the enumerated
38    /// `MAX`. Rows appended above the captured `MAX` between coordination and
39    /// shard execution are read by this shard instead of being lost (F55).
40    hi_unbounded: bool,
41    /// When `true` this shard *additionally* matches rows whose `key` is NULL.
42    ///
43    /// SQL aggregates (`MIN`/`MAX`) ignore NULLs, so a nullable shard key never
44    /// produces a `[lo, hi]` range covering NULL-key rows — without this flag
45    /// every sharded run would silently drop them (audit F37). Exactly one
46    /// shard (the last) carries this flag, so NULL-key rows are read by
47    /// precisely one shard: no loss, no duplication.
48    include_null: bool,
49}
50
51impl ShardBounds {
52    /// Parse from a [`ShardSpec`] descriptor produced by `enumerate_shards`.
53    fn from_spec(spec: &ShardSpec) -> Option<Self> {
54        let d = &spec.descriptor;
55        Some(Self {
56            key: d.get("key")?.as_str()?.to_string(),
57            lo: d.get("lo")?.as_i64()?,
58            hi: d.get("hi")?.as_i64()?,
59            lo_unbounded: d
60                .get("lo_unbounded")
61                .and_then(Value::as_bool)
62                .unwrap_or(false),
63            hi_unbounded: d
64                .get("hi_unbounded")
65                .and_then(Value::as_bool)
66                .unwrap_or(false),
67            include_null: d
68                .get("include_null")
69                .and_then(Value::as_bool)
70                .unwrap_or(false),
71        })
72    }
73
74    /// Wrap `inner` so only rows whose `key` falls in this shard's range are
75    /// returned. The key is quoted (injection-safe); the bounds are inlined as
76    /// integer literals (safe — they are `i64`s produced by enumeration).
77    ///
78    /// The boundary shards are **open-ended** (`lo_unbounded` / `hi_unbounded`)
79    /// so the union of all shards tiles `(-∞, +∞)`, matching unsharded
80    /// semantics — no row is dropped for sorting outside the `[MIN, MAX]`
81    /// captured at enumeration time (F54/F55). The single shard with
82    /// `include_null` also matches `key IS NULL` so NULL-key rows (invisible to
83    /// the `MIN`/`MAX` enumeration) are still read.
84    fn wrap(&self, inner: &str) -> String {
85        let key = quote_ident(&self.key);
86        let mut parts: Vec<String> = Vec::with_capacity(2);
87        if !self.lo_unbounded {
88            parts.push(format!("{key} >= {lo}", lo = self.lo));
89        }
90        if !self.hi_unbounded {
91            parts.push(format!("{key} < {hi}", hi = self.hi));
92        }
93        let range = parts.join(" AND ");
94        let predicate = if self.include_null {
95            if range.is_empty() {
96                // A single fully-unbounded shard owns the whole dataset,
97                // NULL-key rows included.
98                "TRUE".to_string()
99            } else {
100                // Parenthesize so the OR binds correctly inside the WHERE clause.
101                format!("(({range}) OR {key} IS NULL)")
102            }
103        } else if range.is_empty() {
104            "TRUE".to_string()
105        } else {
106            range
107        };
108        format!("SELECT * FROM ({inner}) AS _faucet_shard WHERE {predicate}")
109    }
110}
111
112impl PostgresSource {
113    /// Create a new PostgreSQL source. Establishes a connection pool.
114    pub async fn new(config: PostgresSourceConfig) -> Result<Self, FaucetError> {
115        faucet_core::validate_batch_size(config.batch_size)?;
116
117        let pool = PgPoolOptions::new()
118            .max_connections(config.max_connections)
119            .connect(&config.connection_url)
120            .await
121            .map_err(|e| FaucetError::Config(format!("PostgreSQL connection failed: {e}")))?;
122
123        Ok(Self {
124            config,
125            pool,
126            applied_shard: Mutex::new(None),
127        })
128    }
129
130    /// Apply the currently-set shard (if any) to a resolved query string.
131    fn shard_wrap(&self, query: String) -> String {
132        match &*self.applied_shard.lock().expect("shard mutex poisoned") {
133            Some(bounds) => bounds.wrap(&query),
134            None => query,
135        }
136    }
137}
138
139/// Split the integer range observed as `[min, max]` into up to `target`
140/// contiguous shards, each described by
141/// `{key, lo, hi, lo_unbounded, hi_unbounded, include_null}`. Interior cut
142/// points are half-open `[lo, hi)`, but the **boundary shards are open-ended**:
143/// the first shard has no lower bound and the last shard has no upper bound, so
144/// the union of all shards tiles `(-∞, +∞)`.
145///
146/// Open-ended boundaries match unsharded semantics: `min`/`max` are captured
147/// once at enumeration time, but rows can be inserted below `min` or above `max`
148/// (or backfilled) before the workers actually stream their shards. Clamping the
149/// boundary shards to the captured `[min, max]` would silently drop those rows
150/// (audit F54/F55); leaving them open captures everything.
151///
152/// The last shard also carries `include_null: true` so that NULL-key rows —
153/// invisible to the `MIN`/`MAX` enumeration that produced `[min, max]` — are
154/// read by exactly one shard (no loss, no duplication; audit F37). Picking the
155/// *last* shard means a single-shard plan still covers NULLs.
156///
157/// Coverage scheme (proven by `predicate_coverage_*` tests):
158/// - non-NULL keys: the first shard owns `key < cut₁`, interior shards own
159///   `[cutᵢ, cutᵢ₊₁)`, and the last shard owns `key >= cutₙ` — every value
160///   (including below `min` and above `max`) falls in exactly one shard;
161/// - NULL keys: matched only by the last shard's `OR key IS NULL` clause —
162///   exactly one shard.
163///
164/// Pure function (no I/O) so it is unit-testable without a database.
165fn plan_pk_shards(key: &str, min: i64, max: i64, target: usize) -> Vec<ShardSpec> {
166    let target = target.max(1);
167    // Range width as u128 to avoid i64 overflow on full-range PKs.
168    let width = (max as i128 - min as i128 + 1).max(1) as u128;
169    let n = (target as u128).min(width) as usize; // never more shards than values
170    let step = width.div_ceil(n as u128); // ceil so shards cover the whole range
171
172    let mut shards = Vec::with_capacity(n);
173    let mut lo = min as i128;
174    for i in 0..n {
175        let mut hi = lo + step as i128;
176        let is_first = i == 0;
177        let is_last = i == n - 1;
178        if is_last || hi > max as i128 {
179            hi = max as i128; // last interior cut closes at max (the last shard
180            // is unbounded above, so `hi` is unused there)
181        }
182        let descriptor = serde_json::json!({
183            "key": key,
184            "lo": lo as i64,
185            "hi": hi as i64,
186            // Boundary shards are open-ended so the union tiles (-∞, +∞).
187            "lo_unbounded": is_first,
188            "hi_unbounded": is_last,
189            // Exactly one shard (the last) owns the NULL-key rows.
190            "include_null": is_last,
191        });
192        let size = (hi - lo).max(0) as u64 + if is_last { 1 } else { 0 };
193        shards.push(ShardSpec::new(i.to_string(), descriptor).with_size(size));
194        if is_last {
195            break;
196        }
197        lo = hi;
198    }
199    shards
200}
201
202/// Convert a raw sqlx column value to a `serde_json::Value`.
203///
204/// Uses `try_get_raw` to inspect the type info and convert accordingly.
205/// Falls back to `Value::Null` for unsupported or null columns.
206fn pg_value_to_json(row: &sqlx::postgres::PgRow, col_name: &str) -> Value {
207    // Try JSON/JSONB first — this is the most flexible
208    if let Ok(v) = row.try_get::<Value, _>(col_name) {
209        return v;
210    }
211
212    // Try common scalar types
213    if let Ok(v) = row.try_get::<String, _>(col_name) {
214        return Value::String(v);
215    }
216    if let Ok(v) = row.try_get::<i64, _>(col_name) {
217        return Value::Number(v.into());
218    }
219    if let Ok(v) = row.try_get::<i32, _>(col_name) {
220        return Value::Number(v.into());
221    }
222    if let Ok(v) = row.try_get::<i16, _>(col_name) {
223        return Value::Number(v.into());
224    }
225    if let Ok(v) = row.try_get::<f64, _>(col_name) {
226        return serde_json::Number::from_f64(v)
227            .map(Value::Number)
228            .unwrap_or(Value::Null);
229    }
230    if let Ok(v) = row.try_get::<f32, _>(col_name) {
231        return serde_json::Number::from_f64(v as f64)
232            .map(Value::Number)
233            .unwrap_or(Value::Null);
234    }
235    if let Ok(v) = row.try_get::<bool, _>(col_name) {
236        return Value::Bool(v);
237    }
238
239    // Richer types that would otherwise silently decode to Null (#78/#43).
240    // Timestamps → RFC3339 / ISO-8601 strings.
241    if let Ok(v) =
242        row.try_get::<sqlx::types::chrono::DateTime<sqlx::types::chrono::Utc>, _>(col_name)
243    {
244        return Value::String(v.to_rfc3339());
245    }
246    if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveDateTime, _>(col_name) {
247        return Value::String(v.to_string());
248    }
249    if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveDate, _>(col_name) {
250        return Value::String(v.to_string());
251    }
252    if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveTime, _>(col_name) {
253        return Value::String(v.to_string());
254    }
255    // UUID → canonical hyphenated string.
256    if let Ok(v) = row.try_get::<sqlx::types::Uuid, _>(col_name) {
257        return Value::String(v.to_string());
258    }
259    // NUMERIC / DECIMAL → string, preserving exact precision.
260    if let Ok(v) = row.try_get::<sqlx::types::BigDecimal, _>(col_name) {
261        return Value::String(v.to_string());
262    }
263    // BYTEA → base64 (so binary survives the JSON round-trip).
264    if let Ok(v) = row.try_get::<Vec<u8>, _>(col_name) {
265        use base64::Engine as _;
266        return Value::String(base64::engine::general_purpose::STANDARD.encode(v));
267    }
268
269    Value::Null
270}
271
272/// Build the effective SQL query and ordered context-bind values for a given
273/// parent context. Returns the literal query when there is no context.
274fn resolve_query(
275    config: &PostgresSourceConfig,
276    context: &std::collections::HashMap<String, Value>,
277) -> (String, Vec<Value>) {
278    if context.is_empty() {
279        (config.query.clone(), Vec::new())
280    } else {
281        faucet_core::util::substitute_context_bind_params(
282            &config.query,
283            context,
284            config.params.len() + 1,
285            |i| format!("${i}"),
286        )
287    }
288}
289
290/// How a numeric bind value should be bound onto a sqlx query.
291///
292/// Classifying *before* binding keeps the integer/float decision in one pure,
293/// unit-testable place and — critically — binds any integer in
294/// `[i64::MIN, i64::MAX]` as an exact `i64` rather than an `f64`. Binding an
295/// integer above `2^53` as `f64` silently rounds it (audit F38), so a large
296/// 64-bit id threaded into `WHERE id = $1` would compare against the *wrong*
297/// value and return wrong rows.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299enum NumberBind {
300    /// Exact `i64` — covers every integer in `[i64::MIN, i64::MAX]`.
301    I64,
302    /// Value above `i64::MAX`; bind the `u64` reinterpreted as `i64` (two's
303    /// complement) so the bytes round-trip into an `int8`/`bigint` column.
304    U64,
305    /// Genuine floating-point value — bind as `f64`.
306    F64,
307}
308
309/// Classify a JSON number into the bind category to use.
310///
311/// `is_i64()` losslessly covers `[i64::MIN, i64::MAX]` (including the
312/// `(2^53, i64::MAX]` range that `f64` would round); `is_u64()` covers values
313/// above `i64::MAX`; everything else is a real float.
314fn classify_number(n: &serde_json::Number) -> NumberBind {
315    if n.is_i64() {
316        NumberBind::I64
317    } else if n.is_u64() {
318        NumberBind::U64
319    } else {
320        NumberBind::F64
321    }
322}
323
324/// Apply configured params followed by context-derived bind values onto a
325/// sqlx query.
326fn bind_params<'q>(
327    mut query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
328    config_params: &'q [Value],
329    bind_values: &'q [Value],
330) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
331    // Bind the static config params and the per-context values as native
332    // scalar types, in positional order ($1, $2, …). Binding a raw
333    // `serde_json::Value` encodes it as `jsonb` (sqlx), which breaks comparisons
334    // against typed columns — e.g. `WHERE id = $1` against an integer column
335    // fails with "operator does not exist: integer = jsonb". config_params
336    // previously bound the raw Value and hit exactly this (audit #146 H12).
337    for value in config_params.iter().chain(bind_values) {
338        query = match value {
339            Value::String(s) => query.bind(s.clone()),
340            Value::Number(n) => match classify_number(n) {
341                // `unwrap()` is sound: the classifier proves the predicate.
342                NumberBind::I64 => query.bind(n.as_i64().unwrap()),
343                // `u64::MAX` has no `i64` representation; reinterpret the bits
344                // so the value round-trips into an `int8`/`bigint` column
345                // without the precision loss an `f64` cast would introduce.
346                NumberBind::U64 => query.bind(n.as_u64().unwrap() as i64),
347                NumberBind::F64 => query.bind(n.as_f64().unwrap_or(0.0)),
348            },
349            Value::Bool(b) => query.bind(*b),
350            Value::Null => query.bind(None::<String>),
351            _ => query.bind(value.to_string()),
352        };
353    }
354    query
355}
356
357/// Convert a single `PgRow` into a JSON object whose keys are the row's
358/// column names.
359fn row_to_json(row: &sqlx::postgres::PgRow) -> Value {
360    let mut map = serde_json::Map::new();
361    for col in row.columns() {
362        let name = col.name().to_string();
363        let value = pg_value_to_json(row, &name);
364        map.insert(name, value);
365    }
366    Value::Object(map)
367}
368
369#[async_trait]
370impl faucet_core::Source for PostgresSource {
371    async fn fetch_with_context(
372        &self,
373        context: &std::collections::HashMap<String, serde_json::Value>,
374    ) -> Result<Vec<Value>, FaucetError> {
375        let (query_str, bind_values) = resolve_query(&self.config, context);
376        let query_str = self.shard_wrap(query_str);
377        let query = bind_params(sqlx::query(&query_str), &self.config.params, &bind_values);
378
379        let rows = query
380            .fetch_all(&self.pool)
381            .await
382            .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?;
383
384        let records: Vec<Value> = rows.iter().map(row_to_json).collect();
385        tracing::info!(rows = records.len(), query = %self.config.query, "PostgreSQL source fetch complete");
386        Ok(records)
387    }
388
389    /// Stream rows from the underlying sqlx cursor without buffering the full
390    /// result set. Each emitted [`StreamPage`] holds up to
391    /// [`PostgresSourceConfig::batch_size`] rows.
392    ///
393    /// The trait-level `batch_size` argument is ignored in favour of the
394    /// config field — the config is the user-facing knob the README
395    /// documents, and routing the pipeline-supplied hint through it would
396    /// silently override an explicit config value.
397    ///
398    /// `batch_size = 0` drains the entire cursor into a single page. The
399    /// postgres query source has no incremental-replication mode today, so
400    /// every emitted page carries `bookmark: None`.
401    fn stream_pages<'a>(
402        &'a self,
403        context: &'a std::collections::HashMap<String, Value>,
404        _batch_size: usize,
405    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
406        let batch_size = self.config.batch_size;
407
408        Box::pin(async_stream::try_stream! {
409            let (query_str, bind_values) = resolve_query(&self.config, context);
410            let query_str = self.shard_wrap(query_str);
411            let query = bind_params(
412                sqlx::query(&query_str),
413                &self.config.params,
414                &bind_values,
415            );
416
417            let mut rows = query.fetch(&self.pool);
418            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
419            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
420            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
421            let mut total = 0usize;
422
423            while let Some(row) = rows
424                .try_next()
425                .await
426                .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?
427            {
428                buffer.push(row_to_json(&row));
429                if buffer.len() >= chunk {
430                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
431                    total += page.len();
432                    yield StreamPage { records: page, bookmark: None };
433                }
434            }
435            if !buffer.is_empty() {
436                total += buffer.len();
437                yield StreamPage { records: buffer, bookmark: None };
438            }
439
440            tracing::info!(
441                rows = total,
442                batch_size,
443                query = %self.config.query,
444                "PostgreSQL source stream complete",
445            );
446        })
447    }
448
449    fn config_schema(&self) -> serde_json::Value {
450        serde_json::to_value(faucet_core::schema_for!(PostgresSourceConfig))
451            .expect("schema serialization")
452    }
453
454    fn dataset_uri(&self) -> String {
455        format!(
456            "{}?query={}",
457            faucet_core::redact_uri_credentials(&self.config.connection_url),
458            self.config.query
459        )
460    }
461
462    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
463    fn is_shardable(&self) -> bool {
464        self.config.shard.is_some()
465    }
466
467    /// Enumerate contiguous primary-key range shards by computing the `key`
468    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting that
469    /// range into ~`target` slices. Returns a single whole-dataset shard when no
470    /// `shard` config is set or the result set is empty.
471    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
472        let Some(shard_cfg) = &self.config.shard else {
473            return Ok(vec![ShardSpec::whole()]);
474        };
475
476        let key = quote_ident(&shard_cfg.key);
477        let bounds_sql = format!(
478            "SELECT MIN({key})::int8 AS lo, MAX({key})::int8 AS hi \
479             FROM ({inner}) AS _faucet_bounds",
480            inner = self.config.query
481        );
482        let row = bind_params(sqlx::query(&bounds_sql), &self.config.params, &[])
483            .fetch_one(&self.pool)
484            .await
485            .map_err(|e| {
486                FaucetError::Source(format!(
487                    "postgres: failed to compute shard bounds for key {:?} \
488                     (it must be an integer-typed column): {e}",
489                    shard_cfg.key
490                ))
491            })?;
492
493        let lo: Option<i64> = row.try_get("lo").map_err(|e| {
494            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
495        })?;
496        let hi: Option<i64> = row.try_get("hi").map_err(|e| {
497            FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
498        })?;
499
500        match (lo, hi) {
501            (Some(lo), Some(hi)) => Ok(plan_pk_shards(&shard_cfg.key, lo, hi, target)),
502            // Empty result set → nothing to shard; one (empty) whole shard.
503            _ => Ok(vec![ShardSpec::whole()]),
504        }
505    }
506
507    /// Narrow this source to a single PK-range shard. The whole-dataset shard
508    /// clears any applied range (streams the full query).
509    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
510        let bounds = if shard.is_whole() {
511            None
512        } else {
513            Some(ShardBounds::from_spec(shard).ok_or_else(|| {
514                FaucetError::Source(format!(
515                    "postgres: invalid shard descriptor: {}",
516                    shard.descriptor
517                ))
518            })?)
519        };
520        *self.applied_shard.lock().expect("shard mutex poisoned") = bounds;
521        Ok(())
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[tokio::test]
530    async fn new_rejects_out_of_range_batch_size() {
531        let mut config = PostgresSourceConfig::new("postgres://localhost/test", "SELECT 1");
532        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
533        match PostgresSource::new(config).await {
534            Err(faucet_core::FaucetError::Config(m)) => {
535                assert!(m.contains("batch_size"), "got: {m}")
536            }
537            _ => panic!("expected a batch_size Config error"),
538        }
539    }
540
541    // ── F38: numeric bind classification (precision-safe) ───────────────────
542
543    fn num(v: serde_json::Value) -> serde_json::Number {
544        match v {
545            serde_json::Value::Number(n) => n,
546            _ => panic!("not a number"),
547        }
548    }
549
550    #[test]
551    fn classify_small_int_is_i64() {
552        assert_eq!(
553            classify_number(&num(serde_json::json!(42))),
554            NumberBind::I64
555        );
556        assert_eq!(
557            classify_number(&num(serde_json::json!(-7))),
558            NumberBind::I64
559        );
560        assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
561    }
562
563    #[test]
564    fn classify_above_2_pow_53_stays_i64_not_f64() {
565        // The key precision bug: 2^53 + 1 must NOT be bound as f64 (which would
566        // round it). It is a valid i64, so it must classify as I64.
567        let v = 9_007_199_254_740_993i64; // 2^53 + 1
568        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
569    }
570
571    #[test]
572    fn classify_i64_max_is_i64() {
573        assert_eq!(
574            classify_number(&num(serde_json::json!(i64::MAX))),
575            NumberBind::I64
576        );
577        assert_eq!(
578            classify_number(&num(serde_json::json!(i64::MIN))),
579            NumberBind::I64
580        );
581    }
582
583    #[test]
584    fn classify_above_i64_max_is_u64() {
585        // i64::MAX + 1 has no i64 representation but fits u64.
586        let v: u64 = i64::MAX as u64 + 1;
587        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
588        assert_eq!(
589            classify_number(&num(serde_json::json!(u64::MAX))),
590            NumberBind::U64
591        );
592    }
593
594    #[test]
595    fn classify_float_is_f64() {
596        assert_eq!(
597            classify_number(&num(serde_json::json!(3.5))),
598            NumberBind::F64
599        );
600        assert_eq!(
601            classify_number(&num(serde_json::json!(-0.5))),
602            NumberBind::F64
603        );
604    }
605
606    // ── PK-range sharding (pure logic) ──────────────────────────────────────
607
608    #[test]
609    fn plan_pk_shards_covers_full_range_without_gaps_or_overlap() {
610        let shards = plan_pk_shards("id", 0, 99, 4);
611        assert_eq!(shards.len(), 4);
612        // Contiguous half-open interior cuts; boundary shards are open-ended.
613        let mut expected_lo = 0i64;
614        for (i, s) in shards.iter().enumerate() {
615            let d = &s.descriptor;
616            assert_eq!(d["key"], "id");
617            assert_eq!(d["lo"].as_i64().unwrap(), expected_lo);
618            let hi = d["hi"].as_i64().unwrap();
619            let first = i == 0;
620            let last = i == shards.len() - 1;
621            assert_eq!(d["lo_unbounded"].as_bool().unwrap(), first);
622            assert_eq!(d["hi_unbounded"].as_bool().unwrap(), last);
623            expected_lo = hi; // next shard starts where this half-open one ended
624        }
625    }
626
627    #[test]
628    fn plan_pk_shards_never_more_shards_than_values() {
629        // Range [5, 7] has 3 values; asking for 10 shards yields at most 3.
630        let shards = plan_pk_shards("pk", 5, 7, 10);
631        assert!(shards.len() <= 3, "got {} shards", shards.len());
632        assert!(
633            shards[0].descriptor["lo_unbounded"].as_bool().unwrap(),
634            "first shard is unbounded below"
635        );
636        assert!(
637            shards.last().unwrap().descriptor["hi_unbounded"]
638                .as_bool()
639                .unwrap(),
640            "last shard is unbounded above"
641        );
642    }
643
644    #[test]
645    fn plan_pk_shards_single_value_one_shard() {
646        let shards = plan_pk_shards("id", 42, 42, 8);
647        assert_eq!(shards.len(), 1);
648        // A lone shard is open-ended on both sides → the whole dataset.
649        assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
650        assert!(shards[0].descriptor["hi_unbounded"].as_bool().unwrap());
651    }
652
653    #[test]
654    fn plan_pk_shards_target_zero_treated_as_one() {
655        let shards = plan_pk_shards("id", 0, 9, 0);
656        assert_eq!(shards.len(), 1);
657        assert_eq!(shards[0].descriptor["hi"].as_i64().unwrap(), 9);
658    }
659
660    #[test]
661    fn shard_bounds_wrap_builds_half_open_predicate() {
662        // An interior shard (bounded both sides) is half-open `[lo, hi)`.
663        let spec = ShardSpec::new(
664            "1",
665            serde_json::json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
666        );
667        let b = ShardBounds::from_spec(&spec).unwrap();
668        let sql = b.wrap("SELECT * FROM t");
669        assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"));
670        assert!(sql.contains(r#""id" >= 100"#), "got: {sql}");
671        assert!(
672            sql.contains(r#""id" < 200"#),
673            "half-open upper bound: {sql}"
674        );
675    }
676
677    #[test]
678    fn shard_bounds_wrap_first_shard_has_no_lower_bound() {
679        // F54: the first shard omits the `>= lo` floor so keys below the
680        // enumerated MIN are still read.
681        let spec = ShardSpec::new(
682            "0",
683            serde_json::json!({"key": "id", "lo": 0, "hi": 100, "lo_unbounded": true, "hi_unbounded": false}),
684        );
685        let b = ShardBounds::from_spec(&spec).unwrap();
686        let sql = b.wrap("SELECT * FROM t");
687        assert!(sql.contains(r#""id" < 100"#), "upper bound present: {sql}");
688        assert!(!sql.contains(">="), "first shard has no lower floor: {sql}");
689    }
690
691    #[test]
692    fn shard_bounds_wrap_last_shard_has_no_upper_bound() {
693        // F55: the last shard omits the upper bound so keys above the
694        // enumerated MAX are still read.
695        let spec = ShardSpec::new(
696            "2",
697            serde_json::json!({"key": "id", "lo": 200, "hi": 300, "lo_unbounded": false, "hi_unbounded": true}),
698        );
699        let b = ShardBounds::from_spec(&spec).unwrap();
700        let sql = b.wrap("SELECT * FROM t");
701        assert!(sql.contains(r#""id" >= 200"#), "lower bound present: {sql}");
702        assert!(
703            !sql.contains(" < ") && !sql.contains("<="),
704            "last shard has no upper bound: {sql}"
705        );
706    }
707
708    #[test]
709    fn shard_bounds_quotes_key_against_injection() {
710        let spec = ShardSpec::new(
711            "0",
712            serde_json::json!({"key": "weird\"; DROP", "lo": 0, "hi": 1, "lo_unbounded": false, "hi_unbounded": false}),
713        );
714        let b = ShardBounds::from_spec(&spec).unwrap();
715        let sql = b.wrap("SELECT 1");
716        // The doubled quote escaping proves the identifier was quoted, not raw.
717        assert!(
718            sql.contains(r#""weird""; DROP""#),
719            "key must be quoted: {sql}"
720        );
721    }
722
723    #[test]
724    fn shard_bounds_from_spec_rejects_malformed_descriptor() {
725        let spec = ShardSpec::new("0", serde_json::json!({"key": "id"})); // no lo/hi
726        assert!(ShardBounds::from_spec(&spec).is_none());
727        assert!(ShardBounds::from_spec(&ShardSpec::whole()).is_none());
728    }
729
730    // ── F37: NULL-key shard coverage ────────────────────────────────────────
731
732    #[test]
733    fn exactly_one_shard_includes_null() {
734        let shards = plan_pk_shards("id", 0, 99, 5);
735        let null_owners: Vec<usize> = shards
736            .iter()
737            .enumerate()
738            .filter(|(_, s)| s.descriptor["include_null"].as_bool().unwrap_or(false))
739            .map(|(i, _)| i)
740            .collect();
741        assert_eq!(
742            null_owners,
743            vec![shards.len() - 1],
744            "exactly the last shard owns NULL keys"
745        );
746    }
747
748    #[test]
749    fn single_shard_plan_still_owns_null() {
750        // A single value yields one shard; it must still cover NULL keys.
751        let shards = plan_pk_shards("id", 7, 7, 4);
752        assert_eq!(shards.len(), 1);
753        assert!(shards[0].descriptor["include_null"].as_bool().unwrap());
754    }
755
756    #[test]
757    fn last_shard_wrap_emits_is_null_clause() {
758        let shards = plan_pk_shards("id", 0, 99, 3);
759        let last = ShardBounds::from_spec(shards.last().unwrap()).unwrap();
760        let sql = last.wrap("SELECT * FROM t");
761        assert!(
762            sql.contains(r#""id" IS NULL"#),
763            "last shard must match NULL keys: {sql}"
764        );
765        assert!(sql.contains(" OR "), "NULL clause OR'd with range: {sql}");
766    }
767
768    #[test]
769    fn non_last_shard_wrap_omits_is_null_clause() {
770        let shards = plan_pk_shards("id", 0, 99, 3);
771        // First shard is not the last → no NULL clause.
772        let first = ShardBounds::from_spec(&shards[0]).unwrap();
773        let sql = first.wrap("SELECT * FROM t");
774        assert!(
775            !sql.contains("IS NULL"),
776            "non-last shard must not match NULL keys: {sql}"
777        );
778    }
779
780    /// Property check on the generated predicates: OR-ing every shard's WHERE
781    /// predicate must cover (a) every non-NULL key — including values *outside*
782    /// the enumerated `[min, max]` (F54/F55) — exactly once and (b) NULL keys
783    /// exactly once.
784    #[test]
785    fn predicate_coverage_complete_and_non_overlapping() {
786        let (min, max, target) = (0i64, 19i64, 4usize);
787        let bounds: Vec<ShardBounds> = plan_pk_shards("k", min, max, target)
788            .iter()
789            .map(|s| ShardBounds::from_spec(s).unwrap())
790            .collect();
791
792        // The boundary shards model SQL membership: open below for the first
793        // shard, open above for the last.
794        let matches_key = |b: &ShardBounds, key: i64| -> bool {
795            let lower = b.lo_unbounded || key >= b.lo;
796            let upper = b.hi_unbounded || key < b.hi;
797            lower && upper
798        };
799
800        // (a) Every non-NULL key — well below min, in range, and well above max
801        // — matches exactly one shard. Keys outside [min, max] model rows
802        // inserted/backfilled during the coordinate→execute window.
803        for key in (min - 50)..=(max + 50) {
804            let matches = bounds.iter().filter(|b| matches_key(b, key)).count();
805            assert_eq!(matches, 1, "key {key} matched {matches} shards (want 1)");
806        }
807
808        // (b) NULL keys match exactly one shard (the one with include_null).
809        let null_matches = bounds.iter().filter(|b| b.include_null).count();
810        assert_eq!(null_matches, 1, "NULL keys must match exactly one shard");
811    }
812
813    #[test]
814    fn single_shard_wrap_selects_whole_dataset_including_null() {
815        // A lone open-ended shard must select every row, NULL keys included.
816        let shards = plan_pk_shards("id", 7, 7, 1);
817        assert_eq!(shards.len(), 1);
818        let b = ShardBounds::from_spec(&shards[0]).unwrap();
819        let sql = b.wrap("SELECT * FROM t");
820        assert!(sql.contains("WHERE TRUE"), "whole-dataset predicate: {sql}");
821        assert!(!sql.contains(">="), "no bounds on a lone shard: {sql}");
822    }
823
824    // dataset_uri is a pure-config method; the source requires a live DB to
825    // construct so we test it via a config-derived assertion instead.
826    #[test]
827    fn dataset_uri_strips_credentials() {
828        // We cannot construct PostgresSource offline, so we verify the
829        // credential-stripping logic used by dataset_uri() directly.
830        let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
831        let uri = format!("{}?query={}", redacted, "SELECT 1");
832        assert_eq!(uri, "postgres://h:5432/db?query=SELECT 1");
833    }
834}