Skip to main content

laminar_connectors/postgres/sink/
input.rs

1//! Admitted schema validation, changelog normalization, and retained-batch accounting.
2
3use std::sync::Arc;
4
5use arrow_array::{Array, RecordBatch};
6use arrow_schema::{DataType, Field, Schema, SchemaRef};
7
8use crate::changelog::collapse_changelog;
9use crate::error::ConnectorError;
10
11use super::super::sink_config::{
12    quote_sql_identifier, validate_sql_identifier, PostgresSinkConfig,
13};
14use super::super::types::postgres_type;
15#[cfg(feature = "postgres-sink")]
16use super::super::types::{arrow_column_to_pg_array, validate_postgres_array_values};
17#[cfg(feature = "postgres-sink")]
18use super::postgres_dispatched_write_error;
19use super::PostgresSink;
20
21const CHANGELOG_METADATA_COLUMNS: &[&str] = &["_op", "_ts_ms", "__weight"];
22
23impl PostgresSink {
24    /// Splits a changelog `RecordBatch` into insert and delete batches.
25    ///
26    /// Uses the `_op` metadata column:
27    /// - `"I"` (insert), `"U"` (update-after), `"r"` (snapshot read) → insert batch
28    /// - `"D"` (delete) → delete batch
29    ///
30    /// The returned batches exclude the exact changelog metadata field set.
31    ///
32    /// # Errors
33    ///
34    /// Returns `ConnectorError::ConfigurationError` if the `_op` column is
35    /// missing or not a string type.
36    pub fn split_changelog_batch(
37        batch: &RecordBatch,
38    ) -> Result<(RecordBatch, RecordBatch), ConnectorError> {
39        let op_idx = batch.schema().index_of("_op").map_err(|_| {
40            ConnectorError::ConfigurationError(
41                "changelog mode requires '_op' column in input schema".into(),
42            )
43        })?;
44
45        let op_array = batch
46            .column(op_idx)
47            .as_any()
48            .downcast_ref::<arrow_array::StringArray>()
49            .ok_or_else(|| {
50                ConnectorError::ConfigurationError("'_op' column must be String (Utf8) type".into())
51            })?;
52
53        validate_operation_values(op_array)?;
54
55        let mut insert_indices = Vec::new();
56        let mut delete_indices = Vec::new();
57
58        for i in 0..op_array.len() {
59            match op_array.value(i) {
60                "I" | "U" | "U+" | "R" | "r" => {
61                    insert_indices.push(u32::try_from(i).map_err(|_| {
62                        ConnectorError::Internal(
63                            "PostgreSQL changelog batch exceeds UInt32 row indexing".into(),
64                        )
65                    })?);
66                }
67                "D" | "U-" => {
68                    delete_indices.push(u32::try_from(i).map_err(|_| {
69                        ConnectorError::Internal(
70                            "PostgreSQL changelog batch exceeds UInt32 row indexing".into(),
71                        )
72                    })?);
73                }
74                _ => unreachable!("operation values validated above"),
75            }
76        }
77
78        let insert_batch = filter_batch_by_indices(batch, &insert_indices)?;
79        let delete_batch = filter_batch_by_indices(batch, &delete_indices)?;
80
81        Ok((insert_batch, delete_batch))
82    }
83}
84
85fn is_changelog_metadata(name: &str) -> bool {
86    CHANGELOG_METADATA_COLUMNS.contains(&name)
87}
88
89pub(super) fn quoted_user_columns(schema: &SchemaRef) -> Vec<String> {
90    user_fields(schema)
91        .iter()
92        .map(|field| quote_sql_identifier(field.name()))
93        .collect()
94}
95
96/// Returns writable fields, excluding only the defined changelog metadata columns.
97pub(super) fn user_fields(schema: &SchemaRef) -> Vec<&Arc<Field>> {
98    schema
99        .fields()
100        .iter()
101        .filter(|field| !is_changelog_metadata(field.name()))
102        .collect()
103}
104
105/// Builds a schema containing only user-visible columns.
106pub(super) fn build_user_schema(schema: &SchemaRef) -> SchemaRef {
107    Arc::new(Schema::new(
108        schema
109            .fields()
110            .iter()
111            .filter(|field| !is_changelog_metadata(field.name()))
112            .cloned()
113            .collect::<Vec<_>>(),
114    ))
115}
116
117pub(super) fn validate_sink_schema(
118    schema: &SchemaRef,
119    config: &PostgresSinkConfig,
120) -> Result<(), ConnectorError> {
121    config.validate()?;
122    let fields = user_fields(schema);
123    if fields.is_empty() {
124        return Err(ConnectorError::SchemaMismatch(
125            "PostgreSQL sink schema has no writable columns".into(),
126        ));
127    }
128
129    let mut names = std::collections::HashSet::new();
130    for field in schema.fields() {
131        validate_sql_identifier(field.name(), "PostgreSQL sink column name")?;
132        if !names.insert(field.name()) {
133            return Err(ConnectorError::ConfigurationError(format!(
134                "PostgreSQL sink schema contains duplicate column '{}'",
135                field.name()
136            )));
137        }
138        if !is_changelog_metadata(field.name()) {
139            postgres_type(field.data_type()).map_err(|error| {
140                ConnectorError::ConfigurationError(format!(
141                    "PostgreSQL sink column '{}': {error}",
142                    field.name()
143                ))
144            })?;
145        }
146    }
147
148    let op = schema.field_with_name("_op").ok();
149    let weight = schema.field_with_name("__weight").ok();
150    let timestamp = schema.field_with_name("_ts_ms").ok();
151    if config.changelog_mode {
152        if op.is_none() && weight.is_none() {
153            return Err(ConnectorError::ConfigurationError(
154                "PostgreSQL changelog mode requires an Utf8 '_op' or Int64 '__weight' column"
155                    .into(),
156            ));
157        }
158    } else if op.is_some() || weight.is_some() || timestamp.is_some() {
159        return Err(ConnectorError::ConfigurationError(
160            "PostgreSQL sink '_op', '_ts_ms', and '__weight' columns require changelog.mode=true"
161                .into(),
162        ));
163    }
164    if let Some(field) = op {
165        if field.data_type() != &DataType::Utf8 || field.is_nullable() {
166            return Err(ConnectorError::ConfigurationError(
167                "PostgreSQL changelog '_op' must be non-null Utf8".into(),
168            ));
169        }
170    }
171    if let Some(field) = weight {
172        if field.data_type() != &DataType::Int64 || field.is_nullable() {
173            return Err(ConnectorError::ConfigurationError(
174                "PostgreSQL changelog '__weight' must be non-null Int64".into(),
175            ));
176        }
177    }
178
179    for primary_key in &config.primary_key_columns {
180        let field = schema.field_with_name(primary_key).map_err(|_| {
181            ConnectorError::ConfigurationError(format!(
182                "primary key column '{primary_key}' is not present in PostgreSQL sink schema"
183            ))
184        })?;
185        if is_changelog_metadata(primary_key) {
186            return Err(ConnectorError::ConfigurationError(format!(
187                "primary key column '{primary_key}' is reserved changelog metadata"
188            )));
189        }
190        if field.is_nullable() {
191            return Err(ConnectorError::ConfigurationError(format!(
192                "primary key column '{primary_key}' must be non-nullable"
193            )));
194        }
195    }
196    Ok(())
197}
198
199#[cfg(feature = "postgres-sink")]
200pub(super) fn validate_input_batch(
201    batch: &RecordBatch,
202    expected_schema: &SchemaRef,
203    config: &PostgresSinkConfig,
204) -> Result<(), ConnectorError> {
205    if batch.schema().as_ref() != expected_schema.as_ref() {
206        return Err(ConnectorError::SchemaMismatch(
207            "PostgreSQL sink batch schema does not match its admitted Arrow schema".into(),
208        ));
209    }
210    for (field, column) in batch.schema().fields().iter().zip(batch.columns()) {
211        if !is_changelog_metadata(field.name()) {
212            validate_postgres_array_values(column.as_ref())?;
213        }
214    }
215    for primary_key in &config.primary_key_columns {
216        let index = batch.schema().index_of(primary_key).map_err(|_| {
217            ConnectorError::SchemaMismatch(format!(
218                "primary key column '{primary_key}' is absent from PostgreSQL sink batch"
219            ))
220        })?;
221        if batch.column(index).null_count() != 0 {
222            return Err(ConnectorError::SchemaMismatch(format!(
223                "PostgreSQL primary key column '{primary_key}' contains NULL"
224            )));
225        }
226    }
227    if config.changelog_mode {
228        validate_changelog_input(batch)?;
229    }
230    Ok(())
231}
232
233fn validate_operation_values(operations: &arrow_array::StringArray) -> Result<(), ConnectorError> {
234    for row in 0..operations.len() {
235        if operations.is_null(row) {
236            return Err(ConnectorError::SchemaMismatch(format!(
237                "PostgreSQL changelog '_op' is NULL at row {row}"
238            )));
239        }
240        let operation = operations.value(row);
241        if !matches!(operation, "I" | "U" | "U+" | "R" | "r" | "D" | "U-") {
242            return Err(ConnectorError::SchemaMismatch(format!(
243                "PostgreSQL changelog operation '{operation}' at row {row} is not supported"
244            )));
245        }
246    }
247    Ok(())
248}
249
250pub(super) fn validate_changelog_input(batch: &RecordBatch) -> Result<(), ConnectorError> {
251    let schema = batch.schema();
252    let mut encoding_found = false;
253    if let Ok(index) = schema.index_of("_op") {
254        encoding_found = true;
255        let operations = batch
256            .column(index)
257            .as_any()
258            .downcast_ref::<arrow_array::StringArray>()
259            .ok_or_else(|| {
260                ConnectorError::ConfigurationError("PostgreSQL changelog '_op' must be Utf8".into())
261            })?;
262        validate_operation_values(operations)?;
263    }
264    if let Ok(index) = schema.index_of("__weight") {
265        encoding_found = true;
266        let weights = batch
267            .column(index)
268            .as_any()
269            .downcast_ref::<arrow_array::Int64Array>()
270            .ok_or_else(|| {
271                ConnectorError::ConfigurationError(
272                    "PostgreSQL changelog '__weight' must be Int64".into(),
273                )
274            })?;
275        if weights.null_count() != 0 {
276            return Err(ConnectorError::SchemaMismatch(
277                "PostgreSQL changelog '__weight' contains NULL".into(),
278            ));
279        }
280    }
281    if !encoding_found {
282        return Err(ConnectorError::ConfigurationError(
283            "PostgreSQL changelog input requires '_op' or '__weight'".into(),
284        ));
285    }
286    Ok(())
287}
288
289/// Filters a `RecordBatch` to include only rows at the given indices.
290///
291/// Also strips the exact changelog metadata field set from the output.
292fn filter_batch_by_indices(
293    batch: &RecordBatch,
294    indices: &[u32],
295) -> Result<RecordBatch, ConnectorError> {
296    if indices.is_empty() {
297        let user_schema = Arc::new(Schema::new(
298            batch
299                .schema()
300                .fields()
301                .iter()
302                .filter(|field| !is_changelog_metadata(field.name()))
303                .cloned()
304                .collect::<Vec<_>>(),
305        ));
306        return Ok(RecordBatch::new_empty(user_schema));
307    }
308
309    let indices_array = arrow_array::UInt32Array::from(indices.to_vec());
310
311    let user_schema = Arc::new(Schema::new(
312        batch
313            .schema()
314            .fields()
315            .iter()
316            .filter(|field| !is_changelog_metadata(field.name()))
317            .cloned()
318            .collect::<Vec<_>>(),
319    ));
320
321    let filtered_columns: Vec<Arc<dyn arrow_array::Array>> = batch
322        .schema()
323        .fields()
324        .iter()
325        .enumerate()
326        .filter(|(_, field)| !is_changelog_metadata(field.name()))
327        .map(|(i, _)| {
328            arrow_select::take::take(batch.column(i), &indices_array, None)
329                .map_err(|e| ConnectorError::Internal(format!("arrow take failed: {e}")))
330        })
331        .collect::<Result<Vec<_>, _>>()?;
332
333    RecordBatch::try_new(user_schema, filtered_columns)
334        .map_err(|e| ConnectorError::Internal(format!("batch construction failed: {e}")))
335}
336
337/// Strips only the defined changelog metadata columns from a `RecordBatch`.
338pub(super) fn strip_metadata_columns(batch: &RecordBatch) -> Result<RecordBatch, ConnectorError> {
339    let schema = batch.schema();
340    let user_indices: Vec<usize> = schema
341        .fields()
342        .iter()
343        .enumerate()
344        .filter(|(_, field)| !is_changelog_metadata(field.name()))
345        .map(|(i, _)| i)
346        .collect();
347
348    if user_indices.len() == schema.fields().len() {
349        return Ok(batch.clone());
350    }
351
352    let user_schema = Arc::new(Schema::new(
353        user_indices
354            .iter()
355            .map(|&i| schema.field(i).clone())
356            .collect::<Vec<_>>(),
357    ));
358    let columns: Vec<Arc<dyn Array>> = user_indices
359        .iter()
360        .map(|&i| batch.column(i).clone())
361        .collect();
362
363    RecordBatch::try_new(user_schema, columns)
364        .map_err(|e| ConnectorError::Internal(format!("strip metadata: {e}")))
365}
366
367/// Collapse one ordinary upsert flush to the last-arriving row per configured
368/// key, then remove the normalized changelog marker before binding UNNEST
369/// parameters. `PostgreSQL` rejects duplicate conflict keys in one statement.
370pub(super) fn collapse_upsert_batch(
371    batch: &RecordBatch,
372    primary_key_columns: &[String],
373) -> Result<RecordBatch, ConnectorError> {
374    let collapsed = collapse_changelog(batch, primary_key_columns)?;
375    strip_metadata_columns(&collapsed)
376}
377
378/// Executes an UNNEST-based INSERT/UPSERT using Arrow column arrays as parameters.
379#[cfg(feature = "postgres-sink")]
380pub(super) async fn execute_unnest<C>(
381    client: &C,
382    sql: &str,
383    batch: &RecordBatch,
384) -> Result<u64, ConnectorError>
385where
386    C: tokio_postgres::GenericClient + Sync,
387{
388    let params: Vec<Box<dyn postgres_types::ToSql + Sync + Send>> = (0..batch.num_columns())
389        .map(|i| arrow_column_to_pg_array(batch.column(i)))
390        .collect::<Result<_, _>>()?;
391
392    let param_refs: Vec<&(dyn postgres_types::ToSql + Sync)> = params
393        .iter()
394        .map(|p| p.as_ref() as &(dyn postgres_types::ToSql + Sync))
395        .collect();
396
397    client
398        .execute(sql, &param_refs)
399        .await
400        .map_err(|error| postgres_dispatched_write_error("UNNEST execute", &error))
401}
402
403#[cfg(any(feature = "postgres-sink", test))]
404pub(super) fn retained_batch_bytes(batch: &RecordBatch) -> usize {
405    let batch_overhead = std::mem::size_of::<RecordBatch>().saturating_add(
406        batch
407            .num_columns()
408            .saturating_mul(std::mem::size_of::<Arc<dyn Array>>()),
409    );
410    batch
411        .columns()
412        .iter()
413        .fold(batch_overhead, |total, column| {
414            total.saturating_add(column.get_array_memory_size())
415        })
416}
417
418#[cfg(feature = "postgres-sink")]
419pub(super) fn retained_batch_bytes_u64(batch: &RecordBatch) -> u64 {
420    u64::try_from(retained_batch_bytes(batch)).unwrap_or(u64::MAX)
421}
422
423/// Validates a single batch before mutation, then reports whether already-buffered input must be
424/// flushed before admission. Equality is allowed; addition fails closed on overflow.
425#[cfg(any(feature = "postgres-sink", test))]
426pub(super) fn requires_preflush(
427    buffered_bytes: usize,
428    incoming_bytes: usize,
429    retained_limit: usize,
430) -> Result<bool, ConnectorError> {
431    if incoming_bytes > retained_limit {
432        return Err(ConnectorError::ConfigurationError(format!(
433            "PostgreSQL sink input batch retains {incoming_bytes} bytes, exceeding the fixed \
434             {retained_limit}-byte per-sink buffer limit; split the batch upstream"
435        )));
436    }
437
438    Ok(buffered_bytes
439        .checked_add(incoming_bytes)
440        .is_none_or(|total| total > retained_limit))
441}