Skip to main content

laminar_connectors/postgres/
lookup.rs

1//! `PostgreSQL` on-demand lookup source for cache-miss fallback.
2//!
3//! A `deadpool`-pooled client issues one parameterized `WHERE pk = ANY($1)`
4//! per fetch, so all missed keys of a probe fold into one index-served round
5//! trip. [`KeyAligner`] handles key decode and result realignment.
6//!
7//! TLS is server-auth via `rustls`: verified chain and hostname is the default,
8//! using `ssl.ca.cert.path` when set and Mozilla roots otherwise. Plaintext is
9//! available only through explicit `ssl.mode=disable`. Weaker libpq modes are
10//! rejected rather than presented as aliases. v1 limits: single-column key,
11//! server-auth only (no mTLS client certs).
12
13use 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/// Configuration for [`PostgresLookupSource`].
98#[derive(Debug, Clone)]
99pub struct PostgresLookupSourceConfig {
100    /// libpq-style connection settings (host/port/database/user/password or a
101    /// pre-formed `connection` string).
102    pub properties: HashMap<String, String>,
103    /// Table name (optionally schema-qualified).
104    pub table: String,
105    /// Primary key column names (v1: exactly one).
106    pub primary_key_columns: Vec<String>,
107    /// Connection pool size.
108    pub pool_size: usize,
109}
110
111/// `PostgreSQL` lookup source for on-demand/partial cache mode.
112pub struct PostgresLookupSource {
113    pool: Pool,
114    select_sql: String,
115    select_expressions: Vec<String>,
116    /// Quoted table name, kept to build a projected `SELECT`.
117    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    /// Opens a pooled connection and derives the table's Arrow schema.
142    ///
143    /// # Errors
144    ///
145    /// Returns `LookupError` if the pool/connection fails, the key is not a
146    /// single column, or the table schema cannot be read.
147    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        // Keep the checked-out client in the owned task until prepare is terminal. If the
167        // startup waiter is cancelled, the task continues and cannot return an in-flight client.
168        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    /// Build the `ANY($1)` array parameter from the decoded PK column. NULL
252    /// keys are dropped (a NULL never `= ANY`, so they resolve to a miss).
253    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        // Projection pushdown selects only requested columns plus the key used
384        // for result alignment. Unsupported native result types use the text
385        // casts derived during the schema probe.
386        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
535/// Build a `deadpool` pool from libpq-style properties (individual keys or a
536/// pre-formed `connection`/`connection_string` parsed via `tokio_postgres`).
537fn 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
674/// Whether the configuration requests verified TLS. It is secure by default;
675/// plaintext requires an explicit opt-out.
676fn 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
706/// Build a server-auth rustls TLS connector. Roots come from `ssl.ca.cert.path`
707/// (CA PEM) if set, otherwise the Mozilla webpki roots; the server certificate
708/// is always verified (no insecure skip-verify).
709fn 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
717/// Convert `tokio_postgres` rows into one Arrow `RecordBatch` via the
718/// pre-derived schema.
719fn 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
802/// Collect a typed nullable column from all rows.
803fn 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
818/// Map a `tokio_postgres` type to an Arrow `DataType`. Types without a native
819/// mapping are explicitly projected as `PostgreSQL` text.
820fn 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        // Default webpki roots: builds without a CA file.
1055        assert!(build_rustls_connector(&HashMap::new()).is_ok());
1056        // An explicit but missing CA path is a clear error, not a panic.
1057        assert!(
1058            build_rustls_connector(&props(&[("ssl.ca.cert.path", "/no/such/ca.pem")])).is_err()
1059        );
1060    }
1061}