Skip to main content

surrealdb_expr/expr/
operation.rs

1use std::fmt;
2
3use revision::revisioned;
4use surrealdb_strand::Strand;
5use surrealdb_types::{SqlFormat, ToSql};
6
7use crate::expr::part::Part;
8use crate::val::{Array, Object, Value};
9
10#[derive(Debug)]
11pub struct PatchError {
12	pub message: String,
13}
14
15impl fmt::Display for PatchError {
16	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17		write!(f, "Failed to parse JSON patch structure: {}", self.message.to_sql())
18	}
19}
20
21/// A type representing an delta change to a value.
22
23#[revisioned(revision = 1)]
24#[derive(Clone, Debug, Eq, PartialEq, Hash)]
25pub enum Operation {
26	Add {
27		path: Vec<Strand>,
28		value: Value,
29	},
30	Remove {
31		path: Vec<Strand>,
32	},
33	Replace {
34		path: Vec<Strand>,
35		value: Value,
36	},
37	Change {
38		path: Vec<Strand>,
39		value: Value,
40	},
41	Copy {
42		path: Vec<Strand>,
43		from: Vec<Strand>,
44	},
45	Move {
46		path: Vec<Strand>,
47		from: Vec<Strand>,
48	},
49	Test {
50		path: Vec<Strand>,
51		value: Value,
52	},
53}
54
55/// Reject a root `from` pointer for `copy` / `move`. Without this guard, an
56/// empty `from` makes `Value::pick` return the entire current document, which
57/// can copy field values past their SELECT permissions into an attacker-chosen
58/// destination path.
59fn reject_root_from(op: &str, from: Vec<Strand>) -> Result<Vec<Strand>, PatchError> {
60	if from.is_empty() {
61		Err(PatchError {
62			message: format!("'{op}' operation requires a non-root 'from' pointer"),
63		})
64	} else {
65		Ok(from)
66	}
67}
68
69/// Reject a `copy` or `move` whose `from` pointer is a proper prefix of its
70/// `path`.
71///
72/// The destination lies inside the value the operation reads, so the write
73/// lands in the value it came from. For `copy` that nests another snapshot of
74/// the source inside itself on every application, which doubles the document a
75/// caller builds from a patch whose own size never grows. For `move` (RFC 6902
76/// §4.4) the source is removed before the destination is written, so the write
77/// names a location the removal has just taken away.
78///
79/// An equal pair is not a proper prefix: it names one location, and copying or
80/// moving a value onto itself leaves it where it is.
81fn reject_nested_destination(op: &str, path: &[Strand], from: &[Strand]) -> Result<(), PatchError> {
82	if path.len() > from.len() && path.starts_with(from) {
83		return Err(PatchError {
84			message: format!("'{op}' operation requires a 'path' that is not inside 'from'"),
85		});
86	}
87	Ok(())
88}
89
90impl Operation {
91	/// The longest JSON-Pointer path this operation carries. Applying an
92	/// operation drives `Value::put` / `cut` / `pick` once per path segment, and
93	/// those are natively recursive, so a caller bounds this against the value
94	/// depth cap before applying to keep the recursion off the host stack.
95	pub fn max_path_len(&self) -> usize {
96		match self {
97			Operation::Add {
98				path,
99				..
100			}
101			| Operation::Remove {
102				path,
103			}
104			| Operation::Replace {
105				path,
106				..
107			}
108			| Operation::Change {
109				path,
110				..
111			}
112			| Operation::Test {
113				path,
114				..
115			} => path.len(),
116			Operation::Copy {
117				path,
118				from,
119			}
120			| Operation::Move {
121				path,
122				from,
123			} => path.len().max(from.len()),
124		}
125	}
126
127	/// The array index a JSON Pointer reference token names, if it names one.
128	///
129	/// RFC 6901 §4 spells an array index as `0` or `[1-9][0-9]*`. `str::parse`
130	/// is wider than that grammar — it also accepts a leading `+` and leading
131	/// zeroes — and those spellings name distinct object members, so the
132	/// parsed value is re-rendered and compared to reject them. The result is
133	/// bounded by `usize` on the target, which is the range an index part can
134	/// address.
135	pub fn array_index(token: &str) -> Option<usize> {
136		let index: usize = token.parse().ok()?;
137		(index.to_string() == token).then_some(index)
138	}
139
140	/// Maps the reference tokens of a JSON Pointer to the parts that select
141	/// the value it points at.
142	///
143	/// A token spelled as an array index becomes an index part, which selects
144	/// the element at that position in an array and the member of that name in
145	/// an object — the two readings RFC 6901 gives a token, and they agree
146	/// only because the spelling is canonical, so the member name an index
147	/// part derives is the token itself. Every other token becomes a field
148	/// part carrying it verbatim, which keeps members named `007`, `+3` or
149	/// `-1` addressable.
150	///
151	/// Every pointer that `Value::patch` resolves is mapped here, so that a
152	/// caller deciding whether a pointer may be applied — see
153	/// `Document::check_patch_read_pointers` — judges the same location the
154	/// patch will reach.
155	pub fn path_to_parts(path: &[Strand]) -> Vec<Part> {
156		path.iter()
157			.map(|p| match Self::array_index(p.as_str()).and_then(|i| i64::try_from(i).ok()) {
158				Some(i) => Part::index_int(i),
159				None => Part::Field(p.clone()),
160			})
161			.collect()
162	}
163
164	/// Converts a value to a JSON path.
165	fn value_to_jsonpath(val: &Value) -> Vec<Strand> {
166		// Per RFC 6901, an empty JSON Pointer ("") refers to the root document.
167		// Splitting an empty string would produce `[Strand("")]` rather than the
168		// empty `Vec` round-trip target of `path_to_jsonpath(&[])`.
169		let raw = val.to_raw_string();
170		let trimmed = raw.trim_start_matches('/');
171		if trimmed.is_empty() {
172			Vec::new()
173		} else {
174			trimmed.split(&['.', '/']).map(Strand::from).collect()
175		}
176	}
177
178	/// Converts the operation to a JSON patch object.
179	pub fn into_object(self) -> Object {
180		// Converts a path to a JSON path
181		fn path_to_jsonpath(p: &[Strand]) -> Value {
182			let mut res = String::with_capacity(p.len() + p.iter().map(|x| x.len()).sum::<usize>());
183			for p in p {
184				res.push('/');
185				res.push_str(p.as_str());
186			}
187			res.into()
188		}
189		// Return the JSON patch operation
190		Object(match self {
191			Operation::Add {
192				path,
193				value,
194			} => {
195				map! {
196					"op".into() => Value::from("add"),
197					"path".into() => path_to_jsonpath(&path),
198					"value".into() => value,
199				}
200			}
201			Operation::Remove {
202				path,
203			} => {
204				map! {
205					"op".into() => Value::from("remove"),
206					"path".into() => path_to_jsonpath(&path),
207				}
208			}
209			Operation::Replace {
210				path,
211				value,
212			} => {
213				map! {
214					"op".into() => Value::from("replace"),
215					"path".into() => path_to_jsonpath(&path),
216					"value".into() => value,
217				}
218			}
219			Operation::Change {
220				path,
221				value,
222			} => {
223				map! {
224					"op".into() => Value::from("change"),
225					"path".into() => path_to_jsonpath(&path),
226					"value".into() => value,
227				}
228			}
229			Operation::Copy {
230				path,
231				from,
232			} => {
233				map! {
234					"op".into() => Value::from("copy"),
235					"path".into() => path_to_jsonpath(&path),
236					"from".into() => path_to_jsonpath(&from),
237				}
238			}
239			Operation::Move {
240				path,
241				from,
242			} => {
243				map! {
244					"op".into() => Value::from("move"),
245					"path".into() => path_to_jsonpath(&path),
246					"from".into() => path_to_jsonpath(&from),
247				}
248			}
249			Operation::Test {
250				path,
251				value,
252			} => {
253				map! {
254					"op".into() => Value::from("test"),
255					"path".into() => path_to_jsonpath(&path),
256					"value".into() => value,
257				}
258			}
259		})
260	}
261
262	/// Returns the operaton encoded in the object, or an error if the object
263	/// does not contain a valid operation.
264	pub fn operation_from_object(object: &Object) -> Result<Operation, PatchError> {
265		let Some(op) = object.get("op") else {
266			return Err(PatchError {
267				message: "Key 'op' missing".to_owned(),
268			});
269		};
270
271		let Value::String(op) = op else {
272			return Err(PatchError {
273				message: "Key 'op' not a string".to_owned(),
274			});
275		};
276
277		let Some(path) = object.get("path") else {
278			return Err(PatchError {
279				message: "Key 'path' missing".to_owned(),
280			});
281		};
282
283		let from = || {
284			object.get("from").map(Operation::value_to_jsonpath).ok_or_else(|| PatchError {
285				message: "Key 'from' missing".to_owned(),
286			})
287		};
288
289		let value = || {
290			object.get("value").cloned().ok_or_else(|| PatchError {
291				message: "Key 'value' missing".to_owned(),
292			})
293		};
294
295		let path = Operation::value_to_jsonpath(path);
296
297		match op.as_str() {
298			"add" => Ok(Operation::Add {
299				path,
300				value: value()?,
301			}),
302			"remove" => Ok(Operation::Remove {
303				path,
304			}),
305			"replace" => Ok(Operation::Replace {
306				path,
307				value: value()?,
308			}),
309			"change" => Ok(Operation::Change {
310				path,
311				value: value()?,
312			}),
313			"copy" => {
314				let from = reject_root_from("copy", from()?)?;
315				reject_nested_destination("copy", &path, &from)?;
316				Ok(Operation::Copy {
317					path,
318					from,
319				})
320			}
321			"move" => {
322				let from = reject_root_from("move", from()?)?;
323				reject_nested_destination("move", &path, &from)?;
324				Ok(Operation::Move {
325					path,
326					from,
327				})
328			}
329			"test" => Ok(Operation::Test {
330				path,
331				value: value()?,
332			}),
333
334			x => Err(PatchError {
335				message: format!("Invalid operation '{x}'"),
336			}),
337		}
338	}
339
340	/// Turns a value into a list of operations if the value has the right
341	/// structure.
342	pub fn value_to_operations(value: Value) -> Result<Vec<Operation>, PatchError> {
343		let Value::Array(array) = value else {
344			return Err(PatchError {
345				message: "Patch operations should be an array of objects".to_owned(),
346			});
347		};
348
349		let mut res = Vec::new();
350		for o in array {
351			let Value::Object(o) = o else {
352				return Err(PatchError {
353					message: "Patch operations should be an array of objects".to_owned(),
354				});
355			};
356			res.push(Operation::operation_from_object(&o)?)
357		}
358		Ok(res)
359	}
360
361	pub fn operations_to_value(operations: Vec<Operation>) -> Value {
362		let array = operations.into_iter().map(|x| Value::Object(x.into_object())).collect();
363		Value::Array(Array(array))
364	}
365}
366
367impl ToSql for Operation {
368	fn fmt_sql(&self, f: &mut String, fmt: SqlFormat) {
369		self.clone().into_object().fmt_sql(f, fmt);
370	}
371}
372
373#[cfg(test)]
374mod tests {
375	use super::*;
376
377	fn roundtrip(op: &Operation) {
378		let obj = op.clone().into_object();
379		let decoded = Operation::operation_from_object(&obj)
380			.expect("operation_from_object should accept into_object output");
381		assert_eq!(*op, decoded);
382	}
383
384	#[test]
385	fn round_trip_all_variants() {
386		let path: Vec<Strand> = vec!["a".into(), "b".into()];
387		let from: Vec<Strand> = vec!["c".into(), "d".into()];
388		let value = Value::Bool(true);
389
390		roundtrip(&Operation::Add {
391			path: path.clone(),
392			value: value.clone(),
393		});
394		roundtrip(&Operation::Remove {
395			path: path.clone(),
396		});
397		roundtrip(&Operation::Replace {
398			path: path.clone(),
399			value: value.clone(),
400		});
401		roundtrip(&Operation::Change {
402			path: path.clone(),
403			value: value.clone(),
404		});
405		roundtrip(&Operation::Copy {
406			path: path.clone(),
407			from: from.clone(),
408		});
409		roundtrip(&Operation::Move {
410			path: path.clone(),
411			from,
412		});
413		roundtrip(&Operation::Test {
414			path,
415			value,
416		});
417	}
418
419	#[test]
420	fn round_trip_root_path() {
421		// Empty path Vec must round-trip through the JSON Patch object form,
422		// otherwise diff/patch silently no-ops on top-level scalars (issue 7239).
423		let empty: Vec<Strand> = Vec::new();
424		let value = Value::from("Hello there!");
425
426		roundtrip(&Operation::Add {
427			path: empty.clone(),
428			value: value.clone(),
429		});
430		roundtrip(&Operation::Remove {
431			path: empty.clone(),
432		});
433		roundtrip(&Operation::Replace {
434			path: empty.clone(),
435			value: value.clone(),
436		});
437		roundtrip(&Operation::Change {
438			path: empty.clone(),
439			value: value.clone(),
440		});
441		roundtrip(&Operation::Test {
442			path: empty,
443			value,
444		});
445	}
446}