laminar_connectors/postgres/sink_config/
mod.rs1use std::path::PathBuf;
7use std::time::Duration;
8
9use crate::config::ConnectorConfig;
10use crate::error::ConnectorError;
11use crate::postgres::SslMode;
12
13const MAX_POSTGRES_IDENTIFIER_BYTES: usize = 63;
14const MAX_POSTGRES_STATEMENT_TIMEOUT_MS: u128 = 2_147_483_647;
15
16const REMOVED_CONFIG_KEYS: &[&str] = &["batch.size", "pool.size"];
17
18const ALLOWED_CONFIG_KEYS: &[&str] = &[
19 "_arrow_schema",
20 "auto.create.table",
21 "changelog.mode",
22 "connect.timeout.ms",
23 "database",
24 "delivery.guarantee",
25 "flush.interval.ms",
26 "hostname",
27 "password",
28 "port",
29 "primary.key",
30 "schema.name",
31 "ssl.ca.cert.path",
32 "ssl.mode",
33 "statement.timeout.ms",
34 "table.name",
35 "username",
36 "write.mode",
37];
38
39#[derive(Debug, Clone)]
43pub struct PostgresSinkConfig {
44 pub hostname: String,
46
47 pub port: u16,
49
50 pub database: String,
52
53 pub username: String,
55
56 pub password: String,
58
59 pub schema_name: String,
61
62 pub table_name: String,
64
65 pub write_mode: WriteMode,
67
68 pub primary_key_columns: Vec<String>,
70
71 pub flush_interval: Duration,
73
74 pub connect_timeout: Duration,
76
77 pub ssl_mode: SslMode,
79
80 pub ssl_ca_cert_path: Option<PathBuf>,
82
83 pub auto_create_table: bool,
85
86 pub changelog_mode: bool,
88
89 pub statement_timeout: Duration,
91}
92
93impl Default for PostgresSinkConfig {
94 fn default() -> Self {
95 Self {
96 hostname: "localhost".to_string(),
97 port: 5432,
98 database: String::new(),
99 username: "postgres".to_string(),
100 password: String::new(),
101 schema_name: "public".to_string(),
102 table_name: String::new(),
103 write_mode: WriteMode::Append,
104 primary_key_columns: Vec::new(),
105 flush_interval: Duration::from_millis(250),
106 connect_timeout: Duration::from_secs(10),
107 ssl_mode: SslMode::VerifyFull,
108 ssl_ca_cert_path: None,
109 auto_create_table: false,
110 changelog_mode: false,
111 statement_timeout: Duration::from_secs(30),
112 }
113 }
114}
115
116impl PostgresSinkConfig {
117 #[must_use]
119 pub fn new(hostname: &str, database: &str, table_name: &str) -> Self {
120 Self {
121 hostname: hostname.to_string(),
122 database: database.to_string(),
123 table_name: table_name.to_string(),
124 ..Default::default()
125 }
126 }
127
128 #[allow(clippy::field_reassign_with_default)]
142 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
143 Self::reject_removed_keys(config)?;
144 config.reject_unknown_properties(ALLOWED_CONFIG_KEYS, "PostgreSQL sink")?;
145
146 let mut cfg = Self::default();
147
148 cfg.hostname = config.require("hostname")?.to_string();
149 cfg.database = config.require("database")?.to_string();
150 cfg.username = config.require("username")?.to_string();
151 cfg.table_name = config.require("table.name")?.to_string();
152
153 if let Some(v) = config.get("password") {
154 cfg.password = v.to_string();
155 }
156 if let Some(v) = config.get("port") {
157 cfg.port = crate::config::parse_port(v)?;
158 }
159 if let Some(v) = config.get("schema.name") {
160 cfg.schema_name = v.to_string();
161 }
162 if let Some(v) = config.get("write.mode") {
163 cfg.write_mode = v.parse().map_err(|_| {
164 ConnectorError::ConfigurationError(format!(
165 "invalid write.mode: '{v}' (expected 'append' or 'upsert')"
166 ))
167 })?;
168 }
169 if let Some(v) = config.get("primary.key") {
170 if v.trim().is_empty() {
171 cfg.primary_key_columns.clear();
172 } else {
173 let columns: Vec<_> = v.split(',').map(str::trim).collect();
174 if columns.iter().any(|column| column.is_empty()) {
175 return Err(ConnectorError::ConfigurationError(
176 "primary.key contains an empty column name".into(),
177 ));
178 }
179 cfg.primary_key_columns = columns.into_iter().map(str::to_string).collect();
180 }
181 }
182 if let Some(v) = config.get("flush.interval.ms") {
183 let ms: u64 = v.parse().map_err(|_| {
184 ConnectorError::ConfigurationError(format!("invalid flush.interval.ms: '{v}'"))
185 })?;
186 cfg.flush_interval = Duration::from_millis(ms);
187 }
188 if let Some(v) = config.get("connect.timeout.ms") {
189 let ms: u64 = v.parse().map_err(|_| {
190 ConnectorError::ConfigurationError(format!("invalid connect.timeout.ms: '{v}'"))
191 })?;
192 cfg.connect_timeout = Duration::from_millis(ms);
193 }
194 if let Some(v) = config.get("ssl.mode") {
195 cfg.ssl_mode = v.parse().map_err(|_| {
196 ConnectorError::ConfigurationError(format!(
197 "invalid ssl.mode: '{v}' (expected 'disable' or 'verify-full')"
198 ))
199 })?;
200 }
201 cfg.ssl_ca_cert_path = config.get("ssl.ca.cert.path").map(PathBuf::from);
202 if let Some(v) = config.get("auto.create.table") {
203 cfg.auto_create_table = v.eq_ignore_ascii_case("true");
204 }
205 if let Some(v) = config.get("changelog.mode") {
206 cfg.changelog_mode = v.eq_ignore_ascii_case("true");
207 }
208 if let Some(v) = config.get("statement.timeout.ms") {
209 let ms: u64 = v.parse().map_err(|_| {
210 ConnectorError::ConfigurationError(format!("invalid statement.timeout.ms: '{v}'"))
211 })?;
212 cfg.statement_timeout = Duration::from_millis(ms);
213 }
214 cfg.validate()?;
215 Ok(cfg)
216 }
217
218 fn reject_removed_keys(config: &ConnectorConfig) -> Result<(), ConnectorError> {
219 if let Some(key) = REMOVED_CONFIG_KEYS
220 .iter()
221 .find(|key| config.get(key).is_some())
222 {
223 return Err(ConnectorError::ConfigurationError(format!(
224 "PostgreSQL sink property '{key}' is not supported: batching is owned by the runtime and retained-byte limit"
225 )));
226 }
227 Ok(())
228 }
229
230 pub fn validate(&self) -> Result<(), ConnectorError> {
236 crate::config::require_non_empty(&self.hostname, "hostname")?;
237 crate::config::require_non_empty(&self.database, "database")?;
238 crate::config::require_non_empty(&self.username, "username")?;
239 crate::config::require_non_empty(&self.schema_name, "schema.name")?;
240 crate::config::require_non_empty(&self.table_name, "table.name")?;
241 validate_sql_identifier(&self.schema_name, "schema.name")?;
242 validate_sql_identifier(&self.table_name, "table.name")?;
243 let mut primary_keys = std::collections::HashSet::new();
244 for column in &self.primary_key_columns {
245 validate_sql_identifier(column, "primary.key column")?;
246 if !primary_keys.insert(column) {
247 return Err(ConnectorError::ConfigurationError(format!(
248 "primary.key contains duplicate column '{column}'"
249 )));
250 }
251 }
252 if self.port == 0 {
253 return Err(ConnectorError::ConfigurationError(
254 "port must be > 0".into(),
255 ));
256 }
257 if self
258 .ssl_ca_cert_path
259 .as_ref()
260 .is_some_and(|path| path.as_os_str().is_empty())
261 {
262 return Err(ConnectorError::ConfigurationError(
263 "ssl.ca.cert.path must not be empty".into(),
264 ));
265 }
266 if self.ssl_mode == SslMode::Disable && self.ssl_ca_cert_path.is_some() {
267 return Err(ConnectorError::ConfigurationError(
268 "ssl.ca.cert.path requires ssl.mode=verify-full".into(),
269 ));
270 }
271 if self.write_mode == WriteMode::Upsert && self.primary_key_columns.is_empty() {
272 return Err(ConnectorError::ConfigurationError(
273 "upsert mode requires 'primary.key' to be set".into(),
274 ));
275 }
276 if self.changelog_mode && self.write_mode != WriteMode::Upsert {
277 return Err(ConnectorError::ConfigurationError(
278 "changelog mode requires write.mode = 'upsert'".into(),
279 ));
280 }
281 if self.flush_interval.is_zero() {
282 return Err(ConnectorError::ConfigurationError(
283 "flush.interval.ms must be > 0".into(),
284 ));
285 }
286 if self.connect_timeout.is_zero() {
287 return Err(ConnectorError::ConfigurationError(
288 "connect.timeout.ms must be > 0".into(),
289 ));
290 }
291 if self.statement_timeout < Duration::from_secs(1) {
292 return Err(ConnectorError::ConfigurationError(
293 "statement.timeout.ms must be >= 1000 (1 second)".into(),
294 ));
295 }
296 if self.statement_timeout.as_millis() > MAX_POSTGRES_STATEMENT_TIMEOUT_MS {
297 return Err(ConnectorError::ConfigurationError(format!(
298 "statement.timeout.ms must be <= {MAX_POSTGRES_STATEMENT_TIMEOUT_MS}"
299 )));
300 }
301 if !self
302 .statement_timeout
303 .subsec_nanos()
304 .is_multiple_of(1_000_000)
305 {
306 return Err(ConnectorError::ConfigurationError(
307 "statement timeout must be an integer number of milliseconds".into(),
308 ));
309 }
310 Ok(())
311 }
312
313 #[must_use]
315 pub(super) fn statement_timeout_startup_option(&self) -> String {
316 format!(
317 "-c statement_timeout={}",
318 self.statement_timeout.as_millis()
319 )
320 }
321
322 #[must_use]
324 pub fn qualified_table_name(&self) -> String {
325 format!(
326 "{}.{}",
327 quote_sql_identifier(&self.schema_name),
328 quote_sql_identifier(&self.table_name)
329 )
330 }
331}
332
333pub(super) fn validate_sql_identifier(identifier: &str, label: &str) -> Result<(), ConnectorError> {
335 if identifier.is_empty() {
336 return Err(ConnectorError::ConfigurationError(format!(
337 "{label} must not be empty"
338 )));
339 }
340 if identifier.contains('\0') {
341 return Err(ConnectorError::ConfigurationError(format!(
342 "{label} must not contain NUL"
343 )));
344 }
345 if identifier.len() > MAX_POSTGRES_IDENTIFIER_BYTES {
346 return Err(ConnectorError::ConfigurationError(format!(
347 "{label} exceeds PostgreSQL's {MAX_POSTGRES_IDENTIFIER_BYTES}-byte identifier limit"
348 )));
349 }
350 Ok(())
351}
352
353pub(super) fn quote_sql_identifier(identifier: &str) -> String {
356 format!("\"{}\"", identifier.replace('"', "\"\""))
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum WriteMode {
362 Append,
365 Upsert,
368}
369
370str_enum!(WriteMode, lowercase_nodash, String, "unknown write mode",
371 Append => "append", "copy";
372 Upsert => "upsert", "insert"
373);
374
375#[cfg(test)]
376mod tests;