1use 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
15pub struct PostgresSource {
17 config: PostgresSourceConfig,
18 pool: PgPool,
19 applied_shard: Mutex<Option<ShardBounds>>,
23}
24
25#[derive(Clone, Debug)]
27struct ShardBounds {
28 key: String,
29 lo: i64,
30 hi: i64,
31 lo_unbounded: bool,
36 hi_unbounded: bool,
41 include_null: bool,
49}
50
51impl ShardBounds {
52 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 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 "TRUE".to_string()
99 } else {
100 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 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 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
139fn plan_pk_shards(key: &str, min: i64, max: i64, target: usize) -> Vec<ShardSpec> {
166 let target = target.max(1);
167 let width = (max as i128 - min as i128 + 1).max(1) as u128;
169 let n = (target as u128).min(width) as usize; let step = width.div_ceil(n as u128); 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; }
182 let descriptor = serde_json::json!({
183 "key": key,
184 "lo": lo as i64,
185 "hi": hi as i64,
186 "lo_unbounded": is_first,
188 "hi_unbounded": is_last,
189 "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
202fn pg_value_to_json(row: &sqlx::postgres::PgRow, col_name: &str) -> Value {
207 if let Ok(v) = row.try_get::<Value, _>(col_name) {
209 return v;
210 }
211
212 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 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 if let Ok(v) = row.try_get::<sqlx::types::Uuid, _>(col_name) {
257 return Value::String(v.to_string());
258 }
259 if let Ok(v) = row.try_get::<sqlx::types::BigDecimal, _>(col_name) {
261 return Value::String(v.to_string());
262 }
263 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
272fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299enum NumberBind {
300 I64,
302 U64,
305 F64,
307}
308
309fn 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
324fn 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 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 NumberBind::I64 => query.bind(n.as_i64().unwrap()),
343 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
357fn 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 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 fn is_shardable(&self) -> bool {
464 self.config.shard.is_some()
465 }
466
467 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 _ => Ok(vec![ShardSpec::whole()]),
504 }
505 }
506
507 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 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 let v = 9_007_199_254_740_993i64; 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 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 #[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 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; }
625 }
626
627 #[test]
628 fn plan_pk_shards_never_more_shards_than_values() {
629 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 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 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 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 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 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"})); assert!(ShardBounds::from_spec(&spec).is_none());
727 assert!(ShardBounds::from_spec(&ShardSpec::whole()).is_none());
728 }
729
730 #[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 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 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 #[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 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 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 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 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 #[test]
827 fn dataset_uri_strips_credentials() {
828 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}