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