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) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
183 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 NumberBind::I64 => query.bind(n.as_i64().unwrap()),
195 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
209type CatalogRow = (String, String, String, String, bool, Option<i64>);
213
214type PendingTable = (String, String, Option<i64>, Vec<(String, Value)>);
217
218fn descriptors_from_catalog(
222 rows: Vec<CatalogRow>,
223 quote: fn(&str) -> String,
224) -> Vec<faucet_core::DatasetDescriptor> {
225 let mut out: Vec<faucet_core::DatasetDescriptor> = Vec::new();
226 let mut current: Option<PendingTable> = None;
227
228 let flush = |cur: Option<PendingTable>, out: &mut Vec<faucet_core::DatasetDescriptor>| {
229 if let Some((schema, table, est, cols)) = cur {
230 let query = format!("SELECT * FROM {}.{}", quote(&schema), quote(&table));
231 let mut d = faucet_core::DatasetDescriptor::new(
232 format!("{schema}.{table}"),
233 "table",
234 serde_json::json!({ "query": query }),
235 )
236 .with_schema(faucet_core::columns_to_schema(cols));
237 if let Some(n) = est
239 && n >= 0
240 {
241 d = d.with_estimated_rows(n as u64);
242 }
243 out.push(d);
244 }
245 };
246
247 for (schema, table, column, data_type, is_nullable, est) in rows {
248 let same = current
249 .as_ref()
250 .is_some_and(|(s, t, _, _)| *s == schema && *t == table);
251 if !same {
252 flush(current.take(), &mut out);
253 current = Some((schema, table, est, Vec::new()));
254 }
255 let mut fragment = faucet_core::sql_type_to_json_schema(&data_type);
256 if is_nullable {
257 fragment = faucet_core::nullable_type(fragment);
258 }
259 if let Some((_, _, _, cols)) = current.as_mut() {
260 cols.push((column, fragment));
261 }
262 }
263 flush(current, &mut out);
264 out
265}
266
267fn row_to_json(row: &sqlx::postgres::PgRow) -> Value {
270 let mut map = serde_json::Map::new();
271 for col in row.columns() {
272 let name = col.name().to_string();
273 let value = pg_value_to_json(row, &name);
274 map.insert(name, value);
275 }
276 Value::Object(map)
277}
278
279#[async_trait]
280impl faucet_core::Source for PostgresSource {
281 async fn fetch_with_context(
282 &self,
283 context: &std::collections::HashMap<String, serde_json::Value>,
284 ) -> Result<Vec<Value>, FaucetError> {
285 let (query_str, bind_values) = resolve_query(&self.config, context);
286 let query_str = self.shard_wrap(query_str);
287 let query = bind_params(sqlx::query(&query_str), &self.config.params, &bind_values);
288
289 let rows = query
290 .fetch_all(&self.pool)
291 .await
292 .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?;
293
294 let records: Vec<Value> = rows.iter().map(row_to_json).collect();
295 tracing::info!(rows = records.len(), query = %self.config.query, "PostgreSQL source fetch complete");
296 Ok(records)
297 }
298
299 fn stream_pages<'a>(
312 &'a self,
313 context: &'a std::collections::HashMap<String, Value>,
314 _batch_size: usize,
315 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
316 let batch_size = self.config.batch_size;
317
318 Box::pin(async_stream::try_stream! {
319 let (query_str, bind_values) = resolve_query(&self.config, context);
320 let query_str = self.shard_wrap(query_str);
321 let query = bind_params(
322 sqlx::query(&query_str),
323 &self.config.params,
324 &bind_values,
325 );
326
327 let mut rows = query.fetch(&self.pool);
328 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
329 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
330 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
331 let mut total = 0usize;
332
333 while let Some(row) = rows
334 .try_next()
335 .await
336 .map_err(|e| FaucetError::Config(format!("PostgreSQL query failed: {e}")))?
337 {
338 buffer.push(row_to_json(&row));
339 if buffer.len() >= chunk {
340 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
341 total += page.len();
342 yield StreamPage { records: page, bookmark: None };
343 }
344 }
345 if !buffer.is_empty() {
346 total += buffer.len();
347 yield StreamPage { records: buffer, bookmark: None };
348 }
349
350 tracing::info!(
351 rows = total,
352 batch_size,
353 query = %self.config.query,
354 "PostgreSQL source stream complete",
355 );
356 })
357 }
358
359 fn config_schema(&self) -> serde_json::Value {
360 serde_json::to_value(faucet_core::schema_for!(PostgresSourceConfig))
361 .expect("schema serialization")
362 }
363
364 fn dataset_uri(&self) -> String {
365 format!(
366 "{}?query={}",
367 faucet_core::redact_uri_credentials(&self.config.connection_url),
368 self.config.query
369 )
370 }
371
372 fn supports_discover(&self) -> bool {
373 true
374 }
375
376 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
380 let sql = r#"
381 SELECT c.table_schema, c.table_name, c.column_name, c.data_type,
382 (c.is_nullable = 'YES') AS is_nullable,
383 (SELECT pc.reltuples::bigint
384 FROM pg_class pc
385 JOIN pg_namespace pn ON pn.oid = pc.relnamespace
386 WHERE pn.nspname = c.table_schema
387 AND pc.relname = c.table_name) AS estimated_rows
388 FROM information_schema.columns c
389 JOIN information_schema.tables t
390 ON t.table_schema = c.table_schema AND t.table_name = c.table_name
391 WHERE t.table_type = 'BASE TABLE'
392 AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
393 ORDER BY c.table_schema, c.table_name, c.ordinal_position"#;
394 let rows = sqlx::query(sql)
395 .fetch_all(&self.pool)
396 .await
397 .map_err(|e| FaucetError::Source(format!("postgres: catalog discovery failed: {e}")))?;
398
399 let catalog: Vec<CatalogRow> = rows
400 .iter()
401 .map(|row| -> Result<CatalogRow, FaucetError> {
402 let decode = |col: &str| -> Result<String, FaucetError> {
403 row.try_get::<String, _>(col).map_err(|e| {
404 FaucetError::Source(format!("postgres: catalog decode failed ({col}): {e}"))
405 })
406 };
407 Ok((
408 decode("table_schema")?,
409 decode("table_name")?,
410 decode("column_name")?,
411 decode("data_type")?,
412 row.try_get::<bool, _>("is_nullable").unwrap_or(true),
413 row.try_get::<i64, _>("estimated_rows").ok(),
414 ))
415 })
416 .collect::<Result<_, _>>()?;
417
418 Ok(descriptors_from_catalog(catalog, quote_ident))
419 }
420
421 fn is_shardable(&self) -> bool {
423 self.config.shard.is_some()
424 }
425
426 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
431 let Some(shard_cfg) = &self.config.shard else {
432 return Ok(vec![ShardSpec::whole()]);
433 };
434
435 let bounds_sql =
436 pk_bounds_query(&self.config.query, "e_ident(&shard_cfg.key), "BIGINT");
437 let row = bind_params(sqlx::query(&bounds_sql), &self.config.params, &[])
438 .fetch_one(&self.pool)
439 .await
440 .map_err(|e| {
441 FaucetError::Source(format!(
442 "postgres: failed to compute shard bounds for key {:?} \
443 (it must be an integer-typed column): {e}",
444 shard_cfg.key
445 ))
446 })?;
447
448 let lo: Option<i64> = row.try_get("lo").map_err(|e| {
449 FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
450 })?;
451 let hi: Option<i64> = row.try_get("hi").map_err(|e| {
452 FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
453 })?;
454 Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
455 }
456
457 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
460 *self.applied_shard.lock().expect("shard mutex poisoned") =
461 parse_pk_shard(shard, "postgres")?;
462 Ok(())
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use faucet_core::shard::plan_pk_shards;
470
471 type ShardBounds = PkShardBounds;
475
476 #[tokio::test]
477 async fn new_rejects_out_of_range_batch_size() {
478 let mut config = PostgresSourceConfig::new("postgres://localhost/test", "SELECT 1");
479 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
480 match PostgresSource::new(config).await {
481 Err(faucet_core::FaucetError::Config(m)) => {
482 assert!(m.contains("batch_size"), "got: {m}")
483 }
484 _ => panic!("expected a batch_size Config error"),
485 }
486 }
487
488 fn num(v: serde_json::Value) -> serde_json::Number {
491 match v {
492 serde_json::Value::Number(n) => n,
493 _ => panic!("not a number"),
494 }
495 }
496
497 #[test]
498 fn classify_small_int_is_i64() {
499 assert_eq!(
500 classify_number(&num(serde_json::json!(42))),
501 NumberBind::I64
502 );
503 assert_eq!(
504 classify_number(&num(serde_json::json!(-7))),
505 NumberBind::I64
506 );
507 assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
508 }
509
510 #[test]
511 fn classify_above_2_pow_53_stays_i64_not_f64() {
512 let v = 9_007_199_254_740_993i64; assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
516 }
517
518 #[test]
519 fn classify_i64_max_is_i64() {
520 assert_eq!(
521 classify_number(&num(serde_json::json!(i64::MAX))),
522 NumberBind::I64
523 );
524 assert_eq!(
525 classify_number(&num(serde_json::json!(i64::MIN))),
526 NumberBind::I64
527 );
528 }
529
530 #[test]
531 fn classify_above_i64_max_is_u64() {
532 let v: u64 = i64::MAX as u64 + 1;
534 assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
535 assert_eq!(
536 classify_number(&num(serde_json::json!(u64::MAX))),
537 NumberBind::U64
538 );
539 }
540
541 #[test]
542 fn classify_float_is_f64() {
543 assert_eq!(
544 classify_number(&num(serde_json::json!(3.5))),
545 NumberBind::F64
546 );
547 assert_eq!(
548 classify_number(&num(serde_json::json!(-0.5))),
549 NumberBind::F64
550 );
551 }
552
553 #[test]
556 fn plan_pk_shards_covers_full_range_without_gaps_or_overlap() {
557 let shards = plan_pk_shards("id", 0, 99, 4);
558 assert_eq!(shards.len(), 4);
559 let mut expected_lo = 0i64;
561 for (i, s) in shards.iter().enumerate() {
562 let d = &s.descriptor;
563 assert_eq!(d["key"], "id");
564 assert_eq!(d["lo"].as_i64().unwrap(), expected_lo);
565 let hi = d["hi"].as_i64().unwrap();
566 let first = i == 0;
567 let last = i == shards.len() - 1;
568 assert_eq!(d["lo_unbounded"].as_bool().unwrap(), first);
569 assert_eq!(d["hi_unbounded"].as_bool().unwrap(), last);
570 expected_lo = hi; }
572 }
573
574 #[test]
575 fn plan_pk_shards_never_more_shards_than_values() {
576 let shards = plan_pk_shards("pk", 5, 7, 10);
578 assert!(shards.len() <= 3, "got {} shards", shards.len());
579 assert!(
580 shards[0].descriptor["lo_unbounded"].as_bool().unwrap(),
581 "first shard is unbounded below"
582 );
583 assert!(
584 shards.last().unwrap().descriptor["hi_unbounded"]
585 .as_bool()
586 .unwrap(),
587 "last shard is unbounded above"
588 );
589 }
590
591 #[test]
592 fn plan_pk_shards_single_value_one_shard() {
593 let shards = plan_pk_shards("id", 42, 42, 8);
594 assert_eq!(shards.len(), 1);
595 assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
597 assert!(shards[0].descriptor["hi_unbounded"].as_bool().unwrap());
598 }
599
600 #[test]
601 fn plan_pk_shards_target_zero_treated_as_one() {
602 let shards = plan_pk_shards("id", 0, 9, 0);
603 assert_eq!(shards.len(), 1);
604 assert_eq!(shards[0].descriptor["hi"].as_i64().unwrap(), 9);
605 }
606
607 #[test]
608 fn shard_bounds_wrap_builds_half_open_predicate() {
609 let spec = ShardSpec::new(
611 "1",
612 serde_json::json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
613 );
614 let b = ShardBounds::from_spec(&spec).unwrap();
615 let sql = b.wrap("SELECT * FROM t", quote_ident);
616 assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"));
617 assert!(sql.contains(r#""id" >= 100"#), "got: {sql}");
618 assert!(
619 sql.contains(r#""id" < 200"#),
620 "half-open upper bound: {sql}"
621 );
622 }
623
624 #[test]
625 fn shard_bounds_wrap_first_shard_has_no_lower_bound() {
626 let spec = ShardSpec::new(
629 "0",
630 serde_json::json!({"key": "id", "lo": 0, "hi": 100, "lo_unbounded": true, "hi_unbounded": false}),
631 );
632 let b = ShardBounds::from_spec(&spec).unwrap();
633 let sql = b.wrap("SELECT * FROM t", quote_ident);
634 assert!(sql.contains(r#""id" < 100"#), "upper bound present: {sql}");
635 assert!(!sql.contains(">="), "first shard has no lower floor: {sql}");
636 }
637
638 #[test]
639 fn shard_bounds_wrap_last_shard_has_no_upper_bound() {
640 let spec = ShardSpec::new(
643 "2",
644 serde_json::json!({"key": "id", "lo": 200, "hi": 300, "lo_unbounded": false, "hi_unbounded": true}),
645 );
646 let b = ShardBounds::from_spec(&spec).unwrap();
647 let sql = b.wrap("SELECT * FROM t", quote_ident);
648 assert!(sql.contains(r#""id" >= 200"#), "lower bound present: {sql}");
649 assert!(
650 !sql.contains(" < ") && !sql.contains("<="),
651 "last shard has no upper bound: {sql}"
652 );
653 }
654
655 #[test]
656 fn shard_bounds_quotes_key_against_injection() {
657 let spec = ShardSpec::new(
658 "0",
659 serde_json::json!({"key": "weird\"; DROP", "lo": 0, "hi": 1, "lo_unbounded": false, "hi_unbounded": false}),
660 );
661 let b = ShardBounds::from_spec(&spec).unwrap();
662 let sql = b.wrap("SELECT 1", quote_ident);
663 assert!(
665 sql.contains(r#""weird""; DROP""#),
666 "key must be quoted: {sql}"
667 );
668 }
669
670 #[test]
671 fn shard_bounds_from_spec_rejects_malformed_descriptor() {
672 let spec = ShardSpec::new("0", serde_json::json!({"key": "id"})); assert!(ShardBounds::from_spec(&spec).is_none());
674 assert!(ShardBounds::from_spec(&ShardSpec::whole()).is_none());
675 }
676
677 #[test]
680 fn exactly_one_shard_includes_null() {
681 let shards = plan_pk_shards("id", 0, 99, 5);
682 let null_owners: Vec<usize> = shards
683 .iter()
684 .enumerate()
685 .filter(|(_, s)| s.descriptor["include_null"].as_bool().unwrap_or(false))
686 .map(|(i, _)| i)
687 .collect();
688 assert_eq!(
689 null_owners,
690 vec![shards.len() - 1],
691 "exactly the last shard owns NULL keys"
692 );
693 }
694
695 #[test]
696 fn single_shard_plan_still_owns_null() {
697 let shards = plan_pk_shards("id", 7, 7, 4);
699 assert_eq!(shards.len(), 1);
700 assert!(shards[0].descriptor["include_null"].as_bool().unwrap());
701 }
702
703 #[test]
704 fn last_shard_wrap_emits_is_null_clause() {
705 let shards = plan_pk_shards("id", 0, 99, 3);
706 let last = ShardBounds::from_spec(shards.last().unwrap()).unwrap();
707 let sql = last.wrap("SELECT * FROM t", quote_ident);
708 assert!(
709 sql.contains(r#""id" IS NULL"#),
710 "last shard must match NULL keys: {sql}"
711 );
712 assert!(sql.contains(" OR "), "NULL clause OR'd with range: {sql}");
713 }
714
715 #[test]
716 fn non_last_shard_wrap_omits_is_null_clause() {
717 let shards = plan_pk_shards("id", 0, 99, 3);
718 let first = ShardBounds::from_spec(&shards[0]).unwrap();
720 let sql = first.wrap("SELECT * FROM t", quote_ident);
721 assert!(
722 !sql.contains("IS NULL"),
723 "non-last shard must not match NULL keys: {sql}"
724 );
725 }
726
727 #[test]
732 fn predicate_coverage_complete_and_non_overlapping() {
733 let (min, max, target) = (0i64, 19i64, 4usize);
734 let bounds: Vec<ShardBounds> = plan_pk_shards("k", min, max, target)
735 .iter()
736 .map(|s| ShardBounds::from_spec(s).unwrap())
737 .collect();
738
739 let matches_key = |b: &ShardBounds, key: i64| -> bool {
742 let lower = b.lo_unbounded || key >= b.lo;
743 let upper = b.hi_unbounded || key < b.hi;
744 lower && upper
745 };
746
747 for key in (min - 50)..=(max + 50) {
751 let matches = bounds.iter().filter(|b| matches_key(b, key)).count();
752 assert_eq!(matches, 1, "key {key} matched {matches} shards (want 1)");
753 }
754
755 let null_matches = bounds.iter().filter(|b| b.include_null).count();
757 assert_eq!(null_matches, 1, "NULL keys must match exactly one shard");
758 }
759
760 #[test]
761 fn single_shard_wrap_selects_whole_dataset_including_null() {
762 let shards = plan_pk_shards("id", 7, 7, 1);
764 assert_eq!(shards.len(), 1);
765 let b = ShardBounds::from_spec(&shards[0]).unwrap();
766 let sql = b.wrap("SELECT * FROM t", quote_ident);
767 assert!(sql.contains("WHERE TRUE"), "whole-dataset predicate: {sql}");
768 assert!(!sql.contains(">="), "no bounds on a lone shard: {sql}");
769 }
770
771 #[test]
774 fn descriptors_group_catalog_rows_per_table() {
775 let rows = vec![
776 (
777 "public".to_string(),
778 "orders".to_string(),
779 "id".to_string(),
780 "integer".to_string(),
781 false,
782 Some(120i64),
783 ),
784 (
785 "public".to_string(),
786 "orders".to_string(),
787 "note".to_string(),
788 "text".to_string(),
789 true,
790 Some(120i64),
791 ),
792 (
793 "sales".to_string(),
794 "orders".to_string(),
795 "total".to_string(),
796 "numeric".to_string(),
797 false,
798 None,
799 ),
800 ];
801 let ds = descriptors_from_catalog(rows, quote_ident);
802 assert_eq!(ds.len(), 2, "same table name in two schemas = two datasets");
803
804 assert_eq!(ds[0].name, "public.orders");
805 assert_eq!(ds[0].kind, "table");
806 assert_eq!(ds[0].estimated_rows, Some(120));
807 assert_eq!(
808 ds[0].config_patch["query"],
809 r#"SELECT * FROM "public"."orders""#
810 );
811 let schema = ds[0].schema.as_ref().unwrap();
812 assert_eq!(schema["properties"]["id"]["type"], "integer");
813 assert_eq!(
814 schema["properties"]["note"]["type"],
815 serde_json::json!(["string", "null"])
816 );
817
818 assert_eq!(ds[1].name, "sales.orders");
819 assert_eq!(ds[1].estimated_rows, None);
820 assert_eq!(schema["type"], "object");
821 }
822
823 #[test]
824 fn descriptors_negative_reltuples_means_no_estimate() {
825 let rows = vec![(
826 "public".to_string(),
827 "fresh".to_string(),
828 "id".to_string(),
829 "bigint".to_string(),
830 false,
831 Some(-1i64),
832 )];
833 let ds = descriptors_from_catalog(rows, quote_ident);
834 assert_eq!(ds.len(), 1);
835 assert_eq!(ds[0].estimated_rows, None, "-1 = never analyzed");
836 }
837
838 #[test]
839 fn descriptors_quote_hostile_identifiers() {
840 let rows = vec![(
841 "public".to_string(),
842 "weird\"; DROP".to_string(),
843 "id".to_string(),
844 "integer".to_string(),
845 false,
846 None,
847 )];
848 let ds = descriptors_from_catalog(rows, quote_ident);
849 let q = ds[0].config_patch["query"].as_str().unwrap();
850 assert!(q.contains(r#""weird""; DROP""#), "quoted identifier: {q}");
851 }
852
853 #[test]
854 fn descriptors_empty_catalog_is_empty() {
855 assert!(descriptors_from_catalog(Vec::new(), quote_ident).is_empty());
856 }
857
858 #[tokio::test]
859 async fn source_advertises_discover() {
860 use faucet_core::Source as _;
861 let config = PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT 1");
862 let source = lazy_source(config);
863 assert!(source.supports_discover());
864 let err = source.discover().await.unwrap_err();
867 assert!(
868 err.to_string().contains("catalog discovery failed"),
869 "typed error: {err}"
870 );
871 }
872
873 #[test]
876 fn dataset_uri_strips_credentials() {
877 let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
880 let uri = format!("{}?query={}", redacted, "SELECT 1");
881 assert_eq!(uri, "postgres://h:5432/db?query=SELECT 1");
882 }
883
884 fn lazy_source(config: PostgresSourceConfig) -> PostgresSource {
888 let pool = PgPoolOptions::new()
889 .acquire_timeout(std::time::Duration::from_millis(200))
891 .connect_lazy(&config.connection_url)
892 .expect("lazy pool");
893 PostgresSource {
894 config,
895 pool,
896 applied_shard: Mutex::new(None),
897 }
898 }
899
900 #[tokio::test]
901 async fn apply_shard_then_shard_wrap_narrows_query() {
902 use faucet_core::Source as _;
903 let mut config =
904 PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT * FROM t");
905 config.shard = Some(crate::config::ShardConfig { key: "id".into() });
906 let source = lazy_source(config);
907 assert!(source.is_shardable());
908
909 assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
911 source
912 .apply_shard(&faucet_core::ShardSpec::whole())
913 .await
914 .unwrap();
915 assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
916
917 let spec = &plan_pk_shards("id", 0, 99, 2)[0];
919 source.apply_shard(spec).await.unwrap();
920 let wrapped = source.shard_wrap("SELECT * FROM t".into());
921 assert!(wrapped.contains(r#""id""#), "got: {wrapped}");
922 assert!(wrapped.contains("_faucet_shard"), "got: {wrapped}");
923
924 let err = source.enumerate_shards(4).await.unwrap_err();
927 assert!(
928 err.to_string().contains("shard bounds"),
929 "expected bounds-probe error, got: {err}"
930 );
931 }
932}