Skip to main content

linkrs_query/planning/join_order/
join_tree.rs

1//! Join hint support: user-directed binary and multi-way joins.
2//!
3//! Hints arrive as [`JoinHintAst`](crate::parser::ast::hint::JoinHintAst)
4//! from `USING JOIN` clauses and lower to the standalone [`JoinHint`] tree
5//! over query variables via [`JoinHint::from_ast`].
6//! [`JoinTreeConstructor`] resolves the hint against a
7//! [`QueryGraph`](super::query_graph::QueryGraph) into an executable
8//! [`JoinTree`], validating that multi-way joins share exactly one common
9//! neighbor node.
10
11use std::sync::Arc;
12
13use super::query_graph::QueryGraph;
14use super::subquery_graph::SubqueryGraph;
15
16/// Scan vs join shape of a hint tree node.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TreeNodeType {
19    NodeScan,
20    RelScan,
21    BinaryJoin,
22    MultiwayJoin,
23}
24
25/// User-supplied join hint over query variables.
26///
27/// ```text
28/// MATCH (a)-[e1]->(b), (a)-[e2]->(c), (b)-[e3]->(c)
29/// JOIN HINT multiway(probe=binary(e1, e2), builds=[e3]) on c
30/// ```
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum JoinHintNode {
33    /// Leaf scan of one node or rel variable.
34    Scan { variable: String },
35    /// Binary join of two sub-hints.
36    Binary {
37        left: Box<JoinHintNode>,
38        right: Box<JoinHintNode>,
39    },
40    /// Multi-way (WCO) join: first child probes, the rest build.
41    Multiway {
42        probe: Box<JoinHintNode>,
43        builds: Vec<JoinHintNode>,
44    },
45}
46
47/// A complete user join hint.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct JoinHint {
50    pub root: JoinHintNode,
51}
52
53impl JoinHint {
54    pub fn scan(variable: impl Into<String>) -> Self {
55        Self {
56            root: JoinHintNode::Scan {
57                variable: variable.into(),
58            },
59        }
60    }
61
62    /// Lower a parsed `USING JOIN` hint to a hint tree.
63    ///
64    /// `BINARY(a, b)` becomes a binary join of two scans; `MULTIWAY(p,
65    /// b1, ..)` becomes a multi-way join with `p` probing. Returns `None`
66    /// for degenerate inputs (empty build list); unknown variables are
67    /// reported later by [`JoinTreeConstructor`] as `UnknownVariable`.
68    pub fn from_ast(hint: &crate::parser::ast::hint::JoinHintAst) -> Option<Self> {
69        use crate::parser::ast::hint::JoinHintAst as Ast;
70        match hint {
71            Ast::Binary { left, right } => Some(Self {
72                root: JoinHintNode::Binary {
73                    left: Box::new(JoinHintNode::Scan {
74                        variable: left.clone(),
75                    }),
76                    right: Box::new(JoinHintNode::Scan {
77                        variable: right.clone(),
78                    }),
79                },
80            }),
81            Ast::Multiway { probe, builds } => {
82                if builds.is_empty() {
83                    return None;
84                }
85                Some(Self {
86                    root: JoinHintNode::Multiway {
87                        probe: Box::new(JoinHintNode::Scan {
88                            variable: probe.clone(),
89                        }),
90                        builds: builds
91                            .iter()
92                            .map(|var| JoinHintNode::Scan {
93                                variable: var.clone(),
94                            })
95                            .collect(),
96                    },
97                })
98            }
99        }
100    }
101}
102
103/// Extra join metadata carried by inner hint tree nodes.
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub struct JoinTreeExtraInfo {
106    /// Shared node variables joining the children.
107    pub join_nodes: Vec<String>,
108}
109
110/// A resolved node in the user-hinted join tree.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct JoinTreeNode {
113    pub node_type: TreeNodeType,
114    pub extra_info: JoinTreeExtraInfo,
115    pub children: Vec<JoinTreeNode>,
116}
117
118impl JoinTreeNode {
119    pub fn node_scan(variable: impl Into<String>) -> Self {
120        Self {
121            node_type: TreeNodeType::NodeScan,
122            extra_info: JoinTreeExtraInfo {
123                join_nodes: vec![variable.into()],
124            },
125            children: Vec::new(),
126        }
127    }
128
129    pub fn rel_scan(variable: impl Into<String>) -> Self {
130        Self {
131            node_type: TreeNodeType::RelScan,
132            extra_info: JoinTreeExtraInfo {
133                join_nodes: vec![variable.into()],
134            },
135            children: Vec::new(),
136        }
137    }
138
139    pub fn binary_join(left: JoinTreeNode, right: JoinTreeNode, join_nodes: Vec<String>) -> Self {
140        Self {
141            node_type: TreeNodeType::BinaryJoin,
142            extra_info: JoinTreeExtraInfo { join_nodes },
143            children: vec![left, right],
144        }
145    }
146
147    pub fn multiway_join(
148        probe: JoinTreeNode,
149        builds: Vec<JoinTreeNode>,
150        intersect_node: String,
151    ) -> Self {
152        let mut children = Vec::with_capacity(builds.len() + 1);
153        children.push(probe);
154        children.extend(builds);
155        Self {
156            node_type: TreeNodeType::MultiwayJoin,
157            extra_info: JoinTreeExtraInfo {
158                join_nodes: vec![intersect_node],
159            },
160            children,
161        }
162    }
163
164    pub fn is_leaf(&self) -> bool {
165        self.children.is_empty()
166    }
167
168    pub fn is_binary(&self) -> bool {
169        self.node_type == TreeNodeType::BinaryJoin
170    }
171
172    pub fn is_multiway(&self) -> bool {
173        self.node_type == TreeNodeType::MultiwayJoin
174    }
175}
176
177/// A resolved user-hinted join tree.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct JoinTree {
180    pub root: JoinTreeNode,
181}
182
183impl JoinTree {
184    /// Variables covered by the tree, as a subgraph of the query graph.
185    pub fn covered_subgraph(&self, query_graph: &Arc<QueryGraph>) -> SubqueryGraph {
186        covered_subgraph(&self.root, query_graph)
187    }
188}
189
190fn covered_subgraph(node: &JoinTreeNode, query_graph: &Arc<QueryGraph>) -> SubqueryGraph {
191    let mut subgraph = SubqueryGraph::new(Arc::clone(query_graph));
192    match node.node_type {
193        TreeNodeType::NodeScan => {
194            for var in &node.extra_info.join_nodes {
195                if let Some(pos) = query_graph.node_pos(var) {
196                    subgraph.add_query_node(pos);
197                }
198            }
199        }
200        TreeNodeType::RelScan => {
201            // Pattern coverage: scanning a rel binds its endpoints in the
202            // hint view, so both are covered. This differs from the DP
203            // lattice seed `SubqueryGraph::single_rel` (rel-only for
204            // connectivity); the solver maps a RelScan to the DP entry
205            // covering rel plus endpoints (built via extend steps).
206            for var in &node.extra_info.join_nodes {
207                if let Some(pos) = query_graph.rel_pos(var) {
208                    subgraph.add_query_rel(pos);
209                    if let Some((src, dst)) = query_graph.rel_endpoint_positions(pos) {
210                        subgraph.add_query_node(src);
211                        subgraph.add_query_node(dst);
212                    }
213                }
214            }
215        }
216        TreeNodeType::BinaryJoin | TreeNodeType::MultiwayJoin => {
217            for child in &node.children {
218                subgraph.add_subquery_graph(&covered_subgraph(child, query_graph));
219            }
220            // Multi-way joins additionally cover the intersect node.
221            if node.node_type == TreeNodeType::MultiwayJoin {
222                for var in &node.extra_info.join_nodes {
223                    if let Some(pos) = query_graph.node_pos(var) {
224                        subgraph.add_query_node(pos);
225                    }
226                }
227            }
228        }
229    }
230    subgraph
231}
232
233/// Hint resolution failure.
234#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
235pub enum JoinHintError {
236    #[error("unknown variable in join hint: {0}")]
237    UnknownVariable(String),
238    #[error("multi-way join needs at least one build side")]
239    MissingBuildSide,
240    #[error("multi-way join has no single common neighbor node")]
241    NoCommonIntersectNode,
242}
243
244/// Builds a [`JoinTree`] from a user [`JoinHint`].
245#[derive(Debug, Default, Clone, Copy)]
246pub struct JoinTreeConstructor;
247
248impl JoinTreeConstructor {
249    pub fn construct(
250        query_graph: &Arc<QueryGraph>,
251        hint: &JoinHint,
252    ) -> Result<JoinTree, JoinHintError> {
253        Ok(JoinTree {
254            root: Self::construct_tree_node(query_graph, &hint.root)?,
255        })
256    }
257
258    fn construct_tree_node(
259        query_graph: &Arc<QueryGraph>,
260        hint_node: &JoinHintNode,
261    ) -> Result<JoinTreeNode, JoinHintError> {
262        match hint_node {
263            JoinHintNode::Scan { variable } => Self::construct_scan_node(query_graph, variable),
264            JoinHintNode::Binary { left, right } => {
265                let left = Self::construct_tree_node(query_graph, left)?;
266                let right = Self::construct_tree_node(query_graph, right)?;
267                let left_sg = covered_subgraph(&left, query_graph);
268                let right_sg = covered_subgraph(&right, query_graph);
269                let mut join_nodes: Vec<String> = left_sg
270                    .get_connected_node_positions(&right_sg)
271                    .into_iter()
272                    .filter_map(|pos| query_graph.query_nodes.get(pos))
273                    .map(|n| n.variable.clone())
274                    .collect();
275                join_nodes.sort();
276                Ok(JoinTreeNode::binary_join(left, right, join_nodes))
277            }
278            JoinHintNode::Multiway { probe, builds } => {
279                Self::construct_multiway_join(query_graph, probe, builds)
280            }
281        }
282    }
283
284    fn construct_scan_node(
285        query_graph: &Arc<QueryGraph>,
286        variable: &str,
287    ) -> Result<JoinTreeNode, JoinHintError> {
288        if query_graph.node_pos(variable).is_some() {
289            return Ok(JoinTreeNode::node_scan(variable));
290        }
291        if query_graph.rel_pos(variable).is_some() {
292            return Ok(JoinTreeNode::rel_scan(variable));
293        }
294        Err(JoinHintError::UnknownVariable(variable.to_string()))
295    }
296
297    fn construct_multiway_join(
298        query_graph: &Arc<QueryGraph>,
299        probe: &JoinHintNode,
300        builds: &[JoinHintNode],
301    ) -> Result<JoinTreeNode, JoinHintError> {
302        if builds.is_empty() {
303            return Err(JoinHintError::MissingBuildSide);
304        }
305        let probe = Self::construct_tree_node(query_graph, probe)?;
306        let probe_subgraph = covered_subgraph(&probe, query_graph);
307        let mut build_nodes = Vec::with_capacity(builds.len());
308        let mut build_subgraphs = Vec::with_capacity(builds.len());
309        for child in builds {
310            let build = Self::construct_tree_node(query_graph, child)?;
311            build_subgraphs.push(covered_subgraph(&build, query_graph));
312            build_nodes.push(build);
313        }
314        let intersect = Self::common_neighbor(query_graph, &build_subgraphs, &probe_subgraph)?;
315        Ok(JoinTreeNode::multiway_join(probe, build_nodes, intersect))
316    }
317
318    /// The shared neighbor of all build subgraphs: node positions adjacent
319    /// to every build side. The WCO freshness constraint is enforced:
320    /// the intersect node must be absent from the probe scope, otherwise
321    /// planning would duplicate an already-bound variable. Returns
322    /// `NoCommonIntersectNode` when no fresh common neighbor exists.
323    fn common_neighbor(
324        query_graph: &Arc<QueryGraph>,
325        build_subgraphs: &[SubqueryGraph],
326        probe_subgraph: &SubqueryGraph,
327    ) -> Result<String, JoinHintError> {
328        let mut common: Option<Vec<usize>> = None;
329        for subgraph in build_subgraphs {
330            let mut adjacent = subgraph.get_node_neighbor_positions();
331            adjacent.extend(subgraph.node_positions());
332            adjacent.sort_unstable();
333            adjacent.dedup();
334            common = Some(match common {
335                None => adjacent,
336                Some(prev) => prev.into_iter().filter(|p| adjacent.contains(p)).collect(),
337            });
338        }
339        let common = common.unwrap_or_default();
340        let chosen = common
341            .iter()
342            .filter(|p| !probe_subgraph.contains_node(**p))
343            .min()
344            .copied();
345        chosen
346            .and_then(|pos| query_graph.query_nodes.get(pos))
347            .map(|n| n.variable.clone())
348            .ok_or(JoinHintError::NoCommonIntersectNode)
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    use crate::planning::join_order::query_graph::{QueryNode, QueryRel};
357
358    fn triangle() -> Arc<QueryGraph> {
359        let mut qg = QueryGraph::new();
360        qg.add_node(QueryNode::new("a", "a"));
361        qg.add_node(QueryNode::new("b", "b"));
362        qg.add_node(QueryNode::new("c", "c"));
363        qg.add_rel(QueryRel::new("e1", "e1", "a", "b"));
364        qg.add_rel(QueryRel::new("e2", "e2", "a", "c"));
365        qg.add_rel(QueryRel::new("e3", "e3", "b", "c"));
366        Arc::new(qg)
367    }
368
369    #[test]
370    fn scan_resolves_node_vs_rel() {
371        let qg = triangle();
372        let node = JoinTreeConstructor::construct(&qg, &JoinHint::scan("a")).expect("node");
373        assert_eq!(node.root.node_type, TreeNodeType::NodeScan);
374        let rel = JoinTreeConstructor::construct(&qg, &JoinHint::scan("e1")).expect("rel");
375        assert_eq!(rel.root.node_type, TreeNodeType::RelScan);
376    }
377
378    #[test]
379    fn unknown_variable_is_error() {
380        let qg = triangle();
381        let err = JoinTreeConstructor::construct(&qg, &JoinHint::scan("zzz")).unwrap_err();
382        assert_eq!(err, JoinHintError::UnknownVariable("zzz".to_string()));
383    }
384
385    #[test]
386    fn binary_join_collects_shared_nodes() {
387        let qg = triangle();
388        let hint = JoinHint {
389            root: JoinHintNode::Binary {
390                left: Box::new(JoinHintNode::Scan {
391                    variable: "e1".to_string(),
392                }),
393                right: Box::new(JoinHintNode::Scan {
394                    variable: "e2".to_string(),
395                }),
396            },
397        };
398        let tree = JoinTreeConstructor::construct(&qg, &hint).expect("binary");
399        assert_eq!(tree.root.node_type, TreeNodeType::BinaryJoin);
400        // e1 covers {a, b, e1}, e2 covers {a, c, e2}: shared node a.
401        assert_eq!(tree.root.extra_info.join_nodes, vec!["a".to_string()]);
402    }
403
404    #[test]
405    fn multiway_join_finds_intersect_node() {
406        let qg = triangle();
407        // probe covers a and b; builds e2 (a-c) and e3 (b-c) share c.
408        let hint = JoinHint {
409            root: JoinHintNode::Multiway {
410                probe: Box::new(JoinHintNode::Binary {
411                    left: Box::new(JoinHintNode::Scan {
412                        variable: "a".to_string(),
413                    }),
414                    right: Box::new(JoinHintNode::Scan {
415                        variable: "b".to_string(),
416                    }),
417                }),
418                builds: vec![
419                    JoinHintNode::Scan {
420                        variable: "e2".to_string(),
421                    },
422                    JoinHintNode::Scan {
423                        variable: "e3".to_string(),
424                    },
425                ],
426            },
427        };
428        let tree = JoinTreeConstructor::construct(&qg, &hint).expect("multiway");
429        assert_eq!(tree.root.node_type, TreeNodeType::MultiwayJoin);
430        assert_eq!(tree.root.extra_info.join_nodes, vec!["c".to_string()]);
431        assert!(tree.root.is_multiway());
432        // Covered: probe {a, b} + builds {a, c, e2} and {b, c, e3}.
433        let covered = tree.covered_subgraph(&qg);
434        assert_eq!(covered.total_num_variables(), 5);
435    }
436
437    #[test]
438    fn from_ast_lowers_binary_and_multiway() {
439        use crate::parser::ast::hint::JoinHintAst as Ast;
440        let binary = JoinHint::from_ast(&Ast::Binary {
441            left: "e1".to_string(),
442            right: "e2".to_string(),
443        })
444        .expect("binary");
445        assert!(matches!(binary.root, JoinHintNode::Binary { .. }));
446        let multiway = JoinHint::from_ast(&Ast::Multiway {
447            probe: "e1".to_string(),
448            builds: vec!["e2".to_string(), "e3".to_string()],
449        })
450        .expect("multiway");
451        match &multiway.root {
452            JoinHintNode::Multiway { builds, .. } => assert_eq!(builds.len(), 2),
453            other => panic!("expected multiway, got {other:?}"),
454        }
455        let qg = triangle();
456        let tree = JoinTreeConstructor::construct(&qg, &binary).expect("tree");
457        assert_eq!(tree.root.node_type, TreeNodeType::BinaryJoin);
458    }
459
460    #[test]
461    fn multiway_without_builds_is_error() {
462        let qg = triangle();
463        let hint = JoinHint {
464            root: JoinHintNode::Multiway {
465                probe: Box::new(JoinHintNode::Scan {
466                    variable: "a".to_string(),
467                }),
468                builds: vec![],
469            },
470        };
471        assert_eq!(
472            JoinTreeConstructor::construct(&qg, &hint).unwrap_err(),
473            JoinHintError::MissingBuildSide
474        );
475    }
476}