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