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