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}
74
75impl std::fmt::Debug for MssqlSourceConfig {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct("MssqlSourceConfig")
78 .field("connection", &"***")
79 .field("query", &self.query)
80 .field("params", &self.params)
81 .field("max_connections", &self.max_connections)
82 .field("batch_size", &self.batch_size)
83 .field("statement_timeout_secs", &self.statement_timeout_secs)
84 .field("replication", &self.replication)
85 .field("state_key", &self.state_key)
86 .finish()
87 }
88}
89
90impl MssqlSourceConfig {
91 pub fn new(connection_url: impl Into<String>, query: impl Into<String>) -> Self {
93 Self {
94 connection: MssqlConnectionConfig {
95 connection_url: Some(connection_url.into()),
96 ..Default::default()
97 },
98 query: query.into(),
99 params: Vec::new(),
100 max_connections: default_max_connections(),
101 batch_size: default_batch_size(),
102 statement_timeout_secs: default_statement_timeout_secs(),
103 replication: MssqlReplication::Full,
104 state_key: None,
105 }
106 }
107
108 pub fn validate(&self) -> Result<(), FaucetError> {
110 self.connection.validate()?;
111 validate_batch_size(self.batch_size)?;
112 if let MssqlReplication::Incremental { column, .. } = &self.replication
113 && column.trim().is_empty()
114 {
115 return Err(FaucetError::Config(
116 "MSSQL incremental replication requires a non-empty `column`".into(),
117 ));
118 }
119 if self.incremental_without_bookmark_pushdown() {
120 tracing::warn!(
121 "MSSQL incremental replication query has no `@bookmark` token: the \
122 cursor is applied client-side only, so the server returns the ENTIRE \
123 table on every run (correctness is preserved, but it is a full re-scan). \
124 Add `@bookmark` to the WHERE clause to push the cursor down, e.g. \
125 `... WHERE {column} > @bookmark`",
126 column = match &self.replication {
127 MssqlReplication::Incremental { column, .. } => column.as_str(),
128 _ => "<column>",
129 }
130 );
131 }
132 Ok(())
133 }
134
135 pub(crate) fn incremental_without_bookmark_pushdown(&self) -> bool {
140 matches!(self.replication, MssqlReplication::Incremental { .. })
141 && !self.query.contains("@bookmark")
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use serde_json::json;
149
150 #[test]
151 fn replication_full_is_default_and_round_trips() {
152 let r: MssqlReplication = serde_json::from_value(json!({"type": "full"})).unwrap();
153 assert_eq!(r, MssqlReplication::Full);
154 assert_eq!(MssqlReplication::default(), MssqlReplication::Full);
155 }
156
157 #[test]
158 fn replication_incremental_parses_column_and_initial_value() {
159 let r: MssqlReplication = serde_json::from_value(json!({
160 "type": "incremental",
161 "column": "updated_at",
162 "initial_value": "1970-01-01T00:00:00Z"
163 }))
164 .unwrap();
165 assert_eq!(
166 r,
167 MssqlReplication::Incremental {
168 column: "updated_at".into(),
169 initial_value: json!("1970-01-01T00:00:00Z"),
170 }
171 );
172 }
173
174 #[test]
175 fn config_flattens_connection_fields() {
176 let cfg: MssqlSourceConfig = serde_json::from_value(json!({
177 "connection_url": "mssql://sa:pw@host:1433/db",
178 "query": "SELECT 1",
179 }))
180 .unwrap();
181 assert_eq!(
182 cfg.connection.connection_url.as_deref(),
183 Some("mssql://sa:pw@host:1433/db")
184 );
185 assert_eq!(cfg.batch_size, DEFAULT_BATCH_SIZE);
186 assert_eq!(cfg.max_connections, 10);
187 assert_eq!(cfg.statement_timeout_secs, 300);
188 }
189
190 #[test]
191 fn validate_rejects_incremental_without_column() {
192 let cfg = MssqlSourceConfig {
193 replication: MssqlReplication::Incremental {
194 column: " ".into(),
195 initial_value: json!(0),
196 },
197 ..MssqlSourceConfig::new("mssql://sa:pw@h/db", "SELECT 1")
198 };
199 assert!(cfg.validate().is_err());
200 }
201
202 #[test]
203 fn incremental_without_bookmark_pushdown_flags_missing_token() {
204 let missing = MssqlSourceConfig {
206 replication: MssqlReplication::Incremental {
207 column: "updated_at".into(),
208 initial_value: json!("1970-01-01"),
209 },
210 ..MssqlSourceConfig::new("mssql://sa:pw@h/db", "SELECT * FROM t")
211 };
212 assert!(missing.incremental_without_bookmark_pushdown());
213 assert!(missing.validate().is_ok());
215
216 let with_token = MssqlSourceConfig {
218 replication: MssqlReplication::Incremental {
219 column: "updated_at".into(),
220 initial_value: json!("1970-01-01"),
221 },
222 ..MssqlSourceConfig::new(
223 "mssql://sa:pw@h/db",
224 "SELECT * FROM t WHERE updated_at > @bookmark",
225 )
226 };
227 assert!(!with_token.incremental_without_bookmark_pushdown());
228
229 let full = MssqlSourceConfig::new("mssql://sa:pw@h/db", "SELECT * FROM t");
231 assert!(!full.incremental_without_bookmark_pushdown());
232 }
233
234 #[test]
235 fn validate_rejects_bad_batch_size() {
236 let cfg = MssqlSourceConfig {
237 batch_size: faucet_core::MAX_BATCH_SIZE + 1,
238 ..MssqlSourceConfig::new("mssql://sa:pw@h/db", "SELECT 1")
239 };
240 assert!(cfg.validate().is_err());
241 }
242
243 #[test]
244 fn debug_masks_connection() {
245 let cfg = MssqlSourceConfig::new("mssql://sa:secret@h/db", "SELECT 1");
246 let dbg = format!("{cfg:?}");
247 assert!(dbg.contains("***"));
248 assert!(!dbg.contains("secret"));
249 }
250}