use crate::error::FaucetError;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::HashMap;
#[derive(
Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum WriteMode {
#[default]
Append,
Upsert,
Delete,
Overwrite,
}
impl WriteMode {
pub fn as_str(&self) -> &'static str {
match self {
WriteMode::Append => "append",
WriteMode::Upsert => "upsert",
WriteMode::Delete => "delete",
WriteMode::Overwrite => "overwrite",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct DeleteMarker {
pub field: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum OverwriteScope {
Window {
column: String,
from: Value,
to: Value,
},
}
impl OverwriteScope {
pub fn column(&self) -> &str {
match self {
OverwriteScope::Window { column, .. } => column,
}
}
pub fn validate(&self) -> Result<(), FaucetError> {
match self {
OverwriteScope::Window { column, from, to } => {
if column.trim().is_empty() {
return Err(FaucetError::Config(
"overwrite scope: window `column` must not be empty".into(),
));
}
if from.is_null() || to.is_null() {
return Err(FaucetError::Config(
"overwrite scope: window `from`/`to` must not be null".into(),
));
}
Ok(())
}
}
}
pub fn render_where_literal(&self, quoted_col: &str) -> String {
self.render_where_with(quoted_col, sql_literal)
}
pub fn render_where_with(
&self,
quoted_col: &str,
literal: impl Fn(&Value) -> String,
) -> String {
match self {
OverwriteScope::Window { from, to, .. } => format!(
"{quoted_col} >= {} AND {quoted_col} < {}",
literal(from),
literal(to)
),
}
}
}
pub fn sql_literal(v: &Value) -> String {
match v {
Value::String(s) => format!("'{}'", s.replace('\'', "''")),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => "NULL".to_owned(),
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WriteSpec {
#[serde(default)]
pub write_mode: WriteMode,
#[serde(default)]
pub key: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delete_marker: Option<DeleteMarker>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rollback: Option<crate::rollback::RollbackWriteSpec>,
}
impl WriteSpec {
pub fn rollback_run_id(&self) -> Option<&str> {
self.rollback.as_ref().map(|r| r.run_id.as_str())
}
pub fn journals(&self) -> bool {
self.rollback.as_ref().is_some_and(|r| r.journal)
}
pub fn keeps_previous(&self) -> bool {
self.rollback.as_ref().is_some_and(|r| r.keep_previous)
}
pub fn validate(&self) -> Result<(), FaucetError> {
if matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && self.key.is_empty() {
return Err(FaucetError::Config(format!(
"write_mode: {} requires a non-empty `key`",
self.write_mode.as_str()
)));
}
Ok(())
}
pub fn dedups_by_key(&self) -> bool {
matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && !self.key.is_empty()
}
pub fn is_overwrite(&self) -> bool {
matches!(self.write_mode, WriteMode::Overwrite)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct KeyTuple(pub Vec<(String, Value)>);
#[derive(Debug, Default)]
pub struct WritePlan {
pub upserts: Vec<Value>,
pub deletes: Vec<KeyTuple>,
pub failed: Vec<(usize, String)>,
}
#[derive(Clone)]
enum Action {
Upsert(Value),
Delete(KeyTuple),
}
pub fn plan_writes(page: &[Value], spec: &WriteSpec) -> WritePlan {
plan_writes_by(page, spec, canonical)
}
pub fn plan_writes_exact(page: &[Value], spec: &WriteSpec) -> WritePlan {
plan_writes_by(page, spec, |k| {
let values: Vec<Value> = k.0.iter().map(|(_, v)| numeric_value(v)).collect();
crate::util::canonical_json(&Value::Array(values))
})
}
fn numeric_value(v: &Value) -> Value {
match v {
Value::Number(n) if n.is_f64() => {
let f = n.as_f64().unwrap_or(f64::NAN);
let whole = f.is_finite() && f.fract() == 0.0;
if whole && f >= i64::MIN as f64 && f < i64::MAX as f64 {
Value::from(f as i64)
} else if whole && f >= 0.0 && f < u64::MAX as f64 {
Value::from(f as u64)
} else {
v.clone()
}
}
Value::Array(items) => Value::Array(items.iter().map(numeric_value).collect()),
Value::Object(map) => Value::Object(
map.iter()
.map(|(k, v)| (k.clone(), numeric_value(v)))
.collect(),
),
_ => v.clone(),
}
}
fn plan_writes_by(
page: &[Value],
spec: &WriteSpec,
canonical: impl Fn(&KeyTuple) -> String,
) -> WritePlan {
debug_assert!(
matches!(spec.write_mode, WriteMode::Upsert | WriteMode::Delete),
"plan_writes is only for Upsert/Delete — Append and Overwrite are routed separately"
);
let mut plan = WritePlan::default();
let mut index: HashMap<String, usize> = HashMap::new();
let mut order: Vec<Action> = Vec::new();
for (i, rec) in page.iter().enumerate() {
let key_tuple = match extract_key(rec, &spec.key) {
Ok(k) => k,
Err(msg) => {
plan.failed.push((i, msg));
continue;
}
};
let canon = canonical(&key_tuple);
let is_delete = match spec.write_mode {
WriteMode::Delete => true,
WriteMode::Upsert => is_delete_marked(rec, spec.delete_marker.as_ref()),
WriteMode::Append | WriteMode::Overwrite => false,
};
let action = if is_delete {
Action::Delete(key_tuple)
} else {
Action::Upsert(strip_marker(rec.clone(), spec.delete_marker.as_ref()))
};
match index.get(&canon) {
Some(&slot) => order[slot] = action,
None => {
index.insert(canon, order.len());
order.push(action);
}
}
}
for action in order {
match action {
Action::Upsert(v) => plan.upserts.push(v),
Action::Delete(k) => plan.deletes.push(k),
}
}
plan
}
pub fn plan_writes_ordered(page: &[Value], spec: &WriteSpec) -> Vec<WritePlan> {
let mut plans = Vec::new();
let mut run: Vec<Value> = Vec::new();
let mut run_is_delete = None;
for rec in page {
let single = plan_writes(std::slice::from_ref(rec), spec);
if !single.failed.is_empty() {
continue;
}
let is_delete = !single.deletes.is_empty();
if run_is_delete.is_some_and(|d| d != is_delete) {
plans.push(plan_writes(&std::mem::take(&mut run), spec));
}
run_is_delete = Some(is_delete);
run.push(rec.clone());
}
if !run.is_empty() {
plans.push(plan_writes(&run, spec));
}
plans
}
pub fn record_key(rec: &Value, key: &[String]) -> Option<KeyTuple> {
extract_key(rec, key).ok()
}
fn extract_key(rec: &Value, key: &[String]) -> Result<KeyTuple, String> {
let obj = rec
.as_object()
.ok_or_else(|| "record is not a JSON object".to_string())?;
let mut out = Vec::with_capacity(key.len());
for col in key {
match obj.get(col) {
None => return Err(format!("missing key column '{col}'")),
Some(Value::Null) => return Err(format!("null value for key column '{col}'")),
Some(v) => out.push((col.clone(), v.clone())),
}
}
Ok(KeyTuple(out))
}
fn is_delete_marked(rec: &Value, marker: Option<&DeleteMarker>) -> bool {
let Some(dm) = marker else { return false };
let Some(v) = rec.get(&dm.field) else {
return false;
};
let Some(s) = v.as_str() else { return false };
dm.values.iter().any(|m| m == s)
}
fn strip_marker(mut rec: Value, marker: Option<&DeleteMarker>) -> Value {
if let (Some(dm), Value::Object(map)) = (marker, &mut rec) {
map.remove(&dm.field);
}
rec
}
fn canonical(k: &KeyTuple) -> String {
let arr: Vec<String> = k.0.iter().map(|(_, v)| key_scalar_text(v)).collect();
serde_json::to_string(&arr).expect("a Vec<String> always serializes")
}
pub(crate) fn key_scalar_text(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => match n.as_f64() {
Some(f)
if n.as_i64().is_none()
&& n.as_u64().is_none()
&& f.fract() == 0.0
&& f.abs() < 9_007_199_254_740_992.0 =>
{
(f as i64).to_string()
}
_ => n.to_string(),
},
Value::Bool(b) => b.to_string(),
other => crate::util::canonical_json(other),
}
}
pub fn key_to_doc_id(k: &KeyTuple, separator: &str) -> String {
let _ = separator; if k.0.len() == 1 {
return match &k.0[0].1 {
Value::String(s) => s.clone(),
other => crate::util::canonical_json(other),
};
}
let values: Vec<Value> = k.0.iter().map(|(_, v)| v.clone()).collect();
crate::util::canonical_json(&Value::Array(values))
}
pub fn key_to_filter(k: &KeyTuple) -> Map<String, Value> {
k.0.iter().map(|(c, v)| (c.clone(), v.clone())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn exact_planning_keeps_keys_of_different_json_types_apart() {
let spec = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec!["id".into()],
..Default::default()
};
let page = vec![
json!({"id": 7, "v": "a"}),
json!({"id": "7", "v": "b"}),
json!({"id": 7, "v": "c"}),
];
let exact = plan_writes_exact(&page, &spec);
assert_eq!(
exact.upserts,
vec![json!({"id": 7, "v": "c"}), json!({"id": "7", "v": "b"})]
);
assert_eq!(
plan_writes(&page, &spec).upserts,
vec![json!({"id": 7, "v": "c"})]
);
}
#[test]
fn exact_planning_compares_numbers_by_value() {
let spec = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec!["id".into()],
..Default::default()
};
let page = vec![
json!({"id": 7, "v": 1}),
json!({"id": 7.0, "v": 2}),
json!({"id": 7.5, "v": 3}),
json!({"id": {"a": [1.0]}, "v": 4}),
json!({"id": {"a": [1]}, "v": 5}),
json!({"id": 9_007_199_254_740_993_u64, "v": 6}),
json!({"id": 9_007_199_254_740_992.0, "v": 7}),
json!({"id": 1.8e19, "v": 8}),
json!({"id": 1e300, "v": 9}),
];
let v: Vec<Value> = plan_writes_exact(&page, &spec)
.upserts
.iter()
.map(|r| r["v"].clone())
.collect();
assert_eq!(
v,
vec![
json!(2),
json!(3),
json!(5),
json!(6),
json!(7),
json!(8),
json!(9)
]
);
}
fn login_then_id() -> Value {
let mut m = serde_json::Map::new();
m.insert("login".into(), json!("o"));
m.insert("id".into(), json!(2));
Value::Object(m)
}
#[test]
fn object_key_values_render_with_sorted_keys() {
assert_eq!(key_scalar_text(&login_then_id()), r#"{"id":2,"login":"o"}"#);
let k = KeyTuple(vec![("k".into(), login_then_id())]);
assert_eq!(key_to_doc_id(&k, "_"), r#"{"id":2,"login":"o"}"#);
}
fn upsert_spec(keys: &[&str]) -> WriteSpec {
WriteSpec {
write_mode: WriteMode::Upsert,
key: keys.iter().map(|s| s.to_string()).collect(),
delete_marker: None,
rollback: None,
}
}
#[test]
fn ordered_plans_keep_page_order_across_upsert_delete_runs() {
let spec = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec!["name".into()],
delete_marker: Some(DeleteMarker {
field: "op".into(),
values: vec!["d".into()],
}),
rollback: None,
};
let page = vec![
json!({"name": "Bob", "op": "d"}),
json!({"name": "bob", "v": 1}),
json!({"name": "carl", "v": 2}),
json!({"v": 3}),
json!({"name": "dee", "op": "d"}),
];
let plans = plan_writes_ordered(&page, &spec);
let shape: Vec<(usize, usize)> = plans
.iter()
.map(|p| (p.upserts.len(), p.deletes.len()))
.collect();
assert_eq!(shape, vec![(0, 1), (2, 0), (0, 1)]);
assert!(plans.iter().all(|p| p.failed.is_empty()));
assert!(plan_writes_ordered(&[], &spec).is_empty());
}
#[test]
fn upsert_extracts_key_and_keeps_row() {
let plan = plan_writes(&[json!({"id": 1, "name": "a"})], &upsert_spec(&["id"]));
assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
assert!(plan.deletes.is_empty());
assert!(plan.failed.is_empty());
}
#[test]
fn key_to_doc_id_single_key_is_plain() {
let k = KeyTuple(vec![("id".into(), json!(7))]);
assert_eq!(key_to_doc_id(&k, "_"), "7");
let k = KeyTuple(vec![("name".into(), json!("alice"))]);
assert_eq!(key_to_doc_id(&k, "_"), "alice");
}
#[test]
fn key_to_doc_id_composite_is_injective() {
let k1 = KeyTuple(vec![("x".into(), json!("a_")), ("y".into(), json!("b"))]);
let k2 = KeyTuple(vec![("x".into(), json!("a")), ("y".into(), json!("_b"))]);
let id1 = key_to_doc_id(&k1, "_");
let id2 = key_to_doc_id(&k2, "_");
assert_ne!(id1, id2, "distinct composite keys must map to distinct ids");
let k3 = KeyTuple(vec![("x".into(), json!(1)), ("y".into(), json!("2"))]);
let k4 = KeyTuple(vec![("x".into(), json!("1")), ("y".into(), json!(2))]);
assert_ne!(key_to_doc_id(&k3, "_"), key_to_doc_id(&k4, "_"));
}
#[test]
fn missing_key_goes_to_failed_with_original_index() {
let plan = plan_writes(
&[json!({"id": 1}), json!({"name": "no-key"})],
&upsert_spec(&["id"]),
);
assert_eq!(plan.upserts.len(), 1);
assert_eq!(plan.failed.len(), 1);
assert_eq!(plan.failed[0].0, 1, "failed row keeps its page index");
}
#[test]
fn null_key_value_is_a_failure() {
let plan = plan_writes(&[json!({"id": null})], &upsert_spec(&["id"]));
assert!(plan.upserts.is_empty());
assert_eq!(plan.failed.len(), 1);
}
#[test]
fn delete_marker_routes_to_deletes_and_strips_marker() {
let spec = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec!["id".into()],
delete_marker: Some(DeleteMarker {
field: "__op".into(),
values: vec!["d".into()],
}),
rollback: None,
};
let plan = plan_writes(
&[
json!({"id": 1, "name": "a", "__op": "u"}),
json!({"id": 2, "__op": "d"}),
],
&spec,
);
assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
assert_eq!(plan.deletes.len(), 1);
assert_eq!(plan.deletes[0].0, vec![("id".to_string(), json!(2))]);
}
#[test]
fn last_write_wins_dedup_keeps_final_upsert() {
let plan = plan_writes(
&[json!({"id": 1, "v": "old"}), json!({"id": 1, "v": "new"})],
&upsert_spec(&["id"]),
);
assert_eq!(plan.upserts, vec![json!({"id": 1, "v": "new"})]);
}
#[test]
fn mixed_json_types_of_one_key_dedup_as_one_key() {
let plan = plan_writes(
&[
json!({"id": 7, "v": "a"}),
json!({"id": 7.0, "v": "b"}),
json!({"id": "7", "v": "c"}),
json!({"id": 7.5, "v": "d"}),
],
&upsert_spec(&["id"]),
);
assert_eq!(
plan.upserts,
vec![json!({"id": "7", "v": "c"}), json!({"id": 7.5, "v": "d"})]
);
}
#[test]
fn last_write_wins_delete_after_upsert_is_a_delete() {
let spec = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec!["id".into()],
delete_marker: Some(DeleteMarker {
field: "__op".into(),
values: vec!["d".into()],
}),
rollback: None,
};
let plan = plan_writes(
&[json!({"id": 1, "__op": "u"}), json!({"id": 1, "__op": "d"})],
&spec,
);
assert!(plan.upserts.is_empty());
assert_eq!(plan.deletes.len(), 1);
}
#[test]
fn delete_mode_routes_every_row_to_deletes() {
let spec = WriteSpec {
write_mode: WriteMode::Delete,
key: vec!["id".into()],
delete_marker: None,
rollback: None,
};
let plan = plan_writes(&[json!({"id": 1}), json!({"id": 2})], &spec);
assert!(plan.upserts.is_empty());
assert_eq!(plan.deletes.len(), 2);
}
#[test]
fn composite_key_tuple_is_ordered() {
let plan = plan_writes(
&[json!({"a": 1, "b": 2, "v": 9})],
&upsert_spec(&["a", "b"]),
);
assert_eq!(plan.upserts.len(), 1);
let plan2 = plan_writes(
&[
json!({"a": 1, "b": 2, "v": "x"}),
json!({"a": 1, "b": 3, "v": "y"}),
],
&upsert_spec(&["a", "b"]),
);
assert_eq!(plan2.upserts.len(), 2, "(1,2) and (1,3) are distinct keys");
}
#[test]
fn validate_rejects_upsert_without_key() {
let spec = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec![],
delete_marker: None,
rollback: None,
};
assert!(spec.validate().is_err());
}
#[test]
fn validate_allows_append_without_key() {
assert!(WriteSpec::default().validate().is_ok());
}
#[test]
fn dedups_by_key_requires_keyed_upsert_or_delete() {
assert!(!WriteSpec::default().dedups_by_key());
let upsert = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec!["id".into()],
delete_marker: None,
rollback: None,
};
assert!(upsert.dedups_by_key());
let delete = WriteSpec {
write_mode: WriteMode::Delete,
key: vec!["id".into()],
delete_marker: None,
rollback: None,
};
assert!(delete.dedups_by_key());
let keyless = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec![],
delete_marker: None,
rollback: None,
};
assert!(!keyless.dedups_by_key());
}
#[test]
fn last_write_wins_upsert_after_delete_is_an_upsert() {
let spec = WriteSpec {
write_mode: WriteMode::Upsert,
key: vec!["id".into()],
delete_marker: Some(DeleteMarker {
field: "__op".into(),
values: vec!["d".into()],
}),
rollback: None,
};
let plan = plan_writes(
&[
json!({"id": 1, "__op": "d"}),
json!({"id": 1, "v": 9, "__op": "u"}),
],
&spec,
);
assert!(plan.deletes.is_empty());
assert_eq!(plan.upserts, vec![json!({"id": 1, "v": 9})]);
}
#[test]
fn overwrite_mode_flags_and_needs_no_key() {
let spec = WriteSpec {
write_mode: WriteMode::Overwrite,
..Default::default()
};
assert!(spec.is_overwrite());
assert!(!spec.dedups_by_key());
assert!(spec.validate().is_ok());
assert_eq!(WriteMode::Overwrite.as_str(), "overwrite");
assert!(!WriteSpec::default().is_overwrite());
assert!(!upsert_spec(&["id"]).is_overwrite());
}
#[test]
fn overwrite_scope_window_validates_and_renders() {
let scope = OverwriteScope::Window {
column: "posting_date".into(),
from: json!("2024-06-01"),
to: json!("2024-07-01"),
};
assert_eq!(scope.column(), "posting_date");
assert!(scope.validate().is_ok());
let whr = scope.render_where_literal("\"posting_date\"");
assert_eq!(
whr,
"\"posting_date\" >= '2024-06-01' AND \"posting_date\" < '2024-07-01'"
);
assert!(
OverwriteScope::Window {
column: " ".into(),
from: json!(1),
to: json!(2)
}
.validate()
.is_err()
);
assert!(
OverwriteScope::Window {
column: "c".into(),
from: json!(null),
to: json!(2)
}
.validate()
.is_err()
);
}
#[test]
fn scope_window_number_bounds() {
let scope = OverwriteScope::Window {
column: "seq".into(),
from: json!(100),
to: json!(200),
};
assert!(scope.validate().is_ok());
assert_eq!(
scope.render_where_literal("`seq`"),
"`seq` >= 100 AND `seq` < 200"
);
}
#[test]
fn sql_literal_renders_each_scalar_kind_by_the_ansi_rule() {
assert_eq!(sql_literal(&json!("plain")), "'plain'");
assert_eq!(sql_literal(&json!("O'Brien")), "'O''Brien'");
assert_eq!(sql_literal(&json!("a\\b")), "'a\\b'");
assert_eq!(sql_literal(&json!("'' ' ''")), "''''' '' '''''");
assert_eq!(sql_literal(&json!("")), "''");
assert_eq!(sql_literal(&json!(42)), "42");
assert_eq!(sql_literal(&json!(-1.5)), "-1.5");
assert_eq!(sql_literal(&json!(true)), "true");
assert_eq!(sql_literal(&json!(false)), "false");
assert_eq!(sql_literal(&Value::Null), "NULL");
assert_eq!(sql_literal(&json!({"a": 1})), "NULL");
assert_eq!(sql_literal(&json!([1, 2])), "NULL");
}
#[test]
fn sql_literal_neutralises_a_quote_breakout_attempt() {
let out = sql_literal(&json!("x' OR 1=1 --"));
assert_eq!(out, "'x'' OR 1=1 --'");
let interior = &out[1..out.len() - 1];
assert!(
interior.matches('\'').count().is_multiple_of(2),
"unpaired quote inside literal: {out}"
);
}
#[test]
fn scope_literal_escapes_quotes() {
let scope = OverwriteScope::Window {
column: "c".into(),
from: json!("x' OR '1'='1"),
to: json!("z"),
};
let whr = scope.render_where_literal("\"c\"");
assert!(whr.contains("'x'' OR ''1''=''1'"), "{whr}");
}
#[test]
fn render_where_with_uses_the_dialect_renderer() {
let scope = OverwriteScope::Window {
column: "c".into(),
from: json!("a"),
to: json!(5),
};
let whr = scope.render_where_with("`c`", |v| format!("<{v}>"));
assert_eq!(whr, "`c` >= <\"a\"> AND `c` < <5>");
}
#[test]
fn overwrite_deserializes_from_wire() {
let spec: WriteSpec = serde_json::from_value(json!({"write_mode": "overwrite"})).unwrap();
assert_eq!(spec.write_mode, WriteMode::Overwrite);
assert!(spec.is_overwrite());
}
#[test]
fn empty_page_produces_empty_plan() {
let plan = plan_writes(&[], &upsert_spec(&["id"]));
assert!(plan.upserts.is_empty());
assert!(plan.deletes.is_empty());
assert!(plan.failed.is_empty());
}
#[test]
fn delete_mode_dedups_repeated_key() {
let spec = WriteSpec {
write_mode: WriteMode::Delete,
key: vec!["id".into()],
delete_marker: None,
rollback: None,
};
let plan = plan_writes(&[json!({"id": 1}), json!({"id": 1})], &spec);
assert_eq!(plan.deletes.len(), 1);
}
}