Skip to main content

laminar_connectors/postgres/
types.rs

1//! Validated Arrow-to-PostgreSQL type contract for the sink.
2
3use arrow_schema::{DataType, TimeUnit};
4
5use crate::error::ConnectorError;
6
7/// SQL spellings used by COPY table DDL and typed UNNEST parameters.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub(super) struct PostgresType {
10    sql: &'static str,
11    ddl: &'static str,
12}
13
14impl PostgresType {
15    #[must_use]
16    pub(super) const fn sql(self) -> &'static str {
17        self.sql
18    }
19
20    #[must_use]
21    pub(super) const fn ddl(self) -> &'static str {
22        self.ddl
23    }
24}
25
26/// Returns the single supported type mapping used by admission, DDL, COPY, and UNNEST.
27///
28/// The surface is intentionally the intersection of the COPY encoder and the Rust `PostgreSQL`
29/// parameter encoder. Types are added only when both write paths have the same lossless contract.
30pub(super) fn postgres_type(data_type: &DataType) -> Result<PostgresType, ConnectorError> {
31    let mapping = match data_type {
32        DataType::Boolean => PostgresType {
33            sql: "bool",
34            ddl: "BOOLEAN",
35        },
36        DataType::Int8 | DataType::UInt8 | DataType::Int16 => PostgresType {
37            sql: "int2",
38            ddl: "SMALLINT",
39        },
40        DataType::UInt16 | DataType::Int32 => PostgresType {
41            sql: "int4",
42            ddl: "INTEGER",
43        },
44        DataType::UInt32 | DataType::Int64 | DataType::UInt64 => PostgresType {
45            sql: "int8",
46            ddl: "BIGINT",
47        },
48        DataType::Float32 => PostgresType {
49            sql: "float4",
50            ddl: "REAL",
51        },
52        DataType::Float64 => PostgresType {
53            sql: "float8",
54            ddl: "DOUBLE PRECISION",
55        },
56        DataType::Utf8 | DataType::LargeUtf8 => PostgresType {
57            sql: "text",
58            ddl: "TEXT",
59        },
60        DataType::Binary | DataType::LargeBinary => PostgresType {
61            sql: "bytea",
62            ddl: "BYTEA",
63        },
64        DataType::Date32 => PostgresType {
65            sql: "date",
66            ddl: "DATE",
67        },
68        DataType::Timestamp(
69            TimeUnit::Second | TimeUnit::Millisecond | TimeUnit::Microsecond,
70            None,
71        ) => PostgresType {
72            sql: "timestamp",
73            ddl: "TIMESTAMP",
74        },
75        DataType::Timestamp(
76            TimeUnit::Second | TimeUnit::Millisecond | TimeUnit::Microsecond,
77            Some(_),
78        ) => PostgresType {
79            sql: "timestamptz",
80            ddl: "TIMESTAMPTZ",
81        },
82        unsupported => {
83            return Err(ConnectorError::ConfigurationError(format!(
84                "PostgreSQL sink does not support Arrow type {unsupported:?}; supported types are \
85                 Boolean, signed integers, UInt8/16/32, range-checked UInt64, Float32/64, \
86                 Utf8/LargeUtf8, Binary/LargeBinary, Date32, and second/millisecond/microsecond \
87                 Timestamp"
88            )));
89        }
90    };
91    Ok(mapping)
92}
93
94/// `PostgreSQL` type used in an UNNEST cast.
95pub(super) fn arrow_type_to_pg_sql(data_type: &DataType) -> Result<&'static str, ConnectorError> {
96    postgres_type(data_type).map(PostgresType::sql)
97}
98
99/// `PostgreSQL` type used in generated CREATE TABLE DDL.
100pub(super) fn arrow_to_pg_ddl_type(data_type: &DataType) -> Result<&'static str, ConnectorError> {
101    postgres_type(data_type).map(PostgresType::ddl)
102}
103
104/// Typed `PostgreSQL` array parameter used by an UNNEST statement.
105pub(super) fn arrow_type_to_pg_array_cast(
106    data_type: &DataType,
107    parameter: usize,
108) -> Result<String, ConnectorError> {
109    Ok(format!(
110        "${parameter}::{}[]",
111        arrow_type_to_pg_sql(data_type)?
112    ))
113}
114
115#[cfg(feature = "postgres-sink")]
116fn checked_u64(value: u64, row: usize) -> Result<i64, ConnectorError> {
117    i64::try_from(value).map_err(|_| {
118        ConnectorError::SchemaMismatch(format!(
119            "PostgreSQL BIGINT cannot represent UInt64 value {value} at row {row}"
120        ))
121    })
122}
123
124#[cfg(feature = "postgres-sink")]
125fn checked_date32(value: i32, row: usize) -> Result<chrono::NaiveDate, ConnectorError> {
126    let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid Unix epoch");
127    epoch
128        .checked_add_signed(chrono::Duration::days(i64::from(value)))
129        .ok_or_else(|| {
130            ConnectorError::SchemaMismatch(format!(
131                "PostgreSQL sink cannot represent Date32 value {value} at row {row}"
132            ))
133        })
134}
135
136/// Validates range-sensitive values before a batch is admitted to the sink buffer.
137#[cfg(feature = "postgres-sink")]
138pub(super) fn validate_postgres_array_values(
139    array: &dyn arrow_array::Array,
140) -> Result<(), ConnectorError> {
141    use arrow_array::{Array as _, Date32Array, UInt64Array};
142
143    postgres_type(array.data_type())?;
144    match array.data_type() {
145        DataType::UInt64 => {
146            let values = array
147                .as_any()
148                .downcast_ref::<UInt64Array>()
149                .ok_or_else(|| ConnectorError::Internal("downcast to UInt64Array failed".into()))?;
150            for row in 0..values.len() {
151                if !values.is_null(row) {
152                    checked_u64(values.value(row), row)?;
153                }
154            }
155        }
156        DataType::Date32 => {
157            let values = array
158                .as_any()
159                .downcast_ref::<Date32Array>()
160                .ok_or_else(|| ConnectorError::Internal("downcast to Date32Array failed".into()))?;
161            for row in 0..values.len() {
162                if !values.is_null(row) {
163                    checked_date32(values.value(row), row)?;
164                }
165            }
166        }
167        DataType::Timestamp(unit, _) => validate_timestamp_values(array, *unit)?,
168        _ => {}
169    }
170    Ok(())
171}
172
173/// Produces the batch schema expected by COPY BINARY.
174///
175/// `pgpq` encodes Arrow `UInt64` as `PostgreSQL` NUMERIC. The sink deliberately exposes `UInt64` as a
176/// range-checked BIGINT so COPY and UNNEST have identical table types; values are therefore widened
177/// to an Int64 Arrow column after validation and before the COPY encoder is constructed.
178#[cfg(feature = "postgres-sink")]
179pub(super) fn postgres_copy_batch(
180    batch: &arrow_array::RecordBatch,
181) -> Result<arrow_array::RecordBatch, ConnectorError> {
182    use std::sync::Arc;
183
184    use arrow_array::{Array as _, ArrayRef, Int64Array, UInt64Array};
185    use arrow_schema::Schema;
186
187    let mut changed = false;
188    let mut fields = Vec::with_capacity(batch.num_columns());
189    let mut columns: Vec<ArrayRef> = Vec::with_capacity(batch.num_columns());
190    for (field, column) in batch.schema().fields().iter().zip(batch.columns()) {
191        postgres_type(column.data_type())?;
192        if field.data_type() == &DataType::UInt64 {
193            let values = column
194                .as_any()
195                .downcast_ref::<UInt64Array>()
196                .ok_or_else(|| ConnectorError::Internal("downcast to UInt64Array failed".into()))?;
197            let converted = (0..values.len())
198                .map(|row| {
199                    if values.is_null(row) {
200                        Ok(None)
201                    } else {
202                        checked_u64(values.value(row), row).map(Some)
203                    }
204                })
205                .collect::<Result<Vec<Option<i64>>, ConnectorError>>()?;
206            fields.push(Arc::new(
207                field.as_ref().clone().with_data_type(DataType::Int64),
208            ));
209            columns.push(Arc::new(Int64Array::from(converted)));
210            changed = true;
211        } else {
212            fields.push(field.clone());
213            columns.push(column.clone());
214        }
215    }
216
217    if changed {
218        arrow_array::RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).map_err(|error| {
219            ConnectorError::Internal(format!("build PostgreSQL COPY batch: {error}"))
220        })
221    } else {
222        Ok(batch.clone())
223    }
224}
225
226#[cfg(feature = "postgres-sink")]
227fn validate_timestamp_values(
228    array: &dyn arrow_array::Array,
229    unit: TimeUnit,
230) -> Result<(), ConnectorError> {
231    use arrow_array::{
232        Array as _, TimestampMicrosecondArray, TimestampMillisecondArray, TimestampSecondArray,
233    };
234
235    macro_rules! validate {
236        ($array_type:ty) => {{
237            let values = array
238                .as_any()
239                .downcast_ref::<$array_type>()
240                .ok_or_else(|| {
241                    ConnectorError::Internal(format!(
242                        "timestamp array does not match declared unit {unit:?}"
243                    ))
244                })?;
245            for row in 0..values.len() {
246                if !values.is_null(row) && to_naive_datetime(values.value(row), unit).is_none() {
247                    return Err(ConnectorError::SchemaMismatch(format!(
248                        "PostgreSQL sink cannot represent {unit:?} timestamp value {} at row {row}",
249                        values.value(row)
250                    )));
251                }
252            }
253        }};
254    }
255
256    match unit {
257        TimeUnit::Second => validate!(TimestampSecondArray),
258        TimeUnit::Millisecond => validate!(TimestampMillisecondArray),
259        TimeUnit::Microsecond => validate!(TimestampMicrosecondArray),
260        TimeUnit::Nanosecond => unreachable!("nanosecond timestamps fail type admission"),
261    }
262    Ok(())
263}
264
265/// Converts an Arrow column to a `PostgreSQL` array parameter for UNNEST.
266#[cfg(feature = "postgres-sink")]
267pub(super) fn arrow_column_to_pg_array(
268    array: &dyn arrow_array::Array,
269) -> Result<Box<dyn postgres_types::ToSql + Sync + Send>, ConnectorError> {
270    use arrow_array::{
271        Array as _, BinaryArray, BooleanArray, Date32Array, Float32Array, Float64Array, Int16Array,
272        Int32Array, Int64Array, Int8Array, LargeBinaryArray, LargeStringArray, StringArray,
273        TimestampMicrosecondArray, TimestampMillisecondArray, TimestampSecondArray, UInt16Array,
274        UInt32Array, UInt64Array, UInt8Array,
275    };
276
277    postgres_type(array.data_type())?;
278
279    macro_rules! widen {
280        ($array_type:ty, $target:ty) => {{
281            let values = array
282                .as_any()
283                .downcast_ref::<$array_type>()
284                .ok_or_else(|| {
285                    ConnectorError::Internal(format!(
286                        "downcast to {} failed",
287                        stringify!($array_type)
288                    ))
289                })?;
290            let converted: Vec<Option<$target>> = (0..values.len())
291                .map(|row| (!values.is_null(row)).then(|| <$target>::from(values.value(row))))
292                .collect();
293            Ok(Box::new(converted))
294        }};
295    }
296
297    match array.data_type() {
298        DataType::Boolean => {
299            let values = array
300                .as_any()
301                .downcast_ref::<BooleanArray>()
302                .ok_or_else(|| {
303                    ConnectorError::Internal("downcast to BooleanArray failed".into())
304                })?;
305            Ok(Box::new(
306                (0..values.len())
307                    .map(|row| (!values.is_null(row)).then(|| values.value(row)))
308                    .collect::<Vec<Option<bool>>>(),
309            ))
310        }
311        DataType::Int8 => widen!(Int8Array, i16),
312        DataType::UInt8 => widen!(UInt8Array, i16),
313        DataType::Int16 => widen!(Int16Array, i16),
314        DataType::UInt16 => widen!(UInt16Array, i32),
315        DataType::Int32 => widen!(Int32Array, i32),
316        DataType::UInt32 => widen!(UInt32Array, i64),
317        DataType::Int64 => widen!(Int64Array, i64),
318        DataType::UInt64 => {
319            let values = array
320                .as_any()
321                .downcast_ref::<UInt64Array>()
322                .ok_or_else(|| ConnectorError::Internal("downcast to UInt64Array failed".into()))?;
323            let converted = (0..values.len())
324                .map(|row| {
325                    if values.is_null(row) {
326                        Ok(None)
327                    } else {
328                        checked_u64(values.value(row), row).map(Some)
329                    }
330                })
331                .collect::<Result<Vec<Option<i64>>, ConnectorError>>()?;
332            Ok(Box::new(converted))
333        }
334        DataType::Float32 => widen!(Float32Array, f32),
335        DataType::Float64 => widen!(Float64Array, f64),
336        DataType::Utf8 => {
337            let values = array
338                .as_any()
339                .downcast_ref::<StringArray>()
340                .ok_or_else(|| ConnectorError::Internal("downcast to StringArray failed".into()))?;
341            Ok(Box::new(
342                (0..values.len())
343                    .map(|row| (!values.is_null(row)).then(|| values.value(row).to_owned()))
344                    .collect::<Vec<Option<String>>>(),
345            ))
346        }
347        DataType::LargeUtf8 => {
348            let values = array
349                .as_any()
350                .downcast_ref::<LargeStringArray>()
351                .ok_or_else(|| {
352                    ConnectorError::Internal("downcast to LargeStringArray failed".into())
353                })?;
354            Ok(Box::new(
355                (0..values.len())
356                    .map(|row| (!values.is_null(row)).then(|| values.value(row).to_owned()))
357                    .collect::<Vec<Option<String>>>(),
358            ))
359        }
360        DataType::Binary => {
361            let values = array
362                .as_any()
363                .downcast_ref::<BinaryArray>()
364                .ok_or_else(|| ConnectorError::Internal("downcast to BinaryArray failed".into()))?;
365            Ok(Box::new(
366                (0..values.len())
367                    .map(|row| (!values.is_null(row)).then(|| values.value(row).to_vec()))
368                    .collect::<Vec<Option<Vec<u8>>>>(),
369            ))
370        }
371        DataType::LargeBinary => {
372            let values = array
373                .as_any()
374                .downcast_ref::<LargeBinaryArray>()
375                .ok_or_else(|| {
376                    ConnectorError::Internal("downcast to LargeBinaryArray failed".into())
377                })?;
378            Ok(Box::new(
379                (0..values.len())
380                    .map(|row| (!values.is_null(row)).then(|| values.value(row).to_vec()))
381                    .collect::<Vec<Option<Vec<u8>>>>(),
382            ))
383        }
384        DataType::Date32 => {
385            let values = array
386                .as_any()
387                .downcast_ref::<Date32Array>()
388                .ok_or_else(|| ConnectorError::Internal("downcast to Date32Array failed".into()))?;
389            let converted = (0..values.len())
390                .map(|row| {
391                    if values.is_null(row) {
392                        Ok(None)
393                    } else {
394                        checked_date32(values.value(row), row).map(Some)
395                    }
396                })
397                .collect::<Result<Vec<Option<chrono::NaiveDate>>, ConnectorError>>()?;
398            Ok(Box::new(converted))
399        }
400        DataType::Timestamp(unit, timezone) => {
401            macro_rules! timestamps {
402                ($array_type:ty) => {{
403                    let values = array
404                        .as_any()
405                        .downcast_ref::<$array_type>()
406                        .ok_or_else(|| {
407                            ConnectorError::Internal(format!(
408                                "timestamp array does not match declared unit {unit:?}"
409                            ))
410                        })?;
411                    (0..values.len())
412                        .map(|row| {
413                            if values.is_null(row) {
414                                Ok(None)
415                            } else {
416                                to_naive_datetime(values.value(row), *unit)
417                                    .map(Some)
418                                    .ok_or_else(|| {
419                                        ConnectorError::SchemaMismatch(format!(
420                                            "PostgreSQL sink cannot represent {unit:?} timestamp \
421                                             value {} at row {row}",
422                                            values.value(row)
423                                        ))
424                                    })
425                            }
426                        })
427                        .collect::<Result<Vec<Option<chrono::NaiveDateTime>>, ConnectorError>>()?
428                }};
429            }
430
431            let values = match unit {
432                TimeUnit::Second => timestamps!(TimestampSecondArray),
433                TimeUnit::Millisecond => timestamps!(TimestampMillisecondArray),
434                TimeUnit::Microsecond => timestamps!(TimestampMicrosecondArray),
435                TimeUnit::Nanosecond => unreachable!("nanosecond timestamps fail type admission"),
436            };
437            if timezone.is_some() {
438                Ok(Box::new(
439                    values
440                        .into_iter()
441                        .map(|value| value.map(|timestamp| timestamp.and_utc()))
442                        .collect::<Vec<Option<chrono::DateTime<chrono::Utc>>>>(),
443                ))
444            } else {
445                Ok(Box::new(values))
446            }
447        }
448        unsupported => Err(ConnectorError::ConfigurationError(format!(
449            "PostgreSQL sink does not support Arrow type {unsupported:?}"
450        ))),
451    }
452}
453
454/// Converts a Unix timestamp using Euclidean division so negative fractional values retain their
455/// correct instant (for example, -1 ms is 1969-12-31 23:59:59.999, not one second late).
456#[cfg(feature = "postgres-sink")]
457fn to_naive_datetime(value: i64, unit: TimeUnit) -> Option<chrono::NaiveDateTime> {
458    let (units_per_second, nanos_per_unit) = match unit {
459        TimeUnit::Second => (1_i64, 1_000_000_000_u32),
460        TimeUnit::Millisecond => (1_000, 1_000_000),
461        TimeUnit::Microsecond => (1_000_000, 1_000),
462        TimeUnit::Nanosecond => (1_000_000_000, 1),
463    };
464    let seconds = value.div_euclid(units_per_second);
465    let fraction = u32::try_from(value.rem_euclid(units_per_second)).ok()?;
466    let nanos = fraction.checked_mul(nanos_per_unit)?;
467    chrono::DateTime::from_timestamp(seconds, nanos).map(|timestamp| timestamp.naive_utc())
468}
469
470#[cfg(test)]
471mod tests {
472    use std::sync::Arc;
473
474    use super::*;
475
476    #[test]
477    fn mappings_match_parameter_rust_types() {
478        assert_eq!(arrow_type_to_pg_sql(&DataType::UInt16).unwrap(), "int4");
479        assert_eq!(arrow_type_to_pg_sql(&DataType::UInt32).unwrap(), "int8");
480        assert_eq!(
481            arrow_to_pg_ddl_type(&DataType::Float64).unwrap(),
482            "DOUBLE PRECISION"
483        );
484        assert_eq!(
485            arrow_type_to_pg_array_cast(&DataType::Timestamp(TimeUnit::Microsecond, None), 2)
486                .unwrap(),
487            "$2::timestamp[]"
488        );
489    }
490
491    #[test]
492    fn unsupported_types_never_fall_back_to_text() {
493        let list = DataType::List(Arc::new(arrow_schema::Field::new(
494            "item",
495            DataType::Int32,
496            true,
497        )));
498        assert!(postgres_type(&list).is_err());
499        assert!(postgres_type(&DataType::FixedSizeBinary(16)).is_err());
500        assert!(postgres_type(&DataType::Timestamp(TimeUnit::Nanosecond, None)).is_err());
501    }
502
503    #[cfg(feature = "postgres-sink")]
504    #[test]
505    fn uint64_above_bigint_range_is_an_error() {
506        let values = arrow_array::UInt64Array::from(vec![u64::MAX]);
507        let error = validate_postgres_array_values(&values).unwrap_err();
508        assert!(!error.is_transient());
509        let error = error.to_string();
510        assert!(
511            error.contains("BIGINT") && error.contains(&u64::MAX.to_string()),
512            "{error}"
513        );
514    }
515
516    #[cfg(feature = "postgres-sink")]
517    #[test]
518    fn copy_uint64_uses_the_same_bigint_wire_type_as_unnest() {
519        use arrow_array::Array as _;
520
521        let schema = Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
522            "value",
523            DataType::UInt64,
524            false,
525        )]));
526        let batch = arrow_array::RecordBatch::try_new(
527            schema,
528            vec![Arc::new(arrow_array::UInt64Array::from(vec![7]))],
529        )
530        .unwrap();
531        let normalized = postgres_copy_batch(&batch).unwrap();
532
533        assert_eq!(normalized.schema().field(0).data_type(), &DataType::Int64);
534        let values = normalized
535            .column(0)
536            .as_any()
537            .downcast_ref::<arrow_array::Int64Array>()
538            .unwrap();
539        assert_eq!(values.value(0), 7);
540    }
541
542    #[cfg(feature = "postgres-sink")]
543    #[test]
544    fn negative_fractional_timestamps_use_euclidean_division() {
545        let millis = to_naive_datetime(-1, TimeUnit::Millisecond).unwrap();
546        assert_eq!(millis.and_utc().timestamp(), -1);
547        assert_eq!(millis.and_utc().timestamp_subsec_nanos(), 999_000_000);
548
549        let micros = to_naive_datetime(-1, TimeUnit::Microsecond).unwrap();
550        assert_eq!(micros.and_utc().timestamp(), -1);
551        assert_eq!(micros.and_utc().timestamp_subsec_nanos(), 999_999_000);
552    }
553
554    #[cfg(feature = "postgres-sink")]
555    #[test]
556    fn out_of_range_non_null_temporal_value_is_rejected() {
557        let values = arrow_array::Date32Array::from(vec![i32::MAX]);
558        assert!(validate_postgres_array_values(&values).is_err());
559    }
560}