Skip to main content

surrealdb_expr/expr/idiom/
mod.rs

1use std::cmp::Ordering;
2use std::collections::HashMap;
3use std::ops::Deref;
4use std::str::FromStr;
5
6use common::fmt::EscapeKwFreeIdent;
7use reblessive::Stack;
8use revision::{DeserializeRevisioned, Revisioned, SerializeRevisioned};
9use surrealdb_strand::Strand;
10use surrealdb_types::{SqlFormat, ToSql};
11
12use crate::expr::paths::{ID, IN, OUT};
13use crate::expr::statements::info::InfoStructure;
14use crate::expr::{Expr, Literal, Part, Value};
15
16pub mod recursion;
17
18#[derive(Clone, Debug, Default, Eq, PartialEq, Hash)]
19#[allow(dead_code)]
20pub struct Idioms(pub Vec<Idiom>);
21
22impl Deref for Idioms {
23	type Target = Vec<Idiom>;
24	fn deref(&self) -> &Self::Target {
25		&self.0
26	}
27}
28
29impl IntoIterator for Idioms {
30	type Item = Idiom;
31	type IntoIter = std::vec::IntoIter<Self::Item>;
32	fn into_iter(self) -> Self::IntoIter {
33		self.0.into_iter()
34	}
35}
36
37impl PartialOrd for Idioms {
38	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
39		Some(self.cmp(other))
40	}
41}
42
43impl Ord for Idioms {
44	fn cmp(&self, other: &Self) -> Ordering {
45		for (a, b) in self.0.iter().zip(other.0.iter()) {
46			let o = a.cmp(b);
47			if o != Ordering::Equal {
48				return o;
49			}
50		}
51		Ordering::Equal
52	}
53}
54
55/// An idiom defines a way to reference a field, reference, or other part of the document graph.
56#[derive(Clone, Debug, Default, Eq, PartialEq, Hash)]
57pub struct Idiom(pub Vec<Part>);
58
59impl Idiom {
60	/// Returns an idiom for a field of the given name.
61	pub fn field(field_name: impl Into<Strand>) -> Self {
62		Idiom(vec![Part::Field(field_name.into())])
63	}
64
65	/// Appends a part to the end of this Idiom
66	pub fn push(mut self, n: Part) -> Idiom {
67		self.0.push(n);
68		self
69	}
70	/// Simplifies this Idiom for use in object keys
71	pub fn simplify(&self) -> Idiom {
72		self.0
73			.iter()
74			.filter(|&p| matches!(p, Part::Field(_) | Part::Start(_) | Part::Lookup(_)))
75			.cloned()
76			.collect::<Vec<_>>()
77			.into()
78	}
79	/// Check if this Idiom is an 'id' field
80	pub fn is_id(&self) -> bool {
81		self.0.len() == 1 && self.0[0].eq(&ID[0])
82	}
83	/// Check if this Idiom is a special field such as `id`, `in` or `out`.
84	pub fn is_special(&self) -> bool {
85		self.0.len() == 1 && [&ID[0], &IN[0], &OUT[0]].contains(&&self.0[0])
86	}
87
88	/// Returns a raw string representation of this idiom without any escaping.
89	pub fn to_raw_string(&self) -> String {
90		use std::fmt::Write;
91
92		let mut s = String::new();
93
94		let mut iter = self.0.iter();
95		match iter.next() {
96			Some(Part::Field(v)) => {
97				write!(&mut s, "{}", EscapeKwFreeIdent(v).to_sql()).expect("writing to string")
98			}
99			Some(x) => s.push_str(&x.to_raw_string()),
100			None => {}
101		};
102
103		for p in iter {
104			s.push_str(&p.to_raw_string());
105		}
106
107		s
108	}
109
110	/// Check if this is an expression with multiple yields
111	pub fn is_multi_yield(&self) -> bool {
112		self.iter().any(Self::part_is_multi_yield)
113	}
114	/// Check if the path part is a yield in a multi-yield expression
115	pub fn part_is_multi_yield(v: &Part) -> bool {
116		matches!(v, Part::Lookup(g) if g.alias.is_some())
117	}
118
119	/// Check if this Idiom starts with a specific path part
120	pub fn starts_with(&self, other: &[Part]) -> bool {
121		self.0.starts_with(other)
122	}
123
124	/// Check if we require a writeable transaction
125	pub fn read_only(&self) -> bool {
126		self.0.iter().all(|v| v.read_only())
127	}
128
129	/// Validate that this idiom is a valid "local" field path. Returns an
130	/// error describing the offending part otherwise. The `into` argument is
131	/// used in the error message to identify the kind of path being checked
132	/// (e.g. "field name").
133	pub fn validate_local(&self, into: &str) -> anyhow::Result<()> {
134		for part in self.iter() {
135			match part {
136				Part::Field(_) | Part::All | Part::Flatten | Part::First | Part::Last => {}
137				Part::Value(Expr::Literal(lit)) => match lit {
138					Literal::Integer(_) | Literal::String(_) => {}
139					_ => {
140						return Err(anyhow::anyhow!(
141							"Invalid {into}: Field path index must evaluate to an integer or string"
142						));
143					}
144				},
145				_ => {
146					return Err(anyhow::anyhow!(
147						"Invalid {into}: contains a part that is not allowed in a field path"
148					));
149				}
150			}
151		}
152		Ok(())
153	}
154}
155
156impl Deref for Idiom {
157	type Target = [Part];
158	fn deref(&self) -> &Self::Target {
159		self.0.as_slice()
160	}
161}
162
163impl From<Vec<Part>> for Idiom {
164	fn from(v: Vec<Part>) -> Self {
165		Self(v)
166	}
167}
168
169impl PartialOrd for Idiom {
170	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
171		Some(self.cmp(other))
172	}
173}
174
175impl Ord for Idiom {
176	fn cmp(&self, other: &Self) -> Ordering {
177		for (a, b) in self.0.iter().zip(other.0.iter()) {
178			let o = a.partial_cmp(b).unwrap_or(Ordering::Equal);
179			if o != Ordering::Equal {
180				return o;
181			}
182		}
183
184		// If all parts match so far, shorter idiom comes first
185		// This ensures that `a` < `a.b`
186		self.0.len().cmp(&other.0.len())
187	}
188}
189
190impl ToSql for Idiom {
191	fn fmt_sql(&self, f: &mut String, sql_fmt: SqlFormat) {
192		let sql_idiom: crate::sql::Idiom = self.clone().into();
193		sql_idiom.fmt_sql(f, sql_fmt);
194	}
195}
196
197impl Idiom {
198	/// Shared parse for the two string forms of an idiom: fresh runtime
199	/// strings ([`FromStr`], default limits) and the engine-rendered wire
200	/// encoding ([`DeserializeRevisioned`],
201	/// [`crate::syn::parser::ParserSettings::STORED_TEXT`]).
202	///
203	/// Parses the idiom grammar, which is what both forms are: a field path.
204	/// It has to be that grammar rather than the expression one, because a
205	/// leading field is escaped with `EscapeKwFreeIdent` — which backticks only
206	/// a name that would not lex as an identifier, and so leaves a reserved
207	/// keyword such as `function` bare. Read as an expression, that bare
208	/// keyword is the start of a call or a statement and fails to parse. This
209	/// is the same parser, and the same reasoning, as the catalog's funnel for
210	/// the equivalent stored text (`syn::idiom_for_definition`).
211	///
212	/// The idiom grammar admits only an identifier or a graph edge at the
213	/// start, so an idiom rooted in a [`Part::Start`] expression (`$param.x`,
214	/// `person:test.x`) is rejected rather than accepted as some other path.
215	/// Nothing that reaches here can hold one: every persisted idiom is a field
216	/// name or index column (stored as `IdiomText` and parsed by the same idiom
217	/// grammar) or a `Selector` alias in a pre-v2 subscription's field list
218	/// (parsed by `parse_plain_idiom`), and `Value` — the one stored type that
219	/// carries arbitrary user data — has no `Idiom` variant. A runtime-resolved
220	/// field name is not a valid field path either, so failing is the useful
221	/// answer there.
222	///
223	/// The whole input must be consumed. Without that, trailing content after a
224	/// valid idiom prefix would be silently dropped (e.g. "foo bogus" would
225	/// parse to just "foo"), which is a footgun for any caller that resolves a
226	/// string at runtime and re-parses it as an idiom.
227	fn parse_str_with_settings(
228		s: &str,
229		settings: crate::syn::parser::ParserSettings,
230	) -> Result<Self, revision::Error> {
231		let buf = s.as_bytes();
232		let mut stack = Stack::new();
233		let mut parser = crate::syn::parser::Parser::new_with_settings(buf, settings);
234		let idiom = stack
235			.enter(|stk| parser.parse_plain_idiom(stk))
236			.finish()
237			.map_err(|err| revision::Error::Conversion(format!("{err:?}")))?;
238		parser.assert_finished().map_err(|err| revision::Error::Conversion(format!("{err:?}")))?;
239
240		Ok(idiom.into())
241	}
242}
243
244impl FromStr for Idiom {
245	type Err = revision::Error;
246	fn from_str(s: &str) -> Result<Self, Self::Err> {
247		// Fresh input (e.g. a runtime-evaluated field name): parse under the
248		// default limits, not the stored-text profile.
249		Self::parse_str_with_settings(
250			s,
251			crate::syn::parser::ParserSettings::default_with_experimental(true),
252		)
253	}
254}
255
256impl Revisioned for Idiom {
257	fn revision() -> u16 {
258		1
259	}
260}
261
262impl SerializeRevisioned for Idiom {
263	fn serialize_revisioned<W: std::io::Write>(
264		&self,
265		writer: &mut W,
266	) -> Result<(), revision::Error> {
267		SerializeRevisioned::serialize_revisioned(&self.to_raw_string(), writer)
268	}
269}
270
271impl DeserializeRevisioned for Idiom {
272	fn deserialize_revisioned<R: std::io::Read>(reader: &mut R) -> Result<Self, revision::Error> {
273		let s: String = DeserializeRevisioned::deserialize_revisioned(reader)?;
274		// The wire format is engine-rendered SurrealQL text; see the constant
275		// for why decode is capability- and limit-independent.
276		Idiom::parse_str_with_settings(&s, crate::syn::parser::ParserSettings::STORED_TEXT)
277	}
278}
279
280impl revision::SkipRevisioned for Idiom {
281	fn skip_revisioned<R: std::io::Read>(reader: &mut R) -> Result<(), revision::Error> {
282		<String as revision::SkipRevisioned>::skip_revisioned(reader)
283	}
284}
285
286impl revision::WalkRevisioned for Idiom {
287	type Walker<'r, R: revision::BorrowedReader + 'r> = revision::LeafWalker<'r, Idiom, R>;
288
289	fn walk_revisioned<'r, R: revision::BorrowedReader>(
290		reader: &'r mut R,
291	) -> Result<Self::Walker<'r, R>, revision::Error> {
292		Ok(revision::LeafWalker::new(reader))
293	}
294}
295
296impl revision::LengthPrefixedBytes for Idiom {}
297
298impl InfoStructure for Idiom {
299	fn structure(self) -> Value {
300		self.to_sql().into()
301	}
302}
303
304/// A trie structure for storing idioms.
305///
306/// This is used for efficient searching and retrieval of idioms based on their
307/// path parts.
308///
309/// Note: This is a simplified version of a trie and does not implement all the
310/// features of a full trie.
311#[derive(Debug)]
312pub struct IdiomTrie<T> {
313	/// The children of this node, indexed by their path part.
314	pub children: HashMap<Part, IdiomTrie<T>>,
315	/// The data associated with this node, if any.
316	pub data: Option<T>,
317}
318
319impl<T: Clone + std::fmt::Debug> Default for IdiomTrie<T> {
320	fn default() -> Self {
321		Self::new()
322	}
323}
324
325impl<T: Clone + std::fmt::Debug> IdiomTrie<T> {
326	/// Creates a new empty [`IdiomTrie`].
327	pub fn new() -> Self {
328		IdiomTrie {
329			children: HashMap::new(),
330			data: None,
331		}
332	}
333
334	/// Inserts a new path and associated data into the trie.
335	pub fn insert(&mut self, path: &[Part], data: T) {
336		let mut node = self;
337		for part in path {
338			node = node.children.entry(part.clone()).or_default();
339		}
340		node.data = Some(data);
341	}
342
343	/// Checks if the trie contains a path and returns the associated data.
344	///
345	/// If the path is found, it returns [`IdiomTrieContains::Exact`].
346	/// If the path is not found but an ancestor is found, it returns
347	/// [`IdiomTrieContains::Ancestor`]. If an ancestor is not found, it
348	/// returns [`IdiomTrieContains::None`].
349	pub fn contains(&self, path: &[Part]) -> IdiomTrieContains<T> {
350		let mut node = self;
351		let mut last_node_had_data = false;
352
353		for part in path {
354			if let Some(child) = node.children.get(part) {
355				last_node_had_data = child.data.is_some();
356				node = child;
357			} else {
358				// No more children, stop searching
359				last_node_had_data = false;
360				break;
361			}
362		}
363
364		if let Some(data) = node.data.as_ref() {
365			if last_node_had_data {
366				IdiomTrieContains::Exact(data.clone())
367			} else {
368				IdiomTrieContains::Ancestor(data.clone())
369			}
370		} else {
371			IdiomTrieContains::None
372		}
373	}
374}
375
376/// The result of a search in the [`IdiomTrie`].
377pub enum IdiomTrieContains<T> {
378	/// The path was not found and none of it had no ancestors in the trie.
379	None,
380	/// The path was found and the data is associated with it.
381	Exact(T),
382	/// The path was not found, but an ancestor was found.
383	Ancestor(T),
384}
385
386#[cfg(test)]
387mod tests {
388	use rstest::rstest;
389
390	use super::*;
391
392	#[rstest]
393	#[case(Idiom::from(vec![Part::Field(Strand::new_static("name"))]), "name")]
394	#[case(Idiom::from(vec![Part::Field(Strand::new_static("nested")), Part::Field(Strand::new_static("nested")), Part::Field(Strand::new_static("name"))]), "nested.nested.name")]
395	#[case(Idiom::from(vec![Part::Field(Strand::new_static("nested")), Part::Field(Strand::new_static("nested")), Part::Field(Strand::new_static("value"))]), "nested.nested.value")]
396	#[case(Idiom::from(vec![Part::Field(Strand::new_static("value"))]), "`value`")]
397	fn test_idiom_to_string(#[case] idiom: Idiom, #[case] expected: &'static str) {
398		assert_eq!(idiom.to_sql(), expected.to_string());
399	}
400
401	#[rstest]
402	#[case(Idiom::from(vec![Part::Field(Strand::new_static("name"))]), "name")]
403	#[case(Idiom::from(vec![Part::Field(Strand::new_static("nested")), Part::Field(Strand::new_static("nested")), Part::Field(Strand::new_static("name"))]), "nested.nested.name")]
404	#[case(Idiom::from(vec![Part::Field(Strand::new_static("nested")), Part::Field(Strand::new_static("nested")), Part::Field(Strand::new_static("value"))]), "nested.nested.value")]
405	#[case(Idiom::from(vec![Part::Field(Strand::new_static("value"))]), "value")]
406	fn test_idiom_to_raw_string(#[case] idiom: Idiom, #[case] expected: &'static str) {
407		assert_eq!(idiom.to_raw_string(), expected.to_string());
408	}
409
410	#[rstest]
411	// Test b, a ==> a, b (alphabetical ordering)
412	#[case(
413		vec![Idiom::from(vec![Part::Field(Strand::new_static("b"))]), Idiom::from(vec![Part::Field(Strand::new_static("a"))])],
414		vec![Idiom::from(vec![Part::Field(Strand::new_static("a"))]), Idiom::from(vec![Part::Field(Strand::new_static("b"))])]
415	)]
416	// Test a.b, a ==> a, a.b (prefix comes first)
417	#[case(
418		vec![Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::Field(Strand::new_static("b"))]), Idiom::from(vec![Part::Field(Strand::new_static("a"))])],
419		vec![Idiom::from(vec![Part::Field(Strand::new_static("a"))]), Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::Field(Strand::new_static("b"))])]
420	)]
421	// Test complex nested case: author.company, author ==> author, author.company
422	#[case(
423		vec![
424			Idiom::from(vec![Part::Field(Strand::new_static("author")), Part::Field(Strand::new_static("company"))]),
425			Idiom::from(vec![Part::Field(Strand::new_static("author"))])
426		],
427		vec![
428			Idiom::from(vec![Part::Field(Strand::new_static("author"))]),
429			Idiom::from(vec![Part::Field(Strand::new_static("author")), Part::Field(Strand::new_static("company"))])
430		]
431	)]
432	// Test deeply nested: author.company.address, author, author.company ==> author,
433	// author.company, author.company.address
434	#[case(
435		vec![
436			Idiom::from(vec![Part::Field(Strand::new_static("author")), Part::Field(Strand::new_static("company")), Part::Field(Strand::new_static("address"))]),
437			Idiom::from(vec![Part::Field(Strand::new_static("author"))]),
438			Idiom::from(vec![Part::Field(Strand::new_static("author")), Part::Field(Strand::new_static("company"))])
439		],
440		vec![
441			Idiom::from(vec![Part::Field(Strand::new_static("author"))]),
442			Idiom::from(vec![Part::Field(Strand::new_static("author")), Part::Field(Strand::new_static("company"))]),
443			Idiom::from(vec![Part::Field(Strand::new_static("author")), Part::Field(Strand::new_static("company")), Part::Field(Strand::new_static("address"))])
444		]
445	)]
446	// Test mixed alphabetical and nested: d, a.b.c, b, a, a.b ==> a, a.b, a.b.c, b, d
447	#[case(
448		vec![
449			Idiom::from(vec![Part::Field(Strand::new_static("d"))]),
450			Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::Field(Strand::new_static("b")), Part::Field(Strand::new_static("c"))]),
451			Idiom::from(vec![Part::Field(Strand::new_static("b"))]),
452			Idiom::from(vec![Part::Field(Strand::new_static("a"))]),
453			Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::Field(Strand::new_static("b"))])
454		],
455		vec![
456			Idiom::from(vec![Part::Field(Strand::new_static("a"))]),
457			Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::Field(Strand::new_static("b"))]),
458			Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::Field(Strand::new_static("b")), Part::Field(Strand::new_static("c"))]),
459			Idiom::from(vec![Part::Field(Strand::new_static("b"))]),
460			Idiom::from(vec![Part::Field(Strand::new_static("d"))])
461		]
462	)]
463	// Test with different Part variants: Field comes before All
464	#[case(
465		vec![
466			Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::All]),
467			Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::Field(Strand::new_static("b"))])
468		],
469		vec![
470			Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::Field(Strand::new_static("b"))]),
471			Idiom::from(vec![Part::Field(Strand::new_static("a")), Part::All])
472		]
473	)]
474	fn test_idiom_sorting(#[case] mut idioms: Vec<Idiom>, #[case] expected: Vec<Idiom>) {
475		idioms.sort();
476		assert_eq!(idioms, expected);
477	}
478
479	// Tests for Idiom::validate_local
480
481	fn field(name: &'static str) -> Part {
482		Part::Field(Strand::new_static(name))
483	}
484	fn lit_int(n: i64) -> Part {
485		Part::Value(Expr::Literal(Literal::Integer(n)))
486	}
487	fn lit_str(s: &'static str) -> Part {
488		Part::Value(Expr::Literal(Literal::String(Strand::new_static(s))))
489	}
490	fn lit_bool(b: bool) -> Part {
491		Part::Value(Expr::Literal(Literal::Bool(b)))
492	}
493
494	fn lit_float(f: f64) -> Part {
495		Part::Value(Expr::Literal(Literal::Float(f)))
496	}
497
498	#[rstest]
499	// Plain field
500	#[case(Idiom::from(vec![field("foo")]), true)]
501	// Nested fields
502	#[case(Idiom::from(vec![field("foo"), field("bar")]), true)]
503	// Integer index
504	#[case(Idiom::from(vec![field("foo"), lit_int(0)]), true)]
505	// String index
506	#[case(Idiom::from(vec![field("foo"), lit_str("city")]), true)]
507	// Wildcard
508	#[case(Idiom::from(vec![field("foo"), Part::All]), true)]
509	// Flatten / First / Last markers
510	#[case(Idiom::from(vec![field("foo"), Part::Flatten]), true)]
511	#[case(Idiom::from(vec![field("foo"), Part::First]), true)]
512	#[case(Idiom::from(vec![field("foo"), Part::Last]), true)]
513	// Optional is not a static schema-key concept
514	#[case(Idiom::from(vec![field("foo"), Part::Optional]), false)]
515	// Float / decimal indices are not meaningful as array indices or object keys
516	#[case(Idiom::from(vec![field("foo"), lit_float(1.5)]), false)]
517	// Bool index is not a valid local idiom
518	#[case(Idiom::from(vec![field("foo"), lit_bool(true)]), false)]
519	// Non-literal Part::Value (un-substituted) is not a valid local idiom
520	#[case(Idiom::from(vec![field("foo"), Part::Value(Expr::Param(crate::expr::Param::from("n".to_owned())))]), false)]
521	// Where clause is not allowed in a field path
522	#[case(Idiom::from(vec![field("foo"), Part::Where(Expr::Literal(Literal::Bool(true)))]), false)]
523	// Method call is not allowed in a field path
524	#[case(Idiom::from(vec![field("foo"), Part::Method(Strand::new_static("bar"), vec![])]), false)]
525	fn test_idiom_validate_local(#[case] idiom: Idiom, #[case] expected: bool) {
526		assert_eq!(idiom.validate_local("field name").is_ok(), expected);
527	}
528
529	#[test]
530	fn test_validate_local_error_message_for_bracket() {
531		let idiom = Idiom::from(vec![field("foo"), lit_bool(true)]);
532		let err = idiom.validate_local("field name").unwrap_err().to_string();
533		assert!(
534			err.contains("Field path index must evaluate to an integer"),
535			"unexpected error: {err}"
536		);
537	}
538
539	#[test]
540	fn test_validate_local_error_message_for_disallowed_part() {
541		let idiom =
542			Idiom::from(vec![field("foo"), Part::Method(Strand::new_static("bar"), vec![])]);
543		let err = idiom.validate_local("field name").unwrap_err().to_string();
544		assert!(err.contains("not allowed in a field path"), "unexpected error: {err}");
545	}
546
547	#[test]
548	fn idiom_with_bytes_matches_serialize() {
549		use revision::{SerializeRevisioned, WalkRevisioned};
550
551		let idiom = Idiom::from(vec![Part::Field(Strand::new_static("foo"))]);
552		let mut bytes = Vec::new();
553		idiom.serialize_revisioned(&mut bytes).unwrap();
554		let expected_raw = idiom.to_raw_string();
555		let mut r = bytes.as_slice();
556		let walker = Idiom::walk_revisioned(&mut r).unwrap();
557		let observed = walker.with_bytes(|raw| raw.to_vec()).unwrap();
558		assert_eq!(observed.as_slice(), expected_raw.as_bytes());
559		assert!(r.is_empty());
560	}
561
562	// Round-trip via SerializeRevisioned -> DeserializeRevisioned exercises the
563	// `Idiom::from_str` path used to read stored idioms back from the catalog.
564	// Covers every parser-producible part kind that can be persisted (via
565	// DEFINE FIELD, DEFINE INDEX FIELDS, etc.) so the strict `assert_finished`
566	// check in `from_str` can't silently regress on a part whose
567	// `to_raw_string` round-trip drifts. `Part::First` is omitted because the
568	// parser produces `Part::Value(Literal::Integer(0))` for `[0]` instead.
569	#[rstest]
570	#[case::field(Idiom::from(vec![field("foo")]))]
571	#[case::nested_field(Idiom::from(vec![field("foo"), field("bar")]))]
572	#[case::int_index(Idiom::from(vec![field("data"), lit_int(0)]))]
573	#[case::all(Idiom::from(vec![field("foo"), Part::All]))]
574	#[case::last(Idiom::from(vec![field("foo"), Part::Last]))]
575	#[case::flatten(Idiom::from(vec![field("foo"), Part::Flatten]))]
576	#[case::method(Idiom::from(vec![field("id"), Part::Method(Strand::new_static("id"), vec![]), field("val")]))]
577	#[case::destructure(Idiom::from(vec![
578		field("addr"),
579		Part::Destructure(vec![
580			crate::expr::part::DestructurePart::Field(Strand::new_static("city")),
581			crate::expr::part::DestructurePart::Field(Strand::new_static("zip")),
582		]),
583	]))]
584	// A reserved keyword is rendered bare when it leads the path, so the
585	// decoder has to read it as a field rather than as the statement or call
586	// the same word starts. `function` and `select` are the two shapes that
587	// fail differently as expressions (a call needing `(`, and a statement
588	// needing a field list).
589	#[case::keyword_field(Idiom::from(vec![field("function")]))]
590	#[case::keyword_field_select(Idiom::from(vec![field("select")]))]
591	#[case::keyword_field_nested(Idiom::from(vec![field("function"), field("sub")]))]
592	#[case::keyword_field_tail(Idiom::from(vec![field("meta"), field("function")]))]
593	#[case::keyword_field_index(Idiom::from(vec![field("select"), lit_int(0)]))]
594	fn idiom_revisioned_roundtrip(#[case] idiom: Idiom) {
595		let mut bytes = Vec::new();
596		idiom.serialize_revisioned(&mut bytes).unwrap();
597		let parsed = Idiom::deserialize_revisioned(&mut bytes.as_slice())
598			.unwrap_or_else(|e| panic!("round-trip failed for {idiom:?}: {e}"));
599		assert_eq!(idiom, parsed, "round-trip mismatch for {idiom:?}");
600	}
601
602	/// Every reserved keyword must survive the wire round-trip as a leading
603	/// field name, both alone and with a tail. The rendering leaves a leading
604	/// keyword bare, so this is the property that keeps the two halves of
605	/// `Idiom`'s wire format agreeing; pinning the whole keyword set rather
606	/// than samples means a word added to `RESERVED_KEYWORD` cannot introduce
607	/// a name that stores but does not load.
608	#[test]
609	fn every_reserved_keyword_round_trips_as_a_field_name() {
610		for kw in common::keywords::RESERVED_KEYWORD.iter() {
611			let kw = kw.into_inner();
612			for idiom in [
613				Idiom::from(vec![field(kw)]),
614				Idiom::from(vec![field(kw), field("tail")]),
615				Idiom::from(vec![field("head"), field(kw)]),
616			] {
617				let mut bytes = Vec::new();
618				idiom.serialize_revisioned(&mut bytes).unwrap();
619				let parsed =
620					Idiom::deserialize_revisioned(&mut bytes.as_slice()).unwrap_or_else(|e| {
621						panic!("keyword `{kw}` did not survive the wire round-trip: {e}")
622					});
623				assert_eq!(idiom, parsed, "round-trip mismatch for keyword `{kw}`");
624			}
625		}
626	}
627
628	/// A string that merely *starts* with a valid idiom is rejected rather than
629	/// truncated to that prefix, so a caller resolving a field path at runtime
630	/// cannot silently address a shorter path than the one it was given.
631	/// An idiom rooted in a `Part::Start` expression renders as that
632	/// expression, which is not a field path and so does not read back. This is
633	/// pinned rather than left implicit: nothing that reaches this funnel can
634	/// hold such a part (see `parse_str_with_settings`), so the useful contract
635	/// is that the string is rejected outright instead of being accepted as a
636	/// different path.
637	#[test]
638	fn an_expression_rooted_idiom_is_not_a_field_path() {
639		let idiom = Idiom::from(vec![
640			Part::Start(Expr::Param(crate::expr::Param::from("test".to_string()))),
641			field("temporary"),
642		]);
643		assert_eq!(idiom.to_raw_string(), "$test.temporary");
644		assert!(
645			Idiom::from_str(&idiom.to_raw_string()).is_err(),
646			"an expression-rooted idiom should not parse as a field path"
647		);
648	}
649
650	#[test]
651	fn trailing_content_after_an_idiom_is_rejected() {
652		for s in ["foo bogus", "function bogus", "foo.bar baz"] {
653			assert!(Idiom::from_str(s).is_err(), "`{s}` should not parse to just its idiom prefix");
654		}
655	}
656}