Skip to main content

uqa_planner/
join_order.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Join-order optimization.
8//!
9//! Bridges DPccp join enumeration ([`crate::join_enumerator`]) and the
10//! row-oriented join algorithms in `uqa-joins`. The optimizer accepts
11//! a list of [`JoinRelation`] descriptors plus equijoin
12//! [`JoinPredicate`] descriptors, builds an internal [`JoinGraph`],
13//! runs DPccp, and materializes the chosen plan into a
14//! [`JoinOrderTree`] -- a tree of join descriptors the engine
15//! interprets to drive the actual row-tuple join algorithms.
16//!
17//! `JoinOrderTree` retains the executable physical strategy selected by the
18//! enumerator. Today relational equijoins are hash joins; the planner does not
19//! pretend that a pre-existing index join is available when the engine cannot
20//! execute one.
21
22use std::collections::BTreeMap;
23
24use crate::cardinality::{AccessParadigm, ColumnStats};
25use crate::cost_model::{CostEstimator, OperatorKind};
26use crate::join_enumerator::{enumerate_dpccp_with_cost_estimator, JoinPlan};
27use crate::join_graph::{JoinEdge, JoinGraph, JoinGraphResult};
28
29/// Description of a base relation feeding a join-order search.
30#[derive(Debug, Clone)]
31pub struct JoinRelation {
32    pub alias: String,
33    pub cardinality: f64,
34    pub column_stats: BTreeMap<String, ColumnStats>,
35    /// Cost of producing this relation after local access predicates.
36    pub access_cost: f64,
37    /// Physical domain used by the relation-local access path.
38    pub paradigm: AccessParadigm,
39    /// Relation reference the engine resolves back to its backing
40    /// data (e.g. a table id or scan handle).
41    pub source_id: u64,
42}
43
44/// Equijoin predicate between two named relations.
45#[derive(Debug, Clone)]
46pub struct JoinPredicate {
47    pub left_alias: String,
48    pub right_alias: String,
49    pub left_field: String,
50    pub right_field: String,
51}
52
53/// Algorithm hint for an inner join.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum JoinAlgorithm {
56    Hash,
57}
58
59/// Equijoin condition.
60#[derive(Debug, Clone)]
61pub struct JoinCondition {
62    pub left_field: String,
63    pub right_field: String,
64}
65
66/// Output of the join order optimizer. The engine walks this tree to
67/// drive the actual row-tuple join algorithms.
68#[derive(Debug, Clone)]
69pub enum JoinOrderTree {
70    /// Base relation -- a single scan source.
71    Scan(JoinRelation),
72    /// Inner equijoin with the chosen algorithm.
73    Inner {
74        algorithm: JoinAlgorithm,
75        condition: JoinCondition,
76        left: Box<JoinOrderTree>,
77        right: Box<JoinOrderTree>,
78    },
79    /// Cross join -- no predicate connecting the two sides.
80    Cross {
81        left: Box<JoinOrderTree>,
82        right: Box<JoinOrderTree>,
83    },
84}
85
86/// Result of optimization: the chosen join tree plus the alias of the
87/// first non-empty relation (used as the engine's primary table
88/// context).
89#[derive(Debug, Clone)]
90pub struct JoinOrderResult {
91    pub tree: JoinOrderTree,
92    pub primary_alias: Option<String>,
93}
94
95/// Determines an optimal join ordering using DPccp.
96#[derive(Debug, Clone, Default)]
97pub struct JoinOrderOptimizer {
98    pub cost_estimator: CostEstimator,
99}
100
101impl JoinOrderOptimizer {
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    pub fn with_cost_estimator(mut self, est: CostEstimator) -> Self {
107        self.cost_estimator = est;
108        self
109    }
110
111    /// Find the optimal join order and build the operator tree.
112    pub fn optimize(
113        &self,
114        relations: Vec<JoinRelation>,
115        predicates: Vec<JoinPredicate>,
116    ) -> JoinGraphResult<JoinOrderResult> {
117        if relations.is_empty() {
118            return Err(crate::join_graph::JoinGraphError::EmptyGraph);
119        }
120
121        if relations.len() == 1 {
122            let relation = relations
123                .first()
124                .ok_or(crate::join_graph::JoinGraphError::EmptyGraph)?;
125            let mut validation = JoinGraph::new();
126            validation.add_relation_with_cost(
127                relation.alias.clone(),
128                relation.cardinality,
129                relation.access_cost,
130            )?;
131            let rel = relations.into_iter().next().ok_or(
132                crate::join_graph::JoinGraphError::UnknownRelation {
133                    index: 0,
134                    relation_count: 0,
135                },
136            )?;
137            let alias = rel.alias.clone();
138            return Ok(JoinOrderResult {
139                tree: JoinOrderTree::Scan(rel),
140                primary_alias: Some(alias),
141            });
142        }
143
144        let primary_alias = relations.first().map(|r| r.alias.clone());
145
146        // Build the abstract JoinGraph for DPccp.
147        let mut graph = JoinGraph::new();
148        let mut alias_to_idx: BTreeMap<String, usize> = BTreeMap::new();
149        for rel in &relations {
150            let idx = graph.add_relation_with_cost(
151                rel.alias.clone(),
152                rel.cardinality,
153                rel.access_cost,
154            )?;
155            if alias_to_idx.insert(rel.alias.clone(), idx).is_some() {
156                return Err(crate::join_graph::JoinGraphError::DuplicateAlias {
157                    alias: rel.alias.clone(),
158                });
159            }
160        }
161
162        // Materialize predicates as edges with column-stats-derived
163        // selectivity.
164        let mut predicate_lookup: BTreeMap<(usize, usize), JoinPredicate> = BTreeMap::new();
165        for pred in predicates {
166            let l = alias_to_idx.get(&pred.left_alias).copied().ok_or_else(|| {
167                crate::join_graph::JoinGraphError::UnknownAlias {
168                    alias: pred.left_alias.clone(),
169                }
170            })?;
171            let r = alias_to_idx
172                .get(&pred.right_alias)
173                .copied()
174                .ok_or_else(|| crate::join_graph::JoinGraphError::UnknownAlias {
175                    alias: pred.right_alias.clone(),
176                })?;
177            let selectivity = Self::estimate_predicate_selectivity(
178                &relations,
179                l,
180                r,
181                &pred.left_field,
182                &pred.right_field,
183            );
184            graph.add_edge(l, r, selectivity)?;
185            // Store both orientations so materialize_plan can resolve
186            // either when DPccp swaps sides.
187            predicate_lookup.insert((l.min(r), l.max(r)), pred);
188        }
189
190        let plan = enumerate_dpccp_with_cost_estimator(&graph, self.cost_estimator.clone());
191        let tree = match plan {
192            Some(p) => Self::materialize_plan(&p, &graph, &relations, &predicate_lookup)?,
193            None => {
194                // No connecting edges -- fall back to a left-deep
195                // cartesian product. DPccp returns None when the
196                // graph is disconnected, but a cross join is still a
197                // valid (if expensive) plan.
198                let mut iter = relations.into_iter();
199                let first =
200                    iter.next()
201                        .ok_or(crate::join_graph::JoinGraphError::UnknownRelation {
202                            index: 0,
203                            relation_count: 0,
204                        })?;
205                let mut tree = JoinOrderTree::Scan(first);
206                for rel in iter {
207                    tree = JoinOrderTree::Cross {
208                        left: Box::new(tree),
209                        right: Box::new(JoinOrderTree::Scan(rel)),
210                    };
211                }
212                tree
213            }
214        };
215
216        Ok(JoinOrderResult {
217            tree,
218            primary_alias,
219        })
220    }
221
222    fn estimate_predicate_selectivity(
223        relations: &[JoinRelation],
224        left_idx: usize,
225        right_idx: usize,
226        left_field: &str,
227        right_field: &str,
228    ) -> f64 {
229        let l_distinct = relations
230            .get(left_idx)
231            .and_then(|r| r.column_stats.get(left_field))
232            .map(|s| s.distinct_count.max(1));
233        let r_distinct = relations
234            .get(right_idx)
235            .and_then(|r| r.column_stats.get(right_field))
236            .map(|s| s.distinct_count.max(1));
237        match (l_distinct, r_distinct) {
238            (Some(left), Some(right)) => 1.0 / left.max(right) as f64,
239            (Some(distinct), None) | (None, Some(distinct)) => 1.0 / distinct as f64,
240            (None, None) => crate::CardinalityEstimator::new().default_selectivity,
241        }
242    }
243
244    fn materialize_plan(
245        plan: &JoinPlan,
246        graph: &JoinGraph,
247        relations: &[JoinRelation],
248        predicates: &BTreeMap<(usize, usize), JoinPredicate>,
249    ) -> JoinGraphResult<JoinOrderTree> {
250        match (&plan.left, &plan.right) {
251            (None, None) => {
252                // Leaf: `relations` is a singleton bitmask of the
253                // source relation index.
254                let idx = usize::try_from(plan.relations.trailing_zeros()).map_err(|_| {
255                    crate::join_graph::JoinGraphError::InvalidPlan {
256                        detail: "join leaf index exceeds usize".into(),
257                    }
258                })?;
259                relations
260                    .get(idx)
261                    .cloned()
262                    .map(JoinOrderTree::Scan)
263                    .ok_or_else(|| crate::join_graph::JoinGraphError::InvalidPlan {
264                        detail: format!("leaf references relation index {idx}"),
265                    })
266            }
267            (Some(left), Some(right)) => {
268                let l_tree = Self::materialize_plan(left, graph, relations, predicates)?;
269                let r_tree = Self::materialize_plan(right, graph, relations, predicates)?;
270                let l_set = left.relations;
271                let r_set = right.relations;
272                let Some(edge) = graph.edges.iter().find(|e| edge_connects(e, l_set, r_set)) else {
273                    return Ok(JoinOrderTree::Cross {
274                        left: Box::new(l_tree),
275                        right: Box::new(r_tree),
276                    });
277                };
278
279                let edge_left_bit = usize::try_from(edge.left.trailing_zeros()).map_err(|_| {
280                    crate::join_graph::JoinGraphError::InvalidPlan {
281                        detail: "left join edge index exceeds usize".into(),
282                    }
283                })?;
284                let edge_right_bit =
285                    usize::try_from(edge.right.trailing_zeros()).map_err(|_| {
286                        crate::join_graph::JoinGraphError::InvalidPlan {
287                            detail: "right join edge index exceeds usize".into(),
288                        }
289                    })?;
290                let left_in_l = (l_set & (1u64 << edge_left_bit)) != 0;
291                let right_in_l = (l_set & (1u64 << edge_right_bit)) != 0;
292                let key = (
293                    edge_left_bit.min(edge_right_bit),
294                    edge_left_bit.max(edge_right_bit),
295                );
296                let Some(pred) = predicates.get(&key) else {
297                    return Err(crate::join_graph::JoinGraphError::InvalidPlan {
298                        detail: format!(
299                            "join edge between relation indices {edge_left_bit} and {edge_right_bit} has no predicate"
300                        ),
301                    });
302                };
303
304                // Orient condition fields: if the plan put the edge's
305                // RIGHT relation on the LEFT side, swap the fields so
306                // left_field corresponds to the actual left side.
307                let condition = if !left_in_l && right_in_l {
308                    JoinCondition {
309                        left_field: pred.right_field.clone(),
310                        right_field: pred.left_field.clone(),
311                    }
312                } else {
313                    JoinCondition {
314                        left_field: pred.left_field.clone(),
315                        right_field: pred.right_field.clone(),
316                    }
317                };
318
319                let algorithm = match plan.kind {
320                    Some(OperatorKind::HashJoinInner) => JoinAlgorithm::Hash,
321                    Some(kind) => {
322                        return Err(crate::join_graph::JoinGraphError::InvalidPlan {
323                            detail: format!(
324                                "DPccp selected non-executable equijoin strategy {kind:?}"
325                            ),
326                        });
327                    }
328                    None => {
329                        return Err(crate::join_graph::JoinGraphError::InvalidPlan {
330                            detail: "DPccp equijoin node has no physical strategy".into(),
331                        });
332                    }
333                };
334
335                Ok(JoinOrderTree::Inner {
336                    algorithm,
337                    condition,
338                    left: Box::new(l_tree),
339                    right: Box::new(r_tree),
340                })
341            }
342            (Some(_), None) | (None, Some(_)) => {
343                Err(crate::join_graph::JoinGraphError::InvalidPlan {
344                    detail: "join node contains exactly one child".into(),
345                })
346            }
347        }
348    }
349}
350
351fn edge_connects(edge: &JoinEdge, l_set: u64, r_set: u64) -> bool {
352    let l_in_l = edge.left & l_set != 0;
353    let r_in_r = edge.right & r_set != 0;
354    let l_in_r = edge.left & r_set != 0;
355    let r_in_l = edge.right & l_set != 0;
356    (l_in_l && r_in_r) || (l_in_r && r_in_l)
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use uqa_core::Value;
363
364    fn rel(alias: &str, card: f64, src: u64) -> JoinRelation {
365        JoinRelation {
366            alias: alias.into(),
367            cardinality: card,
368            column_stats: BTreeMap::new(),
369            access_cost: card,
370            paradigm: AccessParadigm::Relational,
371            source_id: src,
372        }
373    }
374
375    fn rel_with_stats(alias: &str, card: f64, src: u64, field: &str, ndv: u64) -> JoinRelation {
376        let mut cs = BTreeMap::new();
377        cs.insert(
378            field.to_string(),
379            ColumnStats {
380                distinct_count: ndv,
381                row_count: card as u64,
382                ..Default::default()
383            },
384        );
385        JoinRelation {
386            alias: alias.into(),
387            cardinality: card,
388            column_stats: cs,
389            access_cost: card,
390            paradigm: AccessParadigm::Relational,
391            source_id: src,
392        }
393    }
394
395    fn assert_hash_join_tree(tree: &JoinOrderTree) {
396        match tree {
397            JoinOrderTree::Inner {
398                algorithm,
399                left,
400                right,
401                ..
402            } => {
403                assert_eq!(*algorithm, JoinAlgorithm::Hash);
404                assert_hash_join_tree(left);
405                assert_hash_join_tree(right);
406            }
407            JoinOrderTree::Cross { left, right } => {
408                assert_hash_join_tree(left);
409                assert_hash_join_tree(right);
410            }
411            JoinOrderTree::Scan(_) => {}
412        }
413    }
414
415    #[test]
416    fn single_relation_returns_scan() {
417        let opt = JoinOrderOptimizer::new();
418        let result = opt.optimize(vec![rel("a", 100.0, 1)], vec![]).unwrap();
419        assert!(matches!(result.tree, JoinOrderTree::Scan(_)));
420        assert_eq!(result.primary_alias.as_deref(), Some("a"));
421    }
422
423    #[test]
424    fn three_chain_uses_executable_hash_strategies() {
425        let opt = JoinOrderOptimizer::new();
426        let result = opt
427            .optimize(
428                vec![
429                    rel_with_stats("small", 10.0, 1, "id", 10),
430                    rel_with_stats("mid", 10_000.0, 2, "small_id", 10_000),
431                    rel_with_stats("big", 1_000_000.0, 3, "mid_id", 1_000_000),
432                ],
433                vec![
434                    JoinPredicate {
435                        left_alias: "small".into(),
436                        right_alias: "mid".into(),
437                        left_field: "id".into(),
438                        right_field: "small_id".into(),
439                    },
440                    JoinPredicate {
441                        left_alias: "mid".into(),
442                        right_alias: "big".into(),
443                        left_field: "id".into(),
444                        right_field: "mid_id".into(),
445                    },
446                ],
447            )
448            .unwrap();
449        assert_hash_join_tree(&result.tree);
450        assert_eq!(result.primary_alias.as_deref(), Some("small"));
451    }
452
453    #[test]
454    fn cross_join_when_no_predicate() {
455        let opt = JoinOrderOptimizer::new();
456        let result = opt
457            .optimize(vec![rel("a", 50.0, 1), rel("b", 50.0, 2)], vec![])
458            .unwrap();
459        match result.tree {
460            JoinOrderTree::Inner { .. } => panic!("expected cross join, got inner"),
461            JoinOrderTree::Scan(_) => panic!("expected cross join, got scan"),
462            JoinOrderTree::Cross { .. } => {}
463        }
464    }
465
466    #[test]
467    fn predicate_selectivity_uses_max_distinct() {
468        let relations = vec![
469            rel_with_stats("a", 1000.0, 1, "x", 100),
470            rel_with_stats("b", 1000.0, 2, "y", 50),
471        ];
472        let s = JoinOrderOptimizer::estimate_predicate_selectivity(&relations, 0, 1, "x", "y");
473        assert!((s - 0.01).abs() < 1e-9);
474        let _ = Value::Int(0);
475    }
476
477    #[test]
478    fn predicate_selectivity_uses_the_configured_fallback_without_statistics() {
479        let relations = vec![rel("a", 1000.0, 1), rel("b", 1000.0, 2)];
480        let selectivity =
481            JoinOrderOptimizer::estimate_predicate_selectivity(&relations, 0, 1, "x", "y");
482        assert_eq!(
483            selectivity,
484            crate::CardinalityEstimator::new().default_selectivity
485        );
486    }
487
488    #[test]
489    fn invalid_join_inputs_are_reported_instead_of_silently_rewritten() {
490        let optimizer = JoinOrderOptimizer::new();
491        assert!(matches!(
492            optimizer.optimize(Vec::new(), Vec::new()),
493            Err(crate::join_graph::JoinGraphError::EmptyGraph)
494        ));
495        assert!(matches!(
496            optimizer.optimize(vec![rel("a", 1.0, 1), rel("a", 2.0, 2)], Vec::new()),
497            Err(crate::join_graph::JoinGraphError::DuplicateAlias { .. })
498        ));
499        assert!(matches!(
500            optimizer.optimize(
501                vec![rel("a", 1.0, 1), rel("b", 2.0, 2)],
502                vec![JoinPredicate {
503                    left_alias: "a".into(),
504                    right_alias: "missing".into(),
505                    left_field: "id".into(),
506                    right_field: "id".into(),
507                }],
508            ),
509            Err(crate::join_graph::JoinGraphError::UnknownAlias { .. })
510        ));
511        assert!(matches!(
512            optimizer.optimize(vec![rel("bad", f64::NAN, 1)], Vec::new()),
513            Err(crate::join_graph::JoinGraphError::InvalidCardinality { .. })
514        ));
515    }
516}