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