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}