Skip to main content

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