1use 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
121pub struct PostgresReferenceTableSource {
123 config: ConnectorConfig,
124 declared_schema: SchemaRef,
125 state: State,
126 session: Option<PostgresSession>,
127}
128
129impl PostgresReferenceTableSource {
130 #[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 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}