1use std::collections::HashMap;
4
5use faucet_common_spanner::SpannerConnection;
6use faucet_core::{DEFAULT_BATCH_SIZE, FaucetError, validate_batch_size};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11fn default_batch_size() -> usize {
12 DEFAULT_BATCH_SIZE
13}
14
15#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum SpannerReplication {
22 #[default]
24 Full,
25 Incremental {
42 column: String,
44 initial_value: Value,
47 },
48}
49
50#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
62pub struct ShardConfig {
63 pub key: String,
66}
67
68#[derive(Clone, Serialize, Deserialize, JsonSchema)]
70pub struct SpannerSourceConfig {
71 #[serde(flatten)]
74 pub connection: SpannerConnection,
75 pub query: String,
79 #[serde(default)]
83 pub params: HashMap<String, Value>,
84 #[serde(default = "default_batch_size")]
88 pub batch_size: usize,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub exact_staleness_secs: Option<u64>,
94 #[serde(default)]
96 pub replication: SpannerReplication,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub state_key: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub shard: Option<ShardConfig>,
109}
110
111impl std::fmt::Debug for SpannerSourceConfig {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.debug_struct("SpannerSourceConfig")
116 .field("connection", &self.connection)
117 .field("query", &self.query)
118 .field("params", &self.params)
119 .field("batch_size", &self.batch_size)
120 .field("exact_staleness_secs", &self.exact_staleness_secs)
121 .field("replication", &self.replication)
122 .field("state_key", &self.state_key)
123 .field("shard", &self.shard)
124 .finish()
125 }
126}
127
128fn valid_param_name(name: &str) -> bool {
131 let mut chars = name.chars();
132 match chars.next() {
133 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
134 _ => return false,
135 }
136 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
137}
138
139impl SpannerSourceConfig {
140 pub fn new(
143 project_id: impl Into<String>,
144 instance: impl Into<String>,
145 database: impl Into<String>,
146 query: impl Into<String>,
147 ) -> Self {
148 Self {
149 connection: SpannerConnection {
150 project_id: project_id.into(),
151 instance: instance.into(),
152 database: database.into(),
153 auth: Default::default(),
154 max_sessions: 100,
155 emulator_host: None,
156 },
157 query: query.into(),
158 params: HashMap::new(),
159 batch_size: default_batch_size(),
160 exact_staleness_secs: None,
161 replication: SpannerReplication::Full,
162 state_key: None,
163 shard: None,
164 }
165 }
166
167 pub fn validate(&self) -> Result<(), FaucetError> {
169 self.connection.validate()?;
170 validate_batch_size(self.batch_size)?;
171 if self.query.trim().is_empty() {
172 return Err(FaucetError::Config(
173 "spanner: `query` must not be empty".into(),
174 ));
175 }
176 for (name, value) in &self.params {
177 if !valid_param_name(name) {
178 return Err(FaucetError::Config(format!(
179 "spanner: invalid parameter name `{name}` \
180 (must match [A-Za-z_][A-Za-z0-9_]*)"
181 )));
182 }
183 match value {
184 Value::String(_) | Value::Bool(_) => {}
185 Value::Number(n) => {
186 if n.as_u64().is_some_and(|u| i64::try_from(u).is_err()) {
187 return Err(FaucetError::Config(format!(
188 "spanner: parameter `{name}` ({n}) overflows INT64"
189 )));
190 }
191 }
192 _ => {
193 return Err(FaucetError::Config(format!(
194 "spanner: parameter `{name}` must be a scalar \
195 (string / integer / float / boolean) — Spanner \
196 parameters are typed and null/array/object values \
197 have no unambiguous type"
198 )));
199 }
200 }
201 }
202 if let SpannerReplication::Incremental {
203 column,
204 initial_value,
205 } = &self.replication
206 {
207 if column.trim().is_empty() {
208 return Err(FaucetError::Config(
209 "spanner: incremental replication requires a non-empty `column`".into(),
210 ));
211 }
212 if !matches!(
213 initial_value,
214 Value::String(_) | Value::Number(_) | Value::Bool(_)
215 ) {
216 return Err(FaucetError::Config(
217 "spanner: incremental replication requires a scalar `initial_value` \
218 (string / integer / float / boolean)"
219 .into(),
220 ));
221 }
222 if self.params.contains_key("bookmark") {
223 return Err(FaucetError::Config(
224 "spanner: the parameter name `bookmark` is reserved for the \
225 incremental replication cursor"
226 .into(),
227 ));
228 }
229 if strict_bookmark_comparison(&self.query) {
230 return Err(FaucetError::Config(format!(
231 "spanner: compare `{BOOKMARK_TOKEN}` with `>=`, not `>`: the source \
232 re-reads the bookmark value and drops the rows it already emitted, and a \
233 strict comparison skips rows that share it"
234 )));
235 }
236 }
237 Ok(())
238 }
239}
240
241pub const BOOKMARK_TOKEN: &str = "@bookmark";
244
245fn strict_bookmark_comparison(query: &str) -> bool {
248 let ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
249 let mut unwrapped = String::with_capacity(query.len());
250 let mut rest = query;
251 while let Some(i) = rest.find(BOOKMARK_TOKEN) {
252 let end = i + BOOKMARK_TOKEN.len();
253 if rest[end..].starts_with(ident) {
254 unwrapped.push_str(&rest[..i]);
256 unwrapped.push_str("@_param");
257 rest = &rest[end..];
258 continue;
259 }
260 let (mut start, mut stop) = (i, end);
261 let before = rest[..i].trim_end();
262 if let Some(open) = before.strip_suffix('(') {
263 let name = open.trim_end();
264 let fname = name.trim_end_matches(ident);
265 let after = rest[end..].trim_start();
266 let after = match after.get(..3) {
267 Some(kw) if kw.eq_ignore_ascii_case("AS ") => after[3..]
268 .trim_start()
269 .trim_start_matches(ident)
270 .trim_start(),
271 _ => after,
272 };
273 if let Some(tail) = after.strip_prefix(')') {
274 start = fname.len();
275 stop = rest.len() - tail.len();
276 }
277 }
278 unwrapped.push_str(&rest[..start]);
279 unwrapped.push_str(BOOKMARK_TOKEN);
280 rest = &rest[stop..];
281 }
282 unwrapped.push_str(rest);
283 faucet_core::replication::cursor::strict_comparison(&unwrapped, BOOKMARK_TOKEN)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use serde_json::json;
290
291 fn base() -> SpannerSourceConfig {
292 SpannerSourceConfig::new("p", "i", "d", "SELECT * FROM t")
293 }
294
295 #[test]
296 fn replication_full_is_default_and_round_trips() {
297 let r: SpannerReplication = serde_json::from_value(json!({"type": "full"})).unwrap();
298 assert_eq!(r, SpannerReplication::Full);
299 assert_eq!(SpannerReplication::default(), SpannerReplication::Full);
300 }
301
302 #[test]
303 fn replication_incremental_parses_column_and_initial_value() {
304 let r: SpannerReplication = serde_json::from_value(json!({
305 "type": "incremental",
306 "column": "updated_at",
307 "initial_value": "1970-01-01T00:00:00Z"
308 }))
309 .unwrap();
310 assert_eq!(
311 r,
312 SpannerReplication::Incremental {
313 column: "updated_at".into(),
314 initial_value: json!("1970-01-01T00:00:00Z"),
315 }
316 );
317 }
318
319 #[test]
320 fn config_flattens_connection_fields() {
321 let cfg: SpannerSourceConfig = serde_json::from_value(json!({
322 "project_id": "p",
323 "instance": "i",
324 "database": "d",
325 "query": "SELECT 1",
326 }))
327 .unwrap();
328 assert_eq!(cfg.connection.project_id, "p");
329 assert_eq!(cfg.connection.max_sessions, 100);
330 assert_eq!(cfg.batch_size, DEFAULT_BATCH_SIZE);
331 assert!(cfg.exact_staleness_secs.is_none());
332 assert!(cfg.validate().is_ok());
333 }
334
335 #[test]
336 fn validate_rejects_empty_query_and_bad_batch() {
337 let mut cfg = base();
338 cfg.query = " ".into();
339 assert!(cfg.validate().is_err());
340 let mut cfg = base();
341 cfg.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
342 assert!(cfg.validate().is_err());
343 }
344
345 #[test]
346 fn validate_rejects_non_scalar_and_ill_named_params() {
347 let mut cfg = base();
348 cfg.params.insert("ok".into(), json!("x"));
349 assert!(cfg.validate().is_ok());
350
351 let mut cfg = base();
352 cfg.params.insert("bad".into(), json!(null));
353 assert!(cfg.validate().is_err());
354 let mut cfg = base();
355 cfg.params.insert("bad".into(), json!([1]));
356 assert!(cfg.validate().is_err());
357 let mut cfg = base();
358 cfg.params.insert("bad".into(), json!({"a": 1}));
359 assert!(cfg.validate().is_err());
360 let mut cfg = base();
361 cfg.params.insert("9lives".into(), json!(1));
362 assert!(cfg.validate().is_err(), "names must not start with a digit");
363 let mut cfg = base();
364 cfg.params.insert("we-ird".into(), json!(1));
365 assert!(cfg.validate().is_err(), "names must be [A-Za-z0-9_]");
366 let mut cfg = base();
367 cfg.params.insert("big".into(), json!(u64::MAX));
368 assert!(
369 cfg.validate().is_err(),
370 "u64 above i64::MAX overflows INT64"
371 );
372 }
373
374 #[test]
375 fn validate_rejects_incremental_without_column_or_with_reserved_param() {
376 let mut cfg = base();
377 cfg.replication = SpannerReplication::Incremental {
378 column: " ".into(),
379 initial_value: json!(0),
380 };
381 assert!(cfg.validate().is_err());
382
383 let mut cfg = base();
384 cfg.query = "SELECT * FROM t WHERE c > @bookmark".into();
385 cfg.replication = SpannerReplication::Incremental {
386 column: "c".into(),
387 initial_value: json!(0),
388 };
389 cfg.params.insert("bookmark".into(), json!(1));
390 assert!(cfg.validate().is_err(), "`bookmark` is reserved");
391 }
392
393 #[test]
394 fn validate_rejects_bad_incremental_settings() {
395 for (query, column, initial, needle) in [
396 (
397 "SELECT * FROM t",
398 "updated_at",
399 json!(null),
400 "initial_value",
401 ),
402 ("SELECT * FROM t", "updated_at", json!([1]), "initial_value"),
403 ("SELECT * FROM t WHERE c > @bookmark", "c", json!(0), ">="),
404 ("SELECT * FROM t WHERE @bookmark < c", "c", json!(0), ">="),
405 (
406 "SELECT * FROM t WHERE ts > TIMESTAMP(@bookmark)",
407 "ts",
408 json!("1970-01-01T00:00:00Z"),
409 ">=",
410 ),
411 (
412 "SELECT * FROM t WHERE n > CAST( @bookmark AS NUMERIC ) ORDER BY n",
413 "n",
414 json!("0"),
415 ">=",
416 ),
417 ("SELECT * FROM t WHERE c > (@bookmark)", "c", json!(0), ">="),
418 ] {
419 let mut cfg = base();
420 cfg.query = query.into();
421 cfg.replication = SpannerReplication::Incremental {
422 column: column.into(),
423 initial_value: initial,
424 };
425 let err = cfg.validate().unwrap_err().to_string();
426 assert!(err.contains(needle), "{err}");
427 }
428 let mut ok = base();
429 ok.query = "SELECT * FROM t WHERE c >= @bookmark".into();
430 ok.replication = SpannerReplication::Incremental {
431 column: "c".into(),
432 initial_value: json!(0),
433 };
434 assert!(ok.validate().is_ok());
435 for query in [
436 "SELECT * FROM t WHERE ts >= TIMESTAMP(@bookmark)",
437 "SELECT * FROM t WHERE n >= CAST(@bookmark AS NUMERIC)",
438 "SELECT * FROM t WHERE c > @bookmark_other AND c >= @bookmark",
439 "SELECT * FROM t WHERE c > f (x) AND c >= @bookmark",
440 ] {
441 assert!(!strict_bookmark_comparison(query), "{query}");
442 }
443 }
444
445 #[test]
446 fn valid_param_names() {
447 assert!(valid_param_name("cursor"));
448 assert!(valid_param_name("_x9"));
449 assert!(valid_param_name("A_b_1"));
450 assert!(!valid_param_name(""));
451 assert!(!valid_param_name("1abc"));
452 assert!(!valid_param_name("a-b"));
453 assert!(!valid_param_name("a b"));
454 }
455
456 #[test]
457 fn debug_does_not_leak_inline_key() {
458 let mut cfg = base();
459 cfg.connection.auth = faucet_common_spanner::SpannerCredentials::ServiceAccountKey {
460 json: "{\"private_key\":\"SECRET\"}".into(),
461 };
462 let dbg = format!("{cfg:?}");
463 assert!(!dbg.contains("SECRET"));
464 }
465}