1use faucet_common_mssql::MssqlConnectionConfig;
4use faucet_core::{DEFAULT_BATCH_SIZE, FaucetError, validate_batch_size};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9fn default_max_connections() -> u32 {
10 10
11}
12fn default_batch_size() -> usize {
13 DEFAULT_BATCH_SIZE
14}
15fn default_statement_timeout_secs() -> u64 {
16 300
17}
18
19#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum MssqlReplication {
26 #[default]
28 Full,
29 Incremental {
37 column: String,
39 initial_value: Value,
41 },
42}
43
44#[derive(Clone, Serialize, Deserialize, JsonSchema)]
46pub struct MssqlSourceConfig {
47 #[serde(flatten)]
49 pub connection: MssqlConnectionConfig,
50 pub query: String,
53 #[serde(default)]
55 pub params: Vec<Value>,
56 #[serde(default = "default_max_connections")]
58 pub max_connections: u32,
59 #[serde(default = "default_batch_size")]
62 pub batch_size: usize,
63 #[serde(default = "default_statement_timeout_secs")]
65 pub statement_timeout_secs: u64,
66 #[serde(default)]
68 pub replication: MssqlReplication,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub state_key: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub shard: Option<ShardConfig>,
82}
83
84#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
100pub struct ShardConfig {
101 pub key: String,
105}
106
107impl std::fmt::Debug for MssqlSourceConfig {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("MssqlSourceConfig")
110 .field("connection", &"***")
111 .field("query", &self.query)
112 .field("params", &self.params)
113 .field("max_connections", &self.max_connections)
114 .field("batch_size", &self.batch_size)
115 .field("statement_timeout_secs", &self.statement_timeout_secs)
116 .field("replication", &self.replication)
117 .field("state_key", &self.state_key)
118 .finish()
119 }
120}
121
122impl MssqlSourceConfig {
123 pub fn new(connection_url: impl Into<String>, query: impl Into<String>) -> Self {
125 Self {
126 connection: MssqlConnectionConfig {
127 connection_url: Some(connection_url.into()),
128 ..Default::default()
129 },
130 query: query.into(),
131 params: Vec::new(),
132 max_connections: default_max_connections(),
133 batch_size: default_batch_size(),
134 statement_timeout_secs: default_statement_timeout_secs(),
135 replication: MssqlReplication::Full,
136 state_key: None,
137 shard: None,
138 }
139 }
140
141 pub fn validate(&self) -> Result<(), FaucetError> {
143 self.connection.validate()?;
144 validate_batch_size(self.batch_size)?;
145 if let MssqlReplication::Incremental { column, .. } = &self.replication
146 && column.trim().is_empty()
147 {
148 return Err(FaucetError::Config(
149 "MSSQL incremental replication requires a non-empty `column`".into(),
150 ));
151 }
152 if self.incremental_without_bookmark_pushdown() {
153 tracing::warn!(
154 "MSSQL incremental replication query has no `@bookmark` token: the \
155 cursor is applied client-side only, so the server returns the ENTIRE \
156 table on every run (correctness is preserved, but it is a full re-scan). \
157 Add `@bookmark` to the WHERE clause to push the cursor down, e.g. \
158 `... WHERE {column} > @bookmark`",
159 column = match &self.replication {
160 MssqlReplication::Incremental { column, .. } => column.as_str(),
161 _ => "<column>",
162 }
163 );
164 }
165 Ok(())
166 }
167
168 pub(crate) fn incremental_without_bookmark_pushdown(&self) -> bool {
173 matches!(self.replication, MssqlReplication::Incremental { .. })
174 && !self.query.contains("@bookmark")
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use serde_json::json;
182
183 #[test]
184 fn replication_full_is_default_and_round_trips() {
185 let r: MssqlReplication = serde_json::from_value(json!({"type": "full"})).unwrap();
186 assert_eq!(r, MssqlReplication::Full);
187 assert_eq!(MssqlReplication::default(), MssqlReplication::Full);
188 }
189
190 #[test]
191 fn replication_incremental_parses_column_and_initial_value() {
192 let r: MssqlReplication = serde_json::from_value(json!({
193 "type": "incremental",
194 "column": "updated_at",
195 "initial_value": "1970-01-01T00:00:00Z"
196 }))
197 .unwrap();
198 assert_eq!(
199 r,
200 MssqlReplication::Incremental {
201 column: "updated_at".into(),
202 initial_value: json!("1970-01-01T00:00:00Z"),
203 }
204 );
205 }
206
207 #[test]
208 fn config_flattens_connection_fields() {
209 let cfg: MssqlSourceConfig = serde_json::from_value(json!({
210 "connection_url": "mssql://sa:pw@host:1433/db",
211 "query": "SELECT 1",
212 }))
213 .unwrap();
214 assert_eq!(
215 cfg.connection.connection_url.as_deref(),
216 Some("mssql://sa:pw@host:1433/db")
217 );
218 assert_eq!(cfg.batch_size, DEFAULT_BATCH_SIZE);
219 assert_eq!(cfg.max_connections, 10);
220 assert_eq!(cfg.statement_timeout_secs, 300);
221 }
222
223 #[test]
224 fn validate_rejects_incremental_without_column() {
225 let cfg = MssqlSourceConfig {
226 replication: MssqlReplication::Incremental {
227 column: " ".into(),
228 initial_value: json!(0),
229 },
230 ..MssqlSourceConfig::new("mssql://sa:pw@h/db", "SELECT 1")
231 };
232 assert!(cfg.validate().is_err());
233 }
234
235 #[test]
236 fn incremental_without_bookmark_pushdown_flags_missing_token() {
237 let missing = MssqlSourceConfig {
239 replication: MssqlReplication::Incremental {
240 column: "updated_at".into(),
241 initial_value: json!("1970-01-01"),
242 },
243 ..MssqlSourceConfig::new("mssql://sa:pw@h/db", "SELECT * FROM t")
244 };
245 assert!(missing.incremental_without_bookmark_pushdown());
246 assert!(missing.validate().is_ok());
248
249 let with_token = MssqlSourceConfig {
251 replication: MssqlReplication::Incremental {
252 column: "updated_at".into(),
253 initial_value: json!("1970-01-01"),
254 },
255 ..MssqlSourceConfig::new(
256 "mssql://sa:pw@h/db",
257 "SELECT * FROM t WHERE updated_at > @bookmark",
258 )
259 };
260 assert!(!with_token.incremental_without_bookmark_pushdown());
261
262 let full = MssqlSourceConfig::new("mssql://sa:pw@h/db", "SELECT * FROM t");
264 assert!(!full.incremental_without_bookmark_pushdown());
265 }
266
267 #[test]
268 fn validate_rejects_bad_batch_size() {
269 let cfg = MssqlSourceConfig {
270 batch_size: faucet_core::MAX_BATCH_SIZE + 1,
271 ..MssqlSourceConfig::new("mssql://sa:pw@h/db", "SELECT 1")
272 };
273 assert!(cfg.validate().is_err());
274 }
275
276 #[test]
277 fn debug_masks_connection() {
278 let cfg = MssqlSourceConfig::new("mssql://sa:secret@h/db", "SELECT 1");
279 let dbg = format!("{cfg:?}");
280 assert!(dbg.contains("***"));
281 assert!(!dbg.contains("secret"));
282 }
283}