Skip to main content

hawdb_optimizer/
relational_join_hypergraph.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Hypergraph csg-cmp enumeration for bound relational join operators.
16
17use crate::{
18    analyze_relational_join_conflicts,
19    relational_join_cost::{RelationalJoinRightInput, RelationalJoinSelectivity},
20    relational_join_rewrite::validate_problem_relations,
21    PhysicalProperties, PlanCost, PlanCostBreakdown, RelationalJoinAccessPath,
22    RelationalJoinConflictAnalysis, RelationalJoinEnumerationConfig, RelationalJoinOperatorId,
23    RelationalJoinOperatorKind, RelationalJoinPredicateId, RelationalJoinRewriteError,
24    RelationalJoinRewriteProblem, RequiredProperties,
25};
26use hawdb_expression::{BindingId, BindingSet};
27
28/// Execution capability for a CSG-CMP join's non-singleton right input.
29///
30/// A materialized right input is only safe when the executor has a bounded
31/// materialization implementation, such as a spill-capable blocking operator.
32/// Callers must select this policy according to their executor's capabilities.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub enum RelationalCsgCmpRightInputPolicy {
35    #[default]
36    AllowMaterialized,
37    ProbeOnly,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub enum RelationalEquiJoinAlgorithm {
42    Hash,
43    Merge,
44}
45
46impl RelationalEquiJoinAlgorithm {
47    pub const fn right_input(self) -> RelationalJoinRightInput {
48        match self {
49            Self::Hash => RelationalJoinRightInput::Hash,
50            Self::Merge => RelationalJoinRightInput::Merge,
51        }
52    }
53}
54
55/// A pre-bound implementation supported by the caller's two-relation kernel.
56/// It competes inside memo selection, not after choosing a join order. Predicate
57/// IDs name the complete operator, including residual predicates.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct RelationalCsgCmpJoinImplementation {
60    pub operator_id: RelationalJoinOperatorId,
61    pub operator_kind: RelationalJoinOperatorKind,
62    pub predicate_ids: Vec<RelationalJoinPredicateId>,
63    pub left_binding: BindingId,
64    pub left_access: RelationalJoinAccessPath,
65    pub right_binding: BindingId,
66    pub right_access: RelationalJoinAccessPath,
67    pub algorithm: RelationalEquiJoinAlgorithm,
68    pub selectivity: RelationalJoinSelectivity,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RelationalCsgCmpAlternative {
73    pub left_bindings: BindingSet,
74    pub right_bindings: BindingSet,
75    pub operator_id: RelationalJoinOperatorId,
76    pub operator_kind: RelationalJoinOperatorKind,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum RelationalCsgCmpPlanNode {
81    Relation {
82        binding: BindingId,
83        access_path: RelationalJoinAccessPath,
84    },
85    Join {
86        operator_id: RelationalJoinOperatorId,
87        operator_kind: RelationalJoinOperatorKind,
88        predicate_ids: Vec<RelationalJoinPredicateId>,
89        implementation: Option<Box<RelationalCsgCmpJoinImplementation>>,
90        left: Box<Self>,
91        right: Box<Self>,
92    },
93}
94
95impl RelationalCsgCmpPlanNode {
96    pub fn bindings(&self) -> BindingSet {
97        match self {
98            Self::Relation { binding, .. } => (*binding).into(),
99            Self::Join { left, right, .. } => binding_union(&left.bindings(), &right.bindings()),
100        }
101    }
102
103    pub fn has_materialized_right(&self) -> bool {
104        match self {
105            Self::Relation { .. } => false,
106            Self::Join { left, right, .. } => {
107                matches!(right.as_ref(), Self::Join { .. })
108                    || left.has_materialized_right()
109                    || right.has_materialized_right()
110            }
111        }
112    }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct RelationalCsgCmpPlan {
117    pub root: RelationalCsgCmpPlanNode,
118    pub cost_breakdown: PlanCostBreakdown,
119    pub properties: PhysicalProperties,
120}
121
122impl RelationalCsgCmpPlan {
123    pub fn cost(&self) -> PlanCost {
124        self.cost_breakdown.as_plan_cost()
125    }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct RelationalCsgCmpEnumeration {
130    pub plan: RelationalCsgCmpPlan,
131    pub conflict_analysis: RelationalJoinConflictAnalysis,
132    pub alternatives: Vec<RelationalCsgCmpAlternative>,
133    pub memo_groups: usize,
134    pub memo_expressions: usize,
135    pub root_alternatives: usize,
136}
137
138mod memo;
139pub(crate) use memo::{enumerate_inner_graph, enumerate_left_deep_rewrites};
140
141pub fn enumerate_relational_csg_cmp_joins(
142    problem: &RelationalJoinRewriteProblem,
143    required_properties: &RequiredProperties,
144    config: RelationalJoinEnumerationConfig,
145) -> Result<RelationalCsgCmpEnumeration, RelationalJoinRewriteError> {
146    enumerate_relational_csg_cmp_joins_with_right_input_policy(
147        problem,
148        required_properties,
149        config,
150        RelationalCsgCmpRightInputPolicy::default(),
151    )
152}
153
154pub fn enumerate_relational_csg_cmp_joins_with_right_input_policy(
155    problem: &RelationalJoinRewriteProblem,
156    required_properties: &RequiredProperties,
157    config: RelationalJoinEnumerationConfig,
158    right_input_policy: RelationalCsgCmpRightInputPolicy,
159) -> Result<RelationalCsgCmpEnumeration, RelationalJoinRewriteError> {
160    enumerate_relational_csg_cmp_joins_with_implementations(
161        problem,
162        required_properties,
163        config,
164        right_input_policy,
165        &[],
166    )
167}
168
169pub fn enumerate_relational_csg_cmp_joins_with_implementations(
170    problem: &RelationalJoinRewriteProblem,
171    required_properties: &RequiredProperties,
172    config: RelationalJoinEnumerationConfig,
173    right_input_policy: RelationalCsgCmpRightInputPolicy,
174    implementations: &[RelationalCsgCmpJoinImplementation],
175) -> Result<RelationalCsgCmpEnumeration, RelationalJoinRewriteError> {
176    let analysis = analyze_relational_join_conflicts(
177        &problem.initial_tree,
178        problem.post_join_filter.as_ref(),
179    )?;
180    validate_problem_relations(problem, &analysis)?;
181    memo::enumerate_csg_cmp(
182        problem,
183        analysis,
184        required_properties,
185        config,
186        right_input_policy,
187        implementations,
188    )
189}
190
191fn binding_union(left: &BindingSet, right: &BindingSet) -> BindingSet {
192    left.iter().chain(right.iter()).collect()
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    mod implementations;
199    mod memo_contracts;
200    use crate::{
201        RelationalAccessPathDescriptor, RelationalAccessPathKind, RelationalJoinEnumerationError,
202        RelationalJoinOperator, RelationalJoinRelation, RelationalJoinTree,
203    };
204    use hawdb_expression::{BoundPredicate, BoundScalarExpression, ScalarNullability};
205    use std::collections::BTreeSet;
206
207    const A: BindingId = BindingId::new(1);
208    const B: BindingId = BindingId::new(2);
209    const C: BindingId = BindingId::new(3);
210    const D: BindingId = BindingId::new(4);
211
212    fn value(binding: BindingId) -> BoundScalarExpression {
213        BoundScalarExpression::BindingValue {
214            binding,
215            nullability: ScalarNullability::MaybeNull,
216        }
217    }
218
219    fn predicate(bindings: &[BindingId]) -> BoundPredicate {
220        BoundPredicate::And(
221            bindings
222                .windows(2)
223                .map(|pair| BoundPredicate::Comparison {
224                    left: value(pair[0]),
225                    right: value(pair[1]),
226                })
227                .collect(),
228        )
229    }
230
231    fn operator(
232        id: u32,
233        kind: RelationalJoinOperatorKind,
234        bindings: &[BindingId],
235    ) -> RelationalJoinOperator {
236        RelationalJoinOperator {
237            id: RelationalJoinOperatorId::new(id),
238            kind,
239            predicate_ids: vec![RelationalJoinPredicateId::new(id)],
240            predicate: predicate(bindings),
241        }
242    }
243
244    fn relation(binding: BindingId, rows: usize) -> RelationalJoinRelation {
245        RelationalJoinRelation {
246            binding,
247            access_paths: vec![RelationalJoinAccessPath::base_and_probe(
248                RelationalAccessPathDescriptor {
249                    kind: RelationalAccessPathKind::FullScan,
250                    name: "__full_scan".to_string(),
251                    index_columns: Vec::new(),
252                    access_columns: BTreeSet::new(),
253                    equality_prefix_len: 0,
254                    order_prefix_len: 0,
255                    exclusive_range: false,
256                    reverse_order: false,
257                    unique_point: false,
258                    covering: false,
259                    requires_row_fetch: false,
260                    estimated_rows: rows,
261                },
262            )],
263        }
264    }
265
266    fn indexed_relation(
267        binding: BindingId,
268        rows: usize,
269        required: BindingId,
270    ) -> RelationalJoinRelation {
271        let mut relation = relation(binding, rows);
272        relation.access_paths.push(RelationalJoinAccessPath::probe(
273            RelationalAccessPathDescriptor {
274                kind: RelationalAccessPathKind::Index,
275                name: "by_outer".to_string(),
276                index_columns: vec!["id".to_string()],
277                access_columns: BTreeSet::from(["id".to_string()]),
278                equality_prefix_len: 1,
279                order_prefix_len: 0,
280                exclusive_range: false,
281                reverse_order: false,
282                unique_point: true,
283                covering: false,
284                requires_row_fetch: true,
285                estimated_rows: 1,
286            },
287            required.into(),
288        ));
289        relation
290    }
291
292    fn problem(
293        relations: &[BindingId],
294        tree: RelationalJoinTree,
295        filter: Option<BoundPredicate>,
296    ) -> RelationalJoinRewriteProblem {
297        RelationalJoinRewriteProblem {
298            relations: relations
299                .iter()
300                .enumerate()
301                .map(|(index, binding)| relation(*binding, (index + 1) * 10))
302                .collect(),
303            initial_tree: tree,
304            post_join_filter: filter,
305        }
306    }
307
308    #[test]
309    fn csg_cmp_enumerates_bushy_hypergraph_partitions_into_one_semantic_group() {
310        let left = RelationalJoinTree::join(
311            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
312            RelationalJoinTree::Relation(A),
313            RelationalJoinTree::Relation(B),
314        );
315        let right = RelationalJoinTree::join(
316            operator(2, RelationalJoinOperatorKind::Inner, &[C, D]),
317            RelationalJoinTree::Relation(C),
318            RelationalJoinTree::Relation(D),
319        );
320        let tree = RelationalJoinTree::join(
321            operator(3, RelationalJoinOperatorKind::Inner, &[B, C]),
322            left,
323            right,
324        );
325        let result = enumerate_relational_csg_cmp_joins(
326            &problem(&[A, B, C, D], tree, None),
327            &RequiredProperties::default(),
328            RelationalJoinEnumerationConfig::default(),
329        )
330        .unwrap();
331
332        assert_eq!(result.plan.root.bindings(), BindingSet::from([A, B, C, D]));
333        assert!(result.root_alternatives > 1);
334        assert!(result.alternatives.iter().any(|alternative| {
335            alternative.operator_id == RelationalJoinOperatorId::new(3)
336                && alternative.left_bindings == BindingSet::from([A, B])
337                && alternative.right_bindings == BindingSet::from([C, D])
338        }));
339    }
340
341    #[test]
342    fn csg_cmp_probe_only_policy_excludes_unbounded_materialization() {
343        let left = RelationalJoinTree::join(
344            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
345            RelationalJoinTree::Relation(A),
346            RelationalJoinTree::Relation(B),
347        );
348        let right = RelationalJoinTree::join(
349            operator(2, RelationalJoinOperatorKind::Inner, &[C, D]),
350            RelationalJoinTree::Relation(C),
351            RelationalJoinTree::Relation(D),
352        );
353        let tree = RelationalJoinTree::join(
354            operator(3, RelationalJoinOperatorKind::Inner, &[B, C]),
355            left,
356            right,
357        );
358
359        let result = enumerate_relational_csg_cmp_joins_with_right_input_policy(
360            &problem(&[A, B, C, D], tree, None),
361            &RequiredProperties::default(),
362            RelationalJoinEnumerationConfig::default(),
363            RelationalCsgCmpRightInputPolicy::ProbeOnly,
364        )
365        .expect("probe-only CSG-CMP can choose a left-deep alternative");
366
367        assert!(!result.plan.root.has_materialized_right());
368    }
369
370    #[test]
371    fn csg_cmp_costing_uses_a_singleton_index_probe_from_the_outer_group() {
372        let tree = RelationalJoinTree::join(
373            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
374            RelationalJoinTree::Relation(A),
375            RelationalJoinTree::Relation(B),
376        );
377        let problem = RelationalJoinRewriteProblem {
378            relations: vec![relation(A, 1), indexed_relation(B, 1_000, A)],
379            initial_tree: tree,
380            post_join_filter: None,
381        };
382        let result = enumerate_relational_csg_cmp_joins(
383            &problem,
384            &RequiredProperties::default(),
385            RelationalJoinEnumerationConfig::default(),
386        )
387        .unwrap();
388
389        assert_eq!(
390            result.plan.cost(),
391            PlanCost {
392                estimated_rows: 1,
393                cost: 14,
394            }
395        );
396        let RelationalCsgCmpPlanNode::Join { left, right, .. } = result.plan.root else {
397            panic!("expected costed join root");
398        };
399        assert_eq!(left.bindings(), A.into());
400        let RelationalCsgCmpPlanNode::Relation { access_path, .. } = *right else {
401            panic!("expected singleton probe on the right");
402        };
403        assert_eq!(access_path.descriptor.name, "by_outer");
404    }
405
406    #[test]
407    fn csg_cmp_probe_prefers_a_cheaper_scan_over_a_longer_equality_prefix() {
408        let tree = RelationalJoinTree::join(
409            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
410            RelationalJoinTree::Relation(A),
411            RelationalJoinTree::Relation(B),
412        );
413        let mut inner = relation(B, 10);
414        inner.access_paths.push(RelationalJoinAccessPath::probe(
415            RelationalAccessPathDescriptor {
416                kind: RelationalAccessPathKind::Index,
417                name: "by_outer".to_string(),
418                index_columns: vec!["outer_id".to_string()],
419                access_columns: BTreeSet::from(["outer_id".to_string()]),
420                equality_prefix_len: 1,
421                order_prefix_len: 0,
422                exclusive_range: false,
423                reverse_order: false,
424                unique_point: false,
425                covering: false,
426                requires_row_fetch: true,
427                estimated_rows: 10,
428            },
429            A.into(),
430        ));
431        let problem = RelationalJoinRewriteProblem {
432            relations: vec![relation(A, 1), inner],
433            initial_tree: tree,
434            post_join_filter: None,
435        };
436
437        let result = enumerate_relational_csg_cmp_joins(
438            &problem,
439            &RequiredProperties::default(),
440            RelationalJoinEnumerationConfig::default(),
441        )
442        .unwrap();
443
444        let RelationalCsgCmpPlanNode::Join { right, .. } = result.plan.root else {
445            panic!("expected a join root");
446        };
447        let RelationalCsgCmpPlanNode::Relation { access_path, .. } = *right else {
448            panic!("expected a singleton probe");
449        };
450        assert_eq!(access_path.descriptor.name, "__full_scan");
451    }
452
453    #[test]
454    fn multi_relation_hyperedge_waits_for_its_complete_endpoint() {
455        let lower = RelationalJoinTree::join(
456            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
457            RelationalJoinTree::Relation(A),
458            RelationalJoinTree::Relation(B),
459        );
460        let tree = RelationalJoinTree::join(
461            operator(2, RelationalJoinOperatorKind::Inner, &[A, B, C]),
462            lower,
463            RelationalJoinTree::Relation(C),
464        );
465        let result = enumerate_relational_csg_cmp_joins(
466            &problem(&[A, B, C], tree, None),
467            &RequiredProperties::default(),
468            RelationalJoinEnumerationConfig::default(),
469        )
470        .unwrap();
471
472        assert!(result
473            .alternatives
474            .iter()
475            .filter(|alternative| alternative.operator_id == RelationalJoinOperatorId::new(2))
476            .all(|alternative| {
477                alternative.left_bindings == BindingSet::from([A, B])
478                    || alternative.right_bindings == BindingSet::from([A, B])
479            }));
480    }
481
482    #[test]
483    fn csg_cmp_never_inserts_the_illegal_outer_join_association() {
484        let lower = RelationalJoinTree::join(
485            operator(1, RelationalJoinOperatorKind::LeftOuter, &[A, B]),
486            RelationalJoinTree::Relation(A),
487            RelationalJoinTree::Relation(B),
488        );
489        let tree = RelationalJoinTree::join(
490            operator(2, RelationalJoinOperatorKind::Inner, &[B, C]),
491            lower,
492            RelationalJoinTree::Relation(C),
493        );
494        let result = enumerate_relational_csg_cmp_joins(
495            &problem(&[A, B, C], tree, None),
496            &RequiredProperties::default(),
497            RelationalJoinEnumerationConfig::default(),
498        )
499        .unwrap();
500
501        assert!(!result.alternatives.iter().any(|alternative| {
502            alternative.operator_id == RelationalJoinOperatorId::new(2)
503                && binding_union(&alternative.left_bindings, &alternative.right_bindings)
504                    == BindingSet::from([B, C])
505        }));
506    }
507
508    #[test]
509    fn null_rejection_opens_the_inner_hyperedge_only_after_outer_simplification() {
510        let lower = RelationalJoinTree::join(
511            operator(1, RelationalJoinOperatorKind::LeftOuter, &[A, B]),
512            RelationalJoinTree::Relation(A),
513            RelationalJoinTree::Relation(B),
514        );
515        let tree = RelationalJoinTree::join(
516            operator(2, RelationalJoinOperatorKind::Inner, &[B, C]),
517            lower,
518            RelationalJoinTree::Relation(C),
519        );
520        let filter = BoundPredicate::IsNotNull(value(B));
521        let result = enumerate_relational_csg_cmp_joins(
522            &problem(&[A, B, C], tree, Some(filter)),
523            &RequiredProperties::default(),
524            RelationalJoinEnumerationConfig::default(),
525        )
526        .unwrap();
527
528        assert!(result.alternatives.iter().any(|alternative| {
529            alternative.operator_id == RelationalJoinOperatorId::new(2)
530                && binding_union(&alternative.left_bindings, &alternative.right_bindings)
531                    == BindingSet::from([B, C])
532        }));
533    }
534}