1use crate::error::FaucetError;
4use chrono::{DateTime, NaiveDate, Utc};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::cmp::Ordering;
9
10pub mod cursor;
11
12#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
14#[serde(tag = "type")]
15pub enum ReplicationMethod {
16 #[default]
18 FullTable,
19 Incremental,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum OnMissingKey {
28 #[default]
31 Keep,
32 Drop,
34 Fail,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39enum KeyForm {
40 TopLevel,
41 DotPath(Vec<String>),
42 Pointer,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ReplicationKey {
54 raw: String,
55 form: KeyForm,
56}
57
58impl ReplicationKey {
59 pub fn parse(raw: &str) -> Result<Self, FaucetError> {
61 if raw.trim().is_empty() {
62 return Err(FaucetError::Config(
63 "replication_key must not be empty".to_owned(),
64 ));
65 }
66 if raw.starts_with('/') {
67 return Ok(Self {
68 raw: raw.to_owned(),
69 form: KeyForm::Pointer,
70 });
71 }
72 if !raw.contains('.') {
73 return Ok(Self::top_level(raw));
74 }
75 let segments: Vec<String> = raw.split('.').map(str::to_owned).collect();
76 if segments.iter().any(String::is_empty) {
77 return Err(FaucetError::Config(format!(
78 "replication_key '{raw}': empty path segment (use the JSON Pointer form \
79 `/a/b` for field names that contain dots)"
80 )));
81 }
82 Ok(Self {
83 raw: raw.to_owned(),
84 form: KeyForm::DotPath(segments),
85 })
86 }
87
88 pub fn top_level(name: &str) -> Self {
90 Self {
91 raw: name.to_owned(),
92 form: KeyForm::TopLevel,
93 }
94 }
95
96 pub fn as_str(&self) -> &str {
98 &self.raw
99 }
100
101 pub fn is_pointer(&self) -> bool {
103 self.form == KeyForm::Pointer
104 }
105
106 pub fn is_nested(&self) -> bool {
108 self.form != KeyForm::TopLevel
109 }
110
111 pub fn resolve<'a>(&self, record: &'a Value) -> Option<&'a Value> {
113 match &self.form {
114 KeyForm::TopLevel => record.get(&self.raw),
115 KeyForm::Pointer => record.pointer(&self.raw),
116 KeyForm::DotPath(segments) => {
117 if let Some(v) = record.get(&self.raw) {
118 return Some(v);
119 }
120 segments.iter().try_fold(record, |cur, seg| match cur {
121 Value::Object(m) => m.get(seg),
122 Value::Array(a) => seg.parse::<usize>().ok().and_then(|i| a.get(i)),
123 _ => None,
124 })
125 }
126 }
127 }
128
129 fn resolve_present<'a>(&self, record: &'a Value) -> Option<&'a Value> {
130 self.resolve(record).filter(|v| !v.is_null())
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Default)]
136pub struct IncrementalFilter {
137 pub records: Vec<Value>,
139 pub missing: usize,
141}
142
143pub fn filter_incremental_path(
150 records: Vec<Value>,
151 key: &ReplicationKey,
152 start: &Value,
153 on_missing: OnMissingKey,
154) -> Result<IncrementalFilter, FaucetError> {
155 filter_path(records, key, start, on_missing, false)
156}
157
158pub fn filter_incremental_path_inclusive(
163 records: Vec<Value>,
164 key: &ReplicationKey,
165 start: &Value,
166 on_missing: OnMissingKey,
167) -> Result<IncrementalFilter, FaucetError> {
168 filter_path(records, key, start, on_missing, true)
169}
170
171fn filter_path(
172 records: Vec<Value>,
173 key: &ReplicationKey,
174 start: &Value,
175 on_missing: OnMissingKey,
176 inclusive: bool,
177) -> Result<IncrementalFilter, FaucetError> {
178 let mut missing = 0usize;
179 let mut kept = Vec::with_capacity(records.len());
180 for r in records {
181 let keep = match key.resolve_present(&r) {
182 None => {
183 missing += 1;
184 match on_missing {
185 OnMissingKey::Keep => true,
186 OnMissingKey::Drop => false,
187 OnMissingKey::Fail => {
188 return Err(FaucetError::Source(format!(
189 "incremental replication: a record lacks replication_key '{}' \
190 (on_missing_key: fail)",
191 key.as_str()
192 )));
193 }
194 }
195 }
196 Some(v) if !comparable(v, start) => {
197 tracing::warn!(
198 key = key.as_str(),
199 "incremental replication: record key type does not match the bookmark \
200 type; keeping the record to avoid silently dropping data"
201 );
202 true
203 }
204 Some(v) if inclusive => !json_gt(start, v),
205 Some(v) => json_gt(v, start),
206 };
207 if keep {
208 kept.push(r);
209 }
210 }
211 Ok(IncrementalFilter {
212 records: kept,
213 missing,
214 })
215}
216
217pub fn filter_incremental(records: Vec<Value>, key: &str, start: &Value) -> Vec<Value> {
228 let out = filter_incremental_path(
229 records,
230 &ReplicationKey::top_level(key),
231 start,
232 OnMissingKey::Keep,
233 )
234 .unwrap_or_default();
235 if out.missing > 0 {
236 tracing::warn!(
237 key,
238 missing = out.missing,
239 "incremental replication: {} record(s) lacked replication_key '{key}'; kept to \
240 avoid silent data loss",
241 out.missing
242 );
243 }
244 out.records
245}
246
247pub fn max_replication_value_path<'a>(
249 records: &'a [Value],
250 key: &ReplicationKey,
251) -> Option<&'a Value> {
252 records
253 .iter()
254 .filter_map(|r| key.resolve_present(r))
255 .max_by(|a, b| json_compare(a, b))
256}
257
258pub fn max_replication_value<'a>(records: &'a [Value], key: &str) -> Option<&'a Value> {
260 records
261 .iter()
262 .filter_map(|r| r.get(key))
263 .max_by(|a, b| json_compare(a, b))
264}
265
266pub fn max_value(a: Value, b: Value) -> Value {
269 match json_compare(&a, &b) {
270 Ordering::Less => b,
271 _ => a,
272 }
273}
274
275fn type_rank(v: &Value) -> u8 {
278 match v {
279 Value::Null => 0,
280 Value::Bool(_) => 1,
281 Value::Number(_) => 2,
282 Value::String(_) => 3,
283 Value::Array(_) => 4,
284 Value::Object(_) => 5,
285 }
286}
287
288fn number_as_i128(n: &serde_json::Number) -> Option<i128> {
292 n.as_i64()
293 .map(i128::from)
294 .or_else(|| n.as_u64().map(i128::from))
295}
296
297fn comparable(a: &Value, b: &Value) -> bool {
301 type_rank(a) == type_rank(b) || numeric_text(a).is_some() && numeric_text(b).is_some()
302}
303
304fn numeric_text(v: &Value) -> Option<std::borrow::Cow<'_, str>> {
306 match v {
307 Value::Number(n) => Some(std::borrow::Cow::Owned(n.to_string())),
308 Value::String(s) if Decimal::parse(s).is_some() => Some(std::borrow::Cow::Borrowed(s)),
309 _ => None,
310 }
311}
312
313#[derive(Debug, PartialEq, Eq)]
317struct Decimal {
318 negative: bool,
319 digits: Vec<u8>,
320 exp: i64,
321}
322
323impl Decimal {
324 fn parse(text: &str) -> Option<Self> {
325 let s = text.trim();
326 let (negative, s) = match s.as_bytes().first()? {
327 b'-' => (true, &s[1..]),
328 b'+' => (false, &s[1..]),
329 _ => (false, s),
330 };
331 let (mantissa, exponent) = match s.find(['e', 'E']) {
332 Some(i) => (&s[..i], s[i + 1..].parse::<i64>().ok()?),
333 None => (s, 0),
334 };
335 let (int, frac) = match mantissa.split_once('.') {
336 Some((i, f)) => (i, f),
337 None => (mantissa, ""),
338 };
339 if int.is_empty() && frac.is_empty()
340 || !int.bytes().chain(frac.bytes()).all(|b| b.is_ascii_digit())
341 {
342 return None;
343 }
344 let mut digits: Vec<u8> = int.bytes().chain(frac.bytes()).map(|b| b - b'0').collect();
345 let mut point = i64::try_from(int.len()).ok()?;
346 let leading = digits.iter().take_while(|d| **d == 0).count();
347 digits.drain(..leading);
348 point -= i64::try_from(leading).ok()?;
349 while digits.last() == Some(&0) {
350 digits.pop();
351 }
352 let exp = if digits.is_empty() {
353 0
354 } else {
355 point.checked_add(exponent)?
356 };
357 Some(Self {
358 negative: negative && !digits.is_empty(),
359 digits,
360 exp,
361 })
362 }
363
364 fn signum(&self) -> i8 {
365 match (self.digits.is_empty(), self.negative) {
366 (true, _) => 0,
367 (false, true) => -1,
368 (false, false) => 1,
369 }
370 }
371
372 fn cmp_magnitude(&self, other: &Self) -> Ordering {
373 self.exp
374 .cmp(&other.exp)
375 .then_with(|| self.digits.cmp(&other.digits))
376 }
377}
378
379impl Ord for Decimal {
380 fn cmp(&self, other: &Self) -> Ordering {
381 match self.signum().cmp(&other.signum()) {
382 Ordering::Equal => match self.signum() {
383 1 => self.cmp_magnitude(other),
384 -1 => other.cmp_magnitude(self),
385 _ => Ordering::Equal,
386 },
387 unequal => unequal,
388 }
389 }
390}
391
392impl PartialOrd for Decimal {
393 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
394 Some(self.cmp(other))
395 }
396}
397
398#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
402enum Instant {
403 Zoned(DateTime<Utc>),
404 Naive(chrono::NaiveDateTime),
405}
406
407impl Instant {
408 fn parse(text: &str) -> Option<Self> {
409 let s = text.trim();
410 if s.len() < 19 || !s.as_bytes()[..4].iter().all(u8::is_ascii_digit) {
411 return None;
412 }
413 let normalized = if s.as_bytes()[10] == b' ' {
414 let mut t = s.to_string();
415 t.replace_range(10..11, "T");
416 std::borrow::Cow::Owned(t)
417 } else {
418 std::borrow::Cow::Borrowed(s)
419 };
420 if let Ok(dt) = DateTime::parse_from_rfc3339(&normalized) {
421 return Some(Self::Zoned(dt.with_timezone(&Utc)));
422 }
423 chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%dT%H:%M:%S%.f")
424 .ok()
425 .map(Self::Naive)
426 }
427}
428
429fn compare_strings(x: &str, y: &str) -> Ordering {
432 if let (Some(a), Some(b)) = (Decimal::parse(x), Decimal::parse(y)) {
433 return a.cmp(&b);
434 }
435 match (Instant::parse(x), Instant::parse(y)) {
436 (Some(a @ Instant::Zoned(_)), Some(b @ Instant::Zoned(_)))
437 | (Some(a @ Instant::Naive(_)), Some(b @ Instant::Naive(_))) => a.cmp(&b),
438 _ => x.cmp(y),
439 }
440}
441
442pub(crate) fn json_compare(a: &Value, b: &Value) -> Ordering {
455 match (a, b) {
456 (Value::Number(_), Value::String(_)) | (Value::String(_), Value::Number(_)) => {
457 match (numeric_text(a), numeric_text(b)) {
458 (Some(x), Some(y)) => match (Decimal::parse(&x), Decimal::parse(&y)) {
459 (Some(x), Some(y)) => x.cmp(&y),
460 _ => type_rank(a).cmp(&type_rank(b)),
461 },
462 _ => type_rank(a).cmp(&type_rank(b)),
463 }
464 }
465 (Value::Number(an), Value::Number(bn)) => {
466 match (number_as_i128(an), number_as_i128(bn)) {
467 (Some(ai), Some(bi)) => ai.cmp(&bi),
468 _ => {
469 let af = an.as_f64().unwrap_or(f64::NAN);
470 let bf = bn.as_f64().unwrap_or(f64::NAN);
471 af.partial_cmp(&bf).unwrap_or_else(|| {
472 match (af.is_nan(), bf.is_nan()) {
474 (false, true) => Ordering::Less,
475 (true, false) => Ordering::Greater,
476 _ => Ordering::Equal,
477 }
478 })
479 }
480 }
481 }
482 (Value::String(x), Value::String(y)) => compare_strings(x, y),
483 (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
484 (Value::Null, Value::Null) => Ordering::Equal,
485 (Value::Array(x), Value::Array(y)) => {
486 for (xi, yi) in x.iter().zip(y.iter()) {
487 let c = json_compare(xi, yi);
488 if c != Ordering::Equal {
489 return c;
490 }
491 }
492 x.len().cmp(&y.len())
493 }
494 (Value::Object(_), Value::Object(_)) => a.to_string().cmp(&b.to_string()),
497 _ => type_rank(a).cmp(&type_rank(b)),
499 }
500}
501
502pub fn json_gt(a: &Value, b: &Value) -> bool {
509 json_compare(a, b) == Ordering::Greater
510}
511
512pub const BIND_PLACEHOLDER: &str = "${bookmark}";
517
518fn default_bind_template() -> String {
519 BIND_PLACEHOLDER.to_owned()
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
524#[serde(rename_all = "snake_case")]
525pub enum BindTarget {
526 #[default]
528 Query,
529 Header,
531 Body,
535 Path,
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
550#[serde(rename_all = "snake_case")]
551pub enum BindFormat {
552 #[default]
555 Raw,
556 Iso8601,
558 EpochS,
560 EpochMs,
562 Date,
564}
565
566#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
568#[serde(rename_all = "snake_case")]
569pub enum BindValueType {
570 #[default]
572 String,
573 Number,
575}
576
577impl BindValueType {
578 pub fn to_value(self, rendered: &str) -> Result<Value, FaucetError> {
580 match self {
581 Self::String => Ok(Value::String(rendered.to_owned())),
582 Self::Number => {
583 let n: Option<serde_json::Number> = rendered
584 .parse::<i64>()
585 .map(serde_json::Number::from)
586 .ok()
587 .or_else(|| rendered.parse::<u64>().ok().map(serde_json::Number::from))
588 .or_else(|| {
589 rendered
590 .parse::<f64>()
591 .ok()
592 .and_then(serde_json::Number::from_f64)
593 });
594 n.map(Value::Number).ok_or_else(|| {
595 FaucetError::Source(format!(
596 "bind: rendered value '{rendered}' is not a number (value_type: number)"
597 ))
598 })
599 }
600 }
601 }
602}
603
604fn unescape_pointer_token(token: &str) -> String {
605 token.replace("~1", "/").replace("~0", "~")
606}
607
608pub fn set_body_pointer(body: &mut Value, pointer: &str, value: Value) -> Result<(), FaucetError> {
615 if !pointer.starts_with('/') {
616 return Err(FaucetError::Config(format!(
617 "JSON Pointer '{pointer}' must start with '/'"
618 )));
619 }
620 if let Some(slot) = body.pointer_mut(pointer) {
621 if slot.is_object() || slot.is_array() {
622 return Err(FaucetError::Source(format!(
623 "request body location '{pointer}' holds an object or array; a bind replaces \
624 a scalar value"
625 )));
626 }
627 *slot = value;
628 return Ok(());
629 }
630 let cut = pointer.rfind('/').unwrap_or(0);
631 let (parent, leaf) = (&pointer[..cut], &pointer[cut + 1..]);
632 let parent_value = if parent.is_empty() {
633 Some(body)
634 } else {
635 body.pointer_mut(parent)
636 };
637 match parent_value {
638 Some(Value::Object(map)) => {
639 map.insert(unescape_pointer_token(leaf), value);
640 Ok(())
641 }
642 _ => Err(FaucetError::Source(format!(
643 "request body has no location '{pointer}' (the pointer must resolve to an \
644 existing value, or to a new key of an existing object)"
645 ))),
646 }
647}
648
649pub(crate) fn validate_bind_placement(
652 what: &str,
653 into: BindTarget,
654 name: &str,
655 path: Option<&str>,
656) -> Result<(), FaucetError> {
657 let has_name = !name.trim().is_empty();
658 match (into, path) {
659 (BindTarget::Body, Some(p)) => {
660 if has_name {
661 return Err(FaucetError::Config(format!(
662 "{what}: set either `name` (top-level body field) or `path` (JSON Pointer), \
663 not both"
664 )));
665 }
666 if !p.starts_with('/') || p.len() < 2 {
667 return Err(FaucetError::Config(format!(
668 "{what}: `path` must be a JSON Pointer such as `/filters/0/value`, got '{p}'"
669 )));
670 }
671 Ok(())
672 }
673 (_, Some(_)) => Err(FaucetError::Config(format!(
674 "{what}: `path` applies only to `into: body`"
675 ))),
676 (BindTarget::Body, None) if !has_name => Err(FaucetError::Config(format!(
677 "{what}: `into: body` needs `name` (top-level field) or `path` (JSON Pointer)"
678 ))),
679 (_, None) if !has_name => Err(FaucetError::Config(format!(
680 "{what}: `name` must not be empty"
681 ))),
682 _ => Ok(()),
683 }
684}
685
686#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
695#[serde(deny_unknown_fields)]
696pub struct ReplicationBind {
697 #[serde(default)]
699 pub into: BindTarget,
700 #[serde(default)]
703 pub name: String,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub path: Option<String>,
710 #[serde(default)]
712 pub value_type: BindValueType,
713 #[serde(default = "default_bind_template")]
717 pub template: String,
718 #[serde(default)]
720 pub format: BindFormat,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub advance_from: Option<String>,
725}
726
727impl ReplicationBind {
728 pub fn validate(&self) -> Result<(), FaucetError> {
730 validate_bind_placement(
731 "replication bind",
732 self.into,
733 &self.name,
734 self.path.as_deref(),
735 )?;
736 if !self.template.contains(BIND_PLACEHOLDER) {
737 return Err(FaucetError::Config(format!(
738 "replication bind: `template` must contain the `{BIND_PLACEHOLDER}` placeholder"
739 )));
740 }
741 Ok(())
742 }
743
744 pub fn render(&self, bookmark: &Value) -> Result<String, FaucetError> {
747 let formatted = format_bookmark(bookmark, self.format)?;
748 Ok(self.template.replace(BIND_PLACEHOLDER, &formatted))
749 }
750}
751
752fn bookmark_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
754 match value {
755 Value::String(s) => {
756 let s = s.trim();
757 match Instant::parse(s) {
761 Some(Instant::Zoned(dt)) => return Ok(dt),
762 Some(Instant::Naive(ndt)) => {
763 return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
764 }
765 None => {}
766 }
767 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d")
768 && let Some(ndt) = d.and_hms_opt(0, 0, 0)
769 {
770 return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
771 }
772 Err(FaucetError::Config(format!(
773 "replication bind: cannot parse bookmark '{s}' as a timestamp \
774 (expected RFC 3339, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS)"
775 )))
776 }
777 Value::Number(n) => {
778 let secs = n.as_i64().or_else(|| n.as_f64().map(|f| f as i64));
779 secs.and_then(|s| DateTime::<Utc>::from_timestamp(s, 0))
780 .ok_or_else(|| {
781 FaucetError::Config(format!(
782 "replication bind: numeric bookmark {n} is out of range for epoch seconds"
783 ))
784 })
785 }
786 other => Err(FaucetError::Config(format!(
787 "replication bind: bookmark must be a string or number, got {other}"
788 ))),
789 }
790}
791
792pub fn parse_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
796 bookmark_instant(value)
797}
798
799pub fn format_instant(dt: DateTime<Utc>, format: BindFormat) -> String {
804 match format {
805 BindFormat::Raw | BindFormat::Iso8601 => {
806 dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
807 }
808 BindFormat::Date => dt.format("%Y-%m-%d").to_string(),
809 BindFormat::EpochS => dt.timestamp().to_string(),
810 BindFormat::EpochMs => dt.timestamp_millis().to_string(),
811 }
812}
813
814pub fn format_bookmark(value: &Value, format: BindFormat) -> Result<String, FaucetError> {
816 match format {
817 BindFormat::Raw => match value {
818 Value::String(s) => Ok(s.clone()),
819 Value::Number(n) => Ok(n.to_string()),
820 Value::Bool(b) => Ok(b.to_string()),
821 other => Err(FaucetError::Config(format!(
822 "replication bind: cannot render {other} as a raw scalar"
823 ))),
824 },
825 BindFormat::Iso8601 => {
826 Ok(bookmark_instant(value)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
827 }
828 BindFormat::Date => Ok(bookmark_instant(value)?.format("%Y-%m-%d").to_string()),
829 BindFormat::EpochS | BindFormat::EpochMs if value.is_number() => Ok(epoch_number(value)),
830 BindFormat::EpochS => Ok(bookmark_instant(value)?.timestamp().to_string()),
831 BindFormat::EpochMs => Ok(bookmark_instant(value)?.timestamp_millis().to_string()),
832 }
833}
834
835fn epoch_number(value: &Value) -> String {
839 match value.as_i64() {
840 Some(i) => i.to_string(),
841 None => match value.as_u64() {
842 Some(u) => u.to_string(),
843 None => (value.as_f64().unwrap_or(0.0).trunc() as i64).to_string(),
844 },
845 }
846}
847
848#[cfg(test)]
849mod inclusive_tests {
850 use super::*;
851 use serde_json::json;
852
853 #[test]
854 fn the_inclusive_filter_keeps_rows_at_the_bookmark() {
855 let key = ReplicationKey::top_level("ts");
856 let rows = vec![
857 json!({"ts": 4}),
858 json!({"ts": 5}),
859 json!({"ts": 6}),
860 json!({"ts": "x"}),
861 json!({"id": 1}),
862 ];
863 let kept =
864 filter_incremental_path_inclusive(rows.clone(), &key, &json!(5), OnMissingKey::Keep)
865 .unwrap();
866 assert_eq!(
867 kept.records,
868 vec![
869 json!({"ts": 5}),
870 json!({"ts": 6}),
871 json!({"ts": "x"}),
872 json!({"id": 1})
873 ]
874 );
875 assert_eq!(kept.missing, 1);
876 let strict = filter_incremental_path(rows, &key, &json!(5), OnMissingKey::Drop).unwrap();
877 assert_eq!(strict.records, vec![json!({"ts": 6}), json!({"ts": "x"})]);
878 }
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884 use serde_json::json;
885
886 #[test]
887 fn test_filter_incremental_strings() {
888 let records = vec![
889 json!({"id": 1, "updated_at": "2024-01-01"}),
890 json!({"id": 2, "updated_at": "2024-06-01"}),
891 json!({"id": 3, "updated_at": "2024-12-01"}),
892 ];
893 let start = json!("2024-06-01");
894 let filtered = filter_incremental(records, "updated_at", &start);
895 assert_eq!(filtered.len(), 1);
896 assert_eq!(filtered[0]["id"], 3);
897 }
898
899 #[test]
900 fn test_filter_incremental_numbers() {
901 let records = vec![
902 json!({"id": 1, "seq": 100}),
903 json!({"id": 2, "seq": 200}),
904 json!({"id": 3, "seq": 300}),
905 ];
906 let start = json!(150);
907 let filtered = filter_incremental(records, "seq", &start);
908 assert_eq!(filtered.len(), 2);
909 assert_eq!(filtered[0]["id"], 2);
910 assert_eq!(filtered[1]["id"], 3);
911 }
912
913 #[test]
914 fn test_filter_incremental_missing_key_kept() {
915 let records = vec![
917 json!({"id": 1}),
918 json!({"id": 2, "updated_at": "2024-12-01"}),
919 json!({"id": 3, "updated_at": null}),
920 ];
921 let start = json!("2024-01-01");
922 let filtered = filter_incremental(records, "updated_at", &start);
923 assert_eq!(filtered.len(), 3);
924 }
925
926 #[test]
927 fn replication_key_parse_forms() {
928 assert!(!ReplicationKey::parse("updated").unwrap().is_nested());
929 let dot = ReplicationKey::parse("fields.updated").unwrap();
930 assert!(dot.is_nested() && !dot.is_pointer());
931 assert_eq!(dot.as_str(), "fields.updated");
932 let ptr = ReplicationKey::parse("/a.b/c").unwrap();
933 assert!(ptr.is_pointer() && ptr.is_nested());
934 assert!(ReplicationKey::parse(" ").is_err());
935 assert!(ReplicationKey::parse("a..b").is_err());
936 assert!(ReplicationKey::parse(".a").is_err());
937 }
938
939 #[test]
940 fn replication_key_resolves_nested_array_and_pointer() {
941 let r = json!({
942 "fields": {"updated": "2024-06-01"},
943 "items": [{"date": 1}, {"date": 2}],
944 "a.b": {"c": 7},
945 "x": 5
946 });
947 let k = |s: &str| ReplicationKey::parse(s).unwrap();
948 assert_eq!(k("fields.updated").resolve(&r), Some(&json!("2024-06-01")));
949 assert_eq!(k("items.1.date").resolve(&r), Some(&json!(2)));
950 assert_eq!(k("items.x.date").resolve(&r), None);
951 assert_eq!(k("items.9.date").resolve(&r), None);
952 assert_eq!(k("x.y").resolve(&r), None);
953 assert_eq!(k("/a.b/c").resolve(&r), Some(&json!(7)));
954 assert_eq!(k("x").resolve(&r), Some(&json!(5)));
955 let flat = json!({"Account.LastModifiedDate": "2024"});
956 assert_eq!(
957 k("Account.LastModifiedDate").resolve(&flat),
958 Some(&json!("2024"))
959 );
960 assert_eq!(
961 ReplicationKey::top_level("a.b").resolve(&r),
962 Some(&json!({"c": 7}))
963 );
964 }
965
966 #[test]
967 fn filter_incremental_path_nested_and_policies() {
968 let records = || {
969 vec![
970 json!({"id": 1, "fields": {"updated": "2024-01-01"}}),
971 json!({"id": 2, "fields": {"updated": "2024-12-01"}}),
972 json!({"id": 3, "fields": {}}),
973 json!({"id": 4, "fields": {"updated": 5}}),
974 ]
975 };
976 let key = ReplicationKey::parse("fields.updated").unwrap();
977 let start = json!("2024-06-01");
978 let keep = filter_incremental_path(records(), &key, &start, OnMissingKey::Keep).unwrap();
979 let ids: Vec<i64> = keep
980 .records
981 .iter()
982 .map(|r| r["id"].as_i64().unwrap())
983 .collect();
984 assert_eq!(ids, vec![2, 3, 4]);
985 assert_eq!(keep.missing, 1);
986 let drop = filter_incremental_path(records(), &key, &start, OnMissingKey::Drop).unwrap();
987 assert_eq!(drop.records.len(), 2);
988 assert_eq!(drop.missing, 1);
989 let err = filter_incremental_path(records(), &key, &start, OnMissingKey::Fail).unwrap_err();
990 assert!(err.to_string().contains("fields.updated"), "{err}");
991 }
992
993 #[test]
994 fn max_replication_value_path_skips_missing_and_null() {
995 let key = ReplicationKey::parse("fields.updated").unwrap();
996 let records = vec![
997 json!({"fields": {"updated": "2024-01-01"}}),
998 json!({"fields": {"updated": null}}),
999 json!({"fields": {"updated": "2024-12-01"}}),
1000 json!({}),
1001 ];
1002 assert_eq!(
1003 max_replication_value_path(&records, &key),
1004 Some(&json!("2024-12-01"))
1005 );
1006 assert!(max_replication_value_path(&records[1..2], &key).is_none());
1007 }
1008
1009 #[test]
1010 fn on_missing_key_serde() {
1011 assert_eq!(OnMissingKey::default(), OnMissingKey::Keep);
1012 let v: OnMissingKey = serde_json::from_value(json!("fail")).unwrap();
1013 assert_eq!(v, OnMissingKey::Fail);
1014 }
1015
1016 #[test]
1017 fn test_filter_incremental_equal_excluded() {
1018 let records = vec![
1019 json!({"id": 1, "updated_at": "2024-06-01"}),
1020 json!({"id": 2, "updated_at": "2024-06-02"}),
1021 ];
1022 let start = json!("2024-06-01");
1023 let filtered = filter_incremental(records, "updated_at", &start);
1024 assert_eq!(filtered.len(), 1);
1025 assert_eq!(filtered[0]["id"], 2);
1026 }
1027
1028 #[test]
1029 fn test_max_replication_value_strings() {
1030 let records = vec![
1031 json!({"updated_at": "2024-01-01"}),
1032 json!({"updated_at": "2024-12-01"}),
1033 json!({"updated_at": "2024-06-01"}),
1034 ];
1035 let max = max_replication_value(&records, "updated_at").unwrap();
1036 assert_eq!(max, &json!("2024-12-01"));
1037 }
1038
1039 #[test]
1040 fn test_max_replication_value_numbers() {
1041 let records = vec![json!({"seq": 5}), json!({"seq": 10}), json!({"seq": 3})];
1042 let max = max_replication_value(&records, "seq").unwrap();
1043 assert_eq!(max, &json!(10));
1044 }
1045
1046 #[test]
1047 fn test_max_replication_value_empty() {
1048 let records: Vec<Value> = vec![];
1049 assert!(max_replication_value(&records, "updated_at").is_none());
1050 }
1051
1052 #[test]
1053 fn test_max_value_picks_larger_string() {
1054 assert_eq!(
1055 max_value(json!("2024-01-01"), json!("2024-06-01")),
1056 json!("2024-06-01")
1057 );
1058 }
1059
1060 #[test]
1061 fn test_max_value_picks_larger_number() {
1062 assert_eq!(max_value(json!(5), json!(10)), json!(10));
1063 }
1064
1065 #[test]
1066 fn test_max_value_returns_a_on_type_mismatch() {
1067 assert_eq!(max_value(json!("string"), json!(5)), json!("string"));
1070 }
1071
1072 #[test]
1073 fn filter_incremental_keeps_large_integer_beyond_f64_precision() {
1074 let two_pow_53 = 9_007_199_254_740_992_i64; let records = vec![
1079 json!({"id": 1, "seq": two_pow_53 + 1}),
1080 json!({"id": 2, "seq": two_pow_53 + 2}),
1081 ];
1082 let start = json!(two_pow_53);
1083 let filtered = filter_incremental(records, "seq", &start);
1084 assert_eq!(
1085 filtered.len(),
1086 2,
1087 "both values are strictly greater than 2^53"
1088 );
1089 }
1090
1091 #[test]
1092 fn json_compare_distinguishes_large_integers() {
1093 let a = json!(9_007_199_254_740_993_i64); let b = json!(9_007_199_254_740_992_i64); assert_eq!(json_compare(&a, &b), Ordering::Greater);
1096 }
1097
1098 #[test]
1099 fn filter_incremental_keeps_records_on_type_mismatch() {
1100 let records = vec![json!({"id": 1, "seq": 20_240_701})];
1104 let start = json!("2024-06-01"); let filtered = filter_incremental(records, "seq", &start);
1106 assert_eq!(filtered.len(), 1, "type mismatch must not silently drop");
1107 }
1108
1109 fn cmp(a: Value, b: Value) -> Ordering {
1110 json_compare(&a, &b)
1111 }
1112
1113 #[test]
1114 fn decimal_strings_order_numerically_and_exactly() {
1115 use Ordering::*;
1116 let cases = [
1117 ("99", "100", Less),
1118 ("100", "99", Greater),
1119 ("989", "99", Greater),
1120 ("007", "7", Equal),
1121 ("1.50", "1.5", Equal),
1122 ("0.000", "-0", Equal),
1123 ("-1", "0", Less),
1124 ("-10", "-9", Less),
1125 ("-0.5", "-0.25", Less),
1126 ("1e3", "999", Greater),
1127 ("1E-2", "0.01", Equal),
1128 ("+5", "5", Equal),
1129 (".5", "0.4", Greater),
1130 (
1131 "12345678901234567890123456789012345678",
1132 "12345678901234567890123456789012345677",
1133 Greater,
1134 ),
1135 (
1136 "0.000000000000000000000000000000000002",
1137 "0.000000000000000000000000000000000001",
1138 Greater,
1139 ),
1140 ];
1141 for (a, b, want) in cases {
1142 assert_eq!(cmp(json!(a), json!(b)), want, "{a} vs {b}");
1143 }
1144 }
1145
1146 #[test]
1147 fn decimal_parse_rejects_what_is_not_a_number() {
1148 for text in [
1149 "",
1150 "-",
1151 ".",
1152 "1.2.3",
1153 "1e",
1154 "12a",
1155 "2024-06-01",
1156 "e5",
1157 "1e9999999999999999999",
1158 ] {
1159 assert!(Decimal::parse(text).is_none(), "{text:?}");
1160 }
1161 }
1162
1163 #[test]
1164 fn a_decimal_string_bookmark_keeps_larger_rows() {
1165 let records: Vec<Value> = ["100", "989", "50", "99"]
1166 .iter()
1167 .map(|v| json!({ "k": v }))
1168 .collect();
1169 let kept = filter_incremental(records, "k", &json!("99"));
1170 let ks: Vec<&str> = kept.iter().map(|r| r["k"].as_str().unwrap()).collect();
1171 assert_eq!(ks, ["100", "989"]);
1172 }
1173
1174 #[test]
1175 fn numbers_and_decimal_strings_compare_numerically() {
1176 assert_eq!(cmp(json!(100), json!("99")), Ordering::Greater);
1177 assert_eq!(
1178 cmp(json!("123456789012345678901234567890"), json!(5)),
1179 Ordering::Greater
1180 );
1181 assert_eq!(cmp(json!(2.5), json!("2.50")), Ordering::Equal);
1182 let records = vec![
1183 json!({"k": 7}),
1184 json!({"k": 123456789012345678901234567890_f64}),
1185 ];
1186 let kept = filter_incremental(records, "k", &json!("6.5"));
1187 assert_eq!(kept.len(), 2);
1188 assert_eq!(
1189 max_value(json!("12345678901234567890"), json!(12)),
1190 json!("12345678901234567890")
1191 );
1192 assert_eq!(max_value(json!("12"), json!(13)), json!(13));
1193 }
1194
1195 #[test]
1196 fn timestamps_order_as_instants_across_offsets_and_fraction_widths() {
1197 use Ordering::*;
1198 let cases = [
1199 ("2024-01-01T10:00:00+05:00", "2024-01-01T06:00:00Z", Less),
1200 ("2024-01-01T10:00:00.5Z", "2024-01-01T10:00:00.45Z", Greater),
1201 ("2024-01-01T10:00:00Z", "2024-01-01T10:00:00.000Z", Equal),
1202 ("2024-01-01 10:00:00+00:00", "2024-01-01T09:59:59Z", Greater),
1203 ("2024-01-01T10:00:00.5", "2024-01-01T10:00:00.45", Greater),
1204 ("2024-01-01 10:00:00", "2024-01-01T09:00:00", Greater),
1205 ];
1206 for (a, b, want) in cases {
1207 assert_eq!(cmp(json!(a), json!(b)), want, "{a} vs {b}");
1208 }
1209 let records = vec![
1210 json!({"t": "2024-01-01T08:00:00+01:00"}),
1211 json!({"t": "2024-01-01T08:00:00-01:00"}),
1212 ];
1213 let kept = filter_incremental(records, "t", &json!("2024-01-01T08:00:00Z"));
1214 assert_eq!(kept, vec![json!({"t": "2024-01-01T08:00:00-01:00"})]);
1215 }
1216
1217 #[test]
1218 fn mixed_or_unparseable_strings_stay_lexicographic() {
1219 assert_eq!(
1220 cmp(json!("2024-01-01T10:00:00Z"), json!("2024-01-01T10:00:00")),
1221 "2024-01-01T10:00:00Z".cmp("2024-01-01T10:00:00")
1222 );
1223 assert_eq!(cmp(json!("b"), json!("a")), Ordering::Greater);
1224 assert_eq!(
1225 cmp(json!("2024-13-99T99:99:99"), json!("2024")),
1226 Ordering::Greater
1227 );
1228 assert_eq!(cmp(json!("abc"), json!(5)), Ordering::Greater);
1229 assert_eq!(cmp(json!(true), json!("1")), Ordering::Less);
1230 }
1231
1232 fn bind(into: BindTarget, template: &str, format: BindFormat) -> ReplicationBind {
1235 ReplicationBind {
1236 into,
1237 name: "updated_after".to_owned(),
1238 template: template.to_owned(),
1239 format,
1240 advance_from: None,
1241 path: None,
1242 value_type: BindValueType::String,
1243 }
1244 }
1245
1246 #[test]
1247 fn bind_defaults_template_to_bare_placeholder() {
1248 let b: ReplicationBind =
1249 serde_json::from_value(json!({ "name": "since" })).expect("deserializes");
1250 assert_eq!(b.into, BindTarget::Query);
1251 assert_eq!(b.template, "${bookmark}");
1252 assert_eq!(b.format, BindFormat::Raw);
1253 assert!(b.advance_from.is_none());
1254 }
1255
1256 #[test]
1257 fn bind_render_raw_string_and_number() {
1258 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1259 assert_eq!(b.render(&json!("2024-06-01")).unwrap(), "2024-06-01");
1260 assert_eq!(b.render(&json!(150)).unwrap(), "150");
1261 }
1262
1263 #[test]
1264 fn bind_render_applies_operator_template() {
1265 let b = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
1266 assert_eq!(
1267 b.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1268 "gte|2024-06-01T00:00:00Z"
1269 );
1270 let l = bind(BindTarget::Query, "[${bookmark} TO *]", BindFormat::Raw);
1272 assert_eq!(l.render(&json!("20240601")).unwrap(), "[20240601 TO *]");
1273 }
1274
1275 #[test]
1276 fn bind_format_iso8601_from_date_and_epoch() {
1277 let b = bind(BindTarget::Header, "${bookmark}", BindFormat::Iso8601);
1278 assert_eq!(
1279 b.render(&json!("2024-06-01")).unwrap(),
1280 "2024-06-01T00:00:00Z"
1281 );
1282 assert_eq!(
1284 b.render(&json!(1_717_200_000)).unwrap(),
1285 "2024-06-01T00:00:00Z"
1286 );
1287 }
1288
1289 #[test]
1290 fn bind_format_epoch_s_and_ms_from_iso() {
1291 let s = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochS);
1292 assert_eq!(
1293 s.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1294 "1717200000"
1295 );
1296 let ms = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochMs);
1297 assert_eq!(
1298 ms.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1299 "1717200000000"
1300 );
1301 }
1302
1303 #[test]
1304 fn bind_format_date_truncates_datetime() {
1305 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Date);
1306 assert_eq!(
1307 b.render(&json!("2024-06-01T12:34:56Z")).unwrap(),
1308 "2024-06-01"
1309 );
1310 }
1311
1312 #[test]
1313 fn bind_format_accepts_every_spelling_the_bookmark_ordering_stores() {
1314 let ms = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochMs);
1315 assert_eq!(
1316 ms.render(&json!("2024-06-01T12:00:00.123")).unwrap(),
1317 "1717243200123"
1318 );
1319 assert_eq!(
1320 ms.render(&json!("2024-06-01 12:00:00")).unwrap(),
1321 "1717243200000"
1322 );
1323 assert_eq!(
1324 ms.render(&json!("2024-06-01 12:00:00.5+02:00")).unwrap(),
1325 "1717236000500"
1326 );
1327 assert!(ms.render(&json!("not a time")).is_err());
1328 }
1329
1330 #[test]
1331 fn bind_format_naive_datetime_assumed_utc() {
1332 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
1333 assert_eq!(
1334 b.render(&json!("2024-06-01T08:00:00")).unwrap(),
1335 "2024-06-01T08:00:00Z"
1336 );
1337 }
1338
1339 #[test]
1340 fn bind_format_unparseable_string_errors() {
1341 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
1342 assert!(b.render(&json!("not-a-date")).is_err());
1343 }
1344
1345 #[test]
1346 fn bind_format_raw_rejects_composite() {
1347 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1348 assert!(b.render(&json!({"a": 1})).is_err());
1349 assert!(b.render(&json!(null)).is_err());
1350 }
1351
1352 #[test]
1353 fn bind_validate_rejects_empty_name_and_missing_placeholder() {
1354 let mut b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1355 b.name = " ".to_owned();
1356 assert!(b.validate().is_err());
1357
1358 let mut b2 = bind(BindTarget::Query, "no placeholder here", BindFormat::Raw);
1359 b2.name = "since".to_owned();
1360 assert!(b2.validate().is_err());
1361
1362 let ok = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
1363 assert!(ok.validate().is_ok());
1364 }
1365
1366 #[test]
1367 fn bind_placement_validation() {
1368 let mut b = bind(BindTarget::Body, "${bookmark}", BindFormat::Raw);
1369 assert!(b.validate().is_ok());
1370 b.path = Some("/a/0/b".into());
1371 assert!(b.validate().unwrap_err().to_string().contains("not both"));
1372 b.name.clear();
1373 assert!(b.validate().is_ok());
1374 b.path = Some("a".into());
1375 assert!(b.validate().is_err());
1376 b.path = Some("/".into());
1377 assert!(b.validate().is_err());
1378 b.path = None;
1379 assert!(
1380 b.validate()
1381 .unwrap_err()
1382 .to_string()
1383 .contains("needs `name`")
1384 );
1385 let mut q = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1386 q.path = Some("/a".into());
1387 assert!(
1388 q.validate()
1389 .unwrap_err()
1390 .to_string()
1391 .contains("only to `into: body`")
1392 );
1393 let parsed: ReplicationBind = serde_json::from_value(json!({
1394 "into": "body", "path": "/f/0/v", "value_type": "number"
1395 }))
1396 .unwrap();
1397 assert!(parsed.validate().is_ok());
1398 assert_eq!(parsed.value_type, BindValueType::Number);
1399 }
1400
1401 #[test]
1402 fn value_type_conversion() {
1403 assert_eq!(BindValueType::String.to_value("5").unwrap(), json!("5"));
1404 assert_eq!(BindValueType::Number.to_value("5").unwrap(), json!(5));
1405 assert_eq!(
1406 BindValueType::Number
1407 .to_value("18446744073709551615")
1408 .unwrap(),
1409 json!(18_446_744_073_709_551_615_u64)
1410 );
1411 assert_eq!(BindValueType::Number.to_value("1.5").unwrap(), json!(1.5));
1412 assert!(BindValueType::Number.to_value("x").is_err());
1413 }
1414
1415 #[test]
1416 fn set_body_pointer_rules() {
1417 let mut body = json!({"filterGroups": [{"filters": [{"value": null}]}], "v": {}, "a/b": 1});
1418 set_body_pointer(&mut body, "/filterGroups/0/filters/0/value", json!("x")).unwrap();
1419 assert_eq!(body["filterGroups"][0]["filters"][0]["value"], json!("x"));
1420 set_body_pointer(&mut body, "/v/after", json!("c")).unwrap();
1421 assert_eq!(body["v"]["after"], json!("c"));
1422 set_body_pointer(&mut body, "/top", json!(1)).unwrap();
1423 assert_eq!(body["top"], json!(1));
1424 set_body_pointer(&mut body, "/a~1b", json!(2)).unwrap();
1425 assert_eq!(body["a/b"], json!(2));
1426 set_body_pointer(&mut body, "/v/x~1y~0z", json!(3)).unwrap();
1427 assert_eq!(body["v"]["x/y~z"], json!(3));
1428 assert!(set_body_pointer(&mut body, "/filterGroups/1/filters", json!(1)).is_err());
1429 assert!(set_body_pointer(&mut body, "/missing/leaf", json!(1)).is_err());
1430 assert!(set_body_pointer(&mut body, "/v", json!(1)).is_err());
1431 assert!(set_body_pointer(&mut body, "/filterGroups/0/filters/5", json!(1)).is_err());
1432 assert!(set_body_pointer(&mut body, "nope", json!(1)).is_err());
1433 }
1434
1435 #[test]
1436 fn bind_format_bookmark_bool_raw() {
1437 assert_eq!(
1438 format_bookmark(&json!(true), BindFormat::Raw).unwrap(),
1439 "true"
1440 );
1441 }
1442
1443 #[test]
1444 fn bind_format_non_scalar_bookmark_errors() {
1445 assert!(format_bookmark(&json!({"a": 1}), BindFormat::Iso8601).is_err());
1447 assert!(format_bookmark(&json!(null), BindFormat::EpochS).is_err());
1448 }
1449
1450 #[test]
1451 fn numeric_bookmarks_keep_the_epoch_binds_unit() {
1452 assert_eq!(
1453 format_bookmark(&json!(1_717_200_000_000_i64), BindFormat::EpochMs).unwrap(),
1454 "1717200000000"
1455 );
1456 assert_eq!(
1457 format_bookmark(&json!(1_717_200_000), BindFormat::EpochS).unwrap(),
1458 "1717200000"
1459 );
1460 assert_eq!(
1461 format_bookmark(&json!(u64::MAX), BindFormat::EpochMs).unwrap(),
1462 u64::MAX.to_string()
1463 );
1464 assert_eq!(
1465 format_bookmark(&json!(1_717_200_000.9), BindFormat::EpochS).unwrap(),
1466 "1717200000"
1467 );
1468 }
1469
1470 #[test]
1471 fn bind_format_out_of_range_epoch_errors() {
1472 assert!(format_bookmark(&json!(i64::MAX), BindFormat::Iso8601).is_err());
1474 }
1475}