Skip to main content

uqa_planner/
join_enumerator.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! DPccp join enumeration following Moerkotte and Neumann (2006).
8//!
9//! Enumerates connected-subgraph / complement pairs of the
10//! [`JoinGraph`] in canonical order: each connected subgraph S is
11//! formed by extending a smaller connected subgraph with an adjacent
12//! vertex whose index exceeds `min(S)`, ensuring each subgraph is
13//! emitted exactly once. Complexity is `O(3^n)` over the relation
14//! count; far below the `n!` of exhaustive enumeration. Falls back to
15//! a greedy `O(n^3)` heuristic when the graph has more than
16//! [`MAX_DP_RELATIONS`] relations.
17//!
18//! Internally relation subsets are encoded as `u64` bitmasks for
19//! O(1) hash-table lookup and set operations. Equijoins are costed as
20//! hash joins because that is the physical strategy available to the SQL
21//! execution pipeline. An index-join cost must never influence ordering unless
22//! the planner can prove that a compatible physical index join is executable.
23//!
24//! Returns a [`JoinPlan`] tree where each `Join` node records the
25//! `(left, right, edge, cost, cardinality)` tuple. Disconnected join
26//! graphs are handled by solving each connected component
27//! independently and cross-joining them in cardinality-ascending
28//! order.
29
30use std::collections::{BTreeMap, BTreeSet};
31
32use crate::cost_model::{CostEstimator, OperatorKind};
33use crate::join_graph::{JoinEdge, JoinGraph};
34
35/// Beyond this count, exact enumeration switches to the greedy fallback.
36pub const MAX_DP_RELATIONS: usize = 16;
37
38type StarLeaves = Vec<(usize, Vec<JoinEdge>)>;
39type StarShape = (usize, StarLeaves);
40
41#[derive(Clone, Copy)]
42struct StarState {
43    cardinality: f64,
44    cost: f64,
45    prev_mask: usize,
46    leaf_pos: usize,
47}
48
49/// A (sub)plan for joining a set of relations. `relations` is the bitmask
50/// of relation indices in the plan; `cardinality` and `cost` are the running
51/// estimates, and `left` / `right` / `join_edge` are populated for
52/// internal nodes.
53#[derive(Debug, Clone)]
54pub struct JoinPlan {
55    pub relations: u64,
56    pub cardinality: f64,
57    pub cost: f64,
58    pub left: Option<Box<JoinPlan>>,
59    pub right: Option<Box<JoinPlan>>,
60    pub join_edge: Option<JoinEdge>,
61    /// The join algorithm `_emit_csg_cmp_pair` picked for this node.
62    /// `None` for base relations and cross joins.
63    pub kind: Option<OperatorKind>,
64}
65
66impl JoinPlan {
67    /// Build a leaf plan for a single relation.
68    fn leaf(idx: usize, rows: f64, access_cost: f64) -> Self {
69        Self {
70            relations: 1u64 << idx,
71            cardinality: rows,
72            cost: access_cost,
73            left: None,
74            right: None,
75            join_edge: None,
76            kind: None,
77        }
78    }
79
80    /// Cardinality projected by this (sub)plan.
81    pub fn rows(&self) -> f64 {
82        self.cardinality
83    }
84
85    pub fn cost(&self) -> f64 {
86        self.cost
87    }
88}
89
90/// Run DPccp over `graph` and return the cheapest join plan over the
91/// full relation set. Returns `None` for an empty graph.
92pub fn enumerate_dpccp(graph: &JoinGraph) -> Option<JoinPlan> {
93    DPccp::new(graph).optimize()
94}
95
96/// Run DPccp with an explicit physical cost estimator.
97pub fn enumerate_dpccp_with_cost_estimator(
98    graph: &JoinGraph,
99    cost_estimator: CostEstimator,
100) -> Option<JoinPlan> {
101    DPccp::with_cost_estimator(graph, cost_estimator).optimize()
102}
103
104/// DPccp join-order optimiser. Public so callers that need the
105/// cancellation-friendly stages (`optimize`, `find_connected_components`)
106/// can drive them directly.
107pub struct DPccp<'g> {
108    graph: &'g JoinGraph,
109    dp: BTreeMap<u64, JoinPlan>,
110    all_mask: u64,
111    cost_estimator: CostEstimator,
112}
113
114impl<'g> DPccp<'g> {
115    pub fn new(graph: &'g JoinGraph) -> Self {
116        let all_mask = graph.full_set();
117        Self {
118            graph,
119            dp: BTreeMap::new(),
120            all_mask,
121            cost_estimator: CostEstimator::default(),
122        }
123    }
124
125    pub fn with_cost_estimator(graph: &'g JoinGraph, cost_estimator: CostEstimator) -> Self {
126        let all_mask = graph.full_set();
127        Self {
128            graph,
129            dp: BTreeMap::new(),
130            all_mask,
131            cost_estimator,
132        }
133    }
134
135    /// Find the optimal join plan for the full relation set. Falls
136    /// back to greedy for large queries. Returns `None` for empty
137    /// graphs.
138    pub fn optimize(mut self) -> Option<JoinPlan> {
139        let n = self.graph.relation_count();
140        if n == 0 {
141            return None;
142        }
143        if n == 1 {
144            return Some(JoinPlan::leaf(
145                0,
146                self.graph.cardinalities[0],
147                self.graph.access_costs[0],
148            ));
149        }
150        // Initialise base relations.
151        for i in 0..n {
152            self.dp.insert(
153                1u64 << i,
154                JoinPlan::leaf(i, self.graph.cardinalities[i], self.graph.access_costs[i]),
155            );
156        }
157        if n <= MAX_DP_RELATIONS {
158            if let Some(plan) = self.optimize_star(n) {
159                return Some(plan);
160            }
161        }
162        if n > MAX_DP_RELATIONS {
163            return self.greedy_optimize();
164        }
165        self.enumerate_csg_cmp_pairs(n);
166        if let Some(plan) = self.dp.get(&self.all_mask).cloned() {
167            return Some(plan);
168        }
169        // Disconnected: cross-join the components.
170        self.join_disconnected_components()
171    }
172
173    /// Enumerate every connected-subgraph/complement pair and feed it
174    /// through `enumerate_splits`.
175    fn enumerate_csg_cmp_pairs(&mut self, n: usize) {
176        let neighbors: Vec<Vec<usize>> = (0..n).map(|i| self.graph.neighbors(i)).collect();
177
178        // Keep connected subsets by their native u64 bitmask. This avoids
179        // narrowing a mask to `usize` merely to address a dense side table.
180        let mut connected = BTreeSet::new();
181        let mut prev_layer: Vec<u64> = Vec::with_capacity(n);
182        for i in 0..n {
183            let mask = 1u64 << i;
184            connected.insert(mask);
185            prev_layer.push(mask);
186        }
187
188        for _size in 2..=n {
189            let mut cur_layer: Vec<u64> = Vec::new();
190            for &s_mask in &prev_layer {
191                let Ok(min_node) = usize::try_from(s_mask.trailing_zeros()) else {
192                    return;
193                };
194                let mut node = 0usize;
195                let mut tmp = s_mask;
196                while tmp != 0 {
197                    if tmp & 1 == 1 {
198                        for &nb in &neighbors[node] {
199                            if nb > min_node && (s_mask & (1u64 << nb)) == 0 {
200                                let new_mask = s_mask | (1u64 << nb);
201                                if connected.insert(new_mask) {
202                                    cur_layer.push(new_mask);
203                                }
204                            }
205                        }
206                    }
207                    tmp >>= 1;
208                    node += 1;
209                }
210            }
211            for &subset_mask in &cur_layer {
212                self.enumerate_splits(subset_mask, &connected);
213            }
214            prev_layer = cur_layer;
215        }
216    }
217
218    fn optimize_star(&self, n: usize) -> Option<JoinPlan> {
219        let (centre, leaves) = self.star_shape(n)?;
220        let shift = u32::try_from(leaves.len()).ok()?;
221        let states = 1usize.checked_shl(shift)?;
222        let mut dp: Vec<Option<StarState>> = vec![None; states];
223        dp[0] = Some(StarState {
224            cardinality: self.graph.cardinalities[centre],
225            cost: self.graph.access_costs[centre],
226            prev_mask: 0,
227            leaf_pos: usize::MAX,
228        });
229
230        for mask in 0..states {
231            let Some(base) = dp[mask] else {
232                continue;
233            };
234            for (leaf_pos, (leaf_idx, edges)) in leaves.iter().enumerate() {
235                let bit = 1usize << leaf_pos;
236                if mask & bit != 0 {
237                    continue;
238                }
239                let leaf_cardinality = self.graph.cardinalities[*leaf_idx];
240                let mut cardinality = base.cardinality * leaf_cardinality;
241                for edge in edges {
242                    cardinality *= edge.selectivity;
243                }
244                let join_cost = self.join_cost(base.cardinality, leaf_cardinality).0;
245                let candidate = StarState {
246                    cardinality,
247                    cost: base.cost + self.graph.access_costs[*leaf_idx] + join_cost,
248                    prev_mask: mask,
249                    leaf_pos,
250                };
251                let next_mask = mask | bit;
252                let install = match &dp[next_mask] {
253                    Some(existing) => candidate.cost < existing.cost,
254                    None => true,
255                };
256                if install {
257                    dp[next_mask] = Some(candidate);
258                }
259            }
260        }
261
262        dp[states - 1]?;
263        let mut order: Vec<usize> = Vec::with_capacity(leaves.len());
264        let mut mask = states - 1;
265        while mask != 0 {
266            let state = dp[mask]?;
267            order.push(state.leaf_pos);
268            mask = state.prev_mask;
269        }
270        order.reverse();
271
272        let mut plan = JoinPlan::leaf(
273            centre,
274            self.graph.cardinalities[centre],
275            self.graph.access_costs[centre],
276        );
277        for leaf_pos in order {
278            let (leaf_idx, edges) = &leaves[leaf_pos];
279            let leaf = JoinPlan::leaf(
280                *leaf_idx,
281                self.graph.cardinalities[*leaf_idx],
282                self.graph.access_costs[*leaf_idx],
283            );
284            plan = self.join_plans(&plan, &leaf, edges);
285        }
286        Some(plan)
287    }
288
289    fn star_shape(&self, n: usize) -> Option<StarShape> {
290        if n < 3 || self.graph.edges.is_empty() {
291            return None;
292        }
293        let mut neighbor_masks = vec![0u64; n];
294        for edge in &self.graph.edges {
295            if edge.left.count_ones() != 1 || edge.right.count_ones() != 1 {
296                return None;
297            }
298            let left = usize::try_from(edge.left.trailing_zeros()).ok()?;
299            let right = usize::try_from(edge.right.trailing_zeros()).ok()?;
300            if left == right || left >= n || right >= n {
301                return None;
302            }
303            neighbor_masks[left] |= edge.right;
304            neighbor_masks[right] |= edge.left;
305        }
306        let candidates: Vec<usize> = neighbor_masks
307            .iter()
308            .enumerate()
309            .filter_map(|(idx, mask)| {
310                usize::try_from(mask.count_ones())
311                    .ok()
312                    .is_some_and(|count| count == n - 1)
313                    .then_some(idx)
314            })
315            .collect();
316        if candidates.len() != 1 {
317            return None;
318        }
319        for centre in candidates {
320            let centre_mask = 1u64 << centre;
321            let mut by_leaf: BTreeMap<usize, Vec<JoinEdge>> = BTreeMap::new();
322            let mut valid = true;
323            for edge in &self.graph.edges {
324                let leaf_mask = if edge.left == centre_mask {
325                    edge.right
326                } else if edge.right == centre_mask {
327                    edge.left
328                } else {
329                    valid = false;
330                    break;
331                };
332                if leaf_mask == 0 || leaf_mask == centre_mask {
333                    valid = false;
334                    break;
335                }
336                let leaf = usize::try_from(leaf_mask.trailing_zeros()).ok()?;
337                by_leaf.entry(leaf).or_default().push(edge.clone());
338            }
339            if valid && by_leaf.len() == n - 1 {
340                return Some((centre, by_leaf.into_iter().collect()));
341            }
342        }
343        None
344    }
345
346    /// Enumerate every canonical split `(s1, s2)` of `subset_mask`
347    /// where `s1` contains the lowest set bit. Connectivity is
348    /// checked via the `connected` table; only pairs that survive get
349    /// fed through `emit_csg_cmp_pair`.
350    fn enumerate_splits(&mut self, subset_mask: u64, connected: &BTreeSet<u64>) {
351        let lowest_bit = subset_mask & subset_mask.wrapping_neg();
352        let rest = subset_mask ^ lowest_bit;
353
354        // Iterate proper non-empty submasks of `rest`. Each `sub | lowest_bit`
355        // forms a canonical S1 (containing the min element).
356        let mut sub_rest = rest.wrapping_sub(1) & rest;
357        while sub_rest != 0 {
358            let sub = sub_rest | lowest_bit;
359            let comp = subset_mask ^ sub;
360            if connected.contains(&sub) && connected.contains(&comp) {
361                if let (Some(plan1), Some(plan2)) =
362                    (self.dp.get(&sub).cloned(), self.dp.get(&comp).cloned())
363                {
364                    let edges = self
365                        .graph
366                        .edges_between(plan1.relations, plan2.relations)
367                        .into_iter()
368                        .cloned()
369                        .collect::<Vec<_>>();
370                    if !edges.is_empty() {
371                        self.emit_csg_cmp_pair(&plan1, &plan2, &edges, subset_mask);
372                    }
373                }
374            }
375            sub_rest = sub_rest.wrapping_sub(1) & rest;
376        }
377        // sub_rest == 0: S1 = {min element}, S2 = rest of subset.
378        if connected.contains(&rest) {
379            if let (Some(plan1), Some(plan2)) = (
380                self.dp.get(&lowest_bit).cloned(),
381                self.dp.get(&rest).cloned(),
382            ) {
383                let edges = self
384                    .graph
385                    .edges_between(plan1.relations, plan2.relations)
386                    .into_iter()
387                    .cloned()
388                    .collect::<Vec<_>>();
389                if !edges.is_empty() {
390                    self.emit_csg_cmp_pair(&plan1, &plan2, &edges, subset_mask);
391                }
392            }
393        }
394    }
395
396    /// Cost a candidate join and install the best variant in the DP table.
397    /// Cardinality is the cross-product times every edge's selectivity. The
398    /// physical SQL engine executes these equijoins as hash joins, so the
399    /// enumerator uses the same cost shape and records the executable kind.
400    fn emit_csg_cmp_pair(
401        &mut self,
402        plan1: &JoinPlan,
403        plan2: &JoinPlan,
404        edges: &[JoinEdge],
405        combined_mask: u64,
406    ) {
407        let candidate = self.join_plans(plan1, plan2, edges);
408        let install = match self.dp.get(&combined_mask) {
409            Some(existing) => candidate.cost < existing.cost,
410            None => true,
411        };
412        if install {
413            self.dp.insert(combined_mask, candidate);
414        }
415    }
416
417    fn join_plans(&self, plan1: &JoinPlan, plan2: &JoinPlan, edges: &[JoinEdge]) -> JoinPlan {
418        let mut cardinality = plan1.cardinality * plan2.cardinality;
419        for edge in edges {
420            cardinality *= edge.selectivity;
421        }
422        let c1 = plan1.cardinality;
423        let c2 = plan2.cardinality;
424        let (join_cost, kind) = self.join_cost(c1, c2);
425        JoinPlan {
426            relations: plan1.relations | plan2.relations,
427            cardinality,
428            cost: join_cost + plan1.cost + plan2.cost,
429            left: Some(Box::new(plan1.clone())),
430            right: Some(Box::new(plan2.clone())),
431            join_edge: edges.first().cloned(),
432            kind: Some(kind),
433        }
434    }
435
436    fn join_cost(&self, c1: f64, c2: f64) -> (f64, OperatorKind) {
437        let kind = OperatorKind::HashJoinInner;
438        (
439            self.cost_estimator.estimate_join(kind, c1, c2).total(),
440            kind,
441        )
442    }
443
444    fn cross_join_cost(&self, c1: f64, c2: f64) -> f64 {
445        self.cost_estimator
446            .estimate_join(OperatorKind::CrossJoin, c1, c2)
447            .total()
448    }
449
450    /// Cross-join every connected component in cardinality-ascending order.
451    fn join_disconnected_components(&mut self) -> Option<JoinPlan> {
452        let components = self.find_connected_components();
453        let mut component_plans: Vec<JoinPlan> = Vec::with_capacity(components.len());
454        for comp in &components {
455            if comp.len() == 1 {
456                let idx = *comp.first()?;
457                let plan = self.dp.get(&(1u64 << idx)).cloned()?;
458                component_plans.push(plan);
459                continue;
460            }
461            let mask: u64 = comp.iter().fold(0u64, |acc, i| acc | (1u64 << *i));
462            if let Some(plan) = self.dp.get(&mask).cloned() {
463                component_plans.push(plan);
464                continue;
465            }
466            // Component was not solved; recurse on a subgraph as a
467            // defensive fallback.
468            let original_indices: Vec<usize> = {
469                let mut v: Vec<usize> = comp.clone();
470                v.sort_unstable();
471                v
472            };
473            let sub_graph = self.build_subgraph(&original_indices)?;
474            let sub_plan =
475                DPccp::with_cost_estimator(&sub_graph, self.cost_estimator.clone()).optimize()?;
476            component_plans.push(remap_plan(&sub_plan, &original_indices));
477        }
478        component_plans.sort_by(|a, b| a.cardinality.total_cmp(&b.cardinality));
479        let mut iter = component_plans.into_iter();
480        let mut result = iter.next()?;
481        for plan in iter {
482            let combined = result.relations | plan.relations;
483            let cardinality = result.cardinality * plan.cardinality;
484            let cost = self.cross_join_cost(result.cardinality, plan.cardinality)
485                + result.cost
486                + plan.cost;
487            result = JoinPlan {
488                relations: combined,
489                cardinality,
490                cost,
491                left: Some(Box::new(result)),
492                right: Some(Box::new(plan)),
493                join_edge: None,
494                kind: Some(OperatorKind::CrossJoin),
495            };
496        }
497        Some(result)
498    }
499
500    /// Use BFS to enumerate the join graph's connected components.
501    fn find_connected_components(&self) -> Vec<Vec<usize>> {
502        let n = self.graph.relation_count();
503        let mut remaining: std::collections::BTreeSet<usize> = (0..n).collect();
504        let mut components: Vec<Vec<usize>> = Vec::new();
505        while let Some(&start) = remaining.iter().next() {
506            let mut visited: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
507            visited.insert(start);
508            let mut stack: Vec<usize> = vec![start];
509            while let Some(node) = stack.pop() {
510                for nb in self.graph.neighbors(node) {
511                    if remaining.contains(&nb) && !visited.contains(&nb) {
512                        visited.insert(nb);
513                        stack.push(nb);
514                    }
515                }
516            }
517            for v in &visited {
518                remaining.remove(v);
519            }
520            components.push(visited.into_iter().collect());
521        }
522        components
523    }
524
525    /// Project a subgraph containing only `nodes` in their original indices.
526    /// Edge bitmasks are remapped to the dense `[0, k)` range used by the
527    /// recursive solve.
528    fn build_subgraph(&self, nodes: &[usize]) -> Option<JoinGraph> {
529        let mut sub = JoinGraph::new();
530        let mut index_map: BTreeMap<usize, usize> = BTreeMap::new();
531        for &old_idx in nodes {
532            let new_idx = sub.relations.len();
533            sub.relations.push(self.graph.relations[old_idx].clone());
534            sub.cardinalities.push(self.graph.cardinalities[old_idx]);
535            sub.access_costs.push(self.graph.access_costs[old_idx]);
536            index_map.insert(old_idx, new_idx);
537        }
538        for edge in &self.graph.edges {
539            let l_idx = usize::try_from(edge.left.trailing_zeros()).ok()?;
540            let r_idx = usize::try_from(edge.right.trailing_zeros()).ok()?;
541            if let (Some(&l_new), Some(&r_new)) = (index_map.get(&l_idx), index_map.get(&r_idx)) {
542                sub.edges.push(JoinEdge {
543                    left: 1_u64 << l_new,
544                    right: 1_u64 << r_new,
545                    selectivity: edge.selectivity,
546                });
547            }
548        }
549        Some(sub)
550    }
551
552    /// Greedy fallback for graphs with more than `MAX_DP_RELATIONS`
553    /// relations: at every step, pick the cheapest joinable pair until
554    /// only one plan remains. `O(n^3)`.
555    fn greedy_optimize(self) -> Option<JoinPlan> {
556        let mut active: BTreeMap<u64, JoinPlan> = self.dp.clone();
557        while active.len() > 1 {
558            let mut best_cost = f64::INFINITY;
559            let mut best_combined_mask: u64 = 0;
560            let mut best_plan: Option<JoinPlan> = None;
561            let items: Vec<(u64, JoinPlan)> = active.iter().map(|(k, v)| (*k, v.clone())).collect();
562            for i in 0..items.len() {
563                let (m1, ref p1) = items[i];
564                for (m2, p2) in items.iter().skip(i + 1) {
565                    let edges = self
566                        .graph
567                        .edges_between(p1.relations, p2.relations)
568                        .into_iter()
569                        .cloned()
570                        .collect::<Vec<_>>();
571                    if edges.is_empty() {
572                        continue;
573                    }
574                    let mut cardinality = p1.cardinality * p2.cardinality;
575                    for edge in &edges {
576                        cardinality *= edge.selectivity;
577                    }
578                    let (greedy_join_cost, kind) = self.join_cost(p1.cardinality, p2.cardinality);
579                    let cost = greedy_join_cost + p1.cost + p2.cost;
580                    if cost < best_cost {
581                        best_cost = cost;
582                        best_combined_mask = m1 | m2;
583                        best_plan = Some(JoinPlan {
584                            relations: p1.relations | p2.relations,
585                            cardinality,
586                            cost,
587                            left: Some(Box::new(p1.clone())),
588                            right: Some(Box::new(p2.clone())),
589                            join_edge: edges.first().cloned(),
590                            kind: Some(kind),
591                        });
592                    }
593                }
594            }
595            let Some(best_plan_unwrapped) = best_plan else {
596                // No more joinable edges; cross-join the rest in
597                // cardinality-ascending order.
598                let mut remaining: Vec<JoinPlan> = active.into_values().collect();
599                remaining.sort_by(|a, b| a.cardinality.total_cmp(&b.cardinality));
600                let mut iter = remaining.into_iter();
601                let mut result = iter.next()?;
602                for plan in iter {
603                    let combined = result.relations | plan.relations;
604                    let cardinality = result.cardinality * plan.cardinality;
605                    let cost = self.cross_join_cost(result.cardinality, plan.cardinality)
606                        + result.cost
607                        + plan.cost;
608                    result = JoinPlan {
609                        relations: combined,
610                        cardinality,
611                        cost,
612                        left: Some(Box::new(result)),
613                        right: Some(Box::new(plan)),
614                        join_edge: None,
615                        kind: Some(OperatorKind::CrossJoin),
616                    };
617                }
618                return Some(result);
619            };
620            // Drop every plan whose mask is fully contained in the
621            // newly merged mask, then insert the merged plan.
622            let drop: Vec<u64> = active
623                .keys()
624                .copied()
625                .filter(|rel_mask| rel_mask & best_combined_mask == *rel_mask)
626                .collect();
627            for k in drop {
628                active.remove(&k);
629            }
630            active.insert(best_combined_mask, best_plan_unwrapped);
631        }
632        active.into_values().next()
633    }
634}
635
636/// Remap relation indices in `plan` from a subgraph's dense range back to
637/// the parent graph's original indices.
638fn remap_plan(plan: &JoinPlan, original_indices: &[usize]) -> JoinPlan {
639    let new_relations = remap_mask(plan.relations, original_indices);
640    JoinPlan {
641        relations: new_relations,
642        cardinality: plan.cardinality,
643        cost: plan.cost,
644        left: plan
645            .left
646            .as_deref()
647            .map(|l| Box::new(remap_plan(l, original_indices))),
648        right: plan
649            .right
650            .as_deref()
651            .map(|r| Box::new(remap_plan(r, original_indices))),
652        join_edge: plan.join_edge.clone(),
653        kind: plan.kind,
654    }
655}
656
657fn remap_mask(mask: u64, original_indices: &[usize]) -> u64 {
658    let mut out = 0u64;
659    let mut m = mask;
660    let mut i = 0;
661    while m != 0 {
662        if m & 1 == 1 {
663            out |= 1u64 << original_indices[i];
664        }
665        m >>= 1;
666        i += 1;
667    }
668    out
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use crate::cost_model::CostCoefficients;
675
676    fn assert_executable_join_kinds(plan: &JoinPlan) {
677        match (&plan.left, &plan.right) {
678            (Some(left), Some(right)) => {
679                if plan.join_edge.is_some() {
680                    assert_eq!(plan.kind, Some(OperatorKind::HashJoinInner));
681                } else {
682                    assert_eq!(plan.kind, Some(OperatorKind::CrossJoin));
683                }
684                assert_executable_join_kinds(left);
685                assert_executable_join_kinds(right);
686            }
687            (None, None) => assert!(plan.kind.is_none()),
688            _ => panic!("join plan contains exactly one child: {plan:?}"),
689        }
690    }
691
692    #[test]
693    fn three_way_chain_picks_smallest_first() {
694        let mut g = JoinGraph::new();
695        let a = g.add_relation("a", 10.0).unwrap();
696        let b = g.add_relation("b", 100.0).unwrap();
697        let c = g.add_relation("c", 10_000.0).unwrap();
698        g.add_edge(a, b, 0.01).unwrap();
699        g.add_edge(b, c, 0.001).unwrap();
700        let plan = enumerate_dpccp(&g).unwrap();
701        assert_eq!(plan.relations, 0b111);
702        assert!(plan.left.is_some() && plan.right.is_some());
703        assert_executable_join_kinds(&plan);
704    }
705
706    #[test]
707    fn single_relation_returns_leaf() {
708        let mut g = JoinGraph::new();
709        g.add_relation("solo", 1.0).unwrap();
710        let plan = enumerate_dpccp(&g).unwrap();
711        assert!(plan.left.is_none());
712        assert!(plan.right.is_none());
713        assert_eq!(plan.relations, 0b1);
714    }
715
716    #[test]
717    fn explicit_cost_estimator_drives_join_cost() {
718        let mut graph = JoinGraph::new();
719        let left = graph.add_relation_with_cost("left", 10.0, 7.0).unwrap();
720        let right = graph.add_relation_with_cost("right", 100.0, 11.0).unwrap();
721        graph.add_edge(left, right, 0.5).unwrap();
722
723        let coefficients = CostCoefficients {
724            hashjoin_build_per_row: 2.0,
725            hashjoin_probe_per_row: 3.0,
726            ..CostCoefficients::default()
727        };
728        let estimator = CostEstimator::new(coefficients);
729        let expected_join_cost = estimator
730            .estimate_join(OperatorKind::HashJoinInner, 10.0, 100.0)
731            .total();
732
733        let plan = enumerate_dpccp_with_cost_estimator(&graph, estimator).unwrap();
734
735        assert_eq!(plan.cost, 7.0 + 11.0 + expected_join_cost);
736    }
737
738    #[test]
739    fn empty_graph_returns_none() {
740        let g = JoinGraph::new();
741        assert!(enumerate_dpccp(&g).is_none());
742    }
743
744    #[test]
745    fn disconnected_graph_cross_joins_components() {
746        let mut g = JoinGraph::new();
747        let a = g.add_relation("a", 50.0).unwrap();
748        let b = g.add_relation("b", 60.0).unwrap();
749        let c = g.add_relation("c", 70.0).unwrap();
750        g.add_edge(a, b, 0.5).unwrap();
751        // c is a disconnected component.
752        let _ = c;
753        let plan = enumerate_dpccp(&g).unwrap();
754        // The cross-join must cover every relation.
755        assert_eq!(plan.relations, 0b111);
756        assert_executable_join_kinds(&plan);
757    }
758
759    #[test]
760    fn star_query_picks_nested_plan() {
761        // centre as the centre, leaf_b/c/d as leaves: every connects to
762        // centre.
763        let mut g = JoinGraph::new();
764        let centre = g.add_relation("centre", 1_000.0).unwrap();
765        let leaf_b = g.add_relation("b", 10.0).unwrap();
766        let leaf_c = g.add_relation("c", 20.0).unwrap();
767        let leaf_d = g.add_relation("d", 30.0).unwrap();
768        g.add_edge(centre, leaf_b, 0.01).unwrap();
769        g.add_edge(centre, leaf_c, 0.01).unwrap();
770        g.add_edge(centre, leaf_d, 0.01).unwrap();
771        let plan = enumerate_dpccp(&g).unwrap();
772        assert_eq!(plan.relations, 0b1111);
773        assert!(plan.cost > 0.0);
774        assert_executable_join_kinds(&plan);
775    }
776
777    #[test]
778    fn greedy_fallback_kicks_in_above_threshold() {
779        // With > MAX_DP_RELATIONS we expect the greedy fallback to
780        // produce a plan that still covers every relation.
781        let mut g = JoinGraph::new();
782        let n = MAX_DP_RELATIONS + 2;
783        let mut prev: usize = 0;
784        for i in 0..n {
785            let idx = g.add_relation(format!("t{i}"), 100.0).unwrap();
786            if i > 0 {
787                g.add_edge(prev, idx, 0.05).unwrap();
788            }
789            prev = idx;
790        }
791        let plan = enumerate_dpccp(&g).unwrap();
792        assert_eq!(plan.relations.count_ones() as usize, n);
793        assert_executable_join_kinds(&plan);
794    }
795
796    #[test]
797    fn threshold_sized_star_uses_exact_plan() {
798        let mut g = JoinGraph::new();
799        let centre = g.add_relation("centre", 1_000.0).unwrap();
800        for i in 1..MAX_DP_RELATIONS {
801            let leaf = g
802                .add_relation(format!("leaf{i}"), 100.0 + i as f64)
803                .unwrap();
804            g.add_edge(centre, leaf, 0.01).unwrap();
805        }
806
807        let plan = enumerate_dpccp(&g).unwrap();
808
809        assert_eq!(plan.relations.count_ones() as usize, MAX_DP_RELATIONS);
810        assert_eq!(plan.relations, g.full_set());
811        assert!(plan.cost > 0.0);
812    }
813}