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":"SetOnInsert","amount":v}`. Sets the field only if the write inserts a row.
30    ///
31    /// Note the key: `amount`, not `objects` and not `value`, and it carries an arbitrary value
32    /// rather than a number despite the name (`MongoTransform.js:993-998`). There is no type
33    /// check on it anywhere upstream, so there is none here.
34    SetOnInsert(ParseValue),
35    /// `{"__op":"Delete"}`. Unsets the field.
36    Delete,
37    /// `{"__op":"AddRelation","objects":[pointers]}`
38    AddRelation(Vec<ParseValue>),
39    /// `{"__op":"RemoveRelation","objects":[pointers]}`
40    RemoveRelation(Vec<ParseValue>),
41    /// `{"__op":"Batch","ops":[...]}`. Upstream only ever produces a batch of relation ops, but
42    /// the decoder does not enforce that, so neither does this.
43    Batch(Vec<Op>),
44}
45
46/// Which write path an op is being decoded on.
47///
48/// This exists for exactly one reason: upstream produces a different error message for a
49/// non-numeric `Increment.amount` depending on the path, and the message is wire-visible. Create
50/// goes through `flattenUpdateOperatorsForCreate`, whose `Increment` arm carries a copy-pasted
51/// `'objects to add must be an array'` (`DatabaseController.js:326-330`). Update goes through
52/// `transformUpdateOperator`, which says `'incrementing must provide a number'`
53/// (`MongoTransform.js:983-985`). A decoder that cannot tell the two apart has to pick one and be
54/// wrong half the time, so it is told.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum OpPath {
57    Create,
58    Update,
59}
60
61/// One field of a write body: either a literal value or an operation.
62///
63/// Deliberately an enum rather than "a `ParseValue` that might happen to be an op object". 0.1.0
64/// decoded ops correctly and then never called the decoder from the write path, so
65/// `{"__op":"Increment","amount":1}` was stored as a literal object. A sum type at the field
66/// boundary makes that omission a missing match arm rather than silence.
67#[derive(Debug, Clone)]
68pub enum FieldWrite {
69    Value(ParseValue),
70    Op(Op),
71}
72
73/// Decode one field of a write body.
74///
75/// Tries the op decoder first, because an op object is structurally an ordinary object and
76/// [`crate::decode::classify`] would happily accept it as one.
77pub fn classify_field(value: Json, path: OpPath) -> Result<FieldWrite, ParseError> {
78    if let Some(op) = Op::classify_with(&value, path)? {
79        return Ok(FieldWrite::Op(op));
80    }
81    crate::decode::classify(value).map(FieldWrite::Value)
82}
83
84impl Op {
85    /// Decode an `{"__op":...}` object on the update path.
86    ///
87    /// Returns `Ok(None)` when the value is not an op at all, so a caller can try this before
88    /// falling back to [`crate::decode::classify`] without treating "not an op" as an error.
89    pub fn classify(value: &Json) -> Result<Option<Op>, ParseError> {
90        Op::classify_with(value, OpPath::Update)
91    }
92
93    /// Decode an `{"__op":...}` object, with the path that decides one error message.
94    pub fn classify_with(value: &Json, path: OpPath) -> Result<Option<Op>, ParseError> {
95        let map = match value {
96            Json::Object(m) => m,
97            _ => return Ok(None),
98        };
99        let name = match map.get("__op") {
100            Some(Json::String(s)) => s.as_str(),
101            _ => return Ok(None),
102        };
103
104        let objects = |key: &str| -> Result<Vec<ParseValue>, ParseError> {
105            match map.get(key) {
106                Some(Json::Array(a)) => a
107                    .iter()
108                    .cloned()
109                    .map(crate::decode::classify_nested)
110                    .collect::<Result<Vec<_>, _>>(),
111                // UPSTREAM-QUIRK: this message is emitted for a non-array `objects` on every op
112                // that takes one, including the relation ops. `DatabaseController.js:329`.
113                _ => Err(ParseError::invalid_json(
114                    "objects to add must be an array".to_string(),
115                )),
116            }
117        };
118
119        let op = match name {
120            "Increment" => {
121                let amount = map.get("amount").and_then(|v| v.as_f64()).ok_or_else(|| {
122                    // UPSTREAM-QUIRK: the message for a non-numeric amount differs by path.
123                    // Create says "objects to add must be an array", a copy-paste bug at
124                    // `DatabaseController.js:326-330`; update says "incrementing must provide a
125                    // number" (`MongoTransform.js:983-985`). Both are reproduced deliberately.
126                    ParseError::invalid_json(
127                        match path {
128                            OpPath::Create => "objects to add must be an array",
129                            OpPath::Update => "incrementing must provide a number",
130                        }
131                        .to_string(),
132                    )
133                })?;
134                Op::Increment(amount)
135            }
136            // No validation, matching upstream: neither `flattenUpdateOperatorsForCreate`
137            // (`DatabaseController.js:333-335`) nor `transformUpdateOperator`
138            // (`MongoTransform.js:993-998`) inspects `amount`. An absent one lands as `null`,
139            // which is what the Node driver serializes `undefined` to.
140            "SetOnInsert" => Op::SetOnInsert(match map.get("amount") {
141                Some(value) => crate::decode::classify(value.clone())?,
142                None => ParseValue::Null,
143            }),
144            "Add" => Op::Add(objects("objects")?),
145            "AddUnique" => Op::AddUnique(objects("objects")?),
146            "Remove" => Op::Remove(objects("objects")?),
147            "AddRelation" => Op::AddRelation(objects("objects")?),
148            "RemoveRelation" => Op::RemoveRelation(objects("objects")?),
149            "Delete" => Op::Delete,
150            "Batch" => {
151                let ops = match map.get("ops") {
152                    Some(Json::Array(a)) => a,
153                    _ => {
154                        return Err(ParseError::invalid_json(
155                            "Batch requires an ops array".to_string(),
156                        ))
157                    }
158                };
159                let mut out = Vec::with_capacity(ops.len());
160                for o in ops {
161                    match Op::classify_with(o, path)? {
162                        Some(inner) => out.push(inner),
163                        None => {
164                            return Err(ParseError::invalid_json(
165                                "Batch ops must all be operations".to_string(),
166                            ))
167                        }
168                    }
169                }
170                Op::Batch(out)
171            }
172            other => {
173                return Err(ParseError::invalid_json(format!(
174                    "Unknown operation: {other}"
175                )))
176            }
177        };
178        Ok(Some(op))
179    }
180
181    /// What this op collapses to on a create, per `flattenUpdateOperatorsForCreate`
182    /// (`DatabaseController.js:323-365`).
183    ///
184    /// `Ok(None)` means the key is removed from the row entirely, which is what `Delete` does.
185    /// Two results are counter-intuitive and both are upstream's: `Remove` yields an **empty
186    /// array** rather than removing anything, and the relation ops are not handled here at all
187    /// because `collectRelationUpdates` has already stripped them out.
188    pub fn flatten_for_create(&self) -> Result<Option<ParseValue>, ParseError> {
189        Ok(match self {
190            Op::Increment(amount) => Some(ParseValue::Number(*amount)),
191            Op::SetOnInsert(value) => Some(value.clone()),
192            Op::Add(objects) | Op::AddUnique(objects) => Some(ParseValue::Array(objects.clone())),
193            Op::Remove(_) => Some(ParseValue::Array(Vec::new())),
194            Op::Delete => None,
195            Op::AddRelation(_) | Op::RemoveRelation(_) | Op::Batch(_) => {
196                return Err(ParseError::new(
197                    crate::error::ErrorCode::CommandUnavailable,
198                    format!("The {} operator is not supported yet.", self.name()),
199                ))
200            }
201        })
202    }
203
204    /// The `__op` string, for the error messages that quote it back.
205    pub fn name(&self) -> &'static str {
206        match self {
207            Op::Increment(_) => "Increment",
208            Op::SetOnInsert(_) => "SetOnInsert",
209            Op::Add(_) => "Add",
210            Op::AddUnique(_) => "AddUnique",
211            Op::Remove(_) => "Remove",
212            Op::Delete => "Delete",
213            Op::AddRelation(_) => "AddRelation",
214            Op::RemoveRelation(_) => "RemoveRelation",
215            Op::Batch(_) => "Batch",
216        }
217    }
218
219    /// Does the update response echo this op's resulting value back to the client?
220    ///
221    /// Exactly the five ops in `_sanitizeDatabaseResult`'s allow-list
222    /// (`DatabaseController.js:2140`). `Delete` is not one of them, which is why deleting a field
223    /// produces `{updatedAt}` and nothing else.
224    pub fn echoes_result(&self) -> bool {
225        matches!(
226            self,
227            Op::Increment(_) | Op::SetOnInsert(_) | Op::Add(_) | Op::AddUnique(_) | Op::Remove(_)
228        )
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::error::ErrorCode;
236
237    fn j(s: &str) -> Json {
238        serde_json::from_str(s).expect("test literal must be valid JSON")
239    }
240
241    #[test]
242    fn decodes_every_op() {
243        assert!(matches!(
244            Op::classify(&j(r#"{"__op":"Increment","amount":3}"#)).unwrap(),
245            Some(Op::Increment(a)) if a == 3.0
246        ));
247        // Decrement is Increment with a negative amount; there is no Decrement op.
248        assert!(matches!(
249            Op::classify(&j(r#"{"__op":"Increment","amount":-2}"#)).unwrap(),
250            Some(Op::Increment(a)) if a == -2.0
251        ));
252        assert!(matches!(
253            Op::classify(&j(r#"{"__op":"Delete"}"#)).unwrap(),
254            Some(Op::Delete)
255        ));
256        assert!(matches!(
257            Op::classify(&j(r#"{"__op":"Add","objects":[1,2]}"#)).unwrap(),
258            Some(Op::Add(v)) if v.len() == 2
259        ));
260        assert!(matches!(
261            Op::classify(&j(r#"{"__op":"AddUnique","objects":[]}"#)).unwrap(),
262            Some(Op::AddUnique(v)) if v.is_empty()
263        ));
264        assert!(matches!(
265            Op::classify(&j(r#"{"__op":"Remove","objects":[1]}"#)).unwrap(),
266            Some(Op::Remove(_))
267        ));
268        assert!(matches!(
269            Op::classify(&j(r#"{"__op":"AddRelation","objects":[]}"#)).unwrap(),
270            Some(Op::AddRelation(_))
271        ));
272        assert!(matches!(
273            Op::classify(&j(r#"{"__op":"RemoveRelation","objects":[]}"#)).unwrap(),
274            Some(Op::RemoveRelation(_))
275        ));
276    }
277
278    #[test]
279    fn batch_nests() {
280        let src = r#"{"__op":"Batch","ops":[
281            {"__op":"AddRelation","objects":[]},
282            {"__op":"RemoveRelation","objects":[]}
283        ]}"#;
284        match Op::classify(&j(src)).unwrap() {
285            Some(Op::Batch(ops)) => assert_eq!(ops.len(), 2),
286            other => panic!("expected Batch, got {other:?}"),
287        }
288    }
289
290    #[test]
291    fn non_ops_are_not_errors() {
292        // The caller needs to distinguish "not an op" from "a broken op".
293        assert!(Op::classify(&j("42")).unwrap().is_none());
294        assert!(Op::classify(&j(r#""text""#)).unwrap().is_none());
295        assert!(Op::classify(&j(r#"{"a":1}"#)).unwrap().is_none());
296        // A non-string __op is not an op either.
297        assert!(Op::classify(&j(r#"{"__op":7}"#)).unwrap().is_none());
298    }
299
300    #[test]
301    fn malformed_ops_are_errors() {
302        assert_eq!(
303            Op::classify(&j(r#"{"__op":"Nope"}"#)).unwrap_err().code,
304            ErrorCode::InvalidJson
305        );
306        assert_eq!(
307            Op::classify(&j(r#"{"__op":"Add","objects":3}"#))
308                .unwrap_err()
309                .code,
310            ErrorCode::InvalidJson
311        );
312        assert_eq!(
313            Op::classify(&j(r#"{"__op":"Increment","amount":"x"}"#))
314                .unwrap_err()
315                .code,
316            ErrorCode::InvalidJson
317        );
318        assert_eq!(
319            Op::classify(&j(r#"{"__op":"Batch","ops":[{"a":1}]}"#))
320                .unwrap_err()
321                .code,
322            ErrorCode::InvalidJson
323        );
324    }
325
326    #[test]
327    fn op_objects_may_contain_tagged_values() {
328        let src = r#"{"__op":"AddRelation","objects":[
329            {"__type":"Pointer","className":"Post","objectId":"abc"}
330        ]}"#;
331        match Op::classify(&j(src)).unwrap() {
332            Some(Op::AddRelation(v)) => {
333                assert!(matches!(v[0], ParseValue::Pointer { .. }))
334            }
335            other => panic!("expected AddRelation, got {other:?}"),
336        }
337    }
338
339    /// `SetOnInsert` decodes, carries an arbitrary value under `amount`, and echoes its result.
340    ///
341    /// Decoding it is not the same as accepting it on a REST write: `infer_op_type` refuses it the
342    /// way `getObjectType` does. The decoder exists because the op is real everywhere else
343    /// upstream, and because `Unknown operation: SetOnInsert` was the wrong reason to refuse it.
344    #[test]
345    fn set_on_insert_decodes_with_an_arbitrary_amount() {
346        match Op::classify(&j(r#"{"__op":"SetOnInsert","amount":"a string"}"#)).unwrap() {
347            Some(Op::SetOnInsert(ParseValue::String(s))) => assert_eq!(s, "a string"),
348            other => panic!("expected SetOnInsert, got {other:?}"),
349        }
350        // No `amount` at all. Upstream sets the field to `undefined`, which the driver stores as
351        // null rather than as an error.
352        assert!(matches!(
353            Op::classify(&j(r#"{"__op":"SetOnInsert"}"#)).unwrap(),
354            Some(Op::SetOnInsert(ParseValue::Null))
355        ));
356        assert!(Op::SetOnInsert(ParseValue::Null).echoes_result());
357    }
358}