feldera_types/transport/
postgres.rs1use serde::{Deserialize, Serialize};
2use std::fmt::Display;
3use utoipa::ToSchema;
4
5#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema, Default)]
9pub enum PostgresWriteMode {
10 #[default]
13 #[serde(rename = "materialized")]
14 Materialized,
15
16 #[serde(rename = "cdc")]
21 Cdc,
22}
23
24impl Display for PostgresWriteMode {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 match self {
27 Self::Materialized => write!(f, "materialized"),
28 Self::Cdc => write!(f, "cdc"),
29 }
30 }
31}
32
33#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema, Default)]
35pub struct PostgresTlsConfig {
36 pub ssl_ca_pem: Option<String>,
38
39 pub ssl_ca_location: Option<String>,
41
42 pub ssl_client_pem: Option<String>,
44
45 pub ssl_client_location: Option<String>,
47
48 pub ssl_client_key: Option<String>,
50
51 pub ssl_client_key_location: Option<String>,
53
54 pub ssl_certificate_chain_location: Option<String>,
59
60 pub verify_hostname: Option<bool>,
66}
67
68impl PostgresTlsConfig {
69 pub fn has_tls(&self) -> bool {
70 self.ssl_ca_pem.is_some() || self.ssl_ca_location.is_some()
71 }
72}
73
74#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
81pub struct PostgresCdcReaderConfig {
82 pub uri: String,
85
86 pub publication: String,
88
89 pub source_table: String,
93
94 #[serde(flatten)]
96 #[schema(inline)]
97 pub tls: PostgresTlsConfig,
98}
99
100impl PostgresCdcReaderConfig {
101 pub fn validate(&self) -> Result<(), String> {
102 if self.publication.trim().is_empty() {
103 return Err("publication cannot be empty".to_string());
104 }
105
106 if self.source_table.trim().is_empty() {
107 return Err("source_table cannot be empty".to_string());
108 }
109
110 if self.tls.ssl_client_pem.is_some()
111 || self.tls.ssl_client_location.is_some()
112 || self.tls.ssl_client_key.is_some()
113 || self.tls.ssl_client_key_location.is_some()
114 || self.tls.ssl_certificate_chain_location.is_some()
115 {
116 return Err(
117 "client-certificate TLS options (ssl_client_pem, ssl_client_location, \
118 ssl_client_key, ssl_client_key_location, ssl_certificate_chain_location) \
119 are not supported by the Postgres CDC connector as the underlying etl crate \
120 doesn't support client-certificate TLS yet. CA-based TLS via ssl_ca_pem \
121 or ssl_ca_location is supported. Please file an issue if you require \
122 client-certificate TLS support: https://github.com/feldera/feldera/issues/
123 "
124 .to_string(),
125 );
126 }
127
128 if self.tls.verify_hostname == Some(false) {
129 return Err(
130 "disabling hostname verification is not supported by the Postgres CDC connector"
131 .to_string(),
132 );
133 }
134
135 Ok(())
136 }
137}
138
139#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
141pub struct PostgresReaderConfig {
142 pub uri: String,
145
146 pub query: String,
148
149 #[serde(flatten)]
151 #[schema(inline)]
152 pub tls: PostgresTlsConfig,
153}
154
155#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
157pub struct PostgresWriterConfig {
158 pub uri: String,
161
162 pub table: String,
164
165 #[serde(default)]
171 #[schema(default = PostgresWriteMode::default)]
172 pub mode: PostgresWriteMode,
173
174 #[serde(default = "default_cdc_op_column")]
183 #[schema(default = default_cdc_op_column)]
184 pub cdc_op_column: String,
185
186 #[serde(default = "default_cdc_ts_column")]
194 #[schema(default = default_cdc_ts_column)]
195 pub cdc_ts_column: String,
196
197 #[serde(flatten)]
199 #[schema(inline)]
200 pub tls: PostgresTlsConfig,
201
202 pub max_records_in_buffer: Option<usize>,
204
205 #[schema(default = default_max_buffer_size)]
210 #[serde(default = "default_max_buffer_size")]
211 pub max_buffer_size_bytes: usize,
212
213 #[serde(default)]
227 pub on_conflict_do_nothing: bool,
228
229 #[serde(default = "default_writer_threads")]
233 #[schema(default = default_writer_threads)]
234 pub threads: usize,
235
236 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub extra_columns: Vec<String>,
242}
243
244fn default_max_buffer_size() -> usize {
245 usize::pow(2, 20)
246}
247
248fn default_writer_threads() -> usize {
249 1
250}
251
252fn default_cdc_op_column() -> String {
253 "__feldera_op".to_string()
254}
255
256fn default_cdc_ts_column() -> String {
257 "__feldera_ts".to_string()
258}
259
260impl PostgresWriterConfig {
261 pub fn validate(&self) -> Result<(), String> {
262 match self.mode {
263 PostgresWriteMode::Cdc => {
264 if self.cdc_op_column.trim().is_empty() {
265 return Err("cdc_op_column cannot be empty in CDC mode".to_string());
266 }
267 if self.cdc_ts_column.trim().is_empty() {
268 return Err("cdc_ts_column cannot be empty in CDC mode".to_string());
269 }
270
271 if !self.cdc_op_column.is_ascii() {
272 return Err("cdc_op_column must contain only ASCII characters".to_string());
273 }
274
275 if !self.cdc_ts_column.is_ascii() {
276 return Err("cdc_ts_column must contain only ASCII characters".to_string());
277 }
278
279 if self.on_conflict_do_nothing {
280 return Err("on_conflict_do_nothing not supported in CDC mode since all operations are performed as append-only INSERTs into the target table".to_string());
281 }
282 }
283 PostgresWriteMode::Materialized => {
284 if self.cdc_ts_column != default_cdc_ts_column()
285 && !self.cdc_ts_column.trim().is_empty()
286 {
287 return Err(
288 "cdc_ts_column must not be set when in MATERIALIZED mode".to_string()
289 );
290 }
291 if self.cdc_op_column != default_cdc_op_column()
292 && !self.cdc_op_column.trim().is_empty()
293 {
294 return Err(
295 "cdc_op_column must not be set when in MATERIALIZED mode".to_string()
296 );
297 }
298 }
299 };
300
301 if self.threads == 0 {
302 return Err("threads must be at least 1".to_string());
303 }
304
305 Ok(())
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn postgres_cdc_config(tls: PostgresTlsConfig) -> PostgresCdcReaderConfig {
314 PostgresCdcReaderConfig {
315 uri: "postgres://user:password@localhost:5432/database".to_string(),
316 publication: "publication".to_string(),
317 source_table: "public.table".to_string(),
318 tls,
319 }
320 }
321
322 #[test]
323 fn postgres_cdc_config_rejects_client_certificate_tls_options() {
324 let config = postgres_cdc_config(PostgresTlsConfig {
325 ssl_client_pem: Some("client".to_string()),
326 ..Default::default()
327 });
328
329 let err = config.validate().unwrap_err();
330 assert!(err.contains("client-certificate TLS options"));
331 assert!(err.contains("client-certificate TLS support"));
332 assert!(!err.contains("doesn't support TLS yet"));
333 }
334
335 #[test]
336 fn postgres_cdc_config_rejects_disabled_hostname_verification() {
337 let config = postgres_cdc_config(PostgresTlsConfig {
338 verify_hostname: Some(false),
339 ..Default::default()
340 });
341
342 let err = config.validate().unwrap_err();
343 assert!(err.contains("disabling hostname verification"));
344 }
345
346 #[test]
347 fn postgres_cdc_config_accepts_default_tls() {
348 let config = postgres_cdc_config(PostgresTlsConfig::default());
349
350 assert!(config.validate().is_ok());
351 }
352
353 #[test]
354 fn postgres_cdc_config_rejects_empty_publication() {
355 let mut config = postgres_cdc_config(PostgresTlsConfig::default());
356 config.publication = " ".to_string();
357
358 let err = config.validate().unwrap_err();
359 assert!(err.contains("publication cannot be empty"));
360 }
361
362 #[test]
363 fn postgres_cdc_config_rejects_empty_source_table() {
364 let mut config = postgres_cdc_config(PostgresTlsConfig::default());
365 config.source_table = "\t".to_string();
366
367 let err = config.validate().unwrap_err();
368 assert!(err.contains("source_table cannot be empty"));
369 }
370}