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 config_schema(&self) -> serde_json::Value {
367 serde_json::to_value(faucet_core::schema_for!(PostgresSourceConfig))
368 .expect("schema serialization")
369 }
370
371 fn dataset_uri(&self) -> String {
372 format!(
373 "{}?query={}",
374 faucet_core::redact_uri_credentials(&self.config.connection_url),
375 self.config.query
376 )
377 }
378
379 fn supports_discover(&self) -> bool {
380 true
381 }
382
383 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
387 let sql = r#"
388 SELECT c.table_schema, c.table_name, c.column_name, c.data_type,
389 (c.is_nullable = 'YES') AS is_nullable,
390 (SELECT pc.reltuples::bigint
391 FROM pg_class pc
392 JOIN pg_namespace pn ON pn.oid = pc.relnamespace
393 WHERE pn.nspname = c.table_schema
394 AND pc.relname = c.table_name) AS estimated_rows
395 FROM information_schema.columns c
396 JOIN information_schema.tables t
397 ON t.table_schema = c.table_schema AND t.table_name = c.table_name
398 WHERE t.table_type = 'BASE TABLE'
399 AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
400 ORDER BY c.table_schema, c.table_name, c.ordinal_position"#;
401 let rows = sqlx::query(sql)
402 .fetch_all(&self.pool)
403 .await
404 .map_err(|e| FaucetError::Source(format!("postgres: catalog discovery failed: {e}")))?;
405
406 let catalog: Vec<CatalogRow> = rows
407 .iter()
408 .map(|row| -> Result<CatalogRow, FaucetError> {
409 let decode = |col: &str| -> Result<String, FaucetError> {
410 row.try_get::<String, _>(col).map_err(|e| {
411 FaucetError::Source(format!("postgres: catalog decode failed ({col}): {e}"))
412 })
413 };
414 Ok((
415 decode("table_schema")?,
416 decode("table_name")?,
417 decode("column_name")?,
418 decode("data_type")?,
419 row.try_get::<bool, _>("is_nullable").unwrap_or(true),
420 row.try_get::<i64, _>("estimated_rows").ok(),
421 ))
422 })
423 .collect::<Result<_, _>>()?;
424
425 Ok(descriptors_from_catalog(catalog, quote_ident))
426 }
427
428 fn is_shardable(&self) -> bool {
430 self.config.shard.is_some()
431 }
432
433 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
438 let Some(shard_cfg) = &self.config.shard else {
439 return Ok(vec![ShardSpec::whole()]);
440 };
441
442 let bounds_sql =
443 pk_bounds_query(&self.config.query, "e_ident(&shard_cfg.key), "BIGINT");
444 let row = bind_params(sqlx::query(&bounds_sql), &self.config.params, &[])?
445 .fetch_one(&self.pool)
446 .await
447 .map_err(|e| {
448 FaucetError::Source(format!(
449 "postgres: failed to compute shard bounds for key {:?} \
450 (it must be an integer-typed column): {e}",
451 shard_cfg.key
452 ))
453 })?;
454
455 let lo: Option<i64> = row.try_get("lo").map_err(|e| {
456 FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
457 })?;
458 let hi: Option<i64> = row.try_get("hi").map_err(|e| {
459 FaucetError::Source(format!("postgres: shard bounds decode failed: {e}"))
460 })?;
461 Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
462 }
463
464 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
467 *self.applied_shard.lock().expect("shard mutex poisoned") =
468 parse_pk_shard(shard, "postgres")?;
469 Ok(())
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use faucet_core::shard::plan_pk_shards;
477
478 type ShardBounds = PkShardBounds;
482
483 #[tokio::test]
484 async fn new_rejects_out_of_range_batch_size() {
485 let mut config = PostgresSourceConfig::new("postgres://localhost/test", "SELECT 1");
486 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
487 match PostgresSource::new(config).await {
488 Err(faucet_core::FaucetError::Config(m)) => {
489 assert!(m.contains("batch_size"), "got: {m}")
490 }
491 _ => panic!("expected a batch_size Config error"),
492 }
493 }
494
495 fn num(v: serde_json::Value) -> serde_json::Number {
498 match v {
499 serde_json::Value::Number(n) => n,
500 _ => panic!("not a number"),
501 }
502 }
503
504 #[test]
505 fn classify_small_int_is_i64() {
506 assert_eq!(
507 classify_number(&num(serde_json::json!(42))),
508 NumberBind::I64
509 );
510 assert_eq!(
511 classify_number(&num(serde_json::json!(-7))),
512 NumberBind::I64
513 );
514 assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
515 }
516
517 #[test]
518 fn classify_above_2_pow_53_stays_i64_not_f64() {
519 let v = 9_007_199_254_740_993i64; assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
523 }
524
525 #[test]
526 fn classify_i64_max_is_i64() {
527 assert_eq!(
528 classify_number(&num(serde_json::json!(i64::MAX))),
529 NumberBind::I64
530 );
531 assert_eq!(
532 classify_number(&num(serde_json::json!(i64::MIN))),
533 NumberBind::I64
534 );
535 }
536
537 #[test]
538 fn classify_above_i64_max_is_u64() {
539 let v: u64 = i64::MAX as u64 + 1;
541 assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
542 assert_eq!(
543 classify_number(&num(serde_json::json!(u64::MAX))),
544 NumberBind::U64
545 );
546 }
547
548 #[test]
549 fn classify_float_is_f64() {
550 assert_eq!(
551 classify_number(&num(serde_json::json!(3.5))),
552 NumberBind::F64
553 );
554 assert_eq!(
555 classify_number(&num(serde_json::json!(-0.5))),
556 NumberBind::F64
557 );
558 }
559
560 #[test]
563 fn plan_pk_shards_covers_full_range_without_gaps_or_overlap() {
564 let shards = plan_pk_shards("id", 0, 99, 4);
565 assert_eq!(shards.len(), 4);
566 let mut expected_lo = 0i64;
568 for (i, s) in shards.iter().enumerate() {
569 let d = &s.descriptor;
570 assert_eq!(d["key"], "id");
571 assert_eq!(d["lo"].as_i64().unwrap(), expected_lo);
572 let hi = d["hi"].as_i64().unwrap();
573 let first = i == 0;
574 let last = i == shards.len() - 1;
575 assert_eq!(d["lo_unbounded"].as_bool().unwrap(), first);
576 assert_eq!(d["hi_unbounded"].as_bool().unwrap(), last);
577 expected_lo = hi; }
579 }
580
581 #[test]
582 fn plan_pk_shards_never_more_shards_than_values() {
583 let shards = plan_pk_shards("pk", 5, 7, 10);
585 assert!(shards.len() <= 3, "got {} shards", shards.len());
586 assert!(
587 shards[0].descriptor["lo_unbounded"].as_bool().unwrap(),
588 "first shard is unbounded below"
589 );
590 assert!(
591 shards.last().unwrap().descriptor["hi_unbounded"]
592 .as_bool()
593 .unwrap(),
594 "last shard is unbounded above"
595 );
596 }
597
598 #[test]
599 fn plan_pk_shards_single_value_one_shard() {
600 let shards = plan_pk_shards("id", 42, 42, 8);
601 assert_eq!(shards.len(), 1);
602 assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
604 assert!(shards[0].descriptor["hi_unbounded"].as_bool().unwrap());
605 }
606
607 #[test]
608 fn plan_pk_shards_target_zero_treated_as_one() {
609 let shards = plan_pk_shards("id", 0, 9, 0);
610 assert_eq!(shards.len(), 1);
611 assert_eq!(shards[0].descriptor["hi"].as_i64().unwrap(), 9);
612 }
613
614 #[test]
615 fn shard_bounds_wrap_builds_half_open_predicate() {
616 let spec = ShardSpec::new(
618 "1",
619 serde_json::json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
620 );
621 let b = ShardBounds::from_spec(&spec).unwrap();
622 let sql = b.wrap("SELECT * FROM t", quote_ident);
623 assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"));
624 assert!(sql.contains(r#""id" >= 100"#), "got: {sql}");
625 assert!(
626 sql.contains(r#""id" < 200"#),
627 "half-open upper bound: {sql}"
628 );
629 }
630
631 #[test]
632 fn shard_bounds_wrap_first_shard_has_no_lower_bound() {
633 let spec = ShardSpec::new(
636 "0",
637 serde_json::json!({"key": "id", "lo": 0, "hi": 100, "lo_unbounded": true, "hi_unbounded": false}),
638 );
639 let b = ShardBounds::from_spec(&spec).unwrap();
640 let sql = b.wrap("SELECT * FROM t", quote_ident);
641 assert!(sql.contains(r#""id" < 100"#), "upper bound present: {sql}");
642 assert!(!sql.contains(">="), "first shard has no lower floor: {sql}");
643 }
644
645 #[test]
646 fn shard_bounds_wrap_last_shard_has_no_upper_bound() {
647 let spec = ShardSpec::new(
650 "2",
651 serde_json::json!({"key": "id", "lo": 200, "hi": 300, "lo_unbounded": false, "hi_unbounded": true}),
652 );
653 let b = ShardBounds::from_spec(&spec).unwrap();
654 let sql = b.wrap("SELECT * FROM t", quote_ident);
655 assert!(sql.contains(r#""id" >= 200"#), "lower bound present: {sql}");
656 assert!(
657 !sql.contains(" < ") && !sql.contains("<="),
658 "last shard has no upper bound: {sql}"
659 );
660 }
661
662 #[test]
663 fn shard_bounds_quotes_key_against_injection() {
664 let spec = ShardSpec::new(
665 "0",
666 serde_json::json!({"key": "weird\"; DROP", "lo": 0, "hi": 1, "lo_unbounded": false, "hi_unbounded": false}),
667 );
668 let b = ShardBounds::from_spec(&spec).unwrap();
669 let sql = b.wrap("SELECT 1", quote_ident);
670 assert!(
672 sql.contains(r#""weird""; DROP""#),
673 "key must be quoted: {sql}"
674 );
675 }
676
677 #[test]
678 fn shard_bounds_from_spec_rejects_malformed_descriptor() {
679 let spec = ShardSpec::new("0", serde_json::json!({"key": "id"})); assert!(ShardBounds::from_spec(&spec).is_none());
681 assert!(ShardBounds::from_spec(&ShardSpec::whole()).is_none());
682 }
683
684 #[test]
687 fn exactly_one_shard_includes_null() {
688 let shards = plan_pk_shards("id", 0, 99, 5);
689 let null_owners: Vec<usize> = shards
690 .iter()
691 .enumerate()
692 .filter(|(_, s)| s.descriptor["include_null"].as_bool().unwrap_or(false))
693 .map(|(i, _)| i)
694 .collect();
695 assert_eq!(
696 null_owners,
697 vec![shards.len() - 1],
698 "exactly the last shard owns NULL keys"
699 );
700 }
701
702 #[test]
703 fn single_shard_plan_still_owns_null() {
704 let shards = plan_pk_shards("id", 7, 7, 4);
706 assert_eq!(shards.len(), 1);
707 assert!(shards[0].descriptor["include_null"].as_bool().unwrap());
708 }
709
710 #[test]
711 fn last_shard_wrap_emits_is_null_clause() {
712 let shards = plan_pk_shards("id", 0, 99, 3);
713 let last = ShardBounds::from_spec(shards.last().unwrap()).unwrap();
714 let sql = last.wrap("SELECT * FROM t", quote_ident);
715 assert!(
716 sql.contains(r#""id" IS NULL"#),
717 "last shard must match NULL keys: {sql}"
718 );
719 assert!(sql.contains(" OR "), "NULL clause OR'd with range: {sql}");
720 }
721
722 #[test]
723 fn non_last_shard_wrap_omits_is_null_clause() {
724 let shards = plan_pk_shards("id", 0, 99, 3);
725 let first = ShardBounds::from_spec(&shards[0]).unwrap();
727 let sql = first.wrap("SELECT * FROM t", quote_ident);
728 assert!(
729 !sql.contains("IS NULL"),
730 "non-last shard must not match NULL keys: {sql}"
731 );
732 }
733
734 #[test]
739 fn predicate_coverage_complete_and_non_overlapping() {
740 let (min, max, target) = (0i64, 19i64, 4usize);
741 let bounds: Vec<ShardBounds> = plan_pk_shards("k", min, max, target)
742 .iter()
743 .map(|s| ShardBounds::from_spec(s).unwrap())
744 .collect();
745
746 let matches_key = |b: &ShardBounds, key: i64| -> bool {
749 let lower = b.lo_unbounded || key >= b.lo;
750 let upper = b.hi_unbounded || key < b.hi;
751 lower && upper
752 };
753
754 for key in (min - 50)..=(max + 50) {
758 let matches = bounds.iter().filter(|b| matches_key(b, key)).count();
759 assert_eq!(matches, 1, "key {key} matched {matches} shards (want 1)");
760 }
761
762 let null_matches = bounds.iter().filter(|b| b.include_null).count();
764 assert_eq!(null_matches, 1, "NULL keys must match exactly one shard");
765 }
766
767 #[test]
768 fn single_shard_wrap_selects_whole_dataset_including_null() {
769 let shards = plan_pk_shards("id", 7, 7, 1);
771 assert_eq!(shards.len(), 1);
772 let b = ShardBounds::from_spec(&shards[0]).unwrap();
773 let sql = b.wrap("SELECT * FROM t", quote_ident);
774 assert!(sql.contains("WHERE TRUE"), "whole-dataset predicate: {sql}");
775 assert!(!sql.contains(">="), "no bounds on a lone shard: {sql}");
776 }
777
778 #[test]
781 fn descriptors_group_catalog_rows_per_table() {
782 let rows = vec![
783 (
784 "public".to_string(),
785 "orders".to_string(),
786 "id".to_string(),
787 "integer".to_string(),
788 false,
789 Some(120i64),
790 ),
791 (
792 "public".to_string(),
793 "orders".to_string(),
794 "note".to_string(),
795 "text".to_string(),
796 true,
797 Some(120i64),
798 ),
799 (
800 "sales".to_string(),
801 "orders".to_string(),
802 "total".to_string(),
803 "numeric".to_string(),
804 false,
805 None,
806 ),
807 ];
808 let ds = descriptors_from_catalog(rows, quote_ident);
809 assert_eq!(ds.len(), 2, "same table name in two schemas = two datasets");
810
811 assert_eq!(ds[0].name, "public.orders");
812 assert_eq!(ds[0].kind, "table");
813 assert_eq!(ds[0].estimated_rows, Some(120));
814 assert_eq!(
815 ds[0].config_patch["query"],
816 r#"SELECT * FROM "public"."orders""#
817 );
818 let schema = ds[0].schema.as_ref().unwrap();
819 assert_eq!(schema["properties"]["id"]["type"], "integer");
820 assert_eq!(
821 schema["properties"]["note"]["type"],
822 serde_json::json!(["string", "null"])
823 );
824
825 assert_eq!(ds[1].name, "sales.orders");
826 assert_eq!(ds[1].estimated_rows, None);
827 assert_eq!(schema["type"], "object");
828 }
829
830 #[test]
831 fn descriptors_negative_reltuples_means_no_estimate() {
832 let rows = vec![(
833 "public".to_string(),
834 "fresh".to_string(),
835 "id".to_string(),
836 "bigint".to_string(),
837 false,
838 Some(-1i64),
839 )];
840 let ds = descriptors_from_catalog(rows, quote_ident);
841 assert_eq!(ds.len(), 1);
842 assert_eq!(ds[0].estimated_rows, None, "-1 = never analyzed");
843 }
844
845 #[test]
846 fn descriptors_quote_hostile_identifiers() {
847 let rows = vec![(
848 "public".to_string(),
849 "weird\"; DROP".to_string(),
850 "id".to_string(),
851 "integer".to_string(),
852 false,
853 None,
854 )];
855 let ds = descriptors_from_catalog(rows, quote_ident);
856 let q = ds[0].config_patch["query"].as_str().unwrap();
857 assert!(q.contains(r#""weird""; DROP""#), "quoted identifier: {q}");
858 }
859
860 #[test]
861 fn descriptors_empty_catalog_is_empty() {
862 assert!(descriptors_from_catalog(Vec::new(), quote_ident).is_empty());
863 }
864
865 #[tokio::test]
866 async fn source_advertises_discover() {
867 use faucet_core::Source as _;
868 let config = PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT 1");
869 let source = lazy_source(config);
870 assert!(source.supports_discover());
871 let err = source.discover().await.unwrap_err();
874 assert!(
875 err.to_string().contains("catalog discovery failed"),
876 "typed error: {err}"
877 );
878 }
879
880 #[test]
883 fn dataset_uri_strips_credentials() {
884 let redacted = faucet_core::redact_uri_credentials("postgres://u:p@h:5432/db");
887 let uri = format!("{}?query={}", redacted, "SELECT 1");
888 assert_eq!(uri, "postgres://h:5432/db?query=SELECT 1");
889 }
890
891 fn lazy_source(config: PostgresSourceConfig) -> PostgresSource {
895 let pool = PgPoolOptions::new()
896 .acquire_timeout(std::time::Duration::from_millis(200))
898 .connect_lazy(&config.connection_url)
899 .expect("lazy pool");
900 PostgresSource {
901 config,
902 pool,
903 applied_shard: Mutex::new(None),
904 }
905 }
906
907 #[tokio::test]
908 async fn apply_shard_then_shard_wrap_narrows_query() {
909 use faucet_core::Source as _;
910 let mut config =
911 PostgresSourceConfig::new("postgres://u@127.0.0.1:1/db", "SELECT * FROM t");
912 config.shard = Some(crate::config::ShardConfig { key: "id".into() });
913 let source = lazy_source(config);
914 assert!(source.is_shardable());
915
916 assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
918 source
919 .apply_shard(&faucet_core::ShardSpec::whole())
920 .await
921 .unwrap();
922 assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
923
924 let spec = &plan_pk_shards("id", 0, 99, 2)[0];
926 source.apply_shard(spec).await.unwrap();
927 let wrapped = source.shard_wrap("SELECT * FROM t".into());
928 assert!(wrapped.contains(r#""id""#), "got: {wrapped}");
929 assert!(wrapped.contains("_faucet_shard"), "got: {wrapped}");
930
931 let err = source.enumerate_shards(4).await.unwrap_err();
934 assert!(
935 err.to_string().contains("shard bounds"),
936 "expected bounds-probe error, got: {err}"
937 );
938 }
939}
940
941#[cfg(test)]
942mod bind_overflow_tests {
943 use super::*;
944 use serde_json::json;
945
946 #[test]
950 fn u64_above_i64_max_is_refused_not_wrapped() {
951 let err = match bind_params(sqlx::query("SELECT 1"), &[json!(u64::MAX)], &[]) {
952 Err(e) => e.to_string(),
953 Ok(_) => panic!("u64::MAX must not bind"),
954 };
955 assert!(err.contains(&u64::MAX.to_string()), "{err}");
956 assert!(
957 !err.contains("-9223372036854775808"),
958 "must not show the wrap: {err}"
959 );
960 }
961
962 #[test]
963 fn values_a_signed_column_can_hold_still_bind() {
964 for v in [json!(0), json!(-1), json!(i64::MAX), json!(i64::MAX as u64)] {
965 assert!(
966 bind_params(sqlx::query("SELECT 1"), std::slice::from_ref(&v), &[]).is_ok(),
967 "{v} must still bind"
968 );
969 }
970 }
971}