Skip to main content

surrealdb_expr/expr/
match_plan.rs

1//! The declarative IR for GQL v2 `MATCH` queries.
2//!
3//! `MatchPlan` is the language-neutral binding-table plan node produced by the
4//! GQL lowering and embedded into the logical plan as [`Expr::Match`]. The
5//! streaming execution planner (`exec/planner/match_plan.rs`) compiles it into a
6//! tree of physical operators; it never runs under the compute-only planner.
7//!
8//! See `doc/gql/V2_DESIGN.md` §2 for the normative contract.
9
10use common::fmt::EscapeIdent;
11use surrealdb_strand::TableName;
12use surrealdb_types::{SqlFormat, ToSql};
13
14use crate::expr::{Expr, Idiom};
15
16/// Index into [`MatchPlan::bindings`]; identifies a binding by position.
17///
18/// A raw `u32` index (not a newtype) by design: it is crate-private, never
19/// crosses an API boundary, and the lowering guarantees every emitted id is a
20/// valid `bindings` position (see the `MatchPlan` invariants below), so the
21/// extra wrapping would buy no safety here.
22pub type BindingId = u32;
23
24/// The declarative plan for a single GQL `MATCH` query.
25///
26/// Invariants the lowering guarantees (the planner relies on these and never
27/// re-derives them):
28/// - every [`Expr`] is BINDING-ROW scoped (`a.x` → `Idiom[Field("a"),Field("x")]`), with 3VL guards
29///   already inserted;
30/// - [`MatchPredicate::deps`] is the exact set of bindings the expr reads;
31/// - conjuncts are NNF-split and live on the clause whose pattern scope owns them (critical for
32///   OPTIONAL);
33/// - column names are final (naming rules applied; duplicates rejected);
34/// - ORDER BY aliases are resolved (non-DISTINCT → source exprs; DISTINCT → columns);
35/// - repeated pattern variables are rewritten to hidden bindings + equality conjuncts; anonymous
36///   edges needing DIFFERENT-EDGES tracking have hidden bindings;
37/// - every pattern is anchorable (rule in V2_DESIGN §0).
38#[derive(Clone, Debug, PartialEq, Eq, Hash)]
39pub struct MatchPlan {
40	/// All bindings; index equals the [`BindingId`].
41	pub bindings: Vec<BindingDef>,
42	/// The query's steps in textual order: read clauses and write stages,
43	/// interleaved as written. May be empty only for a bare `RETURN` (rejected
44	/// in lowering).
45	pub stages: Vec<MatchStage>,
46	/// The `RETURN` projection, or `None` for a mutation-only query (no
47	/// trailing `RETURN`).
48	pub output: Option<MatchOutput>,
49}
50
51impl MatchPlan {
52	/// Whether this plan carries any write stage. Drives the transaction type
53	/// (a mutation-bearing plan is not read-only, so the executor opens a write
54	/// transaction) — see `Expr::read_only`.
55	pub fn has_mutations(&self) -> bool {
56		self.stages.iter().any(|s| matches!(s, MatchStage::Mutate(_)))
57	}
58}
59
60/// One step of a lowered query, in textual order: a read clause that extends the
61/// binding table, or a write stage that mutates it. A read clause after a write
62/// re-reads the live (post-write) state in the same transaction.
63#[derive(Clone, Debug, PartialEq, Eq, Hash)]
64pub enum MatchStage {
65	/// A `MATCH`/`OPTIONAL` clause.
66	Read(MatchClausePlan),
67	/// A write stage applied to the binding table.
68	Mutate(MutationStage),
69}
70
71/// One write stage applied to the binding table, in textual order.
72///
73/// A stage consumes the binding rows produced by earlier steps (or a single
74/// empty row, for a leading `INSERT`), performs its write through the native
75/// document pipeline, and passes the rows on so a trailing `RETURN` can
76/// project the post-mutation state.
77#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub enum MutationStage {
79	/// `SET a.p = v` / `SET a = {…}` / `REMOVE a.p`: mutate the record bound at
80	/// `target`.
81	Update {
82		target: BindingId,
83		data: UpdateData,
84	},
85	/// `[DETACH|NODETACH] DELETE a`: delete the record bound at `target`.
86	Delete {
87		target: BindingId,
88		detach: DetachMode,
89	},
90	/// `INSERT …`: create the new nodes and relate the new edges.
91	Insert(InsertStage),
92}
93
94/// The data a [`MutationStage::Update`] applies. Every [`Expr`] is binding-row
95/// scoped (the same `a.x → Idiom[Field("a"),Field("x")]` rule the read side
96/// uses) and is evaluated against the current binding row before the write.
97#[derive(Clone, Debug, PartialEq, Eq, Hash)]
98pub enum UpdateData {
99	/// `SET a.p = v` (one assignment per item): set each field path.
100	Set(Vec<(Idiom, Expr)>),
101	/// `REMOVE a.p` (one path per item): unset each field.
102	Unset(Vec<Idiom>),
103	/// `SET a = {…}`: replace all user properties with the object expression
104	/// (the record's `id`, and an edge's `in`/`out`, are preserved by the
105	/// native `CONTENT` path).
106	Content(Expr),
107}
108
109/// The detach mode of a [`MutationStage::Delete`]. `NoDetach` (the ISO default)
110/// errors if the node still has connected edges; `Detach` cascades them.
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
112pub enum DetachMode {
113	Detach,
114	NoDetach,
115}
116
117/// An `INSERT` write stage: the new nodes to create (in creation order) and the
118/// edges to relate once their endpoints exist.
119#[derive(Clone, Debug, PartialEq, Eq, Hash)]
120pub struct InsertStage {
121	pub nodes: Vec<InsertNodePlan>,
122	pub edges: Vec<InsertEdgePlan>,
123}
124
125/// A new node created by an `INSERT` stage.
126#[derive(Clone, Debug, PartialEq, Eq, Hash)]
127pub struct InsertNodePlan {
128	/// The binding the created record is bound under.
129	pub binding: BindingId,
130	/// The target table (= label).
131	pub label: TableName,
132	/// The property object expression (binding-row scoped), evaluated per row.
133	pub props: Expr,
134}
135
136/// A new edge related by an `INSERT` stage between two node bindings (each of
137/// which is either a node created by this stage or one already bound by the
138/// read body).
139#[derive(Clone, Debug, PartialEq, Eq, Hash)]
140pub struct InsertEdgePlan {
141	/// The binding the created edge record is bound under.
142	pub binding: BindingId,
143	/// The edge table (= label).
144	pub label: TableName,
145	/// The source endpoint node binding.
146	pub from: BindingId,
147	/// The target endpoint node binding.
148	pub to: BindingId,
149	/// The property object expression (binding-row scoped), evaluated per row.
150	pub props: Expr,
151}
152
153#[derive(Clone, Debug, PartialEq, Eq, Hash)]
154pub struct BindingDef {
155	pub name: String,
156	pub kind: BindingKind,
157	/// `true` if the user wrote this binding; `false` for hidden `__e<n>`/`__v<n>`.
158	pub user_named: bool,
159}
160
161#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
162pub enum BindingKind {
163	Node,
164	Edge,
165	EdgeGroup,
166	Path,
167}
168
169#[derive(Clone, Debug, PartialEq, Eq, Hash)]
170pub struct MatchClausePlan {
171	/// The all-or-nothing OPTIONAL block this clause belongs to (R3), or `None`
172	/// for a mandatory clause. Whether a clause is optional is *exactly*
173	/// `optional_group.is_some()` (see [`MatchClausePlan::is_optional`]) — the
174	/// single source of truth, never a separate flag the two could drift apart.
175	///
176	/// Every clause lowered from one `OPTIONAL { MATCH …; MATCH … }` (or one plain
177	/// `OPTIONAL MATCH …`, which is a block of one) shares the same id. The planner
178	/// left-joins all clauses of one group as a SINGLE unit (it must NOT join them
179	/// one inner clause at a time), so a block matches all-or-nothing; distinct ids
180	/// on adjacent optional clauses mean they chain left-to-right as independent
181	/// left-joins. The id is an opaque, per-query dense counter (group order ==
182	/// textual order); only equality is meaningful.
183	pub optional_group: Option<u32>,
184	pub patterns: Vec<PatternPlan>,
185	/// Clause-owned NNF conjuncts.
186	pub predicates: Vec<MatchPredicate>,
187}
188
189impl MatchClausePlan {
190	/// Whether this clause is the body of an `OPTIONAL` operand (a left-outer join
191	/// against the accumulated binding table, R3) — exactly when it carries an
192	/// [`MatchClausePlan::optional_group`] id.
193	pub fn is_optional(&self) -> bool {
194		self.optional_group.is_some()
195	}
196}
197
198#[derive(Clone, Debug, PartialEq, Eq, Hash)]
199pub struct PatternPlan {
200	/// Binding for the whole path value (kind == [`BindingKind::Path`]).
201	pub path_var: Option<BindingId>,
202	/// The lowered path-search / path-mode prefix, or `None` when the pattern
203	/// carried no prefix (the default: every path, edge-unique `WALK`). A search
204	/// other than `All`/`None` is only ever set on a pattern with exactly one
205	/// quantified segment, anchored forward on its start (the lowering enforces
206	/// both), so the executing operator's source is the pattern's start node.
207	pub search: Option<PathPrefixPlan>,
208	pub start: NodeStep,
209	/// Multi-hop chain: each step is an edge followed by the node it reaches.
210	pub steps: Vec<(EdgeStep, NodeStep)>,
211}
212
213#[derive(Clone, Debug, PartialEq, Eq, Hash)]
214pub struct NodeStep {
215	pub binding: BindingId,
216	pub label: Option<TableName>,
217}
218
219#[derive(Clone, Debug, PartialEq, Eq, Hash)]
220pub struct EdgeStep {
221	/// Edge binding, or an [`BindingKind::EdgeGroup`] binding when quantified.
222	pub binding: BindingId,
223	pub label: Option<TableName>,
224	pub direction: ExpandDirection,
225	pub quantifier: Option<EdgeQuantifier>,
226}
227
228#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
229pub enum ExpandDirection {
230	Out,
231	In,
232}
233
234#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
235pub struct EdgeQuantifier {
236	pub min: u32,
237	pub max: Option<u32>,
238}
239
240/// The lowered path-search prefix of a [`PatternPlan`] (`doc/gql/V2_DESIGN.md`).
241/// `None` on the pattern means no prefix was written (the default: every path,
242/// edge-unique `WALK`).
243#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
244pub struct PathPrefixPlan {
245	pub search: PathSearch,
246	pub mode: PathMode,
247}
248
249/// The lowered path-search selector. The AST's optional path/group counts are
250/// resolved here (omitted ⇒ 1).
251#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
252pub enum PathSearch {
253	/// Every path (explicit `ALL`, or a bare path-mode prefix).
254	All,
255	/// Any `count` paths (`ANY [k]`).
256	Any {
257		count: u32,
258	},
259	/// Every minimum-length path (`ALL SHORTEST`).
260	AllShortest,
261	/// One minimum-length path (`ANY SHORTEST`).
262	AnyShortest,
263	/// The `count` shortest paths (`SHORTEST k`).
264	ShortestCounted {
265		count: u32,
266	},
267	/// Every path in the `count` smallest length groups (`SHORTEST [k] GROUP(S)`).
268	ShortestGroups {
269		count: u32,
270	},
271}
272
273/// The lowered path mode. `Walk` and `Trail` are equivalent under SurrealDB's
274/// fixed DIFFERENT EDGES match mode (R2 forbids an edge binding twice within a
275/// path), so the planner maps both to edge-unique traversal; `Simple`/`Acyclic`
276/// additionally forbid repeated nodes.
277#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
278pub enum PathMode {
279	Walk,
280	Trail,
281	Simple,
282	Acyclic,
283}
284
285#[derive(Clone, Debug, PartialEq, Eq, Hash)]
286pub struct MatchPredicate {
287	pub expr: Expr,
288	/// Sorted and deduped set of bindings the expression reads.
289	pub deps: Vec<BindingId>,
290}
291
292#[derive(Clone, Debug, PartialEq, Eq, Hash)]
293pub struct MatchOutput {
294	/// Explicit output columns; `RETURN *` is pre-expanded by the lowering.
295	pub columns: Vec<MatchColumn>,
296	pub distinct: bool,
297	/// Aggregation, if any. `None` means no aggregation (the columns project
298	/// straight from the binding rows). `Some(keys)` means the binding table is
299	/// folded by the (binding-row scoped) group-key expressions before
300	/// projection — an empty `keys` is `GROUP ALL` (a single group over all
301	/// rows, e.g. a bare `RETURN count(*)`). Each non-key column carries an
302	/// aggregate; the planner inserts an `Aggregate` operator in place of the
303	/// projection.
304	pub group_by: Option<Vec<Expr>>,
305	pub order: Vec<MatchOrder>,
306	pub skip: Option<Expr>,
307	pub limit: Option<Expr>,
308}
309
310#[derive(Clone, Debug, PartialEq, Eq, Hash)]
311pub struct MatchColumn {
312	pub name: String,
313	pub expr: Expr,
314	/// A sort-only column materialised by an aggregating query so a non-projected
315	/// ORDER BY key (a grouping key, functionally-dependent value, or aggregate)
316	/// can be sorted on; the planner emits it from the `Aggregate` operator and
317	/// drops it with a final projection before the rows are returned. Always
318	/// `false` for user-projected columns.
319	pub hidden: bool,
320}
321
322#[derive(Clone, Debug, PartialEq, Eq, Hash)]
323pub struct MatchOrder {
324	pub expr: Expr,
325	pub ascending: bool,
326}
327
328impl MatchPlan {
329	/// Look up a binding by id.
330	///
331	/// # Panics
332	/// Panics if `id` is not a valid index into [`MatchPlan::bindings`]. Callers
333	/// (the streaming planner) only ever pass ids that the lowering placed into
334	/// this same plan's `bindings`/patterns, so the index is always in range; an
335	/// out-of-range id is an internal-consistency bug, not a user-reachable path.
336	/// For the never-panicking rendering path use [`MatchPlan::binding_name`].
337	pub fn binding(&self, id: BindingId) -> &BindingDef {
338		&self.bindings[id as usize]
339	}
340
341	/// The name of a binding by id, or `"?"` for an out-of-range id.
342	///
343	/// Used by the never-panicking [`ToSql`] rendering, so it tolerates a
344	/// malformed plan rather than indexing.
345	fn binding_name(&self, id: BindingId) -> &str {
346		self.bindings.get(id as usize).map(|b| b.name.as_str()).unwrap_or("?")
347	}
348}
349
350impl EdgeQuantifier {
351	/// `true` if this quantifier is exactly `{n}` (a fixed repeat count).
352	pub fn is_exact(&self) -> bool {
353		self.max == Some(self.min)
354	}
355}
356
357impl ExpandDirection {
358	/// The reverse direction (used when anchoring mid-pattern).
359	pub fn reverse(self) -> Self {
360		match self {
361			ExpandDirection::Out => ExpandDirection::In,
362			ExpandDirection::In => ExpandDirection::Out,
363		}
364	}
365}
366
367/// Deterministic GQL-ish rendering of a [`MatchPlan`].
368///
369/// Used by EXPLAIN, `Debug`-adjacent tooling, and logs: it must be stable and
370/// must never panic on any plan, well-formed or not. Predicate, column, order,
371/// and skip/limit slots delegate to [`Expr`]'s `ToSql` so 3VL guard shapes stay
372/// textually diffable. The rendering is single-line regardless of `fmt`.
373impl ToSql for MatchPlan {
374	fn fmt_sql(&self, f: &mut String, _fmt: SqlFormat) {
375		// Steps render in textual order, space-separated (reads and mutations
376		// interleaved as written).
377		for (i, stage) in self.stages.iter().enumerate() {
378			if i > 0 {
379				f.push(' ');
380			}
381			match stage {
382				MatchStage::Read(clause) => self.render_clause(f, clause),
383				MatchStage::Mutate(mutation) => self.render_mutation(f, mutation),
384			}
385		}
386
387		let Some(output) = self.output.as_ref() else {
388			return;
389		};
390
391		if !self.stages.is_empty() {
392			f.push(' ');
393		}
394		f.push_str("RETURN");
395		if output.distinct {
396			f.push_str(" DISTINCT");
397		}
398		for (i, column) in output.columns.iter().enumerate() {
399			if i > 0 {
400				f.push(',');
401			}
402			f.push(' ');
403			column.expr.fmt_sql(f, SqlFormat::SingleLine);
404			f.push_str(" AS ");
405			f.push_str(&column.name);
406		}
407
408		// `Some([])` is `GROUP ALL` and renders nothing extra (the aggregates in
409		// the columns already imply it); `Some(keys)` renders `GROUP BY …`.
410		if let Some(keys) = output.group_by.as_ref()
411			&& !keys.is_empty()
412		{
413			f.push_str(" GROUP BY");
414			for (i, key) in keys.iter().enumerate() {
415				if i > 0 {
416					f.push(',');
417				}
418				f.push(' ');
419				key.fmt_sql(f, SqlFormat::SingleLine);
420			}
421		}
422
423		if !output.order.is_empty() {
424			f.push_str(" ORDER BY");
425			for (i, order) in output.order.iter().enumerate() {
426				if i > 0 {
427					f.push(',');
428				}
429				f.push(' ');
430				order.expr.fmt_sql(f, SqlFormat::SingleLine);
431				f.push_str(if order.ascending {
432					" ASC"
433				} else {
434					" DESC"
435				});
436			}
437		}
438
439		if let Some(skip) = output.skip.as_ref() {
440			f.push_str(" SKIP ");
441			skip.fmt_sql(f, SqlFormat::SingleLine);
442		}
443		if let Some(limit) = output.limit.as_ref() {
444			f.push_str(" LIMIT ");
445			limit.fmt_sql(f, SqlFormat::SingleLine);
446		}
447	}
448}
449
450impl MatchPlan {
451	/// Render one read clause as `[OPTIONAL ] MATCH <patterns> [WHERE <preds>]`
452	/// (no trailing space; the step loop joins with single spaces).
453	fn render_clause(&self, f: &mut String, clause: &MatchClausePlan) {
454		if clause.is_optional() {
455			f.push_str("OPTIONAL ");
456		}
457		f.push_str("MATCH ");
458		for (i, pattern) in clause.patterns.iter().enumerate() {
459			if i > 0 {
460				f.push_str(", ");
461			}
462			self.render_pattern(f, pattern);
463		}
464		if !clause.predicates.is_empty() {
465			f.push_str(" WHERE ");
466			for (i, predicate) in clause.predicates.iter().enumerate() {
467				if i > 0 {
468					f.push_str(" AND ");
469				}
470				predicate.expr.fmt_sql(f, SqlFormat::SingleLine);
471			}
472		}
473	}
474
475	/// Render one mutation stage in a deterministic GQL-ish form (EXPLAIN /
476	/// logs). Never panics: out-of-range bindings render as `?` via
477	/// [`MatchPlan::binding_name`].
478	fn render_mutation(&self, f: &mut String, stage: &MutationStage) {
479		match stage {
480			MutationStage::Update {
481				target,
482				data,
483			} => match data {
484				UpdateData::Set(assignments) => {
485					f.push_str("SET");
486					for (i, (place, value)) in assignments.iter().enumerate() {
487						if i > 0 {
488							f.push(',');
489						}
490						f.push(' ');
491						place.fmt_sql(f, SqlFormat::SingleLine);
492						f.push_str(" = ");
493						value.fmt_sql(f, SqlFormat::SingleLine);
494					}
495				}
496				UpdateData::Unset(fields) => {
497					f.push_str("REMOVE");
498					for (i, place) in fields.iter().enumerate() {
499						if i > 0 {
500							f.push(',');
501						}
502						f.push(' ');
503						place.fmt_sql(f, SqlFormat::SingleLine);
504					}
505				}
506				UpdateData::Content(expr) => {
507					f.push_str("SET ");
508					f.push_str(self.binding_name(*target));
509					f.push_str(" = ");
510					expr.fmt_sql(f, SqlFormat::SingleLine);
511				}
512			},
513			MutationStage::Delete {
514				target,
515				detach,
516			} => {
517				if matches!(detach, DetachMode::Detach) {
518					f.push_str("DETACH ");
519				}
520				f.push_str("DELETE ");
521				f.push_str(self.binding_name(*target));
522			}
523			MutationStage::Insert(stage) => {
524				f.push_str("INSERT");
525				for node in stage.nodes.iter() {
526					f.push_str(" (");
527					f.push_str(self.binding_name(node.binding));
528					f.push(':');
529					EscapeIdent(node.label.as_str()).fmt_sql(f, SqlFormat::SingleLine);
530					f.push(' ');
531					node.props.fmt_sql(f, SqlFormat::SingleLine);
532					f.push(')');
533				}
534				for edge in stage.edges.iter() {
535					f.push(' ');
536					f.push_str(self.binding_name(edge.from));
537					f.push_str("-[");
538					f.push_str(self.binding_name(edge.binding));
539					f.push(':');
540					EscapeIdent(edge.label.as_str()).fmt_sql(f, SqlFormat::SingleLine);
541					f.push_str("]->");
542					f.push_str(self.binding_name(edge.to));
543				}
544			}
545		}
546	}
547
548	/// Render one pattern as `p = (a:person)-[k:knows]->(b:person)`.
549	fn render_pattern(&self, f: &mut String, pattern: &PatternPlan) {
550		if let Some(path_var) = pattern.path_var {
551			f.push_str(self.binding_name(path_var));
552			f.push_str(" = ");
553		}
554		if let Some(prefix) = pattern.search.as_ref() {
555			Self::render_prefix(f, prefix);
556		}
557		self.render_node(f, &pattern.start);
558		for (edge, node) in pattern.steps.iter() {
559			self.render_edge(f, edge);
560			self.render_node(f, node);
561		}
562	}
563
564	/// Render a path-search / path-mode prefix in canonical form, trailing a
565	/// single space (so it abuts the start node). The default mode (`Walk`)
566	/// renders implicitly; an `ANY 1` / `SHORTEST 1 GROUP` count renders its
567	/// canonical short form.
568	fn render_prefix(f: &mut String, prefix: &PathPrefixPlan) {
569		match prefix.search {
570			PathSearch::All => f.push_str("ALL"),
571			PathSearch::Any {
572				count,
573			} => {
574				f.push_str("ANY");
575				if count != 1 {
576					f.push(' ');
577					f.push_str(&count.to_string());
578				}
579			}
580			PathSearch::AllShortest => f.push_str("ALL SHORTEST"),
581			PathSearch::AnyShortest => f.push_str("ANY SHORTEST"),
582			PathSearch::ShortestCounted {
583				count,
584			} => {
585				f.push_str("SHORTEST ");
586				f.push_str(&count.to_string());
587			}
588			PathSearch::ShortestGroups {
589				count,
590			} => {
591				f.push_str("SHORTEST ");
592				f.push_str(&count.to_string());
593				f.push_str(if count == 1 {
594					" GROUP"
595				} else {
596					" GROUPS"
597				});
598			}
599		}
600		match prefix.mode {
601			PathMode::Walk => {}
602			PathMode::Trail => f.push_str(" TRAIL"),
603			PathMode::Simple => f.push_str(" SIMPLE"),
604			PathMode::Acyclic => f.push_str(" ACYCLIC"),
605		}
606		f.push(' ');
607	}
608
609	/// Render a node element as `(a:person)`, `(a)`, or `(:person)`.
610	fn render_node(&self, f: &mut String, node: &NodeStep) {
611		f.push('(');
612		let def = self.bindings.get(node.binding as usize);
613		if let Some(def) = def
614			&& def.user_named
615		{
616			f.push_str(&def.name);
617		}
618		if let Some(label) = node.label.as_ref() {
619			f.push(':');
620			EscapeIdent(label.as_str()).fmt_sql(f, SqlFormat::SingleLine);
621		}
622		f.push(')');
623	}
624
625	/// Render an edge element including direction arrows and any quantifier.
626	fn render_edge(&self, f: &mut String, edge: &EdgeStep) {
627		match edge.direction {
628			ExpandDirection::Out => f.push('-'),
629			ExpandDirection::In => f.push_str("<-"),
630		}
631		f.push('[');
632		let def = self.bindings.get(edge.binding as usize);
633		if let Some(def) = def
634			&& def.user_named
635		{
636			f.push_str(&def.name);
637		}
638		if let Some(label) = edge.label.as_ref() {
639			f.push(':');
640			EscapeIdent(label.as_str()).fmt_sql(f, SqlFormat::SingleLine);
641		}
642		f.push(']');
643		match edge.direction {
644			ExpandDirection::Out => f.push_str("->"),
645			ExpandDirection::In => f.push('-'),
646		}
647		if let Some(quantifier) = edge.quantifier.as_ref() {
648			self.render_quantifier(f, quantifier);
649		}
650	}
651
652	/// Render a quantifier as the canonical brace form `{min,max}`.
653	fn render_quantifier(&self, f: &mut String, quantifier: &EdgeQuantifier) {
654		f.push('{');
655		if quantifier.is_exact() {
656			f.push_str(&quantifier.min.to_string());
657		} else {
658			f.push_str(&quantifier.min.to_string());
659			f.push(',');
660			if let Some(max) = quantifier.max {
661				f.push_str(&max.to_string());
662			}
663		}
664		f.push('}');
665	}
666}
667
668#[cfg(test)]
669mod tests {
670	use surrealdb_types::ToSql;
671
672	use super::*;
673	use crate::expr::{Expr, Idiom, Literal, Part};
674
675	/// Wrap read clauses as the plan's [`MatchStage::Read`] steps (test builders
676	/// are all read-only).
677	fn stages_of(clauses: Vec<MatchClausePlan>) -> Vec<MatchStage> {
678		clauses.into_iter().map(MatchStage::Read).collect()
679	}
680
681	/// Mutable access to the read clause at step `i` (panics if it is not a read).
682	fn clause_mut(plan: &mut MatchPlan, i: usize) -> &mut MatchClausePlan {
683		match &mut plan.stages[i] {
684			MatchStage::Read(c) => c,
685			MatchStage::Mutate(_) => panic!("stage {i} is not a read clause"),
686		}
687	}
688
689	/// Shared access to the read clause at step `i` (panics if it is not a read).
690	fn clause(plan: &MatchPlan, i: usize) -> &MatchClausePlan {
691		match &plan.stages[i] {
692			MatchStage::Read(c) => c,
693			MatchStage::Mutate(_) => panic!("stage {i} is not a read clause"),
694		}
695	}
696
697	/// Build `a.x` as a binding-row scoped idiom expression.
698	fn field_path(binding: &str, field: &str) -> Expr {
699		Expr::Idiom(Idiom(vec![
700			Part::Field(binding.to_string().into()),
701			Part::Field(field.to_string().into()),
702		]))
703	}
704
705	/// `MATCH (a:person)-[k:knows]->(b:person) WHERE k.since > 2020
706	///  RETURN a.name AS a_name, b.name AS b_name`
707	fn sample_plan() -> MatchPlan {
708		let bindings = vec![
709			BindingDef {
710				name: "a".to_string(),
711				kind: BindingKind::Node,
712				user_named: true,
713			},
714			BindingDef {
715				name: "k".to_string(),
716				kind: BindingKind::Edge,
717				user_named: true,
718			},
719			BindingDef {
720				name: "b".to_string(),
721				kind: BindingKind::Node,
722				user_named: true,
723			},
724		];
725		let predicate = MatchPredicate {
726			expr: Expr::Binary {
727				left: Box::new(field_path("k", "since")),
728				op: crate::expr::BinaryOperator::MoreThan,
729				right: Box::new(Expr::Literal(Literal::Integer(2020))),
730			},
731			deps: vec![1],
732		};
733		let pattern = PatternPlan {
734			path_var: None,
735			search: None,
736			start: NodeStep {
737				binding: 0,
738				label: Some(TableName::new("person".to_string())),
739			},
740			steps: vec![(
741				EdgeStep {
742					binding: 1,
743					label: Some(TableName::new("knows".to_string())),
744					direction: ExpandDirection::Out,
745					quantifier: None,
746				},
747				NodeStep {
748					binding: 2,
749					label: Some(TableName::new("person".to_string())),
750				},
751			)],
752		};
753		MatchPlan {
754			bindings,
755			stages: stages_of(vec![MatchClausePlan {
756				optional_group: None,
757				patterns: vec![pattern],
758				predicates: vec![predicate],
759			}]),
760			output: Some(MatchOutput {
761				columns: vec![
762					MatchColumn {
763						name: "a_name".to_string(),
764						expr: field_path("a", "name"),
765						hidden: false,
766					},
767					MatchColumn {
768						name: "b_name".to_string(),
769						expr: field_path("b", "name"),
770						hidden: false,
771					},
772				],
773				distinct: false,
774				group_by: None,
775				order: Vec::new(),
776				skip: None,
777				limit: None,
778			}),
779		}
780	}
781
782	#[test]
783	fn to_sql_renders_single_pattern() {
784		let plan = sample_plan();
785		assert_eq!(
786			plan.to_sql(),
787			"MATCH (a:person)-[k:knows]->(b:person) WHERE k.since > 2020 RETURN a.name AS \
788			 a_name, b.name AS b_name"
789		);
790	}
791
792	#[test]
793	fn to_sql_renders_distinct_order_quantifier_path() {
794		let mut plan = sample_plan();
795		// Promote to a path-var, quantified, distinct, ordered query.
796		plan.bindings.push(BindingDef {
797			name: "p".to_string(),
798			kind: BindingKind::Path,
799			user_named: true,
800		});
801		plan.bindings[1].kind = BindingKind::EdgeGroup;
802		let path_id = (plan.bindings.len() - 1) as BindingId;
803		let clause = clause_mut(&mut plan, 0);
804		clause.patterns[0].path_var = Some(path_id);
805		clause.patterns[0].steps[0].0.quantifier = Some(EdgeQuantifier {
806			min: 1,
807			max: Some(3),
808		});
809		clause.predicates.clear();
810		let output = plan.output.as_mut().unwrap();
811		output.distinct = true;
812		output.order = vec![MatchOrder {
813			expr: field_path("a", "age"),
814			ascending: false,
815		}];
816		output.skip = Some(Expr::Literal(Literal::Integer(5)));
817		output.limit = Some(Expr::Literal(Literal::Integer(10)));
818
819		assert_eq!(
820			plan.to_sql(),
821			"MATCH p = (a:person)-[k:knows]->{1,3}(b:person) RETURN DISTINCT a.name AS a_name, \
822			 b.name AS b_name ORDER BY a.age DESC SKIP 5 LIMIT 10"
823		);
824	}
825
826	#[test]
827	fn to_sql_renders_path_search_prefix() {
828		let mut plan = sample_plan();
829		// Promote to a quantified, prefixed pattern: `ANY SHORTEST SIMPLE`.
830		plan.bindings[1].kind = BindingKind::EdgeGroup;
831		clause_mut(&mut plan, 0).patterns[0].steps[0].0.quantifier = Some(EdgeQuantifier {
832			min: 1,
833			max: None,
834		});
835		clause_mut(&mut plan, 0).patterns[0].search = Some(PathPrefixPlan {
836			search: PathSearch::AnyShortest,
837			mode: PathMode::Simple,
838		});
839		clause_mut(&mut plan, 0).predicates.clear();
840		plan.output.as_mut().unwrap().columns.truncate(1);
841		assert_eq!(
842			plan.to_sql(),
843			"MATCH ANY SHORTEST SIMPLE (a:person)-[k:knows]->{1,}(b:person) RETURN a.name AS a_name"
844		);
845	}
846
847	#[test]
848	fn to_sql_omits_default_search_prefix() {
849		// `search: None` (no prefix written) renders nothing — keeping unprefixed
850		// plans byte-identical.
851		let plan = sample_plan();
852		assert!(clause(&plan, 0).patterns[0].search.is_none());
853		let rendered = plan.to_sql();
854		assert!(!rendered.contains("SHORTEST"), "{rendered}");
855		assert!(!rendered.contains("ANY"), "{rendered}");
856		assert!(rendered.starts_with("MATCH (a:person)"), "{rendered}");
857	}
858
859	#[test]
860	fn to_sql_renders_shortest_group_count() {
861		let mut plan = sample_plan();
862		plan.bindings[1].kind = BindingKind::EdgeGroup;
863		clause_mut(&mut plan, 0).patterns[0].steps[0].0.quantifier = Some(EdgeQuantifier {
864			min: 1,
865			max: None,
866		});
867		clause_mut(&mut plan, 0).patterns[0].search = Some(PathPrefixPlan {
868			search: PathSearch::ShortestGroups {
869				count: 2,
870			},
871			mode: PathMode::Walk,
872		});
873		clause_mut(&mut plan, 0).predicates.clear();
874		plan.output.as_mut().unwrap().columns.truncate(1);
875		// `WALK` (the default mode) renders implicitly; `GROUPS` is plural for k > 1.
876		assert_eq!(
877			plan.to_sql(),
878			"MATCH SHORTEST 2 GROUPS (a:person)-[k:knows]->{1,}(b:person) RETURN a.name AS a_name"
879		);
880	}
881
882	#[test]
883	fn to_sql_renders_hidden_bindings_anonymously() {
884		let mut plan = sample_plan();
885		// A hidden edge binding renders without its name.
886		plan.bindings[1].user_named = false;
887		plan.bindings[1].name = "__e0".to_string();
888		clause_mut(&mut plan, 0).predicates.clear();
889		plan.output.as_mut().unwrap().columns.truncate(1);
890		assert_eq!(plan.to_sql(), "MATCH (a:person)-[:knows]->(b:person) RETURN a.name AS a_name");
891	}
892
893	#[test]
894	fn debug_does_not_panic() {
895		let plan = sample_plan();
896		let rendered = format!("{plan:?}");
897		assert!(rendered.contains("MatchPlan"));
898	}
899
900	#[test]
901	fn binding_accessor_returns_def() {
902		let plan = sample_plan();
903		assert_eq!(plan.binding(1).name, "k");
904		assert_eq!(plan.binding(1).kind, BindingKind::Edge);
905	}
906
907	#[test]
908	fn to_sql_tolerates_out_of_range_binding() {
909		// A malformed plan must still render without panicking.
910		let mut plan = sample_plan();
911		clause_mut(&mut plan, 0).patterns[0].start.binding = 99;
912		let rendered = plan.to_sql();
913		assert!(rendered.contains("(:person)"));
914	}
915
916	#[test]
917	fn revisioned_serialize_of_match_fails_loud() {
918		// `Expr::Match` must never enter Revisioned serialization (V2_DESIGN §2;
919		// SECURITY_GUIDE §15a): its `to_sql()` renders GQL-ish text that the
920		// SurrealQL-only deserialize path cannot round-trip. The serialize path
921		// mirrors the `From<expr::Expr> for sql::Expr` arm and fails loud via
922		// `debug_assert!`, so a future regression that nests `Expr::Match` is
923		// caught in debug builds rather than silently emitting corrupt bytes.
924		use revision::SerializeRevisioned;
925
926		let expr = Expr::Match(Box::new(sample_plan()));
927		let mut bytes = Vec::new();
928		let serialize = std::panic::AssertUnwindSafe(|| {
929			let _ = SerializeRevisioned::serialize_revisioned(&expr, &mut bytes);
930		});
931		let outcome = std::panic::catch_unwind(serialize);
932
933		if cfg!(debug_assertions) {
934			// Debug: the guard's `debug_assert!(false)` must trip.
935			assert!(
936				outcome.is_err(),
937				"Expr::Match serialize_revisioned must panic in debug builds"
938			);
939		} else {
940			// Release: no panic, but the bytes it would emit are GQL text that
941			// is NOT valid SurrealQL — the invariant is upheld structurally
942			// (this path is unreachable by construction), the guard is purely a
943			// debug tripwire.
944			assert!(outcome.is_ok());
945		}
946	}
947	/// MATCH labels are table names, so a name colliding with a reserved word
948	/// must render quoted or the plan text cannot be read back.
949	///
950	/// The engine's `TableName` deliberately has no `ToSql` and derefs to `str`,
951	/// which also implements it — so a bare `label.fmt_sql(..)` here compiles
952	/// and silently emits the unescaped name. This asserts the escaping, not
953	/// which impl supplies it.
954	#[test]
955	fn to_sql_escapes_labels_that_collide_with_keywords() {
956		let plan = MatchPlan {
957			bindings: vec![BindingDef {
958				name: "a".to_string(),
959				kind: BindingKind::Node,
960				user_named: true,
961			}],
962			stages: stages_of(vec![MatchClausePlan {
963				optional_group: None,
964				patterns: vec![PatternPlan {
965					search: None,
966					path_var: None,
967					start: NodeStep {
968						binding: 0,
969						label: Some(TableName::new("select".to_string())),
970					},
971					steps: Vec::new(),
972				}],
973				predicates: Vec::new(),
974			}]),
975			output: None,
976		};
977
978		let rendered = plan.to_sql();
979		assert!(rendered.contains("`select`"), "a keyword label must be quoted, got: {rendered}");
980	}
981}