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 array index a JSON Pointer reference token names, if it names one.
92	///
93	/// RFC 6901 §4 spells an array index as `0` or `[1-9][0-9]*`. `str::parse`
94	/// is wider than that grammar — it also accepts a leading `+` and leading
95	/// zeroes — and those spellings name distinct object members, so the
96	/// parsed value is re-rendered and compared to reject them. The result is
97	/// bounded by `usize` on the target, which is the range an index part can
98	/// address.
99	pub fn array_index(token: &str) -> Option<usize> {
100		let index: usize = token.parse().ok()?;
101		(index.to_string() == token).then_some(index)
102	}
103
104	/// Maps the reference tokens of a JSON Pointer to the parts that select
105	/// the value it points at.
106	///
107	/// A token spelled as an array index becomes an index part, which selects
108	/// the element at that position in an array and the member of that name in
109	/// an object — the two readings RFC 6901 gives a token, and they agree
110	/// only because the spelling is canonical, so the member name an index
111	/// part derives is the token itself. Every other token becomes a field
112	/// part carrying it verbatim, which keeps members named `007`, `+3` or
113	/// `-1` addressable.
114	///
115	/// Every pointer that `Value::patch` resolves is mapped here, so that a
116	/// caller deciding whether a pointer may be applied — see
117	/// `Document::check_patch_read_pointers` — judges the same location the
118	/// patch will reach.
119	pub fn path_to_parts(path: &[Strand]) -> Vec<Part> {
120		path.iter()
121			.map(|p| match Self::array_index(p.as_str()).and_then(|i| i64::try_from(i).ok()) {
122				Some(i) => Part::index_int(i),
123				None => Part::Field(p.clone()),
124			})
125			.collect()
126	}
127
128	/// Converts a value to a JSON path.
129	fn value_to_jsonpath(val: &Value) -> Vec<Strand> {
130		// Per RFC 6901, an empty JSON Pointer ("") refers to the root document.
131		// Splitting an empty string would produce `[Strand("")]` rather than the
132		// empty `Vec` round-trip target of `path_to_jsonpath(&[])`.
133		let raw = val.to_raw_string();
134		let trimmed = raw.trim_start_matches('/');
135		if trimmed.is_empty() {
136			Vec::new()
137		} else {
138			trimmed.split(&['.', '/']).map(Strand::from).collect()
139		}
140	}
141
142	/// Converts the operation to a JSON patch object.
143	pub fn into_object(self) -> Object {
144		// Converts a path to a JSON path
145		fn path_to_jsonpath(p: &[Strand]) -> Value {
146			let mut res = String::with_capacity(p.len() + p.iter().map(|x| x.len()).sum::<usize>());
147			for p in p {
148				res.push('/');
149				res.push_str(p.as_str());
150			}
151			res.into()
152		}
153		// Return the JSON patch operation
154		Object(match self {
155			Operation::Add {
156				path,
157				value,
158			} => {
159				map! {
160					"op".into() => Value::from("add"),
161					"path".into() => path_to_jsonpath(&path),
162					"value".into() => value,
163				}
164			}
165			Operation::Remove {
166				path,
167			} => {
168				map! {
169					"op".into() => Value::from("remove"),
170					"path".into() => path_to_jsonpath(&path),
171				}
172			}
173			Operation::Replace {
174				path,
175				value,
176			} => {
177				map! {
178					"op".into() => Value::from("replace"),
179					"path".into() => path_to_jsonpath(&path),
180					"value".into() => value,
181				}
182			}
183			Operation::Change {
184				path,
185				value,
186			} => {
187				map! {
188					"op".into() => Value::from("change"),
189					"path".into() => path_to_jsonpath(&path),
190					"value".into() => value,
191				}
192			}
193			Operation::Copy {
194				path,
195				from,
196			} => {
197				map! {
198					"op".into() => Value::from("copy"),
199					"path".into() => path_to_jsonpath(&path),
200					"from".into() => path_to_jsonpath(&from),
201				}
202			}
203			Operation::Move {
204				path,
205				from,
206			} => {
207				map! {
208					"op".into() => Value::from("move"),
209					"path".into() => path_to_jsonpath(&path),
210					"from".into() => path_to_jsonpath(&from),
211				}
212			}
213			Operation::Test {
214				path,
215				value,
216			} => {
217				map! {
218					"op".into() => Value::from("test"),
219					"path".into() => path_to_jsonpath(&path),
220					"value".into() => value,
221				}
222			}
223		})
224	}
225
226	/// Returns the operaton encoded in the object, or an error if the object
227	/// does not contain a valid operation.
228	pub fn operation_from_object(object: &Object) -> Result<Operation, PatchError> {
229		let Some(op) = object.get("op") else {
230			return Err(PatchError {
231				message: "Key 'op' missing".to_owned(),
232			});
233		};
234
235		let Value::String(op) = op else {
236			return Err(PatchError {
237				message: "Key 'op' not a string".to_owned(),
238			});
239		};
240
241		let Some(path) = object.get("path") else {
242			return Err(PatchError {
243				message: "Key 'path' missing".to_owned(),
244			});
245		};
246
247		let from = || {
248			object.get("from").map(Operation::value_to_jsonpath).ok_or_else(|| PatchError {
249				message: "Key 'from' missing".to_owned(),
250			})
251		};
252
253		let value = || {
254			object.get("value").cloned().ok_or_else(|| PatchError {
255				message: "Key 'value' missing".to_owned(),
256			})
257		};
258
259		let path = Operation::value_to_jsonpath(path);
260
261		match op.as_str() {
262			"add" => Ok(Operation::Add {
263				path,
264				value: value()?,
265			}),
266			"remove" => Ok(Operation::Remove {
267				path,
268			}),
269			"replace" => Ok(Operation::Replace {
270				path,
271				value: value()?,
272			}),
273			"change" => Ok(Operation::Change {
274				path,
275				value: value()?,
276			}),
277			"copy" => {
278				let from = reject_root_from("copy", from()?)?;
279				reject_nested_destination("copy", &path, &from)?;
280				Ok(Operation::Copy {
281					path,
282					from,
283				})
284			}
285			"move" => {
286				let from = reject_root_from("move", from()?)?;
287				reject_nested_destination("move", &path, &from)?;
288				Ok(Operation::Move {
289					path,
290					from,
291				})
292			}
293			"test" => Ok(Operation::Test {
294				path,
295				value: value()?,
296			}),
297
298			x => Err(PatchError {
299				message: format!("Invalid operation '{x}'"),
300			}),
301		}
302	}
303
304	/// Turns a value into a list of operations if the value has the right
305	/// structure.
306	pub fn value_to_operations(value: Value) -> Result<Vec<Operation>, PatchError> {
307		let Value::Array(array) = value else {
308			return Err(PatchError {
309				message: "Patch operations should be an array of objects".to_owned(),
310			});
311		};
312
313		let mut res = Vec::new();
314		for o in array {
315			let Value::Object(o) = o else {
316				return Err(PatchError {
317					message: "Patch operations should be an array of objects".to_owned(),
318				});
319			};
320			res.push(Operation::operation_from_object(&o)?)
321		}
322		Ok(res)
323	}
324
325	pub fn operations_to_value(operations: Vec<Operation>) -> Value {
326		let array = operations.into_iter().map(|x| Value::Object(x.into_object())).collect();
327		Value::Array(Array(array))
328	}
329}
330
331impl ToSql for Operation {
332	fn fmt_sql(&self, f: &mut String, fmt: SqlFormat) {
333		self.clone().into_object().fmt_sql(f, fmt);
334	}
335}
336
337#[cfg(test)]
338mod tests {
339	use super::*;
340
341	fn roundtrip(op: &Operation) {
342		let obj = op.clone().into_object();
343		let decoded = Operation::operation_from_object(&obj)
344			.expect("operation_from_object should accept into_object output");
345		assert_eq!(*op, decoded);
346	}
347
348	#[test]
349	fn round_trip_all_variants() {
350		let path: Vec<Strand> = vec!["a".into(), "b".into()];
351		let from: Vec<Strand> = vec!["c".into(), "d".into()];
352		let value = Value::Bool(true);
353
354		roundtrip(&Operation::Add {
355			path: path.clone(),
356			value: value.clone(),
357		});
358		roundtrip(&Operation::Remove {
359			path: path.clone(),
360		});
361		roundtrip(&Operation::Replace {
362			path: path.clone(),
363			value: value.clone(),
364		});
365		roundtrip(&Operation::Change {
366			path: path.clone(),
367			value: value.clone(),
368		});
369		roundtrip(&Operation::Copy {
370			path: path.clone(),
371			from: from.clone(),
372		});
373		roundtrip(&Operation::Move {
374			path: path.clone(),
375			from,
376		});
377		roundtrip(&Operation::Test {
378			path,
379			value,
380		});
381	}
382
383	#[test]
384	fn round_trip_root_path() {
385		// Empty path Vec must round-trip through the JSON Patch object form,
386		// otherwise diff/patch silently no-ops on top-level scalars (issue 7239).
387		let empty: Vec<Strand> = Vec::new();
388		let value = Value::from("Hello there!");
389
390		roundtrip(&Operation::Add {
391			path: empty.clone(),
392			value: value.clone(),
393		});
394		roundtrip(&Operation::Remove {
395			path: empty.clone(),
396		});
397		roundtrip(&Operation::Replace {
398			path: empty.clone(),
399			value: value.clone(),
400		});
401		roundtrip(&Operation::Change {
402			path: empty.clone(),
403			value: value.clone(),
404		});
405		roundtrip(&Operation::Test {
406			path: empty,
407			value,
408		});
409	}
410}