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>,
62}
63
64impl PostgresTlsConfig {
65 pub fn has_tls(&self) -> bool {
66 self.ssl_ca_pem.is_some() || self.ssl_ca_location.is_some()
67 }
68}
69
70#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
77pub struct PostgresCdcReaderConfig {
78 pub uri: String,
81
82 pub publication: String,
84
85 pub source_table: String,
88
89 #[serde(flatten)]
91 #[schema(inline)]
92 pub tls: PostgresTlsConfig,
93}
94
95impl PostgresCdcReaderConfig {
96 pub fn validate(&self) -> Result<(), String> {
97 if self.publication.trim().is_empty() {
98 return Err("publication cannot be empty".to_string());
99 }
100
101 if self.source_table.trim().is_empty() {
102 return Err("source_table cannot be empty".to_string());
103 }
104
105 if self.tls.ssl_client_pem.is_some()
106 || self.tls.ssl_client_location.is_some()
107 || self.tls.ssl_client_key.is_some()
108 || self.tls.ssl_client_key_location.is_some()
109 || self.tls.ssl_certificate_chain_location.is_some()
110 {
111 return Err(
112 "client-certificate TLS options (ssl_client_pem, ssl_client_location, \
113 ssl_client_key, ssl_client_key_location, ssl_certificate_chain_location) \
114 are not supported by the Postgres CDC connector as the underlying etl crate \
115 doesn't support client-certificate TLS yet. CA-based TLS via ssl_ca_pem \
116 or ssl_ca_location is supported. Please file an issue if you require \
117 client-certificate TLS support: https://github.com/feldera/feldera/issues/
118 "
119 .to_string(),
120 );
121 }
122
123 if self.tls.verify_hostname == Some(false) {
124 return Err(
125 "disabling hostname verification is not supported by the Postgres CDC connector"
126 .to_string(),
127 );
128 }
129
130 Ok(())
131 }
132}
133
134#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
136pub struct PostgresReaderConfig {
137 pub uri: String,
140
141 pub query: String,
143
144 #[serde(flatten)]
146 #[schema(inline)]
147 pub tls: PostgresTlsConfig,
148}
149
150#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
152pub struct PostgresWriterConfig {
153 pub uri: String,
156
157 pub table: String,
159
160 #[serde(default)]
166 #[schema(default = PostgresWriteMode::default)]
167 pub mode: PostgresWriteMode,
168
169 #[serde(default = "default_cdc_op_column")]
178 #[schema(default = default_cdc_op_column)]
179 pub cdc_op_column: String,
180
181 #[serde(default = "default_cdc_ts_column")]
189 #[schema(default = default_cdc_ts_column)]
190 pub cdc_ts_column: String,
191
192 #[serde(flatten)]
194 #[schema(inline)]
195 pub tls: PostgresTlsConfig,
196
197 pub max_records_in_buffer: Option<usize>,
199
200 #[schema(default = default_max_buffer_size)]
205 #[serde(default = "default_max_buffer_size")]
206 pub max_buffer_size_bytes: usize,
207
208 #[serde(default)]
222 pub on_conflict_do_nothing: bool,
223
224 #[serde(default = "default_writer_threads")]
228 #[schema(default = default_writer_threads)]
229 pub threads: usize,
230
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub extra_columns: Vec<String>,
237}
238
239fn default_max_buffer_size() -> usize {
240 usize::pow(2, 20)
241}
242
243fn default_writer_threads() -> usize {
244 1
245}
246
247fn default_cdc_op_column() -> String {
248 "__feldera_op".to_string()
249}
250
251fn default_cdc_ts_column() -> String {
252 "__feldera_ts".to_string()
253}
254
255impl PostgresWriterConfig {
256 pub fn validate(&self) -> Result<(), String> {
257 match self.mode {
258 PostgresWriteMode::Cdc => {
259 if self.cdc_op_column.trim().is_empty() {
260 return Err("cdc_op_column cannot be empty in CDC mode".to_string());
261 }
262 if self.cdc_ts_column.trim().is_empty() {
263 return Err("cdc_ts_column cannot be empty in CDC mode".to_string());
264 }
265
266 if !self.cdc_op_column.is_ascii() {
267 return Err("cdc_op_column must contain only ASCII characters".to_string());
268 }
269
270 if !self.cdc_ts_column.is_ascii() {
271 return Err("cdc_ts_column must contain only ASCII characters".to_string());
272 }
273
274 if self.on_conflict_do_nothing {
275 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());
276 }
277 }
278 PostgresWriteMode::Materialized => {
279 if self.cdc_ts_column != default_cdc_ts_column()
280 && !self.cdc_ts_column.trim().is_empty()
281 {
282 return Err(
283 "cdc_ts_column must not be set when in MATERIALIZED mode".to_string()
284 );
285 }
286 if self.cdc_op_column != default_cdc_op_column()
287 && !self.cdc_op_column.trim().is_empty()
288 {
289 return Err(
290 "cdc_op_column must not be set when in MATERIALIZED mode".to_string()
291 );
292 }
293 }
294 };
295
296 if self.threads == 0 {
297 return Err("threads must be at least 1".to_string());
298 }
299
300 Ok(())
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 fn postgres_cdc_config(tls: PostgresTlsConfig) -> PostgresCdcReaderConfig {
309 PostgresCdcReaderConfig {
310 uri: "postgres://user:password@localhost:5432/database".to_string(),
311 publication: "publication".to_string(),
312 source_table: "public.table".to_string(),
313 tls,
314 }
315 }
316
317 #[test]
318 fn postgres_cdc_config_rejects_client_certificate_tls_options() {
319 let config = postgres_cdc_config(PostgresTlsConfig {
320 ssl_client_pem: Some("client".to_string()),
321 ..Default::default()
322 });
323
324 let err = config.validate().unwrap_err();
325 assert!(err.contains("client-certificate TLS options"));
326 assert!(err.contains("client-certificate TLS support"));
327 assert!(!err.contains("doesn't support TLS yet"));
328 }
329
330 #[test]
331 fn postgres_cdc_config_rejects_disabled_hostname_verification() {
332 let config = postgres_cdc_config(PostgresTlsConfig {
333 verify_hostname: Some(false),
334 ..Default::default()
335 });
336
337 let err = config.validate().unwrap_err();
338 assert!(err.contains("disabling hostname verification"));
339 }
340
341 #[test]
342 fn postgres_cdc_config_accepts_default_tls() {
343 let config = postgres_cdc_config(PostgresTlsConfig::default());
344
345 assert!(config.validate().is_ok());
346 }
347
348 #[test]
349 fn postgres_cdc_config_rejects_empty_publication() {
350 let mut config = postgres_cdc_config(PostgresTlsConfig::default());
351 config.publication = " ".to_string();
352
353 let err = config.validate().unwrap_err();
354 assert!(err.contains("publication cannot be empty"));
355 }
356
357 #[test]
358 fn postgres_cdc_config_rejects_empty_source_table() {
359 let mut config = postgres_cdc_config(PostgresTlsConfig::default());
360 config.source_table = "\t".to_string();
361
362 let err = config.validate().unwrap_err();
363 assert!(err.contains("source_table cannot be empty"));
364 }
365}