laminar_connectors/postgres/sink/
input.rs1use 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 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
96pub(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
105pub(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
289fn 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
337pub(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
367pub(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#[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, ¶m_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#[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}