Skip to main content

parse_rust_core/
op.rs

1//! Field operations: the `{"__op":...}` values a client sends instead of a literal.
2//!
3//! Upstream decodes these in `src/Controllers/DatabaseController.js` and
4//! `src/Adapters/Storage/Mongo/MongoTransform.js`. They are wire-visible in both directions:
5//! a client sends them on a write, and `Increment` is echoed back as its resulting number.
6//!
7//! Deliberately no `PartialEq`, for the same reason as `ParseValue`: an `Op` carries
8//! `ParseValue`, so a derived comparison would inherit the float hazards.
9
10use crate::error::ParseError;
11use crate::value::ParseValue;
12use serde_json::Value as Json;
13
14/// A field operation.
15///
16/// Not `#[non_exhaustive]`, for the reason given on `ParseValue`: a new operation must break
17/// every write path that applies one, rather than falling into a wildcard arm that silently
18/// ignores it.
19#[derive(Debug, Clone)]
20pub enum Op {
21    /// `{"__op":"Increment","amount":n}`. Negative amounts decrement; there is no separate op.
22    Increment(f64),
23    /// `{"__op":"Add","objects":[...]}`. Appends, duplicates allowed.
24    Add(Vec<ParseValue>),
25    /// `{"__op":"AddUnique","objects":[...]}`. Appends only values not already present.
26    AddUnique(Vec<ParseValue>),
27    /// `{"__op":"Remove","objects":[...]}`. Removes every occurrence.
28    Remove(Vec<ParseValue>),
29    /// `{"__op":"Delete"}`. Unsets the field.
30    Delete,
31    /// `{"__op":"AddRelation","objects":[pointers]}`
32    AddRelation(Vec<ParseValue>),
33    /// `{"__op":"RemoveRelation","objects":[pointers]}`
34    RemoveRelation(Vec<ParseValue>),
35    /// `{"__op":"Batch","ops":[...]}`. Upstream only ever produces a batch of relation ops, but
36    /// the decoder does not enforce that, so neither does this.
37    Batch(Vec<Op>),
38}
39
40impl Op {
41    /// Decode an `{"__op":...}` object.
42    ///
43    /// Returns `Ok(None)` when the value is not an op at all, so a caller can try this before
44    /// falling back to [`crate::decode::classify`] without treating "not an op" as an error.
45    pub fn classify(value: &Json) -> Result<Option<Op>, ParseError> {
46        let map = match value {
47            Json::Object(m) => m,
48            _ => return Ok(None),
49        };
50        let name = match map.get("__op") {
51            Some(Json::String(s)) => s.as_str(),
52            _ => return Ok(None),
53        };
54
55        let objects = |key: &str| -> Result<Vec<ParseValue>, ParseError> {
56            match map.get(key) {
57                Some(Json::Array(a)) => a
58                    .iter()
59                    .cloned()
60                    .map(crate::decode::classify_nested)
61                    .collect::<Result<Vec<_>, _>>(),
62                // UPSTREAM-QUIRK: this message is emitted for a non-array `objects` on every op
63                // that takes one, including the relation ops. `DatabaseController.js:329`.
64                _ => Err(ParseError::invalid_json(
65                    "objects to add must be an array".to_string(),
66                )),
67            }
68        };
69
70        let op = match name {
71            "Increment" => {
72                let amount = map.get("amount").and_then(|v| v.as_f64()).ok_or_else(|| {
73                    // UPSTREAM-QUIRK: the message for a non-numeric amount differs by path.
74                    // Create says "objects to add must be an array" (a copy-paste bug at
75                    // `DatabaseController.js:329`); update says this. Recorded in
76                    // reproduced deliberately. The pipeline picks the message; the decoder
77                    // cannot know which path it is on, so it uses the update wording.
78                    ParseError::invalid_json("incrementing must provide a number".to_string())
79                })?;
80                Op::Increment(amount)
81            }
82            "Add" => Op::Add(objects("objects")?),
83            "AddUnique" => Op::AddUnique(objects("objects")?),
84            "Remove" => Op::Remove(objects("objects")?),
85            "AddRelation" => Op::AddRelation(objects("objects")?),
86            "RemoveRelation" => Op::RemoveRelation(objects("objects")?),
87            "Delete" => Op::Delete,
88            "Batch" => {
89                let ops = match map.get("ops") {
90                    Some(Json::Array(a)) => a,
91                    _ => {
92                        return Err(ParseError::invalid_json(
93                            "Batch requires an ops array".to_string(),
94                        ))
95                    }
96                };
97                let mut out = Vec::with_capacity(ops.len());
98                for o in ops {
99                    match Op::classify(o)? {
100                        Some(inner) => out.push(inner),
101                        None => {
102                            return Err(ParseError::invalid_json(
103                                "Batch ops must all be operations".to_string(),
104                            ))
105                        }
106                    }
107                }
108                Op::Batch(out)
109            }
110            other => {
111                return Err(ParseError::invalid_json(format!(
112                    "Unknown operation: {other}"
113                )))
114            }
115        };
116        Ok(Some(op))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::error::ErrorCode;
124
125    fn j(s: &str) -> Json {
126        serde_json::from_str(s).expect("test literal must be valid JSON")
127    }
128
129    #[test]
130    fn decodes_every_op() {
131        assert!(matches!(
132            Op::classify(&j(r#"{"__op":"Increment","amount":3}"#)).unwrap(),
133            Some(Op::Increment(a)) if a == 3.0
134        ));
135        // Decrement is Increment with a negative amount; there is no Decrement op.
136        assert!(matches!(
137            Op::classify(&j(r#"{"__op":"Increment","amount":-2}"#)).unwrap(),
138            Some(Op::Increment(a)) if a == -2.0
139        ));
140        assert!(matches!(
141            Op::classify(&j(r#"{"__op":"Delete"}"#)).unwrap(),
142            Some(Op::Delete)
143        ));
144        assert!(matches!(
145            Op::classify(&j(r#"{"__op":"Add","objects":[1,2]}"#)).unwrap(),
146            Some(Op::Add(v)) if v.len() == 2
147        ));
148        assert!(matches!(
149            Op::classify(&j(r#"{"__op":"AddUnique","objects":[]}"#)).unwrap(),
150            Some(Op::AddUnique(v)) if v.is_empty()
151        ));
152        assert!(matches!(
153            Op::classify(&j(r#"{"__op":"Remove","objects":[1]}"#)).unwrap(),
154            Some(Op::Remove(_))
155        ));
156        assert!(matches!(
157            Op::classify(&j(r#"{"__op":"AddRelation","objects":[]}"#)).unwrap(),
158            Some(Op::AddRelation(_))
159        ));
160        assert!(matches!(
161            Op::classify(&j(r#"{"__op":"RemoveRelation","objects":[]}"#)).unwrap(),
162            Some(Op::RemoveRelation(_))
163        ));
164    }
165
166    #[test]
167    fn batch_nests() {
168        let src = r#"{"__op":"Batch","ops":[
169            {"__op":"AddRelation","objects":[]},
170            {"__op":"RemoveRelation","objects":[]}
171        ]}"#;
172        match Op::classify(&j(src)).unwrap() {
173            Some(Op::Batch(ops)) => assert_eq!(ops.len(), 2),
174            other => panic!("expected Batch, got {other:?}"),
175        }
176    }
177
178    #[test]
179    fn non_ops_are_not_errors() {
180        // The caller needs to distinguish "not an op" from "a broken op".
181        assert!(Op::classify(&j("42")).unwrap().is_none());
182        assert!(Op::classify(&j(r#""text""#)).unwrap().is_none());
183        assert!(Op::classify(&j(r#"{"a":1}"#)).unwrap().is_none());
184        // A non-string __op is not an op either.
185        assert!(Op::classify(&j(r#"{"__op":7}"#)).unwrap().is_none());
186    }
187
188    #[test]
189    fn malformed_ops_are_errors() {
190        assert_eq!(
191            Op::classify(&j(r#"{"__op":"Nope"}"#)).unwrap_err().code,
192            ErrorCode::InvalidJson
193        );
194        assert_eq!(
195            Op::classify(&j(r#"{"__op":"Add","objects":3}"#))
196                .unwrap_err()
197                .code,
198            ErrorCode::InvalidJson
199        );
200        assert_eq!(
201            Op::classify(&j(r#"{"__op":"Increment","amount":"x"}"#))
202                .unwrap_err()
203                .code,
204            ErrorCode::InvalidJson
205        );
206        assert_eq!(
207            Op::classify(&j(r#"{"__op":"Batch","ops":[{"a":1}]}"#))
208                .unwrap_err()
209                .code,
210            ErrorCode::InvalidJson
211        );
212    }
213
214    #[test]
215    fn op_objects_may_contain_tagged_values() {
216        let src = r#"{"__op":"AddRelation","objects":[
217            {"__type":"Pointer","className":"Post","objectId":"abc"}
218        ]}"#;
219        match Op::classify(&j(src)).unwrap() {
220            Some(Op::AddRelation(v)) => {
221                assert!(matches!(v[0], ParseValue::Pointer { .. }))
222            }
223            other => panic!("expected AddRelation, got {other:?}"),
224        }
225    }
226}