1use arrow_schema::{DataType, TimeUnit};
4
5use crate::error::ConnectorError;
6
7#[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
26pub(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
94pub(super) fn arrow_type_to_pg_sql(data_type: &DataType) -> Result<&'static str, ConnectorError> {
96 postgres_type(data_type).map(PostgresType::sql)
97}
98
99pub(super) fn arrow_to_pg_ddl_type(data_type: &DataType) -> Result<&'static str, ConnectorError> {
101 postgres_type(data_type).map(PostgresType::ddl)
102}
103
104pub(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#[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#[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#[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#[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}