use crate::error::FaucetError;
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::cmp::Ordering;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
pub enum ReplicationMethod {
#[default]
FullTable,
Incremental,
}
pub fn filter_incremental(records: Vec<Value>, key: &str, start: &Value) -> Vec<Value> {
records
.into_iter()
.filter(|r| match r.get(key) {
None => false,
Some(v) if type_rank(v) != type_rank(start) => {
tracing::warn!(
key,
"incremental replication: record key type does not match the bookmark \
type; keeping the record to avoid silently dropping data"
);
true
}
Some(v) => json_gt(v, start),
})
.collect()
}
pub fn max_replication_value<'a>(records: &'a [Value], key: &str) -> Option<&'a Value> {
records
.iter()
.filter_map(|r| r.get(key))
.max_by(|a, b| json_compare(a, b))
}
pub fn max_value(a: Value, b: Value) -> Value {
match json_compare(&a, &b) {
Ordering::Less => b,
_ => a,
}
}
fn type_rank(v: &Value) -> u8 {
match v {
Value::Null => 0,
Value::Bool(_) => 1,
Value::Number(_) => 2,
Value::String(_) => 3,
Value::Array(_) => 4,
Value::Object(_) => 5,
}
}
fn number_as_i128(n: &serde_json::Number) -> Option<i128> {
n.as_i64()
.map(i128::from)
.or_else(|| n.as_u64().map(i128::from))
}
pub(crate) fn json_compare(a: &Value, b: &Value) -> Ordering {
match (a, b) {
(Value::Number(an), Value::Number(bn)) => {
match (number_as_i128(an), number_as_i128(bn)) {
(Some(ai), Some(bi)) => ai.cmp(&bi),
_ => {
let af = an.as_f64().unwrap_or(f64::NAN);
let bf = bn.as_f64().unwrap_or(f64::NAN);
af.partial_cmp(&bf).unwrap_or_else(|| {
match (af.is_nan(), bf.is_nan()) {
(false, true) => Ordering::Less,
(true, false) => Ordering::Greater,
_ => Ordering::Equal,
}
})
}
}
}
(Value::String(x), Value::String(y)) => x.cmp(y),
(Value::Bool(x), Value::Bool(y)) => x.cmp(y),
(Value::Null, Value::Null) => Ordering::Equal,
(Value::Array(x), Value::Array(y)) => {
for (xi, yi) in x.iter().zip(y.iter()) {
let c = json_compare(xi, yi);
if c != Ordering::Equal {
return c;
}
}
x.len().cmp(&y.len())
}
(Value::Object(_), Value::Object(_)) => a.to_string().cmp(&b.to_string()),
_ => type_rank(a).cmp(&type_rank(b)),
}
}
pub fn json_gt(a: &Value, b: &Value) -> bool {
json_compare(a, b) == Ordering::Greater
}
pub const BIND_PLACEHOLDER: &str = "${bookmark}";
fn default_bind_template() -> String {
BIND_PLACEHOLDER.to_owned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BindTarget {
#[default]
Query,
Header,
Body,
Path,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BindFormat {
#[default]
Raw,
Iso8601,
EpochS,
EpochMs,
Date,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReplicationBind {
#[serde(default)]
pub into: BindTarget,
pub name: String,
#[serde(default = "default_bind_template")]
pub template: String,
#[serde(default)]
pub format: BindFormat,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advance_from: Option<String>,
}
impl ReplicationBind {
pub fn validate(&self) -> Result<(), FaucetError> {
if self.name.trim().is_empty() {
return Err(FaucetError::Config(
"replication bind: `name` must not be empty".to_owned(),
));
}
if !self.template.contains(BIND_PLACEHOLDER) {
return Err(FaucetError::Config(format!(
"replication bind: `template` must contain the `{BIND_PLACEHOLDER}` placeholder"
)));
}
Ok(())
}
pub fn render(&self, bookmark: &Value) -> Result<String, FaucetError> {
let formatted = format_bookmark(bookmark, self.format)?;
Ok(self.template.replace(BIND_PLACEHOLDER, &formatted))
}
}
fn bookmark_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
match value {
Value::String(s) => {
let s = s.trim();
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&Utc));
}
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d")
&& let Some(ndt) = d.and_hms_opt(0, 0, 0)
{
return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
}
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
}
Err(FaucetError::Config(format!(
"replication bind: cannot parse bookmark '{s}' as a timestamp \
(expected RFC 3339, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS)"
)))
}
Value::Number(n) => {
let secs = n.as_i64().or_else(|| n.as_f64().map(|f| f as i64));
secs.and_then(|s| DateTime::<Utc>::from_timestamp(s, 0))
.ok_or_else(|| {
FaucetError::Config(format!(
"replication bind: numeric bookmark {n} is out of range for epoch seconds"
))
})
}
other => Err(FaucetError::Config(format!(
"replication bind: bookmark must be a string or number, got {other}"
))),
}
}
pub fn parse_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
bookmark_instant(value)
}
pub fn format_instant(dt: DateTime<Utc>, format: BindFormat) -> String {
match format {
BindFormat::Raw | BindFormat::Iso8601 => {
dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
BindFormat::Date => dt.format("%Y-%m-%d").to_string(),
BindFormat::EpochS => dt.timestamp().to_string(),
BindFormat::EpochMs => dt.timestamp_millis().to_string(),
}
}
pub fn format_bookmark(value: &Value, format: BindFormat) -> Result<String, FaucetError> {
match format {
BindFormat::Raw => match value {
Value::String(s) => Ok(s.clone()),
Value::Number(n) => Ok(n.to_string()),
Value::Bool(b) => Ok(b.to_string()),
other => Err(FaucetError::Config(format!(
"replication bind: cannot render {other} as a raw scalar"
))),
},
BindFormat::Iso8601 => {
Ok(bookmark_instant(value)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
}
BindFormat::Date => Ok(bookmark_instant(value)?.format("%Y-%m-%d").to_string()),
BindFormat::EpochS => Ok(bookmark_instant(value)?.timestamp().to_string()),
BindFormat::EpochMs => Ok(bookmark_instant(value)?.timestamp_millis().to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_filter_incremental_strings() {
let records = vec![
json!({"id": 1, "updated_at": "2024-01-01"}),
json!({"id": 2, "updated_at": "2024-06-01"}),
json!({"id": 3, "updated_at": "2024-12-01"}),
];
let start = json!("2024-06-01");
let filtered = filter_incremental(records, "updated_at", &start);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0]["id"], 3);
}
#[test]
fn test_filter_incremental_numbers() {
let records = vec![
json!({"id": 1, "seq": 100}),
json!({"id": 2, "seq": 200}),
json!({"id": 3, "seq": 300}),
];
let start = json!(150);
let filtered = filter_incremental(records, "seq", &start);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0]["id"], 2);
assert_eq!(filtered[1]["id"], 3);
}
#[test]
fn test_filter_incremental_missing_key_excluded() {
let records = vec![
json!({"id": 1}),
json!({"id": 2, "updated_at": "2024-12-01"}),
];
let start = json!("2024-01-01");
let filtered = filter_incremental(records, "updated_at", &start);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0]["id"], 2);
}
#[test]
fn test_filter_incremental_equal_excluded() {
let records = vec![
json!({"id": 1, "updated_at": "2024-06-01"}),
json!({"id": 2, "updated_at": "2024-06-02"}),
];
let start = json!("2024-06-01");
let filtered = filter_incremental(records, "updated_at", &start);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0]["id"], 2);
}
#[test]
fn test_max_replication_value_strings() {
let records = vec![
json!({"updated_at": "2024-01-01"}),
json!({"updated_at": "2024-12-01"}),
json!({"updated_at": "2024-06-01"}),
];
let max = max_replication_value(&records, "updated_at").unwrap();
assert_eq!(max, &json!("2024-12-01"));
}
#[test]
fn test_max_replication_value_numbers() {
let records = vec![json!({"seq": 5}), json!({"seq": 10}), json!({"seq": 3})];
let max = max_replication_value(&records, "seq").unwrap();
assert_eq!(max, &json!(10));
}
#[test]
fn test_max_replication_value_empty() {
let records: Vec<Value> = vec![];
assert!(max_replication_value(&records, "updated_at").is_none());
}
#[test]
fn test_max_value_picks_larger_string() {
assert_eq!(
max_value(json!("2024-01-01"), json!("2024-06-01")),
json!("2024-06-01")
);
}
#[test]
fn test_max_value_picks_larger_number() {
assert_eq!(max_value(json!(5), json!(10)), json!(10));
}
#[test]
fn test_max_value_returns_a_on_type_mismatch() {
assert_eq!(max_value(json!("string"), json!(5)), json!("string"));
}
#[test]
fn filter_incremental_keeps_large_integer_beyond_f64_precision() {
let two_pow_53 = 9_007_199_254_740_992_i64; let records = vec![
json!({"id": 1, "seq": two_pow_53 + 1}),
json!({"id": 2, "seq": two_pow_53 + 2}),
];
let start = json!(two_pow_53);
let filtered = filter_incremental(records, "seq", &start);
assert_eq!(
filtered.len(),
2,
"both values are strictly greater than 2^53"
);
}
#[test]
fn json_compare_distinguishes_large_integers() {
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);
}
#[test]
fn filter_incremental_keeps_records_on_type_mismatch() {
let records = vec![json!({"id": 1, "seq": 20_240_701})];
let start = json!("2024-06-01"); let filtered = filter_incremental(records, "seq", &start);
assert_eq!(filtered.len(), 1, "type mismatch must not silently drop");
}
fn bind(into: BindTarget, template: &str, format: BindFormat) -> ReplicationBind {
ReplicationBind {
into,
name: "updated_after".to_owned(),
template: template.to_owned(),
format,
advance_from: None,
}
}
#[test]
fn bind_defaults_template_to_bare_placeholder() {
let b: ReplicationBind =
serde_json::from_value(json!({ "name": "since" })).expect("deserializes");
assert_eq!(b.into, BindTarget::Query);
assert_eq!(b.template, "${bookmark}");
assert_eq!(b.format, BindFormat::Raw);
assert!(b.advance_from.is_none());
}
#[test]
fn bind_render_raw_string_and_number() {
let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
assert_eq!(b.render(&json!("2024-06-01")).unwrap(), "2024-06-01");
assert_eq!(b.render(&json!(150)).unwrap(), "150");
}
#[test]
fn bind_render_applies_operator_template() {
let b = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
assert_eq!(
b.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
"gte|2024-06-01T00:00:00Z"
);
let l = bind(BindTarget::Query, "[${bookmark} TO *]", BindFormat::Raw);
assert_eq!(l.render(&json!("20240601")).unwrap(), "[20240601 TO *]");
}
#[test]
fn bind_format_iso8601_from_date_and_epoch() {
let b = bind(BindTarget::Header, "${bookmark}", BindFormat::Iso8601);
assert_eq!(
b.render(&json!("2024-06-01")).unwrap(),
"2024-06-01T00:00:00Z"
);
assert_eq!(
b.render(&json!(1_717_200_000)).unwrap(),
"2024-06-01T00:00:00Z"
);
}
#[test]
fn bind_format_epoch_s_and_ms_from_iso() {
let s = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochS);
assert_eq!(
s.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
"1717200000"
);
let ms = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochMs);
assert_eq!(
ms.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
"1717200000000"
);
}
#[test]
fn bind_format_date_truncates_datetime() {
let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Date);
assert_eq!(
b.render(&json!("2024-06-01T12:34:56Z")).unwrap(),
"2024-06-01"
);
}
#[test]
fn bind_format_naive_datetime_assumed_utc() {
let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
assert_eq!(
b.render(&json!("2024-06-01T08:00:00")).unwrap(),
"2024-06-01T08:00:00Z"
);
}
#[test]
fn bind_format_unparseable_string_errors() {
let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
assert!(b.render(&json!("not-a-date")).is_err());
}
#[test]
fn bind_format_raw_rejects_composite() {
let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
assert!(b.render(&json!({"a": 1})).is_err());
assert!(b.render(&json!(null)).is_err());
}
#[test]
fn bind_validate_rejects_empty_name_and_missing_placeholder() {
let mut b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
b.name = " ".to_owned();
assert!(b.validate().is_err());
let mut b2 = bind(BindTarget::Query, "no placeholder here", BindFormat::Raw);
b2.name = "since".to_owned();
assert!(b2.validate().is_err());
let ok = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
assert!(ok.validate().is_ok());
}
#[test]
fn bind_format_bookmark_bool_raw() {
assert_eq!(
format_bookmark(&json!(true), BindFormat::Raw).unwrap(),
"true"
);
}
#[test]
fn bind_format_non_scalar_bookmark_errors() {
assert!(format_bookmark(&json!({"a": 1}), BindFormat::Iso8601).is_err());
assert!(format_bookmark(&json!(null), BindFormat::EpochS).is_err());
}
#[test]
fn bind_format_out_of_range_epoch_errors() {
assert!(format_bookmark(&json!(i64::MAX), BindFormat::Iso8601).is_err());
}
}