use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum CosmosNumber {
Int(i64),
Float(f64),
}
impl From<i64> for CosmosNumber {
fn from(v: i64) -> Self {
CosmosNumber::Int(v)
}
}
impl From<f64> for CosmosNumber {
fn from(v: f64) -> Self {
CosmosNumber::Float(v)
}
}
impl Serialize for CosmosNumber {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
CosmosNumber::Int(n) => serializer.serialize_i64(*n),
CosmosNumber::Float(n) => serializer.serialize_f64(*n),
}
}
}
impl<'de> Deserialize<'de> for CosmosNumber {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Number::deserialize(deserializer)?;
if let Some(n) = value.as_i64() {
Ok(CosmosNumber::Int(n))
} else if let Some(n) = value.as_f64() {
Ok(CosmosNumber::Float(n))
} else {
Err(serde::de::Error::custom("increment value is not a number"))
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
#[non_exhaustive]
pub enum PatchOperation {
Add {
path: String,
value: Value,
},
Set {
path: String,
value: Value,
},
Replace {
path: String,
value: Value,
},
Remove {
path: String,
},
Increment {
path: String,
value: CosmosNumber,
},
Move {
from: String,
path: String,
},
}
impl PatchOperation {
pub fn path(&self) -> &str {
match self {
PatchOperation::Add { path, .. }
| PatchOperation::Set { path, .. }
| PatchOperation::Replace { path, .. }
| PatchOperation::Remove { path }
| PatchOperation::Increment { path, .. }
| PatchOperation::Move { path, .. } => path,
}
}
pub fn add(path: impl Into<String>, value: Value) -> Self {
PatchOperation::Add {
path: path.into(),
value,
}
}
pub fn set(path: impl Into<String>, value: Value) -> Self {
PatchOperation::Set {
path: path.into(),
value,
}
}
pub fn replace(path: impl Into<String>, value: Value) -> Self {
PatchOperation::Replace {
path: path.into(),
value,
}
}
pub fn remove(path: impl Into<String>) -> Self {
PatchOperation::Remove { path: path.into() }
}
pub fn increment(path: impl Into<String>, value: impl Into<CosmosNumber>) -> Self {
PatchOperation::Increment {
path: path.into(),
value: value.into(),
}
}
pub fn move_value(from: impl Into<String>, path: impl Into<String>) -> Self {
PatchOperation::Move {
from: from.into(),
path: path.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub struct PatchInstructions {
pub operations: Vec<PatchOperation>,
}
impl PatchInstructions {
pub fn new() -> Self {
Self {
operations: Vec::new(),
}
}
pub fn with_operation(mut self, operation: PatchOperation) -> Self {
self.operations.push(operation);
self
}
}
impl From<Vec<PatchOperation>> for PatchInstructions {
fn from(operations: Vec<PatchOperation>) -> Self {
PatchInstructions { operations }
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn patch_op_serializes_lowercase() {
let op = PatchOperation::add("/a", json!(1));
let s = serde_json::to_string(&op).unwrap();
assert_eq!(s, r#"{"op":"add","path":"/a","value":1}"#);
}
#[test]
fn move_value_serializes_as_move() {
let op = PatchOperation::move_value("/a", "/b");
let s = serde_json::to_string(&op).unwrap();
assert_eq!(s, r#"{"op":"move","from":"/a","path":"/b"}"#);
}
#[test]
fn increment_preserves_int_fidelity() {
let op = PatchOperation::increment("/n", 9_000_000_000_000_001i64);
let s = serde_json::to_string(&op).unwrap();
assert!(s.contains("9000000000000001"), "actual: {s}");
assert!(!s.contains("e+"), "actual: {s}");
assert!(!s.contains("E+"), "actual: {s}");
}
const PATCH_SPEC_WIRE_JSON: &str = concat!(
r#"{"operations":["#,
r#"{"op":"set","path":"/age","value":31},"#,
r#"{"op":"increment","path":"/visits","value":1},"#,
r#"{"op":"add","path":"/tags/-","value":"rust"},"#,
r#"{"op":"remove","path":"/legacy"},"#,
r#"{"op":"move","from":"/from","path":"/to"}"#,
r#"]}"#,
);
fn canonical_patch_spec() -> PatchInstructions {
PatchInstructions::from(vec![
PatchOperation::set("/age", json!(31)),
PatchOperation::increment("/visits", 1i64),
PatchOperation::add("/tags/-", json!("rust")),
PatchOperation::remove("/legacy"),
PatchOperation::move_value("/from", "/to"),
])
}
#[test]
fn patch_spec_serializes_to_expected_json() {
let s = serde_json::to_string(&canonical_patch_spec()).unwrap();
assert_eq!(s, PATCH_SPEC_WIRE_JSON);
}
#[test]
fn patch_spec_deserializes_from_known_json() {
let parsed: PatchInstructions = serde_json::from_str(PATCH_SPEC_WIRE_JSON).unwrap();
assert_eq!(parsed, canonical_patch_spec());
}
#[test]
fn patch_spec_does_not_serialize_condition_field() {
let spec = PatchInstructions::from(vec![PatchOperation::set("/x", json!(1))]);
let s = serde_json::to_string(&spec).unwrap();
assert!(
!s.contains("condition"),
"PatchInstructions serialization must not include a `condition` field: {s}"
);
}
#[test]
fn incr_value_int_and_float_deserialize() {
let i: CosmosNumber = serde_json::from_str("3").unwrap();
assert_eq!(i, CosmosNumber::Int(3));
let f: CosmosNumber = serde_json::from_str("3.5").unwrap();
assert_eq!(f, CosmosNumber::Float(3.5));
}
}