1use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Duration;
16
17use arrow_array::{Array, RecordBatch};
18use arrow_row::SortField;
19use arrow_schema::{DataType, Field, Schema, SchemaRef, TimeUnit};
20use deadpool_postgres::Pool;
21use tokio_postgres::types::{ToSql, Type};
22
23use laminar_core::lookup::predicate::Predicate;
24use laminar_core::lookup::source::{
25 projection_names, ColumnId, LookupError, LookupSource, LookupSourceCapabilities,
26};
27use laminar_core::lookup::KeyAligner;
28
29use super::await_owned_driver;
30
31const MAX_LOOKUP_KEYS: usize = 4_096;
32const MAX_LOOKUP_KEY_BYTES: usize = 4 * 1024 * 1024;
33const MAX_LOOKUP_RESULT_BYTES: usize = 64 * 1024 * 1024;
34const MAX_POOL_SIZE: usize = 64;
35const POOL_WAIT_TIMEOUT: Duration = Duration::from_secs(5);
36const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
37const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
38const UNIQUE_LOOKUP_KEY_QUERY: &str = r"
39WITH target AS (
40 SELECT pg_catalog.to_regclass($1)::oid AS table_oid
41)
42SELECT
43 target.table_oid,
44 EXISTS (
45 SELECT 1
46 FROM pg_catalog.pg_index AS idx
47 JOIN pg_catalog.pg_attribute AS attr
48 ON attr.attrelid = idx.indrelid
49 AND attr.attnum = idx.indkey[0]
50 WHERE idx.indrelid = target.table_oid
51 AND idx.indisunique
52 AND idx.indisvalid
53 AND idx.indisready
54 AND idx.indislive
55 AND idx.indnkeyatts = 1
56 AND idx.indpred IS NULL
57 AND idx.indexprs IS NULL
58 AND attr.attname = $2
59 AND attr.attnum > 0
60 AND NOT attr.attisdropped
61 ) AS has_unique_key
62FROM target
63";
64
65async fn await_lookup_driver<T>(
66 operation: &'static str,
67 future: impl std::future::Future<Output = Result<T, LookupError>> + Send + 'static,
68) -> Result<T, LookupError>
69where
70 T: Send + 'static,
71{
72 await_owned_driver(future, move |error| {
73 LookupError::Internal(format!("postgres {operation} task failed: {error}"))
74 })
75 .await
76}
77
78fn validate_unique_lookup_key(
79 table: &str,
80 key: &str,
81 table_oid: Option<u32>,
82 has_unique_key: bool,
83) -> Result<u32, LookupError> {
84 let table_oid = table_oid.ok_or_else(|| {
85 LookupError::Internal(format!(
86 "postgres lookup table {table} could not be resolved with to_regclass"
87 ))
88 })?;
89 if !has_unique_key {
90 return Err(LookupError::Internal(format!(
91 "postgres lookup requires a valid, ready, non-partial unique index whose sole key column is '{key}' on {table}"
92 )));
93 }
94 Ok(table_oid)
95}
96
97#[derive(Debug, Clone)]
99pub struct PostgresLookupSourceConfig {
100 pub properties: HashMap<String, String>,
103 pub table: String,
105 pub primary_key_columns: Vec<String>,
107 pub pool_size: usize,
109}
110
111pub struct PostgresLookupSource {
113 pool: Pool,
114 select_sql: String,
115 select_expressions: Vec<String>,
116 table: String,
118 pk_column: String,
119 quoted_pk_column: String,
120 schema: SchemaRef,
121 aligner: KeyAligner,
122}
123
124fn quote_identifier(name: &str) -> Result<String, LookupError> {
125 if name.is_empty() || name.contains('\0') {
126 return Err(LookupError::Internal(
127 "postgres identifiers must be non-empty and cannot contain NUL".into(),
128 ));
129 }
130 Ok(format!("\"{}\"", name.replace('"', "\"\"")))
131}
132
133fn quote_qualified_identifier(name: &str) -> Result<String, LookupError> {
134 name.split('.')
135 .map(quote_identifier)
136 .collect::<Result<Vec<_>, _>>()
137 .map(|parts| parts.join("."))
138}
139
140impl PostgresLookupSource {
141 pub async fn open(config: PostgresLookupSourceConfig) -> Result<Self, LookupError> {
148 if config.primary_key_columns.len() != 1 {
149 return Err(LookupError::Internal(format!(
150 "postgres lookup requires exactly one primary key column, got {}",
151 config.primary_key_columns.len()
152 )));
153 }
154 if config.pool_size == 0 || config.pool_size > MAX_POOL_SIZE {
155 return Err(LookupError::Connection(format!(
156 "postgres lookup pool_size must be between 1 and {MAX_POOL_SIZE}, got {}",
157 config.pool_size
158 )));
159 }
160 let pk_column = config.primary_key_columns[0].clone();
161 let table = quote_qualified_identifier(&config.table)?;
162 let quoted_pk_column = quote_identifier(&pk_column)?;
163
164 let pool = build_pool(&config.properties, config.pool_size)?;
165
166 let probe_pool = pool.clone();
169 let schema_probe = format!("SELECT * FROM {table} LIMIT 0");
170 let probe_table = table.clone();
171 let probe_key = pk_column.clone();
172 let stmt = await_lookup_driver("schema probe", async move {
173 let client = probe_pool
174 .get()
175 .await
176 .map_err(|e| LookupError::Connection(format!("postgres pool: {e}")))?;
177 let identity = if let Ok(result) = tokio::time::timeout(
178 QUERY_TIMEOUT,
179 client.query_one(
180 UNIQUE_LOOKUP_KEY_QUERY,
181 &[&probe_table.as_str(), &probe_key.as_str()],
182 ),
183 )
184 .await
185 {
186 result.map_err(|e| {
187 LookupError::Connection(format!("inspect postgres lookup key index: {e}"))
188 })?
189 } else {
190 discard_pool_client(client);
191 return Err(LookupError::Timeout(QUERY_TIMEOUT));
192 };
193 let table_oid = identity
194 .try_get::<_, Option<u32>>("table_oid")
195 .map_err(|e| LookupError::Connection(format!("decode lookup table OID: {e}")))?;
196 let has_unique_key = identity
197 .try_get::<_, bool>("has_unique_key")
198 .map_err(|e| LookupError::Connection(format!("decode lookup index check: {e}")))?;
199 validate_unique_lookup_key(&probe_table, &probe_key, table_oid, has_unique_key)?;
200 if let Ok(result) =
201 tokio::time::timeout(QUERY_TIMEOUT, client.prepare(&schema_probe)).await
202 {
203 result.map_err(|e| LookupError::Connection(format!("prepare schema probe: {e}")))
204 } else {
205 discard_pool_client(client);
206 Err(LookupError::Timeout(QUERY_TIMEOUT))
207 }
208 })
209 .await?;
210 let fields: Vec<Field> = stmt
211 .columns()
212 .iter()
213 .map(|c| Field::new(c.name(), pg_type_to_arrow(c.type_()), true))
214 .collect();
215 let schema: SchemaRef = Arc::new(Schema::new(fields));
216
217 let pk_idx = schema.index_of(&pk_column).map_err(|_| {
218 LookupError::Internal(format!("pk column not found in table: {pk_column}"))
219 })?;
220 let pk_pg_type = stmt.columns()[pk_idx].type_();
221 if !supports_any_parameter(pk_pg_type) {
222 return Err(LookupError::Internal(format!(
223 "postgres lookup primary key column '{pk_column}' has unsupported type {pk_pg_type}"
224 )));
225 }
226 let select_expressions = stmt
227 .columns()
228 .iter()
229 .map(select_expression)
230 .collect::<Result<Vec<_>, _>>()?;
231 let select_sql = format!(
232 "SELECT {} FROM {table} WHERE {quoted_pk_column} = ANY($1) LIMIT {}",
233 select_expressions.join(", "),
234 MAX_LOOKUP_KEYS + 1
235 );
236 let pk_sort_fields = vec![SortField::new(schema.field(pk_idx).data_type().clone())];
237 let aligner = KeyAligner::new(pk_sort_fields, config.primary_key_columns)?;
238
239 Ok(Self {
240 pool,
241 select_sql,
242 select_expressions,
243 table,
244 pk_column,
245 quoted_pk_column,
246 schema,
247 aligner,
248 })
249 }
250
251 fn build_any_param(pk_array: &dyn Array) -> Result<Box<dyn ToSql + Sync + Send>, LookupError> {
254 use arrow_array::{
255 BooleanArray, Float32Array, Float64Array, Int16Array, Int32Array, Int64Array,
256 LargeStringArray, StringArray, StringViewArray,
257 };
258
259 fn downcast<T: 'static>(array: &dyn Array) -> Result<&T, LookupError> {
260 array
261 .as_any()
262 .downcast_ref::<T>()
263 .ok_or_else(|| LookupError::Internal("pk column downcast failed".into()))
264 }
265 fn non_null<A: Array, T>(a: &A, get: impl Fn(usize) -> T) -> Vec<T> {
266 (0..a.len()).filter(|&i| !a.is_null(i)).map(get).collect()
267 }
268
269 let param: Box<dyn ToSql + Sync + Send> = match pk_array.data_type() {
270 DataType::Int16 => {
271 let a = downcast::<Int16Array>(pk_array)?;
272 Box::new(non_null(a, |i| a.value(i)))
273 }
274 DataType::Int32 => {
275 let a = downcast::<Int32Array>(pk_array)?;
276 Box::new(non_null(a, |i| a.value(i)))
277 }
278 DataType::Int64 => {
279 let a = downcast::<Int64Array>(pk_array)?;
280 Box::new(non_null(a, |i| a.value(i)))
281 }
282 DataType::Float32 => {
283 let a = downcast::<Float32Array>(pk_array)?;
284 Box::new(non_null(a, |i| a.value(i)))
285 }
286 DataType::Float64 => {
287 let a = downcast::<Float64Array>(pk_array)?;
288 Box::new(non_null(a, |i| a.value(i)))
289 }
290 DataType::Boolean => {
291 let a = downcast::<BooleanArray>(pk_array)?;
292 Box::new(non_null(a, |i| a.value(i)))
293 }
294 DataType::Utf8 => {
295 let a = downcast::<StringArray>(pk_array)?;
296 Box::new(non_null(a, |i| a.value(i).to_string()))
297 }
298 DataType::LargeUtf8 => {
299 let a = downcast::<LargeStringArray>(pk_array)?;
300 Box::new(non_null(a, |i| a.value(i).to_string()))
301 }
302 DataType::Utf8View => {
303 let a = downcast::<StringViewArray>(pk_array)?;
304 Box::new(non_null(a, |i| a.value(i).to_string()))
305 }
306 dt => {
307 return Err(LookupError::Internal(format!(
308 "unsupported PK data type for postgres lookup: {dt}"
309 )));
310 }
311 };
312 Ok(param)
313 }
314}
315
316fn validate_lookup_keys(keys: &[&[u8]]) -> Result<(), LookupError> {
317 if keys.len() > MAX_LOOKUP_KEYS {
318 return Err(LookupError::Query(format!(
319 "postgres lookup received {} keys, exceeding the fixed {MAX_LOOKUP_KEYS}-key batch limit",
320 keys.len()
321 )));
322 }
323 let bytes = keys.iter().try_fold(0_usize, |total, key| {
324 total
325 .checked_add(key.len())
326 .ok_or_else(|| LookupError::Query("postgres lookup key byte count overflow".into()))
327 })?;
328 if bytes > MAX_LOOKUP_KEY_BYTES {
329 return Err(LookupError::Query(format!(
330 "postgres lookup received {bytes} key bytes, exceeding the fixed {MAX_LOOKUP_KEY_BYTES}-byte batch limit"
331 )));
332 }
333 Ok(())
334}
335
336fn enforce_lookup_result_bytes(batch: &RecordBatch) -> Result<(), LookupError> {
337 let bytes = batch.columns().iter().try_fold(0_usize, |total, column| {
338 total
339 .checked_add(column.get_array_memory_size())
340 .ok_or_else(|| LookupError::Query("postgres lookup result byte count overflow".into()))
341 })?;
342 if bytes > MAX_LOOKUP_RESULT_BYTES {
343 return Err(LookupError::Query(format!(
344 "postgres lookup result retains {bytes} bytes, exceeding the fixed {MAX_LOOKUP_RESULT_BYTES}-byte limit"
345 )));
346 }
347 Ok(())
348}
349
350impl LookupSource for PostgresLookupSource {
351 async fn query(
352 &self,
353 keys: &[&[u8]],
354 _predicates: &[Predicate],
355 projection: &[ColumnId],
356 ) -> Result<Vec<Option<RecordBatch>>, LookupError> {
357 if keys.is_empty() {
358 return Ok(Vec::new());
359 }
360 validate_lookup_keys(keys)?;
361 let pk_arrays = self.aligner.decode_keys(keys)?;
362 let pk_array = pk_arrays
363 .first()
364 .ok_or_else(|| LookupError::Internal("postgres lookup decoded no key column".into()))?
365 .as_ref();
366 if pk_array.len() != keys.len() {
367 return Err(LookupError::Internal(format!(
368 "postgres lookup decoded {} keys from {} inputs",
369 pk_array.len(),
370 keys.len()
371 )));
372 }
373 let unique_key_count = keys
374 .iter()
375 .enumerate()
376 .filter(|(index, _)| !pk_array.is_null(*index))
377 .map(|(_, key)| *key)
378 .collect::<rustc_hash::FxHashSet<_>>()
379 .len();
380
381 let param = Self::build_any_param(pk_array)?;
382
383 let (sql, out_schema, project_needed) = if projection.is_empty() {
387 (self.select_sql.clone(), Arc::clone(&self.schema), false)
388 } else {
389 let mut proj_names = projection_names(&self.schema, projection)?;
390 let mut idx: Vec<usize> = projection.iter().map(|&c| c as usize).collect();
391 let mut project_needed = false;
392
393 if !proj_names.contains(&self.pk_column) {
394 proj_names.push(self.pk_column.clone());
395 let pk_idx = self
396 .schema
397 .index_of(&self.pk_column)
398 .map_err(|e| LookupError::Internal(format!("pk column index: {e}")))?;
399 idx.push(pk_idx);
400 project_needed = true;
401 }
402
403 let cols = idx
404 .iter()
405 .map(|&index| {
406 self.select_expressions
407 .get(index)
408 .map(String::as_str)
409 .ok_or_else(|| {
410 LookupError::Internal(format!(
411 "postgres projection column index {index} is out of bounds"
412 ))
413 })
414 })
415 .collect::<Result<Vec<_>, _>>()?
416 .join(", ");
417 let sql = format!(
418 "SELECT {cols} FROM {} WHERE {} = ANY($1) LIMIT {}",
419 self.table,
420 self.quoted_pk_column,
421 MAX_LOOKUP_KEYS + 1
422 );
423 let proj_schema = Arc::new(
424 self.schema
425 .project(&idx)
426 .map_err(|e| LookupError::Internal(format!("project postgres schema: {e}")))?,
427 );
428 (sql, proj_schema, project_needed)
429 };
430
431 let query_pool = self.pool.clone();
432 let pg_rows = await_lookup_driver("lookup query", async move {
433 let client = query_pool
434 .get()
435 .await
436 .map_err(|e| LookupError::Connection(format!("postgres pool: {e}")))?;
437 if let Ok(result) =
438 tokio::time::timeout(QUERY_TIMEOUT, client.query(&sql, &[&*param])).await
439 {
440 result.map_err(|e| LookupError::Query(format!("postgres lookup query: {e}")))
441 } else {
442 discard_pool_client(client);
443 Err(LookupError::Timeout(QUERY_TIMEOUT))
444 }
445 })
446 .await?;
447 if pg_rows.len() > unique_key_count {
448 return Err(LookupError::Query(format!(
449 "postgres lookup returned {} rows for {unique_key_count} distinct keys; the configured key column is not unique",
450 pg_rows.len()
451 )));
452 }
453
454 let batches = if pg_rows.is_empty() {
455 Vec::new()
456 } else {
457 let batch = rows_to_batch(&out_schema, &pg_rows)?;
458 enforce_lookup_result_bytes(&batch)?;
459 vec![batch]
460 };
461 let aligned = self.aligner.align(keys, &batches)?;
462
463 if project_needed {
464 let orig_names = projection_names(&self.schema, projection)?;
465 let mut projected_aligned = Vec::with_capacity(aligned.len());
466 for maybe_batch in aligned {
467 if let Some(batch) = maybe_batch {
468 let indices: Vec<usize> = orig_names
469 .iter()
470 .map(|name| {
471 batch.schema().index_of(name).map_err(|e| {
472 LookupError::Internal(format!(
473 "column not found in aligned schema: {e}"
474 ))
475 })
476 })
477 .collect::<Result<Vec<usize>, LookupError>>()?;
478 let projected = batch.project(&indices).map_err(|e| {
479 LookupError::Internal(format!("project aligned batch: {e}"))
480 })?;
481 projected_aligned.push(Some(projected));
482 } else {
483 projected_aligned.push(None);
484 }
485 }
486 Ok(projected_aligned)
487 } else {
488 Ok(aligned)
489 }
490 }
491
492 fn capabilities(&self) -> LookupSourceCapabilities {
493 LookupSourceCapabilities {
494 supports_batch_lookup: true,
495 supports_projection_pushdown: true,
496 max_batch_size: MAX_LOOKUP_KEYS,
497 ..LookupSourceCapabilities::none()
498 }
499 }
500
501 fn source_name(&self) -> &'static str {
502 "postgres"
503 }
504
505 fn schema(&self) -> SchemaRef {
506 Arc::clone(&self.schema)
507 }
508
509 async fn health_check(&self) -> Result<(), LookupError> {
510 let health_pool = self.pool.clone();
511 await_lookup_driver("health check", async move {
512 let client = health_pool
513 .get()
514 .await
515 .map_err(|e| LookupError::Connection(format!("health check pool: {e}")))?;
516 if let Ok(result) =
517 tokio::time::timeout(QUERY_TIMEOUT, client.query_one("SELECT 1", &[])).await
518 {
519 result
520 .map(|_| ())
521 .map_err(|e| LookupError::Connection(format!("health check: {e}")))
522 } else {
523 discard_pool_client(client);
524 Err(LookupError::Timeout(QUERY_TIMEOUT))
525 }
526 })
527 .await
528 }
529}
530
531fn discard_pool_client(client: deadpool_postgres::Client) {
532 drop(deadpool_postgres::Client::take(client));
533}
534
535fn build_pool(props: &HashMap<String, String>, pool_size: usize) -> Result<Pool, LookupError> {
538 if pool_size == 0 || pool_size > MAX_POOL_SIZE {
539 return Err(LookupError::Connection(format!(
540 "postgres lookup pool_size must be between 1 and {MAX_POOL_SIZE}, got {pool_size}"
541 )));
542 }
543 let mut cfg = deadpool_postgres::Config::new();
544 for (left, right) in [
545 ("connection", "connection_string"),
546 ("database", "dbname"),
547 ("user", "username"),
548 ] {
549 if props.contains_key(left) && props.contains_key(right) {
550 return Err(LookupError::Connection(format!(
551 "postgres lookup cannot configure both '{left}' and '{right}'"
552 )));
553 }
554 }
555 for key in ["host", "database", "dbname", "user", "username"] {
556 if props.get(key).is_some_and(|value| value.trim().is_empty()) {
557 return Err(LookupError::Connection(format!(
558 "postgres lookup '{key}' must not be empty"
559 )));
560 }
561 }
562
563 if let Some(conn) = props
564 .get("connection")
565 .or_else(|| props.get("connection_string"))
566 {
567 if conn.trim().is_empty() {
568 return Err(LookupError::Connection(
569 "postgres lookup connection string must not be empty".into(),
570 ));
571 }
572 if let Some(conflict) = [
573 "host", "port", "database", "dbname", "user", "username", "password", "options",
574 ]
575 .into_iter()
576 .find(|key| props.contains_key(*key))
577 {
578 return Err(LookupError::Connection(format!(
579 "postgres lookup cannot combine a connection string with '{conflict}'"
580 )));
581 }
582 let parsed: tokio_postgres::Config = conn
583 .parse()
584 .map_err(|e| LookupError::Connection(format!("parse connection string: {e}")))?;
585 if parsed.get_ports().contains(&0) {
586 return Err(LookupError::Connection(
587 "postgres lookup port must be greater than zero".into(),
588 ));
589 }
590 if parsed.get_user().is_none_or(str::is_empty) {
591 return Err(LookupError::Connection(
592 "postgres lookup connection string must specify a user".into(),
593 ));
594 }
595 if parsed.get_dbname().is_none_or(str::is_empty) {
596 return Err(LookupError::Connection(
597 "postgres lookup connection string must specify a database".into(),
598 ));
599 }
600 cfg.url = Some(conn.clone());
601 } else {
602 cfg.host = props.get("host").cloned();
603 cfg.port = props
604 .get("port")
605 .map(|port| {
606 let parsed = port.parse::<u16>().map_err(|error| {
607 LookupError::Connection(format!(
608 "invalid postgres lookup port '{port}': {error}"
609 ))
610 })?;
611 if parsed == 0 {
612 return Err(LookupError::Connection(
613 "postgres lookup port must be greater than zero".into(),
614 ));
615 }
616 Ok(parsed)
617 })
618 .transpose()?;
619 cfg.dbname = props
620 .get("database")
621 .or_else(|| props.get("dbname"))
622 .cloned();
623 cfg.user = props.get("user").or_else(|| props.get("username")).cloned();
624 if cfg.user.is_none() {
625 return Err(LookupError::Connection(
626 "postgres lookup requires 'user' or 'username'".into(),
627 ));
628 }
629 if cfg.dbname.is_none() {
630 return Err(LookupError::Connection(
631 "postgres lookup requires 'database' or 'dbname'".into(),
632 ));
633 }
634 cfg.password = props.get("password").cloned();
635 if let Some(options) = props.get("options") {
636 if options.contains('\0') {
637 return Err(LookupError::Connection(
638 "postgres lookup options cannot contain NUL".into(),
639 ));
640 }
641 cfg.options = Some(options.clone());
642 }
643 }
644
645 cfg.connect_timeout = Some(CONNECT_TIMEOUT);
646 let mut pool_config = deadpool_postgres::PoolConfig::new(pool_size);
647 pool_config.timeouts.wait = Some(POOL_WAIT_TIMEOUT);
648 pool_config.timeouts.create = Some(CONNECT_TIMEOUT);
649 pool_config.timeouts.recycle = Some(POOL_WAIT_TIMEOUT);
650 cfg.pool = Some(pool_config);
651 let runtime = Some(deadpool_postgres::Runtime::Tokio1);
652 let ssl_mode = ssl_mode(props)?;
653 cfg.ssl_mode = Some(driver_ssl_mode(ssl_mode));
654
655 match ssl_mode {
656 crate::postgres::SslMode::VerifyFull => {
657 let connector = build_rustls_connector(props)?;
658 cfg.create_pool(runtime, connector)
659 .map_err(|e| LookupError::Connection(format!("create pool: {e}")))
660 }
661 crate::postgres::SslMode::Disable => cfg
662 .create_pool(runtime, tokio_postgres::NoTls)
663 .map_err(|e| LookupError::Connection(format!("create pool: {e}"))),
664 }
665}
666
667fn driver_ssl_mode(mode: crate::postgres::SslMode) -> deadpool_postgres::SslMode {
668 match mode {
669 crate::postgres::SslMode::Disable => deadpool_postgres::SslMode::Disable,
670 crate::postgres::SslMode::VerifyFull => deadpool_postgres::SslMode::Require,
671 }
672}
673
674fn ssl_mode(props: &HashMap<String, String>) -> Result<crate::postgres::SslMode, LookupError> {
677 if props.contains_key("sslmode") {
678 return Err(LookupError::Connection(
679 "postgres lookup uses ssl.mode (disable or verify-full), not sslmode".into(),
680 ));
681 }
682 if props
683 .get("ssl.ca.cert.path")
684 .is_some_and(|path| path.trim().is_empty())
685 {
686 return Err(LookupError::Connection(
687 "postgres lookup ssl.ca.cert.path must not be empty".into(),
688 ));
689 }
690 let mode = match props.get("ssl.mode") {
691 Some(value) => value.parse::<crate::postgres::SslMode>().map_err(|_| {
692 LookupError::Connection(format!(
693 "unsupported ssl.mode '{value}' (use disable or verify-full)"
694 ))
695 })?,
696 None => crate::postgres::SslMode::default(),
697 };
698 if mode == crate::postgres::SslMode::Disable && props.contains_key("ssl.ca.cert.path") {
699 return Err(LookupError::Connection(
700 "postgres lookup ssl.ca.cert.path requires ssl.mode=verify-full".into(),
701 ));
702 }
703 Ok(mode)
704}
705
706fn build_rustls_connector(
710 props: &HashMap<String, String>,
711) -> Result<tokio_postgres_rustls::MakeRustlsConnect, LookupError> {
712 let ca_path = props.get("ssl.ca.cert.path").map(std::path::Path::new);
713 crate::postgres::make_rustls_connector(ca_path)
714 .map_err(|error| LookupError::Connection(error.to_string()))
715}
716
717fn rows_to_batch(
720 schema: &SchemaRef,
721 rows: &[tokio_postgres::Row],
722) -> Result<RecordBatch, LookupError> {
723 use arrow_array::{
724 BinaryArray, BooleanArray, Date32Array, Float32Array, Float64Array, Int16Array, Int32Array,
725 Int64Array, StringArray, TimestampMicrosecondArray,
726 };
727
728 let mut columns: Vec<Arc<dyn Array>> = Vec::with_capacity(schema.fields().len());
729 for field in schema.fields() {
730 let name = field.name().as_str();
731 let array: Arc<dyn Array> = match field.data_type() {
732 DataType::Boolean => Arc::new(
733 collect_col::<bool>(rows, name)?
734 .into_iter()
735 .collect::<BooleanArray>(),
736 ),
737 DataType::Int16 => Arc::new(Int16Array::from(collect_col::<i16>(rows, name)?)),
738 DataType::Int32 => Arc::new(Int32Array::from(collect_col::<i32>(rows, name)?)),
739 DataType::Int64 => Arc::new(Int64Array::from(collect_col::<i64>(rows, name)?)),
740 DataType::Float32 => Arc::new(Float32Array::from(collect_col::<f32>(rows, name)?)),
741 DataType::Float64 => Arc::new(Float64Array::from(collect_col::<f64>(rows, name)?)),
742 DataType::Utf8 => {
743 let values = collect_col::<String>(rows, name)?;
744 Arc::new(StringArray::from(
745 values.iter().map(Option::as_deref).collect::<Vec<_>>(),
746 ))
747 }
748 DataType::Binary => {
749 let values = collect_col::<Vec<u8>>(rows, name)?;
750 Arc::new(BinaryArray::from(
751 values.iter().map(Option::as_deref).collect::<Vec<_>>(),
752 ))
753 }
754 DataType::Date32 => {
755 let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch");
756 let values = collect_col::<chrono::NaiveDate>(rows, name)?
757 .into_iter()
758 .map(|value| {
759 value
760 .map(|date| {
761 i32::try_from(date.signed_duration_since(epoch).num_days()).map_err(
762 |_| {
763 LookupError::Internal(format!(
764 "postgres column '{name}' contains a date outside the Arrow Date32 range"
765 ))
766 },
767 )
768 })
769 .transpose()
770 })
771 .collect::<Result<Vec<_>, _>>()?;
772 Arc::new(Date32Array::from(values))
773 }
774 DataType::Timestamp(TimeUnit::Microsecond, None) => {
775 let values = collect_col::<chrono::NaiveDateTime>(rows, name)?
776 .into_iter()
777 .map(|value| value.map(|timestamp| timestamp.and_utc().timestamp_micros()))
778 .collect::<Vec<_>>();
779 Arc::new(TimestampMicrosecondArray::from(values))
780 }
781 DataType::Timestamp(TimeUnit::Microsecond, Some(timezone))
782 if timezone.as_ref() == "UTC" =>
783 {
784 let values = collect_col::<chrono::DateTime<chrono::Utc>>(rows, name)?
785 .into_iter()
786 .map(|value| value.map(|timestamp| timestamp.timestamp_micros()))
787 .collect::<Vec<_>>();
788 Arc::new(TimestampMicrosecondArray::from(values).with_timezone("UTC"))
789 }
790 unsupported => {
791 return Err(LookupError::Internal(format!(
792 "unsupported PostgreSQL lookup result type {unsupported}"
793 )));
794 }
795 };
796 columns.push(array);
797 }
798 RecordBatch::try_new(Arc::clone(schema), columns)
799 .map_err(|e| LookupError::Internal(format!("arrow batch construction: {e}")))
800}
801
802fn collect_col<'a, T>(
804 rows: &'a [tokio_postgres::Row],
805 name: &str,
806) -> Result<Vec<Option<T>>, LookupError>
807where
808 T: tokio_postgres::types::FromSql<'a>,
809{
810 rows.iter()
811 .map(|r| {
812 r.try_get::<_, Option<T>>(name)
813 .map_err(|e| LookupError::Internal(format!("column '{name}': {e}")))
814 })
815 .collect()
816}
817
818fn pg_type_to_arrow(pg_type: &Type) -> DataType {
821 native_pg_type_to_arrow(pg_type).unwrap_or(DataType::Utf8)
822}
823
824fn native_pg_type_to_arrow(pg_type: &Type) -> Option<DataType> {
825 match *pg_type {
826 Type::BOOL => Some(DataType::Boolean),
827 Type::INT2 => Some(DataType::Int16),
828 Type::INT4 => Some(DataType::Int32),
829 Type::INT8 => Some(DataType::Int64),
830 Type::FLOAT4 => Some(DataType::Float32),
831 Type::FLOAT8 => Some(DataType::Float64),
832 Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => Some(DataType::Utf8),
833 Type::BYTEA => Some(DataType::Binary),
834 Type::DATE => Some(DataType::Date32),
835 Type::TIMESTAMP => Some(DataType::Timestamp(TimeUnit::Microsecond, None)),
836 Type::TIMESTAMPTZ => Some(DataType::Timestamp(
837 TimeUnit::Microsecond,
838 Some("UTC".into()),
839 )),
840 _ => None,
841 }
842}
843
844fn supports_any_parameter(pg_type: &Type) -> bool {
845 matches!(
846 *pg_type,
847 Type::BOOL
848 | Type::INT2
849 | Type::INT4
850 | Type::INT8
851 | Type::FLOAT4
852 | Type::FLOAT8
853 | Type::TEXT
854 | Type::VARCHAR
855 | Type::BPCHAR
856 | Type::NAME
857 )
858}
859
860fn select_expression(column: &tokio_postgres::Column) -> Result<String, LookupError> {
861 select_expression_for(column.name(), column.type_())
862}
863
864fn select_expression_for(name: &str, pg_type: &Type) -> Result<String, LookupError> {
865 let identifier = quote_identifier(name)?;
866 if native_pg_type_to_arrow(pg_type).is_some() {
867 Ok(identifier)
868 } else {
869 Ok(format!("CAST({identifier} AS TEXT) AS {identifier}"))
870 }
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876 use arrow_array::{Int64Array, StringArray};
877
878 #[test]
879 fn pg_type_map_native_and_explicit_text_projection() {
880 assert_eq!(pg_type_to_arrow(&Type::INT8), DataType::Int64);
881 assert_eq!(pg_type_to_arrow(&Type::FLOAT8), DataType::Float64);
882 assert_eq!(pg_type_to_arrow(&Type::BOOL), DataType::Boolean);
883 assert_eq!(
884 pg_type_to_arrow(&Type::TIMESTAMP),
885 DataType::Timestamp(TimeUnit::Microsecond, None)
886 );
887 assert_eq!(pg_type_to_arrow(&Type::NUMERIC), DataType::Utf8);
888 assert_eq!(pg_type_to_arrow(&Type::UUID), DataType::Utf8);
889 assert_eq!(
890 select_expression_for("amount", &Type::NUMERIC).unwrap(),
891 "CAST(\"amount\" AS TEXT) AS \"amount\""
892 );
893 assert_eq!(
894 select_expression_for("created_at", &Type::TIMESTAMP).unwrap(),
895 "\"created_at\""
896 );
897 assert!(!supports_any_parameter(&Type::UUID));
898 assert!(supports_any_parameter(&Type::INT8));
899 }
900
901 #[test]
902 fn unique_key_catalog_probe_is_fail_closed_and_allows_include_columns() {
903 for required in [
904 "pg_catalog.to_regclass($1)",
905 "idx.indisunique",
906 "idx.indisvalid",
907 "idx.indisready",
908 "idx.indislive",
909 "idx.indnkeyatts = 1",
910 "idx.indpred IS NULL",
911 "idx.indexprs IS NULL",
912 "attr.attnum = idx.indkey[0]",
913 "attr.attname = $2",
914 ] {
915 assert!(
916 UNIQUE_LOOKUP_KEY_QUERY.contains(required),
917 "missing {required}"
918 );
919 }
920 assert!(
921 !UNIQUE_LOOKUP_KEY_QUERY.contains("indnatts = 1"),
922 "included columns must not invalidate a single-key unique index"
923 );
924
925 assert!(validate_unique_lookup_key("events", "id", None, false).is_err());
926 assert!(validate_unique_lookup_key("events", "id", Some(42), false).is_err());
927 assert_eq!(
928 validate_unique_lookup_key("events", "id", Some(42), true).unwrap(),
929 42
930 );
931 }
932
933 #[test]
934 fn any_param_built_for_supported_types_skipping_nulls() {
935 assert!(
936 PostgresLookupSource::build_any_param(&Int64Array::from(vec![
937 Some(1i64),
938 None,
939 Some(3)
940 ]))
941 .is_ok()
942 );
943 assert!(PostgresLookupSource::build_any_param(&StringArray::from(vec!["a", "b"])).is_ok());
944 }
945
946 #[test]
947 fn any_param_rejects_unsupported_type() {
948 assert!(
949 PostgresLookupSource::build_any_param(&arrow_array::Date32Array::from(vec![1]))
950 .is_err()
951 );
952 }
953
954 fn props(kv: &[(&str, &str)]) -> HashMap<String, String> {
955 kv.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect()
956 }
957
958 #[test]
959 fn tls_mode_parsing() {
960 assert_eq!(
961 ssl_mode(&HashMap::new()).unwrap(),
962 crate::postgres::SslMode::VerifyFull
963 );
964 assert_eq!(
965 ssl_mode(&props(&[("ssl.mode", "disable")])).unwrap(),
966 crate::postgres::SslMode::Disable
967 );
968 assert_eq!(
969 ssl_mode(&props(&[("ssl.mode", "verify-full")])).unwrap(),
970 crate::postgres::SslMode::VerifyFull
971 );
972 assert_eq!(
973 driver_ssl_mode(crate::postgres::SslMode::VerifyFull),
974 deadpool_postgres::SslMode::Require
975 );
976 assert_eq!(
977 driver_ssl_mode(crate::postgres::SslMode::Disable),
978 deadpool_postgres::SslMode::Disable
979 );
980 for rejected in ["prefer", "require", "verify-ca", "bogus"] {
981 assert!(ssl_mode(&props(&[("ssl.mode", rejected)])).is_err());
982 }
983 assert!(ssl_mode(&props(&[("sslmode", "disable")])).is_err());
984 assert!(ssl_mode(&props(&[
985 ("ssl.mode", "disable"),
986 ("ssl.ca.cert.path", "/certs/ca.pem"),
987 ]))
988 .is_err());
989 }
990
991 #[test]
992 fn lookup_key_admission_is_bounded() {
993 assert!(validate_lookup_keys(&[&b"a"[..], &b"bc"[..]]).is_ok());
994
995 let too_many = vec![&b""[..]; MAX_LOOKUP_KEYS + 1];
996 assert!(validate_lookup_keys(&too_many).is_err());
997
998 let oversized = vec![0_u8; MAX_LOOKUP_KEY_BYTES + 1];
999 assert!(validate_lookup_keys(&[oversized.as_slice()]).is_err());
1000 }
1001
1002 #[test]
1003 fn pool_configuration_rejects_invalid_values() {
1004 let base = [
1005 ("host", "localhost"),
1006 ("database", "db"),
1007 ("user", "user"),
1008 ("ssl.mode", "disable"),
1009 ];
1010 assert!(build_pool(&props(&base), 0).is_err());
1011 assert!(build_pool(&props(&base), MAX_POOL_SIZE + 1).is_err());
1012
1013 let mut invalid_port = props(&base);
1014 invalid_port.insert("port".into(), "not-a-port".into());
1015 assert!(build_pool(&invalid_port, 1).is_err());
1016 invalid_port.insert("port".into(), "0".into());
1017 assert!(build_pool(&invalid_port, 1).is_err());
1018
1019 let mut invalid_options = props(&base);
1020 invalid_options.insert("options".into(), "bad\0option".into());
1021 assert!(build_pool(&invalid_options, 1).is_err());
1022
1023 let mut empty_user = props(&base);
1024 empty_user.insert("user".into(), " ".into());
1025 assert!(build_pool(&empty_user, 1).is_err());
1026
1027 let conflict = props(&[
1028 ("connection", "host=localhost dbname=db user=user"),
1029 ("host", "other"),
1030 ("ssl.mode", "disable"),
1031 ]);
1032 assert!(build_pool(&conflict, 1).is_err());
1033
1034 let zero_port = props(&[
1035 ("connection", "host=localhost port=0 dbname=db user=user"),
1036 ("ssl.mode", "disable"),
1037 ]);
1038 assert!(build_pool(&zero_port, 1).is_err());
1039 assert!(build_pool(&props(&[("connection", ""), ("ssl.mode", "disable")]), 1).is_err());
1040 }
1041
1042 #[test]
1043 fn identifier_validation_rejects_unsafe_shapes() {
1044 assert_eq!(
1045 quote_qualified_identifier("public.events").unwrap(),
1046 "\"public\".\"events\""
1047 );
1048 assert!(quote_qualified_identifier("public.").is_err());
1049 assert!(quote_identifier("bad\0name").is_err());
1050 }
1051
1052 #[test]
1053 fn tls_connector_builds_with_roots_and_rejects_bad_ca() {
1054 assert!(build_rustls_connector(&HashMap::new()).is_ok());
1056 assert!(
1058 build_rustls_connector(&props(&[("ssl.ca.cert.path", "/no/such/ca.pem")])).is_err()
1059 );
1060 }
1061}