1use crate::error::FaucetError;
4use chrono::{DateTime, NaiveDate, NaiveDateTime, 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 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
758 return Ok(dt.with_timezone(&Utc));
759 }
760 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d")
761 && let Some(ndt) = d.and_hms_opt(0, 0, 0)
762 {
763 return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
764 }
765 if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
766 return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
767 }
768 Err(FaucetError::Config(format!(
769 "replication bind: cannot parse bookmark '{s}' as a timestamp \
770 (expected RFC 3339, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS)"
771 )))
772 }
773 Value::Number(n) => {
774 let secs = n.as_i64().or_else(|| n.as_f64().map(|f| f as i64));
775 secs.and_then(|s| DateTime::<Utc>::from_timestamp(s, 0))
776 .ok_or_else(|| {
777 FaucetError::Config(format!(
778 "replication bind: numeric bookmark {n} is out of range for epoch seconds"
779 ))
780 })
781 }
782 other => Err(FaucetError::Config(format!(
783 "replication bind: bookmark must be a string or number, got {other}"
784 ))),
785 }
786}
787
788pub fn parse_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
792 bookmark_instant(value)
793}
794
795pub fn format_instant(dt: DateTime<Utc>, format: BindFormat) -> String {
800 match format {
801 BindFormat::Raw | BindFormat::Iso8601 => {
802 dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
803 }
804 BindFormat::Date => dt.format("%Y-%m-%d").to_string(),
805 BindFormat::EpochS => dt.timestamp().to_string(),
806 BindFormat::EpochMs => dt.timestamp_millis().to_string(),
807 }
808}
809
810pub fn format_bookmark(value: &Value, format: BindFormat) -> Result<String, FaucetError> {
812 match format {
813 BindFormat::Raw => match value {
814 Value::String(s) => Ok(s.clone()),
815 Value::Number(n) => Ok(n.to_string()),
816 Value::Bool(b) => Ok(b.to_string()),
817 other => Err(FaucetError::Config(format!(
818 "replication bind: cannot render {other} as a raw scalar"
819 ))),
820 },
821 BindFormat::Iso8601 => {
822 Ok(bookmark_instant(value)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
823 }
824 BindFormat::Date => Ok(bookmark_instant(value)?.format("%Y-%m-%d").to_string()),
825 BindFormat::EpochS | BindFormat::EpochMs if value.is_number() => Ok(epoch_number(value)),
826 BindFormat::EpochS => Ok(bookmark_instant(value)?.timestamp().to_string()),
827 BindFormat::EpochMs => Ok(bookmark_instant(value)?.timestamp_millis().to_string()),
828 }
829}
830
831fn epoch_number(value: &Value) -> String {
835 match value.as_i64() {
836 Some(i) => i.to_string(),
837 None => match value.as_u64() {
838 Some(u) => u.to_string(),
839 None => (value.as_f64().unwrap_or(0.0).trunc() as i64).to_string(),
840 },
841 }
842}
843
844#[cfg(test)]
845mod inclusive_tests {
846 use super::*;
847 use serde_json::json;
848
849 #[test]
850 fn the_inclusive_filter_keeps_rows_at_the_bookmark() {
851 let key = ReplicationKey::top_level("ts");
852 let rows = vec![
853 json!({"ts": 4}),
854 json!({"ts": 5}),
855 json!({"ts": 6}),
856 json!({"ts": "x"}),
857 json!({"id": 1}),
858 ];
859 let kept =
860 filter_incremental_path_inclusive(rows.clone(), &key, &json!(5), OnMissingKey::Keep)
861 .unwrap();
862 assert_eq!(
863 kept.records,
864 vec![
865 json!({"ts": 5}),
866 json!({"ts": 6}),
867 json!({"ts": "x"}),
868 json!({"id": 1})
869 ]
870 );
871 assert_eq!(kept.missing, 1);
872 let strict = filter_incremental_path(rows, &key, &json!(5), OnMissingKey::Drop).unwrap();
873 assert_eq!(strict.records, vec![json!({"ts": 6}), json!({"ts": "x"})]);
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use serde_json::json;
881
882 #[test]
883 fn test_filter_incremental_strings() {
884 let records = vec![
885 json!({"id": 1, "updated_at": "2024-01-01"}),
886 json!({"id": 2, "updated_at": "2024-06-01"}),
887 json!({"id": 3, "updated_at": "2024-12-01"}),
888 ];
889 let start = json!("2024-06-01");
890 let filtered = filter_incremental(records, "updated_at", &start);
891 assert_eq!(filtered.len(), 1);
892 assert_eq!(filtered[0]["id"], 3);
893 }
894
895 #[test]
896 fn test_filter_incremental_numbers() {
897 let records = vec![
898 json!({"id": 1, "seq": 100}),
899 json!({"id": 2, "seq": 200}),
900 json!({"id": 3, "seq": 300}),
901 ];
902 let start = json!(150);
903 let filtered = filter_incremental(records, "seq", &start);
904 assert_eq!(filtered.len(), 2);
905 assert_eq!(filtered[0]["id"], 2);
906 assert_eq!(filtered[1]["id"], 3);
907 }
908
909 #[test]
910 fn test_filter_incremental_missing_key_kept() {
911 let records = vec![
913 json!({"id": 1}),
914 json!({"id": 2, "updated_at": "2024-12-01"}),
915 json!({"id": 3, "updated_at": null}),
916 ];
917 let start = json!("2024-01-01");
918 let filtered = filter_incremental(records, "updated_at", &start);
919 assert_eq!(filtered.len(), 3);
920 }
921
922 #[test]
923 fn replication_key_parse_forms() {
924 assert!(!ReplicationKey::parse("updated").unwrap().is_nested());
925 let dot = ReplicationKey::parse("fields.updated").unwrap();
926 assert!(dot.is_nested() && !dot.is_pointer());
927 assert_eq!(dot.as_str(), "fields.updated");
928 let ptr = ReplicationKey::parse("/a.b/c").unwrap();
929 assert!(ptr.is_pointer() && ptr.is_nested());
930 assert!(ReplicationKey::parse(" ").is_err());
931 assert!(ReplicationKey::parse("a..b").is_err());
932 assert!(ReplicationKey::parse(".a").is_err());
933 }
934
935 #[test]
936 fn replication_key_resolves_nested_array_and_pointer() {
937 let r = json!({
938 "fields": {"updated": "2024-06-01"},
939 "items": [{"date": 1}, {"date": 2}],
940 "a.b": {"c": 7},
941 "x": 5
942 });
943 let k = |s: &str| ReplicationKey::parse(s).unwrap();
944 assert_eq!(k("fields.updated").resolve(&r), Some(&json!("2024-06-01")));
945 assert_eq!(k("items.1.date").resolve(&r), Some(&json!(2)));
946 assert_eq!(k("items.x.date").resolve(&r), None);
947 assert_eq!(k("items.9.date").resolve(&r), None);
948 assert_eq!(k("x.y").resolve(&r), None);
949 assert_eq!(k("/a.b/c").resolve(&r), Some(&json!(7)));
950 assert_eq!(k("x").resolve(&r), Some(&json!(5)));
951 let flat = json!({"Account.LastModifiedDate": "2024"});
952 assert_eq!(
953 k("Account.LastModifiedDate").resolve(&flat),
954 Some(&json!("2024"))
955 );
956 assert_eq!(
957 ReplicationKey::top_level("a.b").resolve(&r),
958 Some(&json!({"c": 7}))
959 );
960 }
961
962 #[test]
963 fn filter_incremental_path_nested_and_policies() {
964 let records = || {
965 vec![
966 json!({"id": 1, "fields": {"updated": "2024-01-01"}}),
967 json!({"id": 2, "fields": {"updated": "2024-12-01"}}),
968 json!({"id": 3, "fields": {}}),
969 json!({"id": 4, "fields": {"updated": 5}}),
970 ]
971 };
972 let key = ReplicationKey::parse("fields.updated").unwrap();
973 let start = json!("2024-06-01");
974 let keep = filter_incremental_path(records(), &key, &start, OnMissingKey::Keep).unwrap();
975 let ids: Vec<i64> = keep
976 .records
977 .iter()
978 .map(|r| r["id"].as_i64().unwrap())
979 .collect();
980 assert_eq!(ids, vec![2, 3, 4]);
981 assert_eq!(keep.missing, 1);
982 let drop = filter_incremental_path(records(), &key, &start, OnMissingKey::Drop).unwrap();
983 assert_eq!(drop.records.len(), 2);
984 assert_eq!(drop.missing, 1);
985 let err = filter_incremental_path(records(), &key, &start, OnMissingKey::Fail).unwrap_err();
986 assert!(err.to_string().contains("fields.updated"), "{err}");
987 }
988
989 #[test]
990 fn max_replication_value_path_skips_missing_and_null() {
991 let key = ReplicationKey::parse("fields.updated").unwrap();
992 let records = vec![
993 json!({"fields": {"updated": "2024-01-01"}}),
994 json!({"fields": {"updated": null}}),
995 json!({"fields": {"updated": "2024-12-01"}}),
996 json!({}),
997 ];
998 assert_eq!(
999 max_replication_value_path(&records, &key),
1000 Some(&json!("2024-12-01"))
1001 );
1002 assert!(max_replication_value_path(&records[1..2], &key).is_none());
1003 }
1004
1005 #[test]
1006 fn on_missing_key_serde() {
1007 assert_eq!(OnMissingKey::default(), OnMissingKey::Keep);
1008 let v: OnMissingKey = serde_json::from_value(json!("fail")).unwrap();
1009 assert_eq!(v, OnMissingKey::Fail);
1010 }
1011
1012 #[test]
1013 fn test_filter_incremental_equal_excluded() {
1014 let records = vec![
1015 json!({"id": 1, "updated_at": "2024-06-01"}),
1016 json!({"id": 2, "updated_at": "2024-06-02"}),
1017 ];
1018 let start = json!("2024-06-01");
1019 let filtered = filter_incremental(records, "updated_at", &start);
1020 assert_eq!(filtered.len(), 1);
1021 assert_eq!(filtered[0]["id"], 2);
1022 }
1023
1024 #[test]
1025 fn test_max_replication_value_strings() {
1026 let records = vec![
1027 json!({"updated_at": "2024-01-01"}),
1028 json!({"updated_at": "2024-12-01"}),
1029 json!({"updated_at": "2024-06-01"}),
1030 ];
1031 let max = max_replication_value(&records, "updated_at").unwrap();
1032 assert_eq!(max, &json!("2024-12-01"));
1033 }
1034
1035 #[test]
1036 fn test_max_replication_value_numbers() {
1037 let records = vec![json!({"seq": 5}), json!({"seq": 10}), json!({"seq": 3})];
1038 let max = max_replication_value(&records, "seq").unwrap();
1039 assert_eq!(max, &json!(10));
1040 }
1041
1042 #[test]
1043 fn test_max_replication_value_empty() {
1044 let records: Vec<Value> = vec![];
1045 assert!(max_replication_value(&records, "updated_at").is_none());
1046 }
1047
1048 #[test]
1049 fn test_max_value_picks_larger_string() {
1050 assert_eq!(
1051 max_value(json!("2024-01-01"), json!("2024-06-01")),
1052 json!("2024-06-01")
1053 );
1054 }
1055
1056 #[test]
1057 fn test_max_value_picks_larger_number() {
1058 assert_eq!(max_value(json!(5), json!(10)), json!(10));
1059 }
1060
1061 #[test]
1062 fn test_max_value_returns_a_on_type_mismatch() {
1063 assert_eq!(max_value(json!("string"), json!(5)), json!("string"));
1066 }
1067
1068 #[test]
1069 fn filter_incremental_keeps_large_integer_beyond_f64_precision() {
1070 let two_pow_53 = 9_007_199_254_740_992_i64; let records = vec![
1075 json!({"id": 1, "seq": two_pow_53 + 1}),
1076 json!({"id": 2, "seq": two_pow_53 + 2}),
1077 ];
1078 let start = json!(two_pow_53);
1079 let filtered = filter_incremental(records, "seq", &start);
1080 assert_eq!(
1081 filtered.len(),
1082 2,
1083 "both values are strictly greater than 2^53"
1084 );
1085 }
1086
1087 #[test]
1088 fn json_compare_distinguishes_large_integers() {
1089 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);
1092 }
1093
1094 #[test]
1095 fn filter_incremental_keeps_records_on_type_mismatch() {
1096 let records = vec![json!({"id": 1, "seq": 20_240_701})];
1100 let start = json!("2024-06-01"); let filtered = filter_incremental(records, "seq", &start);
1102 assert_eq!(filtered.len(), 1, "type mismatch must not silently drop");
1103 }
1104
1105 fn cmp(a: Value, b: Value) -> Ordering {
1106 json_compare(&a, &b)
1107 }
1108
1109 #[test]
1110 fn decimal_strings_order_numerically_and_exactly() {
1111 use Ordering::*;
1112 let cases = [
1113 ("99", "100", Less),
1114 ("100", "99", Greater),
1115 ("989", "99", Greater),
1116 ("007", "7", Equal),
1117 ("1.50", "1.5", Equal),
1118 ("0.000", "-0", Equal),
1119 ("-1", "0", Less),
1120 ("-10", "-9", Less),
1121 ("-0.5", "-0.25", Less),
1122 ("1e3", "999", Greater),
1123 ("1E-2", "0.01", Equal),
1124 ("+5", "5", Equal),
1125 (".5", "0.4", Greater),
1126 (
1127 "12345678901234567890123456789012345678",
1128 "12345678901234567890123456789012345677",
1129 Greater,
1130 ),
1131 (
1132 "0.000000000000000000000000000000000002",
1133 "0.000000000000000000000000000000000001",
1134 Greater,
1135 ),
1136 ];
1137 for (a, b, want) in cases {
1138 assert_eq!(cmp(json!(a), json!(b)), want, "{a} vs {b}");
1139 }
1140 }
1141
1142 #[test]
1143 fn decimal_parse_rejects_what_is_not_a_number() {
1144 for text in [
1145 "",
1146 "-",
1147 ".",
1148 "1.2.3",
1149 "1e",
1150 "12a",
1151 "2024-06-01",
1152 "e5",
1153 "1e9999999999999999999",
1154 ] {
1155 assert!(Decimal::parse(text).is_none(), "{text:?}");
1156 }
1157 }
1158
1159 #[test]
1160 fn a_decimal_string_bookmark_keeps_larger_rows() {
1161 let records: Vec<Value> = ["100", "989", "50", "99"]
1162 .iter()
1163 .map(|v| json!({ "k": v }))
1164 .collect();
1165 let kept = filter_incremental(records, "k", &json!("99"));
1166 let ks: Vec<&str> = kept.iter().map(|r| r["k"].as_str().unwrap()).collect();
1167 assert_eq!(ks, ["100", "989"]);
1168 }
1169
1170 #[test]
1171 fn numbers_and_decimal_strings_compare_numerically() {
1172 assert_eq!(cmp(json!(100), json!("99")), Ordering::Greater);
1173 assert_eq!(
1174 cmp(json!("123456789012345678901234567890"), json!(5)),
1175 Ordering::Greater
1176 );
1177 assert_eq!(cmp(json!(2.5), json!("2.50")), Ordering::Equal);
1178 let records = vec![
1179 json!({"k": 7}),
1180 json!({"k": 123456789012345678901234567890_f64}),
1181 ];
1182 let kept = filter_incremental(records, "k", &json!("6.5"));
1183 assert_eq!(kept.len(), 2);
1184 assert_eq!(
1185 max_value(json!("12345678901234567890"), json!(12)),
1186 json!("12345678901234567890")
1187 );
1188 assert_eq!(max_value(json!("12"), json!(13)), json!(13));
1189 }
1190
1191 #[test]
1192 fn timestamps_order_as_instants_across_offsets_and_fraction_widths() {
1193 use Ordering::*;
1194 let cases = [
1195 ("2024-01-01T10:00:00+05:00", "2024-01-01T06:00:00Z", Less),
1196 ("2024-01-01T10:00:00.5Z", "2024-01-01T10:00:00.45Z", Greater),
1197 ("2024-01-01T10:00:00Z", "2024-01-01T10:00:00.000Z", Equal),
1198 ("2024-01-01 10:00:00+00:00", "2024-01-01T09:59:59Z", Greater),
1199 ("2024-01-01T10:00:00.5", "2024-01-01T10:00:00.45", Greater),
1200 ("2024-01-01 10:00:00", "2024-01-01T09:00:00", Greater),
1201 ];
1202 for (a, b, want) in cases {
1203 assert_eq!(cmp(json!(a), json!(b)), want, "{a} vs {b}");
1204 }
1205 let records = vec![
1206 json!({"t": "2024-01-01T08:00:00+01:00"}),
1207 json!({"t": "2024-01-01T08:00:00-01:00"}),
1208 ];
1209 let kept = filter_incremental(records, "t", &json!("2024-01-01T08:00:00Z"));
1210 assert_eq!(kept, vec![json!({"t": "2024-01-01T08:00:00-01:00"})]);
1211 }
1212
1213 #[test]
1214 fn mixed_or_unparseable_strings_stay_lexicographic() {
1215 assert_eq!(
1216 cmp(json!("2024-01-01T10:00:00Z"), json!("2024-01-01T10:00:00")),
1217 "2024-01-01T10:00:00Z".cmp("2024-01-01T10:00:00")
1218 );
1219 assert_eq!(cmp(json!("b"), json!("a")), Ordering::Greater);
1220 assert_eq!(
1221 cmp(json!("2024-13-99T99:99:99"), json!("2024")),
1222 Ordering::Greater
1223 );
1224 assert_eq!(cmp(json!("abc"), json!(5)), Ordering::Greater);
1225 assert_eq!(cmp(json!(true), json!("1")), Ordering::Less);
1226 }
1227
1228 fn bind(into: BindTarget, template: &str, format: BindFormat) -> ReplicationBind {
1231 ReplicationBind {
1232 into,
1233 name: "updated_after".to_owned(),
1234 template: template.to_owned(),
1235 format,
1236 advance_from: None,
1237 path: None,
1238 value_type: BindValueType::String,
1239 }
1240 }
1241
1242 #[test]
1243 fn bind_defaults_template_to_bare_placeholder() {
1244 let b: ReplicationBind =
1245 serde_json::from_value(json!({ "name": "since" })).expect("deserializes");
1246 assert_eq!(b.into, BindTarget::Query);
1247 assert_eq!(b.template, "${bookmark}");
1248 assert_eq!(b.format, BindFormat::Raw);
1249 assert!(b.advance_from.is_none());
1250 }
1251
1252 #[test]
1253 fn bind_render_raw_string_and_number() {
1254 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1255 assert_eq!(b.render(&json!("2024-06-01")).unwrap(), "2024-06-01");
1256 assert_eq!(b.render(&json!(150)).unwrap(), "150");
1257 }
1258
1259 #[test]
1260 fn bind_render_applies_operator_template() {
1261 let b = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
1262 assert_eq!(
1263 b.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1264 "gte|2024-06-01T00:00:00Z"
1265 );
1266 let l = bind(BindTarget::Query, "[${bookmark} TO *]", BindFormat::Raw);
1268 assert_eq!(l.render(&json!("20240601")).unwrap(), "[20240601 TO *]");
1269 }
1270
1271 #[test]
1272 fn bind_format_iso8601_from_date_and_epoch() {
1273 let b = bind(BindTarget::Header, "${bookmark}", BindFormat::Iso8601);
1274 assert_eq!(
1275 b.render(&json!("2024-06-01")).unwrap(),
1276 "2024-06-01T00:00:00Z"
1277 );
1278 assert_eq!(
1280 b.render(&json!(1_717_200_000)).unwrap(),
1281 "2024-06-01T00:00:00Z"
1282 );
1283 }
1284
1285 #[test]
1286 fn bind_format_epoch_s_and_ms_from_iso() {
1287 let s = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochS);
1288 assert_eq!(
1289 s.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1290 "1717200000"
1291 );
1292 let ms = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochMs);
1293 assert_eq!(
1294 ms.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
1295 "1717200000000"
1296 );
1297 }
1298
1299 #[test]
1300 fn bind_format_date_truncates_datetime() {
1301 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Date);
1302 assert_eq!(
1303 b.render(&json!("2024-06-01T12:34:56Z")).unwrap(),
1304 "2024-06-01"
1305 );
1306 }
1307
1308 #[test]
1309 fn bind_format_naive_datetime_assumed_utc() {
1310 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
1311 assert_eq!(
1312 b.render(&json!("2024-06-01T08:00:00")).unwrap(),
1313 "2024-06-01T08:00:00Z"
1314 );
1315 }
1316
1317 #[test]
1318 fn bind_format_unparseable_string_errors() {
1319 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
1320 assert!(b.render(&json!("not-a-date")).is_err());
1321 }
1322
1323 #[test]
1324 fn bind_format_raw_rejects_composite() {
1325 let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1326 assert!(b.render(&json!({"a": 1})).is_err());
1327 assert!(b.render(&json!(null)).is_err());
1328 }
1329
1330 #[test]
1331 fn bind_validate_rejects_empty_name_and_missing_placeholder() {
1332 let mut b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1333 b.name = " ".to_owned();
1334 assert!(b.validate().is_err());
1335
1336 let mut b2 = bind(BindTarget::Query, "no placeholder here", BindFormat::Raw);
1337 b2.name = "since".to_owned();
1338 assert!(b2.validate().is_err());
1339
1340 let ok = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
1341 assert!(ok.validate().is_ok());
1342 }
1343
1344 #[test]
1345 fn bind_placement_validation() {
1346 let mut b = bind(BindTarget::Body, "${bookmark}", BindFormat::Raw);
1347 assert!(b.validate().is_ok());
1348 b.path = Some("/a/0/b".into());
1349 assert!(b.validate().unwrap_err().to_string().contains("not both"));
1350 b.name.clear();
1351 assert!(b.validate().is_ok());
1352 b.path = Some("a".into());
1353 assert!(b.validate().is_err());
1354 b.path = Some("/".into());
1355 assert!(b.validate().is_err());
1356 b.path = None;
1357 assert!(
1358 b.validate()
1359 .unwrap_err()
1360 .to_string()
1361 .contains("needs `name`")
1362 );
1363 let mut q = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
1364 q.path = Some("/a".into());
1365 assert!(
1366 q.validate()
1367 .unwrap_err()
1368 .to_string()
1369 .contains("only to `into: body`")
1370 );
1371 let parsed: ReplicationBind = serde_json::from_value(json!({
1372 "into": "body", "path": "/f/0/v", "value_type": "number"
1373 }))
1374 .unwrap();
1375 assert!(parsed.validate().is_ok());
1376 assert_eq!(parsed.value_type, BindValueType::Number);
1377 }
1378
1379 #[test]
1380 fn value_type_conversion() {
1381 assert_eq!(BindValueType::String.to_value("5").unwrap(), json!("5"));
1382 assert_eq!(BindValueType::Number.to_value("5").unwrap(), json!(5));
1383 assert_eq!(
1384 BindValueType::Number
1385 .to_value("18446744073709551615")
1386 .unwrap(),
1387 json!(18_446_744_073_709_551_615_u64)
1388 );
1389 assert_eq!(BindValueType::Number.to_value("1.5").unwrap(), json!(1.5));
1390 assert!(BindValueType::Number.to_value("x").is_err());
1391 }
1392
1393 #[test]
1394 fn set_body_pointer_rules() {
1395 let mut body = json!({"filterGroups": [{"filters": [{"value": null}]}], "v": {}, "a/b": 1});
1396 set_body_pointer(&mut body, "/filterGroups/0/filters/0/value", json!("x")).unwrap();
1397 assert_eq!(body["filterGroups"][0]["filters"][0]["value"], json!("x"));
1398 set_body_pointer(&mut body, "/v/after", json!("c")).unwrap();
1399 assert_eq!(body["v"]["after"], json!("c"));
1400 set_body_pointer(&mut body, "/top", json!(1)).unwrap();
1401 assert_eq!(body["top"], json!(1));
1402 set_body_pointer(&mut body, "/a~1b", json!(2)).unwrap();
1403 assert_eq!(body["a/b"], json!(2));
1404 set_body_pointer(&mut body, "/v/x~1y~0z", json!(3)).unwrap();
1405 assert_eq!(body["v"]["x/y~z"], json!(3));
1406 assert!(set_body_pointer(&mut body, "/filterGroups/1/filters", json!(1)).is_err());
1407 assert!(set_body_pointer(&mut body, "/missing/leaf", json!(1)).is_err());
1408 assert!(set_body_pointer(&mut body, "/v", json!(1)).is_err());
1409 assert!(set_body_pointer(&mut body, "/filterGroups/0/filters/5", json!(1)).is_err());
1410 assert!(set_body_pointer(&mut body, "nope", json!(1)).is_err());
1411 }
1412
1413 #[test]
1414 fn bind_format_bookmark_bool_raw() {
1415 assert_eq!(
1416 format_bookmark(&json!(true), BindFormat::Raw).unwrap(),
1417 "true"
1418 );
1419 }
1420
1421 #[test]
1422 fn bind_format_non_scalar_bookmark_errors() {
1423 assert!(format_bookmark(&json!({"a": 1}), BindFormat::Iso8601).is_err());
1425 assert!(format_bookmark(&json!(null), BindFormat::EpochS).is_err());
1426 }
1427
1428 #[test]
1429 fn numeric_bookmarks_keep_the_epoch_binds_unit() {
1430 assert_eq!(
1431 format_bookmark(&json!(1_717_200_000_000_i64), BindFormat::EpochMs).unwrap(),
1432 "1717200000000"
1433 );
1434 assert_eq!(
1435 format_bookmark(&json!(1_717_200_000), BindFormat::EpochS).unwrap(),
1436 "1717200000"
1437 );
1438 assert_eq!(
1439 format_bookmark(&json!(u64::MAX), BindFormat::EpochMs).unwrap(),
1440 u64::MAX.to_string()
1441 );
1442 assert_eq!(
1443 format_bookmark(&json!(1_717_200_000.9), BindFormat::EpochS).unwrap(),
1444 "1717200000"
1445 );
1446 }
1447
1448 #[test]
1449 fn bind_format_out_of_range_epoch_errors() {
1450 assert!(format_bookmark(&json!(i64::MAX), BindFormat::Iso8601).is_err());
1452 }
1453}