faucet_source_redshift/
config.rs1use faucet_common_redshift::RedshiftConnection;
4use faucet_core::{DEFAULT_BATCH_SIZE, FaucetError};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum RedshiftReplication {
16 #[default]
18 Full,
19 Incremental {
32 column: String,
34 initial_value: Value,
37 },
38}
39
40fn default_max_connections() -> u32 {
41 10
42}
43
44fn default_batch_size() -> usize {
45 DEFAULT_BATCH_SIZE
46}
47
48#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
50pub struct RedshiftSourceConfig {
51 #[serde(flatten)]
54 pub connection: RedshiftConnection,
55 pub query: String,
59 #[serde(default)]
62 pub params: Vec<Value>,
63 #[serde(default = "default_max_connections")]
65 pub max_connections: u32,
66 #[serde(default = "default_batch_size")]
73 pub batch_size: usize,
74 #[serde(default)]
76 pub replication: RedshiftReplication,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub state_key: Option<String>,
81}
82
83impl RedshiftSourceConfig {
84 pub fn validate(&self) -> Result<(), FaucetError> {
86 if self.query.trim().is_empty() {
87 return Err(FaucetError::Config(
88 "redshift: `query` must not be empty".into(),
89 ));
90 }
91 faucet_core::validate_batch_size(self.batch_size)?;
92 let RedshiftReplication::Incremental {
93 column,
94 initial_value,
95 } = &self.replication
96 else {
97 return Ok(());
98 };
99 if column.trim().is_empty() {
100 return Err(FaucetError::Config(
101 "redshift: incremental replication `column` must not be empty".into(),
102 ));
103 }
104 if initial_value.is_null() {
105 return Err(FaucetError::Config(
106 "redshift: incremental replication requires a non-null `initial_value`".into(),
107 ));
108 }
109 if faucet_core::replication::cursor::strict_comparison(&self.query, BOOKMARK_TOKEN) {
110 return Err(FaucetError::Config(format!(
111 "redshift: compare `{BOOKMARK_TOKEN}` with `>=`, not `>`: the source re-reads \
112 the bookmark value and drops the rows it already emitted, and a strict \
113 comparison skips rows that share it"
114 )));
115 }
116 Ok(())
117 }
118}
119
120pub const BOOKMARK_TOKEN: &str = "${bookmark}";
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use faucet_common_redshift::RedshiftConnection;
128 use serde_json::json;
129
130 fn base() -> RedshiftSourceConfig {
131 RedshiftSourceConfig {
132 connection: RedshiftConnection::new("host", "db", "user", "pw"),
133 query: "SELECT * FROM events".into(),
134 params: Vec::new(),
135 max_connections: default_max_connections(),
136 batch_size: DEFAULT_BATCH_SIZE,
137 replication: RedshiftReplication::Full,
138 state_key: None,
139 }
140 }
141
142 #[test]
143 fn valid_config_passes() {
144 base().validate().unwrap();
145 }
146
147 #[test]
148 fn rejects_empty_query() {
149 let mut c = base();
150 c.query = " ".into();
151 assert!(c.validate().is_err());
152 }
153
154 #[test]
155 fn rejects_empty_incremental_column() {
156 let mut c = base();
157 c.replication = RedshiftReplication::Incremental {
158 column: "".into(),
159 initial_value: json!(0),
160 };
161 assert!(c.validate().is_err());
162 }
163
164 #[test]
165 fn rejects_bad_incremental_settings() {
166 for (query, initial, needle) in [
167 ("SELECT * FROM t", json!(null), "initial_value"),
168 ("SELECT * FROM t WHERE ts > ${bookmark}", json!(0), ">="),
169 ] {
170 let mut c = base();
171 c.query = query.into();
172 c.replication = RedshiftReplication::Incremental {
173 column: "ts".into(),
174 initial_value: initial,
175 };
176 let err = c.validate().unwrap_err().to_string();
177 assert!(err.contains(needle), "{err}");
178 }
179 }
180
181 #[test]
182 fn rejects_oversized_batch() {
183 let mut c = base();
184 c.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
185 assert!(c.validate().is_err());
186 }
187
188 #[test]
189 fn deserializes_flattened_connection_and_defaults() {
190 let json = r#"{
191 "host": "h",
192 "database": "db",
193 "user": "u",
194 "credentials": {"type": "password", "config": {"password": "pw"}},
195 "query": "SELECT 1"
196 }"#;
197 let c: RedshiftSourceConfig = serde_json::from_str(json).unwrap();
198 assert_eq!(c.connection.host, "h");
199 assert_eq!(c.connection.port, faucet_common_redshift::DEFAULT_PORT);
200 assert_eq!(c.max_connections, 10);
201 assert_eq!(c.batch_size, DEFAULT_BATCH_SIZE);
202 assert!(matches!(c.replication, RedshiftReplication::Full));
203 c.validate().unwrap();
204 }
205
206 #[test]
207 fn deserializes_incremental_replication() {
208 let json = r#"{
209 "host": "h",
210 "database": "db",
211 "user": "u",
212 "credentials": {"type": "password", "config": {"password": "pw"}},
213 "query": "SELECT * FROM t WHERE ts >= ${bookmark}",
214 "replication": {"type": "incremental", "column": "ts", "initial_value": "2026-01-01"},
215 "state_key": "my-key"
216 }"#;
217 let c: RedshiftSourceConfig = serde_json::from_str(json).unwrap();
218 match &c.replication {
219 RedshiftReplication::Incremental {
220 column,
221 initial_value,
222 } => {
223 assert_eq!(column, "ts");
224 assert_eq!(initial_value, &json!("2026-01-01"));
225 }
226 _ => panic!("expected incremental"),
227 }
228 assert_eq!(c.state_key.as_deref(), Some("my-key"));
229 }
230}