Skip to main content

laminar_connectors/postgres/
reference.rs

1//! `PostgreSQL` startup snapshot source for reference tables.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use arrow_array::{Array, RecordBatch};
7use arrow_schema::{DataType, Schema, SchemaRef, TimeUnit};
8
9use crate::config::ConnectorConfig;
10use crate::error::ConnectorError;
11use crate::reference::ReferenceTableSource;
12
13use super::await_owned_driver;
14
15const SNAPSHOT_ROWS_PER_BATCH: usize = 4_096;
16const MAX_SNAPSHOT_BATCH_BYTES: usize = 64 * 1024 * 1024;
17const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
18const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
19const SNAPSHOT_CURSOR: &str = "laminar_reference_snapshot";
20
21fn timeout_error(timeout: Duration) -> ConnectorError {
22    ConnectorError::Timeout(u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX))
23}
24
25async fn await_reference_driver<T>(
26    operation: &'static str,
27    future: impl std::future::Future<Output = Result<T, ConnectorError>> + Send + 'static,
28) -> Result<T, ConnectorError>
29where
30    T: Send + 'static,
31{
32    await_owned_driver(future, move |error| {
33        ConnectorError::Internal(format!(
34            "postgres reference {operation} task failed: {error}"
35        ))
36    })
37    .await
38}
39
40struct PostgresSession {
41    client: tokio_postgres::Client,
42    connection: tokio::task::JoinHandle<()>,
43}
44
45impl Drop for PostgresSession {
46    fn drop(&mut self) {
47        self.connection.abort();
48    }
49}
50
51fn spawn_connection<F>(connection: F) -> tokio::task::JoinHandle<()>
52where
53    F: std::future::Future<Output = Result<(), tokio_postgres::Error>> + Send + 'static,
54{
55    tokio::spawn(async move {
56        if let Err(error) = connection.await {
57            tracing::warn!(%error, "postgres reference connection failed");
58        }
59    })
60}
61
62async fn owned_batch_execute(
63    session: PostgresSession,
64    sql: String,
65    operation: &'static str,
66) -> Result<PostgresSession, ConnectorError> {
67    await_reference_driver(operation, async move {
68        match tokio::time::timeout(QUERY_TIMEOUT, session.client.batch_execute(&sql)).await {
69            Ok(Ok(())) => Ok(session),
70            Ok(Err(error)) => Err(ConnectorError::ReadError(format!(
71                "postgres {operation}: {error}"
72            ))),
73            Err(_) => Err(timeout_error(QUERY_TIMEOUT)),
74        }
75    })
76    .await
77}
78
79async fn owned_prepare(
80    session: PostgresSession,
81    sql: String,
82    operation: &'static str,
83) -> Result<(PostgresSession, tokio_postgres::Statement), ConnectorError> {
84    await_reference_driver(operation, async move {
85        match tokio::time::timeout(QUERY_TIMEOUT, session.client.prepare(&sql)).await {
86            Ok(Ok(statement)) => Ok((session, statement)),
87            Ok(Err(error)) => Err(ConnectorError::ReadError(format!(
88                "postgres {operation}: {error}"
89            ))),
90            Err(_) => Err(timeout_error(QUERY_TIMEOUT)),
91        }
92    })
93    .await
94}
95
96async fn owned_query(
97    session: PostgresSession,
98    sql: String,
99    operation: &'static str,
100) -> Result<(PostgresSession, Vec<tokio_postgres::Row>), ConnectorError> {
101    await_reference_driver(operation, async move {
102        match tokio::time::timeout(QUERY_TIMEOUT, session.client.query(&sql, &[])).await {
103            Ok(Ok(rows)) => Ok((session, rows)),
104            Ok(Err(error)) => Err(ConnectorError::ReadError(format!(
105                "postgres {operation}: {error}"
106            ))),
107            Err(_) => Err(timeout_error(QUERY_TIMEOUT)),
108        }
109    })
110    .await
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114enum State {
115    Ready,
116    Reading,
117    Done,
118    Closed,
119}
120
121/// A finite `PostgreSQL` `SELECT` snapshot projected into the declared table schema.
122pub struct PostgresReferenceTableSource {
123    config: ConnectorConfig,
124    declared_schema: SchemaRef,
125    state: State,
126    session: Option<PostgresSession>,
127}
128
129impl PostgresReferenceTableSource {
130    /// Creates a source for the LaminarDB-declared table schema.
131    #[must_use]
132    pub fn new(config: ConnectorConfig, declared_schema: SchemaRef) -> Self {
133        Self {
134            config,
135            declared_schema,
136            state: State::Ready,
137            session: None,
138        }
139    }
140
141    fn postgres_config(&self) -> Result<tokio_postgres::Config, ConnectorError> {
142        let properties = self.config.properties();
143        for (left, right) in [
144            ("connection", "connection_string"),
145            ("database", "dbname"),
146            ("user", "username"),
147        ] {
148            if properties.contains_key(left) && properties.contains_key(right) {
149                return Err(ConnectorError::ConfigurationError(format!(
150                    "postgres reference cannot configure both '{left}' and '{right}'"
151                )));
152            }
153        }
154        for key in ["host", "database", "dbname", "user", "username"] {
155            if properties
156                .get(key)
157                .is_some_and(|value| value.trim().is_empty())
158            {
159                return Err(ConnectorError::ConfigurationError(format!(
160                    "postgres reference '{key}' must not be empty"
161                )));
162            }
163        }
164        let connection_string = properties
165            .get("connection_string")
166            .or_else(|| properties.get("connection"));
167        let mut config = if let Some(connection_string) = connection_string {
168            if connection_string.trim().is_empty() {
169                return Err(ConnectorError::ConfigurationError(
170                    "postgres reference connection string must not be empty".into(),
171                ));
172            }
173            if let Some(conflict) = [
174                "host", "port", "database", "dbname", "user", "username", "password", "options",
175            ]
176            .into_iter()
177            .find(|key| properties.contains_key(*key))
178            {
179                return Err(ConnectorError::ConfigurationError(format!(
180                    "postgres reference cannot combine a connection string with '{conflict}'"
181                )));
182            }
183            connection_string.parse().map_err(|error| {
184                ConnectorError::ConfigurationError(format!(
185                    "invalid postgres reference connection: {error}"
186                ))
187            })?
188        } else {
189            let mut config = tokio_postgres::Config::new();
190            if let Some(host) = properties.get("host") {
191                config.host(host);
192            }
193            if let Some(port) = properties.get("port") {
194                let port = port.parse::<u16>().map_err(|error| {
195                    ConnectorError::ConfigurationError(format!(
196                        "invalid postgres reference port '{port}': {error}"
197                    ))
198                })?;
199                if port == 0 {
200                    return Err(ConnectorError::ConfigurationError(
201                        "postgres reference port must be greater than zero".into(),
202                    ));
203                }
204                config.port(port);
205            }
206            if let Some(database) = properties
207                .get("database")
208                .or_else(|| properties.get("dbname"))
209            {
210                config.dbname(database);
211            }
212            if let Some(user) = properties
213                .get("user")
214                .or_else(|| properties.get("username"))
215            {
216                config.user(user);
217            }
218            if let Some(password) = properties.get("password") {
219                config.password(password);
220            }
221            if let Some(options) = properties.get("options") {
222                if options.contains('\0') {
223                    return Err(ConnectorError::ConfigurationError(
224                        "postgres reference options cannot contain NUL".into(),
225                    ));
226                }
227                config.options(options);
228            }
229            config
230        };
231        if config.get_ports().contains(&0) {
232            return Err(ConnectorError::ConfigurationError(
233                "postgres reference port must be greater than zero".into(),
234            ));
235        }
236
237        if properties.contains_key("sslmode") {
238            return Err(ConnectorError::ConfigurationError(
239                "postgres reference uses ssl.mode (disable or verify-full), not sslmode".into(),
240            ));
241        }
242        let ssl_mode = self.ssl_mode()?;
243        let ca_path = properties.get("ssl.ca.cert.path");
244        if ca_path.is_some_and(|path| path.trim().is_empty()) {
245            return Err(ConnectorError::ConfigurationError(
246                "postgres reference ssl.ca.cert.path must not be empty".into(),
247            ));
248        }
249        if ssl_mode == crate::postgres::SslMode::Disable && ca_path.is_some() {
250            return Err(ConnectorError::ConfigurationError(
251                "ssl.ca.cert.path requires ssl.mode=verify-full".into(),
252            ));
253        }
254        config.ssl_mode(match ssl_mode {
255            crate::postgres::SslMode::Disable => tokio_postgres::config::SslMode::Disable,
256            crate::postgres::SslMode::VerifyFull => tokio_postgres::config::SslMode::Require,
257        });
258        config.connect_timeout(CONNECT_TIMEOUT);
259        Ok(config)
260    }
261
262    fn snapshot_query(&self) -> Result<String, ConnectorError> {
263        let table = self.config.get("table").ok_or_else(|| {
264            ConnectorError::ConfigurationError(
265                "postgres reference source requires a 'table' property".into(),
266            )
267        })?;
268        let table = quote_qualified_identifier(table)?;
269        let columns = self
270            .declared_schema
271            .fields()
272            .iter()
273            .map(|field| quote_identifier(field.name()))
274            .collect::<Result<Vec<_>, _>>()?;
275        if columns.is_empty() {
276            return Err(ConnectorError::ConfigurationError(
277                "postgres reference source requires at least one declared column".into(),
278            ));
279        }
280        Ok(format!("SELECT {} FROM {table}", columns.join(", ")))
281    }
282
283    fn projected_snapshot_query(
284        &self,
285        columns: &[tokio_postgres::Column],
286    ) -> Result<String, ConnectorError> {
287        if columns.len() != self.declared_schema.fields().len() {
288            return Err(ConnectorError::ReadError(format!(
289                "postgres snapshot has {} columns but {} were declared",
290                columns.len(),
291                self.declared_schema.fields().len()
292            )));
293        }
294        let expressions = columns
295            .iter()
296            .zip(self.declared_schema.fields())
297            .map(|(column, declared)| {
298                if column.name() != declared.name() {
299                    return Err(ConnectorError::ReadError(format!(
300                        "postgres column '{}' does not match declared column '{}'",
301                        column.name(),
302                        declared.name()
303                    )));
304                }
305                reference_select_expression(column.name(), column.type_(), declared.data_type())
306            })
307            .collect::<Result<Vec<_>, _>>()?;
308        let table = self.config.get("table").ok_or_else(|| {
309            ConnectorError::ConfigurationError(
310                "postgres reference source requires a 'table' property".into(),
311            )
312        })?;
313        Ok(format!(
314            "SELECT {} FROM {}",
315            expressions.join(", "),
316            quote_qualified_identifier(table)?
317        ))
318    }
319
320    fn ssl_mode(&self) -> Result<crate::postgres::SslMode, ConnectorError> {
321        self.config
322            .get("ssl.mode")
323            .map(str::parse)
324            .transpose()
325            .map_err(|error| {
326                ConnectorError::ConfigurationError(format!(
327                    "invalid postgres reference ssl.mode: {error}"
328                ))
329            })
330            .map(std::option::Option::unwrap_or_default)
331    }
332
333    async fn start_snapshot(&mut self) -> Result<(), ConnectorError> {
334        let pg_config = self.postgres_config()?;
335        let ssl_mode = self.ssl_mode()?;
336        let probe_query = self.snapshot_query()?;
337        let ca_path = self
338            .config
339            .get("ssl.ca.cert.path")
340            .map(std::path::Path::new);
341        let session = match ssl_mode {
342            crate::postgres::SslMode::Disable => {
343                await_reference_driver("connect", async move {
344                    let (client, connection) = tokio::time::timeout(
345                        CONNECT_TIMEOUT,
346                        pg_config.connect(tokio_postgres::NoTls),
347                    )
348                    .await
349                    .map_err(|_| timeout_error(CONNECT_TIMEOUT))?
350                    .map_err(|error| {
351                        ConnectorError::ConnectionFailed(format!(
352                            "postgres reference connect: {error}"
353                        ))
354                    })?;
355                    Ok(PostgresSession {
356                        client,
357                        connection: spawn_connection(connection),
358                    })
359                })
360                .await?
361            }
362            crate::postgres::SslMode::VerifyFull => {
363                let tls = crate::postgres::make_rustls_connector(ca_path)?;
364                await_reference_driver("TLS connect", async move {
365                    let (client, connection) =
366                        tokio::time::timeout(CONNECT_TIMEOUT, pg_config.connect(tls))
367                            .await
368                            .map_err(|_| timeout_error(CONNECT_TIMEOUT))?
369                            .map_err(|error| {
370                                ConnectorError::ConnectionFailed(format!(
371                                    "postgres reference verified TLS connect: {error}"
372                                ))
373                            })?;
374                    Ok(PostgresSession {
375                        client,
376                        connection: spawn_connection(connection),
377                    })
378                })
379                .await?
380            }
381        };
382
383        let session = owned_batch_execute(
384            session,
385            "BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY".into(),
386            "begin snapshot",
387        )
388        .await?;
389        let (session, probe) =
390            owned_prepare(session, probe_query, "prepare snapshot probe").await?;
391        let query = self.projected_snapshot_query(probe.columns())?;
392        let (session, statement) =
393            owned_prepare(session, query.clone(), "prepare snapshot projection").await?;
394        validate_postgres_schema(statement.columns(), self.declared_schema.as_ref())?;
395        let declare = format!("DECLARE {SNAPSHOT_CURSOR} NO SCROLL CURSOR FOR {query}");
396        let session = owned_batch_execute(session, declare, "declare snapshot cursor").await?;
397
398        self.session = Some(session);
399        self.state = State::Reading;
400        Ok(())
401    }
402
403    async fn read_snapshot_batch(&mut self) -> Result<Option<RecordBatch>, ConnectorError> {
404        let session = self.session.take().ok_or_else(|| {
405            ConnectorError::Internal("postgres reference session is not initialized".into())
406        })?;
407        let fetch = format!("FETCH FORWARD {SNAPSHOT_ROWS_PER_BATCH} FROM {SNAPSHOT_CURSOR}");
408        let (session, rows) = owned_query(session, fetch, "fetch snapshot cursor").await?;
409        self.session = Some(session);
410        let exhausted = rows.len() < SNAPSHOT_ROWS_PER_BATCH;
411
412        if rows.is_empty() {
413            debug_assert!(exhausted);
414            self.finish_snapshot().await?;
415            return Ok(None);
416        }
417
418        let columns = self
419            .declared_schema
420            .fields()
421            .iter()
422            .enumerate()
423            .map(|(index, field)| build_column(&rows, index, field.data_type()))
424            .collect::<Result<Vec<_>, _>>()?;
425        validate_reference_columns(self.declared_schema.as_ref(), &columns)?;
426        enforce_snapshot_batch_bytes(&columns)?;
427        let batch =
428            RecordBatch::try_new(self.declared_schema.clone(), columns).map_err(|error| {
429                ConnectorError::ReadError(format!(
430                    "postgres snapshot does not satisfy the declared schema: {error}"
431                ))
432            })?;
433        if exhausted {
434            self.finish_snapshot().await?;
435        }
436        Ok(Some(batch))
437    }
438
439    async fn finish_snapshot(&mut self) -> Result<(), ConnectorError> {
440        let session = self.session.take().ok_or_else(|| {
441            ConnectorError::Internal("postgres reference session is not initialized".into())
442        })?;
443        let _session = owned_batch_execute(session, "COMMIT".into(), "finish snapshot").await?;
444        self.state = State::Done;
445        Ok(())
446    }
447
448    fn fail_closed(&mut self) {
449        self.session = None;
450        self.state = State::Closed;
451    }
452}
453
454#[async_trait::async_trait]
455impl ReferenceTableSource for PostgresReferenceTableSource {
456    async fn poll_snapshot(&mut self) -> Result<Option<RecordBatch>, ConnectorError> {
457        match self.state {
458            State::Done => return Ok(None),
459            State::Closed => {
460                return Err(ConnectorError::InvalidState {
461                    expected: "open reference snapshot source".into(),
462                    actual: "closed".into(),
463                });
464            }
465            State::Ready | State::Reading => {}
466        }
467
468        let result = async {
469            if self.state == State::Ready {
470                self.start_snapshot().await?;
471            }
472            self.read_snapshot_batch().await
473        }
474        .await;
475        if result.is_err() {
476            self.fail_closed();
477        }
478        result
479    }
480
481    async fn close(&mut self) -> Result<(), ConnectorError> {
482        let session = if self.state == State::Reading {
483            self.session.take()
484        } else {
485            None
486        };
487        // Publish the terminal local state before awaiting. Cancellation leaves the driver task
488        // owning the connection and this source closed instead of reusable with an in-flight query.
489        self.fail_closed();
490        if let Some(session) = session {
491            let _session =
492                owned_batch_execute(session, "ROLLBACK".into(), "close snapshot transaction")
493                    .await?;
494        }
495        Ok(())
496    }
497}
498
499fn quote_qualified_identifier(identifier: &str) -> Result<String, ConnectorError> {
500    identifier
501        .split('.')
502        .map(quote_identifier)
503        .collect::<Result<Vec<_>, _>>()
504        .map(|parts| parts.join("."))
505}
506
507fn quote_identifier(identifier: &str) -> Result<String, ConnectorError> {
508    if identifier.is_empty() || identifier.contains('\0') {
509        return Err(ConnectorError::ConfigurationError(
510            "postgres identifiers must be non-empty and cannot contain NUL".into(),
511        ));
512    }
513    Ok(format!("\"{}\"", identifier.replace('"', "\"\"")))
514}
515
516fn validate_reference_columns(
517    schema: &Schema,
518    columns: &[Arc<dyn Array>],
519) -> Result<(), ConnectorError> {
520    for (field, column) in schema.fields().iter().zip(columns) {
521        if !field.is_nullable() && column.null_count() != 0 {
522            return Err(ConnectorError::ReadError(format!(
523                "postgres reference column '{}' contains {} null values but is declared NOT NULL",
524                field.name(),
525                column.null_count()
526            )));
527        }
528    }
529    Ok(())
530}
531
532fn enforce_snapshot_batch_bytes(columns: &[Arc<dyn Array>]) -> Result<(), ConnectorError> {
533    enforce_snapshot_batch_bytes_with_limit(columns, MAX_SNAPSHOT_BATCH_BYTES)
534}
535
536fn enforce_snapshot_batch_bytes_with_limit(
537    columns: &[Arc<dyn Array>],
538    limit: usize,
539) -> Result<(), ConnectorError> {
540    let bytes = columns.iter().try_fold(0_usize, |total, column| {
541        total
542            .checked_add(column.get_array_memory_size())
543            .ok_or_else(|| {
544                ConnectorError::ReadError("postgres snapshot byte count overflow".into())
545            })
546    })?;
547    if bytes > limit {
548        return Err(ConnectorError::ReadError(format!(
549            "postgres snapshot batch retains {bytes} bytes, exceeding the fixed {limit}-byte limit"
550        )));
551    }
552    Ok(())
553}
554
555fn reference_select_expression(
556    name: &str,
557    pg_type: &tokio_postgres::types::Type,
558    declared_type: &DataType,
559) -> Result<String, ConnectorError> {
560    let identifier = quote_identifier(name)?;
561    match postgres_type_to_arrow(pg_type) {
562        Some(source_type) if &source_type == declared_type => Ok(identifier),
563        None if declared_type == &DataType::Utf8 => {
564            Ok(format!("CAST({identifier} AS TEXT) AS {identifier}"))
565        }
566        Some(source_type) => Err(ConnectorError::ReadError(format!(
567            "postgres column '{name}' has type {source_type}, expected {declared_type}"
568        ))),
569        None => Err(ConnectorError::ReadError(format!(
570            "postgres column '{name}' has unsupported type {pg_type}"
571        ))),
572    }
573}
574
575fn validate_postgres_schema(
576    columns: &[tokio_postgres::Column],
577    declared_schema: &Schema,
578) -> Result<(), ConnectorError> {
579    if columns.len() != declared_schema.fields().len() {
580        return Err(ConnectorError::ReadError(format!(
581            "postgres snapshot has {} columns but {} were declared",
582            columns.len(),
583            declared_schema.fields().len()
584        )));
585    }
586
587    for (column, declared) in columns.iter().zip(declared_schema.fields()) {
588        let source_type = postgres_type_to_arrow(column.type_()).ok_or_else(|| {
589            ConnectorError::ReadError(format!(
590                "postgres column '{}' has unsupported type {}",
591                column.name(),
592                column.type_()
593            ))
594        })?;
595        if column.name() != declared.name() || &source_type != declared.data_type() {
596            return Err(ConnectorError::ReadError(format!(
597                "postgres column '{}' has type {source_type}, expected declared column '{}' with type {}",
598                column.name(),
599                declared.name(),
600                declared.data_type()
601            )));
602        }
603    }
604    Ok(())
605}
606
607fn build_column(
608    rows: &[tokio_postgres::Row],
609    index: usize,
610    data_type: &DataType,
611) -> Result<Arc<dyn Array>, ConnectorError> {
612    macro_rules! primitive {
613        ($native:ty, $array:ty) => {{
614            let values: Vec<Option<$native>> = collect_column(rows, index)?;
615            Arc::new(<$array>::from(values)) as Arc<dyn Array>
616        }};
617    }
618
619    let column = match data_type {
620        DataType::Boolean => primitive!(bool, arrow_array::BooleanArray),
621        DataType::Int16 => primitive!(i16, arrow_array::Int16Array),
622        DataType::Int32 => primitive!(i32, arrow_array::Int32Array),
623        DataType::Int64 => primitive!(i64, arrow_array::Int64Array),
624        DataType::Float32 => primitive!(f32, arrow_array::Float32Array),
625        DataType::Float64 => primitive!(f64, arrow_array::Float64Array),
626        DataType::Utf8 => {
627            let values: Vec<Option<String>> = collect_column(rows, index)?;
628            let values = values.iter().map(Option::as_deref).collect::<Vec<_>>();
629            Arc::new(arrow_array::StringArray::from(values))
630        }
631        DataType::Binary => {
632            let values: Vec<Option<Vec<u8>>> = collect_column(rows, index)?;
633            let values = values
634                .iter()
635                .map(|value| value.as_deref())
636                .collect::<Vec<_>>();
637            Arc::new(arrow_array::BinaryArray::from(values))
638        }
639        DataType::Date32 => {
640            let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch");
641            let values: Vec<Option<chrono::NaiveDate>> = collect_column(rows, index)?;
642            let values = values
643                .into_iter()
644                .map(|value| {
645                    value
646                        .map(|date| {
647                            i32::try_from(date.signed_duration_since(epoch).num_days()).map_err(
648                                |_| {
649                                    ConnectorError::ReadError(
650                                        "postgres date is outside the Arrow Date32 range".into(),
651                                    )
652                                },
653                            )
654                        })
655                        .transpose()
656                })
657                .collect::<Result<Vec<_>, _>>()?;
658            Arc::new(arrow_array::Date32Array::from(values))
659        }
660        DataType::Timestamp(TimeUnit::Microsecond, None) => {
661            let values: Vec<Option<chrono::NaiveDateTime>> = collect_column(rows, index)?;
662            let values = values
663                .into_iter()
664                .map(|value| value.map(|timestamp| timestamp.and_utc().timestamp_micros()))
665                .collect::<Vec<_>>();
666            Arc::new(arrow_array::TimestampMicrosecondArray::from(values))
667        }
668        DataType::Timestamp(TimeUnit::Microsecond, Some(timezone))
669            if timezone.as_ref() == "UTC" =>
670        {
671            let values: Vec<Option<chrono::DateTime<chrono::Utc>>> = collect_column(rows, index)?;
672            let values = values
673                .into_iter()
674                .map(|value| value.map(|timestamp| timestamp.timestamp_micros()))
675                .collect::<Vec<_>>();
676            Arc::new(arrow_array::TimestampMicrosecondArray::from(values).with_timezone("UTC"))
677        }
678        unsupported => {
679            return Err(ConnectorError::ReadError(format!(
680                "declared PostgreSQL reference type {unsupported} is not supported"
681            )));
682        }
683    };
684    Ok(column)
685}
686
687fn collect_column<'a, T>(
688    rows: &'a [tokio_postgres::Row],
689    index: usize,
690) -> Result<Vec<Option<T>>, ConnectorError>
691where
692    T: tokio_postgres::types::FromSql<'a>,
693{
694    rows.iter()
695        .map(|row| {
696            row.try_get::<_, Option<T>>(index).map_err(|error| {
697                ConnectorError::ReadError(format!(
698                    "postgres column {index} could not be decoded: {error}"
699                ))
700            })
701        })
702        .collect()
703}
704
705fn postgres_type_to_arrow(pg_type: &tokio_postgres::types::Type) -> Option<DataType> {
706    use tokio_postgres::types::Type;
707
708    match *pg_type {
709        Type::BOOL => Some(DataType::Boolean),
710        Type::INT2 => Some(DataType::Int16),
711        Type::INT4 => Some(DataType::Int32),
712        Type::INT8 => Some(DataType::Int64),
713        Type::FLOAT4 => Some(DataType::Float32),
714        Type::FLOAT8 => Some(DataType::Float64),
715        Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => Some(DataType::Utf8),
716        Type::BYTEA => Some(DataType::Binary),
717        Type::DATE => Some(DataType::Date32),
718        Type::TIMESTAMP => Some(DataType::Timestamp(TimeUnit::Microsecond, None)),
719        Type::TIMESTAMPTZ => Some(DataType::Timestamp(
720            TimeUnit::Microsecond,
721            Some("UTC".into()),
722        )),
723        _ => None,
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use arrow_schema::Field;
730
731    use super::*;
732
733    fn declared_schema() -> SchemaRef {
734        Arc::new(Schema::new(vec![
735            Field::new("id", DataType::Int64, false),
736            Field::new("name", DataType::Utf8, true),
737        ]))
738    }
739
740    #[test]
741    fn query_uses_declared_field_order_and_quotes_identifiers() {
742        let mut config = ConnectorConfig::new("postgres");
743        config.set("table", "public.order");
744        let source = PostgresReferenceTableSource::new(config, declared_schema());
745
746        assert_eq!(
747            source.snapshot_query().unwrap(),
748            "SELECT \"id\", \"name\" FROM \"public\".\"order\""
749        );
750        assert_eq!(source.declared_schema.field(0).name(), "id");
751        assert!(!source.declared_schema.field(0).is_nullable());
752    }
753
754    #[test]
755    fn tls_is_verified_by_default_and_plaintext_is_explicit() {
756        let source =
757            PostgresReferenceTableSource::new(ConnectorConfig::new("postgres"), declared_schema());
758        assert_eq!(
759            source.ssl_mode().unwrap(),
760            crate::postgres::SslMode::VerifyFull
761        );
762        assert_eq!(
763            source.postgres_config().unwrap().get_ssl_mode(),
764            tokio_postgres::config::SslMode::Require
765        );
766
767        let mut config = ConnectorConfig::new("postgres");
768        config.set(
769            "connection",
770            "postgresql://user@localhost/db?sslmode=disable",
771        );
772        let source = PostgresReferenceTableSource::new(config, declared_schema());
773        assert_eq!(
774            source.postgres_config().unwrap().get_ssl_mode(),
775            tokio_postgres::config::SslMode::Require
776        );
777
778        let mut config = ConnectorConfig::new("postgres");
779        config.set("ssl.mode", "disable");
780        let source = PostgresReferenceTableSource::new(config, declared_schema());
781        assert_eq!(
782            source.ssl_mode().unwrap(),
783            crate::postgres::SslMode::Disable
784        );
785        assert_eq!(
786            source.postgres_config().unwrap().get_ssl_mode(),
787            tokio_postgres::config::SslMode::Disable
788        );
789
790        let mut config = ConnectorConfig::new("postgres");
791        config.set("ssl.mode", "require");
792        let source = PostgresReferenceTableSource::new(config, declared_schema());
793        assert!(source.ssl_mode().is_err());
794    }
795
796    #[test]
797    fn connection_options_fail_closed() {
798        for (key, value) in [
799            ("port", "not-a-port"),
800            ("port", "0"),
801            ("options", "bad\0option"),
802            ("host", " "),
803            ("connection", ""),
804        ] {
805            let mut config = ConnectorConfig::new("postgres");
806            config.set(key, value);
807            let source = PostgresReferenceTableSource::new(config, declared_schema());
808            assert!(source.postgres_config().is_err());
809        }
810
811        let mut config = ConnectorConfig::new("postgres");
812        config.set("connection", "host=localhost dbname=db user=user");
813        config.set("host", "other");
814        let source = PostgresReferenceTableSource::new(config, declared_schema());
815        assert!(source.postgres_config().is_err());
816
817        let mut config = ConnectorConfig::new("postgres");
818        config.set("connection", "host=localhost port=0 dbname=db user=user");
819        let source = PostgresReferenceTableSource::new(config, declared_schema());
820        assert!(source.postgres_config().is_err());
821
822        let mut config = ConnectorConfig::new("postgres");
823        config.set("ssl.mode", "disable");
824        config.set("ssl.ca.cert.path", "/certs/ca.pem");
825        let source = PostgresReferenceTableSource::new(config, declared_schema());
826        assert!(source.postgres_config().is_err());
827    }
828
829    #[test]
830    fn snapshot_batch_memory_is_bounded() {
831        let column: Arc<dyn Array> = Arc::new(arrow_array::StringArray::from(vec!["payload"]));
832        let bytes = column.get_array_memory_size();
833        enforce_snapshot_batch_bytes_with_limit(&[Arc::clone(&column)], bytes).unwrap();
834        assert!(enforce_snapshot_batch_bytes_with_limit(&[column], bytes - 1).is_err());
835    }
836
837    #[test]
838    fn supported_postgres_types_have_explicit_arrow_mappings() {
839        use tokio_postgres::types::Type;
840
841        assert_eq!(postgres_type_to_arrow(&Type::BYTEA), Some(DataType::Binary));
842        assert_eq!(postgres_type_to_arrow(&Type::DATE), Some(DataType::Date32));
843        assert_eq!(postgres_type_to_arrow(&Type::NUMERIC), None);
844        assert_eq!(
845            reference_select_expression("amount", &Type::NUMERIC, &DataType::Utf8).unwrap(),
846            "CAST(\"amount\" AS TEXT) AS \"amount\""
847        );
848        assert!(reference_select_expression("amount", &Type::NUMERIC, &DataType::Float64).is_err());
849        assert_eq!(
850            reference_select_expression("id", &Type::INT8, &DataType::Int64).unwrap(),
851            "\"id\""
852        );
853    }
854
855    #[tokio::test]
856    async fn close_is_idempotent_and_prevents_reads() {
857        let mut source =
858            PostgresReferenceTableSource::new(ConnectorConfig::new("postgres"), declared_schema());
859        source.close().await.unwrap();
860        source.close().await.unwrap();
861        assert!(source.poll_snapshot().await.is_err());
862    }
863}