faucet_source_clickhouse/
config.rs1use faucet_common_clickhouse::ClickHouseConnection;
4use faucet_core::{DEFAULT_BATCH_SIZE, FaucetError, validate_batch_size};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9fn default_batch_size() -> usize {
10 DEFAULT_BATCH_SIZE
11}
12
13#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum ClickHouseReplication {
20 #[default]
22 Full,
23 Incremental {
32 column: String,
34 initial_value: Value,
36 },
37}
38
39#[derive(Clone, Serialize, Deserialize, JsonSchema)]
41pub struct ClickHouseSourceConfig {
42 #[serde(flatten)]
44 pub connection: ClickHouseConnection,
45 pub query: String,
51 #[serde(default = "default_batch_size")]
55 pub batch_size: usize,
56 #[serde(default)]
58 pub replication: ClickHouseReplication,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub state_key: Option<String>,
63}
64
65impl std::fmt::Debug for ClickHouseSourceConfig {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 f.debug_struct("ClickHouseSourceConfig")
68 .field("connection", &self.connection)
69 .field("query", &self.query)
70 .field("batch_size", &self.batch_size)
71 .field("replication", &self.replication)
72 .field("state_key", &self.state_key)
73 .finish()
74 }
75}
76
77impl ClickHouseSourceConfig {
78 pub fn new(url: impl Into<String>, query: impl Into<String>) -> Self {
80 Self {
81 connection: ClickHouseConnection::from_url(url),
82 query: query.into(),
83 batch_size: default_batch_size(),
84 replication: ClickHouseReplication::Full,
85 state_key: None,
86 }
87 }
88
89 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
91 self.batch_size = batch_size;
92 self
93 }
94
95 pub fn incremental(mut self, column: impl Into<String>, initial: Value) -> Self {
97 self.replication = ClickHouseReplication::Incremental {
98 column: column.into(),
99 initial_value: initial,
100 };
101 self
102 }
103
104 pub fn validate(&self) -> Result<(), FaucetError> {
106 self.connection.validate()?;
107 validate_batch_size(self.batch_size)?;
108 if let ClickHouseReplication::Incremental { column, .. } = &self.replication
109 && column.trim().is_empty()
110 {
111 return Err(FaucetError::Config(
112 "ClickHouse incremental replication requires a non-empty `column`".into(),
113 ));
114 }
115 if self.incremental_without_bookmark_pushdown() {
116 tracing::warn!(
117 "ClickHouse incremental replication query has no `@bookmark` token: the \
118 cursor is applied client-side only, so the server returns the ENTIRE \
119 result set on every run (correctness is preserved, but it is a full \
120 re-scan). Add `@bookmark` to the WHERE clause to push the cursor down, \
121 e.g. `... WHERE {column} > @bookmark`",
122 column = match &self.replication {
123 ClickHouseReplication::Incremental { column, .. } => column.as_str(),
124 _ => "<column>",
125 }
126 );
127 }
128 Ok(())
129 }
130
131 pub(crate) fn incremental_without_bookmark_pushdown(&self) -> bool {
136 matches!(self.replication, ClickHouseReplication::Incremental { .. })
137 && !self.query.contains("@bookmark")
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use serde_json::json;
145
146 #[test]
147 fn config_flattens_connection_fields() {
148 let cfg: ClickHouseSourceConfig = serde_json::from_value(json!({
149 "url": "http://localhost:8123",
150 "database": "analytics",
151 "query": "SELECT 1",
152 }))
153 .unwrap();
154 assert_eq!(cfg.connection.url.as_deref(), Some("http://localhost:8123"));
155 assert_eq!(cfg.connection.database, "analytics");
156 assert_eq!(cfg.batch_size, DEFAULT_BATCH_SIZE);
157 }
158
159 #[test]
160 fn replication_full_is_default() {
161 let cfg = ClickHouseSourceConfig::new("http://h:8123", "SELECT 1");
162 assert_eq!(cfg.replication, ClickHouseReplication::Full);
163 }
164
165 #[test]
166 fn replication_incremental_parses() {
167 let r: ClickHouseReplication = serde_json::from_value(json!({
168 "type": "incremental",
169 "column": "updated_at",
170 "initial_value": "1970-01-01",
171 }))
172 .unwrap();
173 assert_eq!(
174 r,
175 ClickHouseReplication::Incremental {
176 column: "updated_at".into(),
177 initial_value: json!("1970-01-01"),
178 }
179 );
180 }
181
182 #[test]
183 fn validate_rejects_incremental_without_column() {
184 let cfg = ClickHouseSourceConfig {
185 replication: ClickHouseReplication::Incremental {
186 column: " ".into(),
187 initial_value: json!(0),
188 },
189 ..ClickHouseSourceConfig::new("http://h:8123", "SELECT 1")
190 };
191 assert!(cfg.validate().is_err());
192 }
193
194 #[test]
195 fn validate_rejects_bad_batch_size() {
196 let cfg = ClickHouseSourceConfig::new("http://h:8123", "SELECT 1")
197 .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
198 assert!(cfg.validate().is_err());
199 }
200
201 #[test]
202 fn validate_rejects_missing_endpoint() {
203 let mut cfg = ClickHouseSourceConfig::new("http://h:8123", "SELECT 1");
204 cfg.connection.url = None;
205 assert!(cfg.validate().is_err());
206 }
207
208 #[test]
209 fn incremental_without_bookmark_pushdown_flags_missing_token() {
210 let missing = ClickHouseSourceConfig::new("http://h:8123", "SELECT * FROM t")
211 .incremental("updated_at", json!("1970-01-01"));
212 assert!(missing.incremental_without_bookmark_pushdown());
213 assert!(missing.validate().is_ok(), "warn, not hard error");
214
215 let with_token = ClickHouseSourceConfig::new(
216 "http://h:8123",
217 "SELECT * FROM t WHERE updated_at > @bookmark",
218 )
219 .incremental("updated_at", json!("1970-01-01"));
220 assert!(!with_token.incremental_without_bookmark_pushdown());
221
222 let full = ClickHouseSourceConfig::new("http://h:8123", "SELECT * FROM t");
223 assert!(!full.incremental_without_bookmark_pushdown());
224 }
225
226 #[test]
227 fn debug_masks_password() {
228 let mut cfg = ClickHouseSourceConfig::new("http://h:8123", "SELECT 1");
229 cfg.connection.password = Some("s3cret".into());
230 let dbg = format!("{cfg:?}");
231 assert!(dbg.contains("***"));
232 assert!(!dbg.contains("s3cret"));
233 }
234}