Skip to main content

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