1use 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
17pub struct PostgresSource {
19 config: PostgresSourceConfig,
20 pool: PgPool,
21 applied_shard: Mutex<Option<PkShardBounds>>,
25}
26
27impl PostgresSource {
28 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 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
54fn pg_value_to_json(row: &sqlx::postgres::PgRow, col_name: &str) -> Value {
59 if let Ok(v) = row.try_get::<Value, _>(col_name) {
61 return v;
62 }
63
64 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 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 if let Ok(v) = row.try_get::<sqlx::types::Uuid, _>(col_name) {
109 return Value::String(v.to_string());
110 }
111 if let Ok(v) = row.try_get::<sqlx::types::BigDecimal, _>(col_name) {
113 return Value::String(v.to_string());
114 }
115 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
124fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum NumberBind {
152 I64,
154 U64,
157 F64,
159}
160
161fn 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
176fn bind_params<'q>(
179 mut query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
180 config_params: &'q [Value],
181 bind_values: &'q [Value],
182) -> Result<sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>, FaucetError> {
183 for (i, value) in config_params.iter().chain(bind_values).enumerate() {
190 query = match value {
191 Value::String(s) => query.bind(s.clone()),
192 Value::Number(n) => match classify_number(n) {
193 NumberBind::I64 => query.bind(n.as_i64().unwrap()),
195 NumberBind::U64 => query.bind(faucet_core::util::u64_to_signed(
203 n.as_u64().unwrap(),
204 &format!("bind parameter ${}", i + 1),
205 )?),
206 NumberBind::F64 => query.bind(n.as_f64().unwrap_or(0.0)),
207 },
208 Value::Bool(b) => query.bind(*b),
209 Value::Null => query.bind(None::<String>),
210 _ => query.bind(value.to_string()),
211 };
212 }
213 Ok(query)
214}
215
216type CatalogRow = (String, String, String, String, bool, Option<i64>);
220
221type PendingTable = (String, String, Option<i64>, Vec<(String, Value)>);
224
225fn descriptors_from_catalog(
229 rows: Vec<CatalogRow>,
230 quote: fn(&str) -> String,
231) -> Vec<faucet_core::DatasetDescriptor> {
232 let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
233 let mut current: Option<PendingTable> = None;
234
235 let flush = |cur: Option<PendingTable>, out: &mut Vec<faucet_core::DatasetDescriptor>| {
236 if let Some((schema, table, est, cols)) = cur {
237 let query = format!("SELECT * FROM {}.{}", quote(&schema), quote(&table));
238 let mut d = faucet_core::DatasetDescriptor::new(
239 format!("{schema}.{table}"),
240 "table",
241 serde_json::json!({ "query": query }),
242 )
243 .with_schema(faucet_core::columns_to_schema(cols));
244 if let Some(n) = est
246 && n >= 0
247 {
248 d = d.with_estimated_rows(n as u64);
249 }
250 out.push(d);
251 }
252 };
253
254 for (schema, table, column, data_type, is_nullable, est) in rows {
255 let same = current
256 .as_ref()
257 .is_some_and(|(s, t, _, _)| *s == schema && *t == table);
258 if !same {
259 flush(current.take(), &mut out);
260 current = Some((schema, table, est, Vec::new()));
261 }
262 let mut fragment = faucet_core::sql_type_to_json_schema(&data_type);
263 if is_nullable {
264 fragment = faucet_core::nullable_type(fragment);
265 }
266 if let Some((_, _, _, cols)) = current.as_mut() {
267 cols.push((column, fragment));
268 }
269 }
270 flush(current, &mut out);
271 out
272}
273
274fn row_to_json(row: &sqlx::postgres::PgRow) -> Value {
277 let mut map = serde_json::Map::new();
278 for col in row.columns() {
279 let name = col.name().to_string();
280 let value = pg_value_to_json(row, &name);
281 map.insert(name, value);
282 }
283 Value::Object(map)
284}
285
286#[async_trait]
287impl faucet_core::Source for PostgresSource {
288 async fn fetch_with_context(
289 &self,
290 context: &std::collections::HashMap<String, serde_json::Value>,
291 ) -> Result<Vec<Value>, FaucetError> {
292 let (query_str, bind_values) = resolve_query(&self.config, context);
293 let query_str = self.shard_wrap(query_str);
294 let query = bind_params(sqlx::query(&query_str), &self.config.params, &bind_values)?;
295
296 let rows = query
297 .fetch_all(&self.pool)
298 .await
299 .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?;
300
301 let records: Vec<Value> = rows.iter().map(row_to_json).collect();
302 tracing::info!(rows = records.len(), query = %self.config.query, "PostgreSQL source fetch complete");
303 Ok(records)
304 }
305
306 fn stream_pages<'a>(
319 &'a self,
320 context: &'a std::collections::HashMap<String, Value>,
321 _batch_size: usize,
322 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
323 let batch_size = self.config.batch_size;
324
325 Box::pin(async_stream::try_stream! {
326 let (query_str, bind_values) = resolve_query(&self.config, context);
327 let query_str = self.shard_wrap(query_str);
328 let query = bind_params(
329 sqlx::query(&query_str),
330 &self.config.params,
331 &bind_values,
332 )?;
333
334 let mut rows = query.fetch(&self.pool);
335 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
336 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
337 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
338 let mut total = 0usize;
339
340 while let Some(row) = rows
341 .try_next()
342 .await
343 .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?
344 {
345 buffer.push(row_to_json(&row));
346 if buffer.len() >= chunk {
347 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
348 total += page.len();
349 yield StreamPage { records: page, bookmark: None };
350 }
351 }
352 if !buffer.is_empty() {
353 total += buffer.len();
354 yield StreamPage { records: buffer, bookmark: None };
355 }
356
357 tracing::info!(
358 rows = total,
359 batch_size,
360 query = %self.config.query,
361 "PostgreSQL source stream complete",
362 );
363 })
364 }
365
366 fn connector_name(&self) -> &'static str {
367 "postgres"
368 }
369
370 fn config_schema(&self) -> serde_json::Value {
371 serde_json::to_value(faucet_core::schema_for!(PostgresSourceConfig))
372 .expect("schema serialization")
373 }
374
375 fn dataset_uri(&self) -> String {
376 format!(
377 "{}?query={}",
378 faucet_core::redact_uri_credentials(&self.config.connection_url),
379 self.config.query
380 )
381 }
382
383 fn supports_discover(&self) -> bool {
384 true
385 }
386
387 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
391 let sql = r#"
392 SELECT c.table_schema, c.table_name, c.column_name, c.data_type,
393 (c.is_nullable = 'YES') AS is_nullable,
394 (SELECT pc.reltuples::bigint
395 FROM pg_class pc
396 JOIN pg_namespace pn ON pn.oid = pc.relnamespace
397 WHERE pn.nspname = c.table_schema
398 AND pc.relname = c.table_name) AS estimated_rows
399 FROM information_schema.columns c
400 JOIN information_schema.tables t
401 ON t.table_schema = c.table_schema AND t.table_name = c.table_name
402 WHERE t.table_type = 'BASE TABLE'
403 AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
404 ORDER BY c.table_schema, c.table_name, c.ordinal_position"#;
405 let rows = sqlx::query(sql)
406 .fetch_all(&self.pool)
407 .await
408 .map_err(|e| FaucetError::Source(format!("postgres: catalog discovery failed: {e}")))?;
409
410 let catalog: Vec<CatalogRow> = rows
411 .iter()
412 .map(|row| -> Result<CatalogRow, FaucetError> {
413 let decode = |col: &str| -> Result<String, FaucetError> {
414 row.try_get::<String, _>(col).map_err(|e| {
415 FaucetError::Source(format!("postgres: catalog decode failed ({col}): {e}"))
416 })
417 };
418 Ok((
419 decode("table_schema")?,
420 decode("table_name")?,
421 decode("column_name")?,
422 decode("data_type")?,
423 row.try_get::<bool, _>("is_nullable").unwrap_or(true),
424 row.try_get::<i64, _>("estimated_rows").ok(),
425 ))
426 })
427 .collect::<Result<_, _>>()?;
428
429 Ok(descriptors_from_catalog(catalog, quote_ident))
430 }
431
432 fn is_shardable(&self) -> bool {
434 self.config.shard.is_some()
435 }
436
437 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
442 let Some(shard_cfg) = &self.config.shard else {
443 return Ok(vec![ShardSpec::whole()]);
444 };
445
446 let bounds_sql =
447 pk_bounds_query(&self.config.query, "e_ident(&shard_cfg.key), "BIGINT");
448 let row = bind_params(sqlx::query(&bounds_sql), &self.config.params, &[])?
449 .fetch_one(&self.pool)
450 .await
451 .map_err(|e| {
452 FaucetError::Source(format!(
453 "postgres: failed to compute shard bounds for key {:?} \
454 (it must be an integer-typed column): {e}",
455 shard_cfg.key
456 ))
457 })?;
458
459 let lo: Option<i64> = row.try_get("lo").map_err(|e| {
460 FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
461 })?;
462 let hi: Option<i64> = row.try_get("hi").map_err(|e| {
463 FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
464 })?;
465 Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
466 }
467
468 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
471 *self.applied_shard.lock().expect("shard mutex poisoned") =
472 parse_pk_shard(shard, "postgres")?;
473 Ok(())
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use faucet_core::shard::plan_pk_shards;
481
482 type ShardBounds = PkShardBounds;
486
487 #[tokio::test]
488 async fn new_rejects_out_of_range_batch_size() {
489 let mut config = PostgresSourceConfig::new("postgres://localhost/test", "SELECT 1");
490 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
491 match PostgresSource::new(config).await {
492 Err(faucet_core::FaucetError::Config(m)) => {
493 assert!(m.contains("batch_size"), "got: {m}")
494 }
495 _ => panic!("expected a batch_size Config error"),
496 }
497 }
498
499 fn num(v: serde_json::Value) -> serde_json::Number {
502 match v {
503 serde_json::Value::Number(n) => n,
504 _ => panic!("not a number"),
505 }
506 }
507
508 #[test]
509 fn classify_small_int_is_i64() {
510 assert_eq!(
511 classify_number(&num(serde_json::json!(42))),
512 NumberBind::I64
513 );
514 assert_eq!(
515 classify_number(&num(serde_json::json!(-7))),
516 NumberBind::I64
517 );
518 assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
519 }
520
521 #[test]
522 fn classify_above_2_pow_53_stays_i64_not_f64() {
523 let v = 9_007_199_254_740_993i64; assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
527 }
528
529 #[test]
530 fn classify_i64_max_is_i64() {
531 assert_eq!(
532 classify_number(&num(serde_json::json!(i64::MAX))),
533 NumberBind::I64
534 );
535 assert_eq!(
536 classify_number(&num(serde_json::json!(i64::MIN))),
537 NumberBind::I64
538 );
539 }
540
541 #[test]
542 fn classify_above_i64_max_is_u64() {
543 let v: u64 = i64::MAX as u64 + 1;
545 assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
546 assert_eq!(
547 classify_number(&num(serde_json::json!(u64::MAX))),
548 NumberBind::U64
549 );
550 }
551
552 #[test]
553 fn classify_float_is_f64() {
554 assert_eq!(
555 classify_number(&num(serde_json::json!(3.5))),
556 NumberBind::F64
557 );
558 assert_eq!(
559 classify_number(&num(serde_json::json!(-0.5))),
560 NumberBind::F64
561 );
562 }
563
564 #[test]
567 fn plan_pk_shards_covers_full_range_without_gaps_or_overlap() {
568 let shards = plan_pk_shards("id", 0, 99, 4);
569 assert_eq!(shards.len(), 4);
570 let mut expected_lo = 0i64;
572 for (i, s) in shards.iter().enumerate() {
573 let d = &s.descriptor;
574 assert_eq!(d["key"], "id");
575 assert_eq!(d["lo"].as_i64().unwrap(), expected_lo);
576 let hi = d["hi"].as_i64().unwrap();
577 let first = i == 0;
578 let last = i == shards.len() - 1;
579 assert_eq!(d["lo_unbounded"].as_bool().unwrap(), first);
580 assert_eq!(d["hi_unbounded"].as_bool().unwrap(), last);
581 expected_lo = hi; }
583 }
584
585 #[test]
586 fn plan_pk_shards_never_more_shards_than_values() {
587 let shards = plan_pk_shards("pk", 5, 7, 10);
589 assert!(shards.len() <= 3, "got {} shards", shards.len());
590 assert!(
591 shards[0].descriptor["lo_unbounded"].as_bool().unwrap(),
592 "first shard is unbounded below"
593 );
594 assert!(
595 shards.last().unwrap().descriptor["hi_unbounded"]
596 .as_bool()
597 .unwrap(),
598 "last shard is unbounded above"
599 );
600 }
601
602 #[test]
603 fn plan_pk_shards_single_value_one_shard() {
604 let shards = plan_pk_shards("id", 42, 42, 8);
605 assert_eq!(shards.len(), 1);
606 assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
608 assert!(shards[0].descriptor["hi_unbounded"].as_bool().unwrap());
609 }
610
611 #[test]
612 fn plan_pk_shards_target_zero_treated_as_one() {
613 let shards = plan_pk_shards("id", 0, 9, 0);
614 assert_eq!(shards.len(), 1);
615 assert_eq!(shards[0].descriptor["hi"].as_i64().unwrap(), 9);
616 }
617
618 #[test]
619 fn shard_bounds_wrap_builds_half_open_predicate() {
620 let spec = ShardSpec::new(
622 "1",
623 serde_json::json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
624 );
625 let b = ShardBounds::from_spec(&spec).unwrap();
626 let sql = b.wrap("SELECT * FROM t", quote_ident);
627 assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"));
628 assert!(sql.contains(r#""id" >= 100"#), "got: {sql}");
629 assert!(
630 sql.contains(r#""id" < 200"#),
631 "half-open upper bound: {sql}"
632 );
633 }
634
635 #[test]
636 fn shard_bounds_wrap_first_shard_has_no_lower_bound() {
637 let spec = ShardSpec::new(
640 "0",
641 serde_json::json!({"key": "id", "lo": 0, "hi": 100, "lo_unbounded": true, "hi_unbounded": false}),
642 );
643 let b = ShardBounds::from_spec(&spec).unwrap();
644 let sql = b.wrap("SELECT * FROM t", quote_ident);
645 assert!(sql.contains(r#""id" < 100"#), "upper bound present: {sql}");
646 assert!(!sql.contains(">="), "first shard has no lower floor: {sql}");
647 }
648
649 #[test]
650 fn shard_bounds_wrap_last_shard_has_no_upper_bound() {
651 let spec = ShardSpec::new(
654 "2",
655 serde_json::json!({"key": "id", "lo": 200, "hi": 300, "lo_unbounded": false, "hi_unbounded": true}),
656 );
657 let b = ShardBounds::from_spec(&spec).unwrap();
658 let sql = b.wrap("SELECT * FROM t", quote_ident);
659 assert!(sql.contains(r#""id" >= 200"#), "lower bound present: {sql}");
660 assert!(
661 !sql.contains(" < ") && !sql.contains("<="),
662 "last shard has no upper bound: {sql}"
663 );
664 }
665
666 #[test]
667 fn shard_bounds_quotes_key_against_injection() {
668 let spec = ShardSpec::new(
669 "0",
670 serde_json::json!({"key": "weird\"; DROP", "lo": 0, "hi": 1, "lo_unbounded": false, "hi_unbounded": false}),
671 );
672 let b = ShardBounds::from_spec(&spec).unwrap();
673 let sql = b.wrap("SELECT 1", quote_ident);
674 assert!(
676 sql.contains(r#""weird""; DROP""#),
677 "key must be quoted: {sql}"
678 );
679 }
680
681 #[test]
682 fn shard_bounds_from_spec_rejects_malformed_descriptor() {
683 let spec = ShardSpec::new("0", serde_json::json!({"key": "id"})); assert!(ShardBounds::from_spec(&spec).is_none());
685 assert!(ShardBounds::from_spec(&ShardSpec::whole()).is_none());
686 }
687
688 #[test]
691 fn exactly_one_shard_includes_null() {
692 let shards = plan_pk_shards("id", 0, 99, 5);
693 let null_owners: Vec<usize> = shards
694 .iter()
695 .enumerate()
696 .filter(|(_, s)| s.descriptor["include_null"].as_bool().unwrap_or(false))
697 .map(|(i, _)| i)
698 .collect();
699 assert_eq!(
700 null_owners,
701 vec![shards.len() - 1],
702 "exactly the last shard owns NULL keys"
703 );
704 }
705
706 #[test]
707 fn single_shard_plan_still_owns_null() {
708 let shards = plan_pk_shards("id", 7, 7, 4);
710 assert_eq!(shards.len(), 1);
711 assert!(shards[0].descriptor["include_null"].as_bool().unwrap());
712 }
713
714 #[test]
715 fn last_shard_wrap_emits_is_null_clause() {
716 let shards = plan_pk_shards("id", 0, 99, 3);
717 let last = ShardBounds::from_spec(shards.last().unwrap()).unwrap();
718 let sql = last.wrap("SELECT * FROM t", quote_ident);
719 assert!(
720 sql.contains(r#""id" IS NULL"#),
721 "last shard must match NULL keys: {sql}"
722 );
723 assert!(sql.contains(" OR "), "NULL clause OR'd with range: {sql}");
724 }
725
726 #[test]
727 fn non_last_shard_wrap_omits_is_null_clause() {
728 let shards = plan_pk_shards("id", 0, 99, 3);
729 let first = ShardBounds::from_spec(&shards[0]).unwrap();
731 let sql = first.wrap("SELECT * FROM t", quote_ident);
732 assert!(
733 !sql.contains("IS NULL"),
734 "non-last shard must not match NULL keys: {sql}"
735 );
736 }
737
738 #[test]
743 fn predicate_coverage_complete_and_non_overlapping() {
744 let (min, max, target) = (0i64, 19i64, 4usize);
745 let bounds: Vec<ShardBounds> = plan_pk_shards("k", min, max, target)
746 .iter()
747 .map(|s| ShardBounds::from_spec(s).unwrap())
748 .collect();
749
750 let matches_key = |b: &ShardBounds, key: i64| -> bool {
753 let lower = b.lo_unbounded || key >= b.lo;
754 let upper = b.hi_unbounded || key < b.hi;
755 lower && upper
756 };
757
758 for key in (min - 50)..=(max + 50) {
762 let matches = bounds.iter().filter(|b| matches_key(b, key)).count();
763 assert_eq!(matches, 1, "key {key} matched {matches} shards (want 1)");
764 }
765
766 let null_matches = bounds.iter().filter(|b| b.include_null).count();
768 assert_eq!(null_matches, 1, "NULL keys must match exactly one shard");
769 }
770
771 #[test]
772 fn single_shard_wrap_selects_whole_dataset_including_null() {
773 let shards = plan_pk_shards("id", 7, 7, 1);
775 assert_eq!(shards.len(), 1);
776 let b = ShardBounds::from_spec(&shards[0]).unwrap();
777 let sql = b.wrap("SELECT * FROM t", quote_ident);
778 assert!(sql.contains("WHERE TRUE"), "whole-dataset predicate: {sql}");
779 assert!(!sql.contains(">="), "no bounds on a lone shard: {sql}");
780 }
781
782 #[test]
785 fn descriptors_group_catalog_rows_per_table() {
786 let rows = vec![
787 (
788 "public".to_string(),
789 "orders".to_string(),
790 "id".to_string(),
791 "integer".to_string(),
792 false,
793 Some(120i64),
794 ),
795 (
796 "public".to_string(),
797 "orders".to_string(),
798 "note".to_string(),
799 "text".to_string(),
800 true,
801 Some(120i64),
802 ),
803 (
804 "sales".to_string(),
805 "orders".to_string(),
806 "total".to_string(),
807 "numeric".to_string(),
808 false,
809 None,
810 ),
811 ];
812 let ds = descriptors_from_catalog(rows, quote_ident);
813 assert_eq!(ds.len(), 2, "same table name in two schemas = two datasets");
814
815 assert_eq!(ds[0].name, "public.orders");
816 assert_eq!(ds[0].kind, "table");
817 assert_eq!(ds[0].estimated_rows, Some(120));
818 assert_eq!(
819 ds[0].config_patch["query"],
820 r#"SELECT * FROM "public"."orders""#
821 );
822 let schema = ds[0].schema.as_ref().unwrap();
823 assert_eq!(schema["properties"]["id"]["type"], "integer");
824 assert_eq!(
825 schema["properties"]["note"]["type"],
826 serde_json::json!(["string", "null"])
827 );
828
829 assert_eq!(ds[1].name, "sales.orders");
830 assert_eq!(ds[1].estimated_rows, None);
831 assert_eq!(schema["type"], "object");
832 }
833
834 #[test]
835 fn descriptors_negative_reltuples_means_no_estimate() {
836 let rows = vec![(
837 "public".to_string(),
838 "fresh".to_string(),
839 "id".to_string(),
840 "bigint".to_string(),
841 false,
842 Some(-1i64),
843 )];
844 let ds = descriptors_from_catalog(rows, quote_ident);
845 assert_eq!(ds.len(), 1);
846 assert_eq!(ds[0].estimated_rows, None, "-1 = never analyzed");
847 }
848
849 #[test]
850 fn descriptors_quote_hostile_identifiers() {
851 let rows = vec![(
852 "public".to_string(),
853 "weird\"; DROP".to_string(),
854 "id".to_string(),
855 "integer".to_string(),
856 false,
857 None,
858 )];
859 let ds = descriptors_from_catalog(rows, quote_ident);
860 let q = ds[0].config_patch["query"].as_str().unwrap();
861 assert!(q.contains(r#""weird""; DROP""#), "quoted identifier: {q}");
862 }
863
864 #[test]
865 fn descriptors_empty_catalog_is_empty() {
866 assert!(descriptors_from_catalog(Vec::new(), quote_ident).is_empty());
867 }
868
869 #[tokio::test]
870 async fn source_advertises_discover() {
871 use faucet_core::Source as _;
872 let config = PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT 1");
873 let source = lazy_source(config);
874 assert!(source.supports_discover());
875 let err = source.discover().await.unwrap_err();
878 assert!(
879 err.to_string().contains("catalog discovery failed"),
880 "typed error: {err}"
881 );
882 }
883
884 #[test]
887 fn dataset_uri_strips_credentials() {
888 let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
891 let uri = format!("{}?query={}", redacted, "SELECT 1");
892 assert_eq!(uri, "postgres://h:5432/db?query=SELECT 1");
893 }
894
895 fn lazy_source(config: PostgresSourceConfig) -> PostgresSource {
899 let pool = PgPoolOptions::new()
900 .acquire_timeout(std::time::Duration::from_millis(200))
902 .connect_lazy(&config.connection_url)
903 .expect("lazy pool");
904 PostgresSource {
905 config,
906 pool,
907 applied_shard: Mutex::new(None),
908 }
909 }
910
911 #[tokio::test]
912 async fn apply_shard_then_shard_wrap_narrows_query() {
913 use faucet_core::Source as _;
914 let mut config =
915 PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT * FROM t");
916 config.shard = Some(crate::config::ShardConfig { key: "id".into() });
917 let source = lazy_source(config);
918 assert!(source.is_shardable());
919
920 assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
922 source
923 .apply_shard(&faucet_core::ShardSpec::whole())
924 .await
925 .unwrap();
926 assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
927
928 let spec = &plan_pk_shards("id", 0, 99, 2)[0];
930 source.apply_shard(spec).await.unwrap();
931 let wrapped = source.shard_wrap("SELECT * FROM t".into());
932 assert!(wrapped.contains(r#""id""#), "got: {wrapped}");
933 assert!(wrapped.contains("_faucet_shard"), "got: {wrapped}");
934
935 let err = source.enumerate_shards(4).await.unwrap_err();
938 assert!(
939 err.to_string().contains("shard bounds"),
940 "expected bounds-probe error, got: {err}"
941 );
942 }
943}
944
945#[cfg(test)]
946mod bind_overflow_tests {
947 use super::*;
948 use serde_json::json;
949
950 #[test]
954 fn u64_above_i64_max_is_refused_not_wrapped() {
955 let err = match bind_params(sqlx::query("SELECT 1"), &[json!(u64::MAX)], &[]) {
956 Err(e) => e.to_string(),
957 Ok(_) => panic!("u64::MAX must not bind"),
958 };
959 assert!(err.contains(&u64::MAX.to_string()), "{err}");
960 assert!(
961 !err.contains("-9223372036854775808"),
962 "must not show the wrap: {err}"
963 );
964 }
965
966 #[test]
967 fn values_a_signed_column_can_hold_still_bind() {
968 for v in [json!(0), json!(-1), json!(i64::MAX), json!(i64::MAX as u64)] {
969 assert!(
970 bind_params(sqlx::query("SELECT 1"), std::slice::from_ref(&v), &[]).is_ok(),
971 "{v} must still bind"
972 );
973 }
974 }
975}