Skip to main content

hawdb_optimizer_relational/
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    enumerate_relational_csg_cmp_joins_with_cost_contexts(
177        problem,
178        required_properties,
179        config,
180        right_input_policy,
181        implementations,
182        &crate::RelationalJoinCostContexts::default(),
183    )
184}
185
186pub fn enumerate_relational_csg_cmp_joins_with_cost_contexts(
187    problem: &RelationalJoinRewriteProblem,
188    required_properties: &RequiredProperties,
189    config: RelationalJoinEnumerationConfig,
190    right_input_policy: RelationalCsgCmpRightInputPolicy,
191    implementations: &[RelationalCsgCmpJoinImplementation],
192    cost_contexts: &crate::RelationalJoinCostContexts,
193) -> Result<RelationalCsgCmpEnumeration, RelationalJoinRewriteError> {
194    let analysis = analyze_relational_join_conflicts(
195        &problem.initial_tree,
196        problem.post_join_filter.as_ref(),
197    )?;
198    validate_problem_relations(problem, &analysis)?;
199    memo::enumerate_csg_cmp(
200        problem,
201        analysis,
202        required_properties,
203        config,
204        right_input_policy,
205        implementations,
206        cost_contexts,
207    )
208}
209
210fn binding_union(left: &BindingSet, right: &BindingSet) -> BindingSet {
211    left.iter().chain(right.iter()).collect()
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    mod cost_contexts;
218    mod implementations;
219    mod memo_contracts;
220    use crate::{
221        RelationalAccessPathDescriptor, RelationalAccessPathKind, RelationalJoinEnumerationError,
222        RelationalJoinOperator, RelationalJoinRelation, RelationalJoinTree,
223    };
224    use hawdb_expression::{BoundPredicate, BoundScalarExpression, ScalarNullability};
225    use std::collections::BTreeSet;
226
227    const A: BindingId = BindingId::new(1);
228    const B: BindingId = BindingId::new(2);
229    const C: BindingId = BindingId::new(3);
230    const D: BindingId = BindingId::new(4);
231
232    fn value(binding: BindingId) -> BoundScalarExpression {
233        BoundScalarExpression::BindingValue {
234            binding,
235            nullability: ScalarNullability::MaybeNull,
236        }
237    }
238
239    fn predicate(bindings: &[BindingId]) -> BoundPredicate {
240        BoundPredicate::And(
241            bindings
242                .windows(2)
243                .map(|pair| BoundPredicate::Comparison {
244                    left: value(pair[0]),
245                    right: value(pair[1]),
246                })
247                .collect(),
248        )
249    }
250
251    fn operator(
252        id: u32,
253        kind: RelationalJoinOperatorKind,
254        bindings: &[BindingId],
255    ) -> RelationalJoinOperator {
256        RelationalJoinOperator {
257            id: RelationalJoinOperatorId::new(id),
258            kind,
259            predicate_ids: vec![RelationalJoinPredicateId::new(id)],
260            predicate: predicate(bindings),
261        }
262    }
263
264    fn relation(binding: BindingId, rows: usize) -> RelationalJoinRelation {
265        RelationalJoinRelation {
266            binding,
267            access_paths: vec![RelationalJoinAccessPath::base_and_probe(
268                RelationalAccessPathDescriptor {
269                    kind: RelationalAccessPathKind::FullScan,
270                    name: "__full_scan".to_string(),
271                    index_columns: Vec::new(),
272                    access_columns: BTreeSet::new(),
273                    equality_prefix_len: 0,
274                    order_prefix_len: 0,
275                    exclusive_range: false,
276                    reverse_order: false,
277                    unique_point: false,
278                    covering: false,
279                    requires_row_fetch: false,
280                    estimated_rows: rows,
281                },
282            )],
283        }
284    }
285
286    fn indexed_relation(
287        binding: BindingId,
288        rows: usize,
289        required: BindingId,
290    ) -> RelationalJoinRelation {
291        let mut relation = relation(binding, rows);
292        relation.access_paths.push(RelationalJoinAccessPath::probe(
293            RelationalAccessPathDescriptor {
294                kind: RelationalAccessPathKind::Index,
295                name: "by_outer".to_string(),
296                index_columns: vec!["id".to_string()],
297                access_columns: BTreeSet::from(["id".to_string()]),
298                equality_prefix_len: 1,
299                order_prefix_len: 0,
300                exclusive_range: false,
301                reverse_order: false,
302                unique_point: true,
303                covering: false,
304                requires_row_fetch: true,
305                estimated_rows: 1,
306            },
307            required.into(),
308        ));
309        relation
310    }
311
312    fn problem(
313        relations: &[BindingId],
314        tree: RelationalJoinTree,
315        filter: Option<BoundPredicate>,
316    ) -> RelationalJoinRewriteProblem {
317        RelationalJoinRewriteProblem {
318            relations: relations
319                .iter()
320                .enumerate()
321                .map(|(index, binding)| relation(*binding, (index + 1) * 10))
322                .collect(),
323            initial_tree: tree,
324            post_join_filter: filter,
325        }
326    }
327
328    #[test]
329    fn csg_cmp_enumerates_bushy_hypergraph_partitions_into_one_semantic_group() {
330        let left = RelationalJoinTree::join(
331            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
332            RelationalJoinTree::Relation(A),
333            RelationalJoinTree::Relation(B),
334        );
335        let right = RelationalJoinTree::join(
336            operator(2, RelationalJoinOperatorKind::Inner, &[C, D]),
337            RelationalJoinTree::Relation(C),
338            RelationalJoinTree::Relation(D),
339        );
340        let tree = RelationalJoinTree::join(
341            operator(3, RelationalJoinOperatorKind::Inner, &[B, C]),
342            left,
343            right,
344        );
345        let result = enumerate_relational_csg_cmp_joins(
346            &problem(&[A, B, C, D], tree, None),
347            &RequiredProperties::default(),
348            RelationalJoinEnumerationConfig::default(),
349        )
350        .unwrap();
351
352        assert_eq!(result.plan.root.bindings(), BindingSet::from([A, B, C, D]));
353        assert!(result.root_alternatives > 1);
354        assert!(result.alternatives.iter().any(|alternative| {
355            alternative.operator_id == RelationalJoinOperatorId::new(3)
356                && alternative.left_bindings == BindingSet::from([A, B])
357                && alternative.right_bindings == BindingSet::from([C, D])
358        }));
359    }
360
361    #[test]
362    fn csg_cmp_probe_only_policy_excludes_unbounded_materialization() {
363        let left = RelationalJoinTree::join(
364            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
365            RelationalJoinTree::Relation(A),
366            RelationalJoinTree::Relation(B),
367        );
368        let right = RelationalJoinTree::join(
369            operator(2, RelationalJoinOperatorKind::Inner, &[C, D]),
370            RelationalJoinTree::Relation(C),
371            RelationalJoinTree::Relation(D),
372        );
373        let tree = RelationalJoinTree::join(
374            operator(3, RelationalJoinOperatorKind::Inner, &[B, C]),
375            left,
376            right,
377        );
378
379        let result = enumerate_relational_csg_cmp_joins_with_right_input_policy(
380            &problem(&[A, B, C, D], tree, None),
381            &RequiredProperties::default(),
382            RelationalJoinEnumerationConfig::default(),
383            RelationalCsgCmpRightInputPolicy::ProbeOnly,
384        )
385        .expect("probe-only CSG-CMP can choose a left-deep alternative");
386
387        assert!(!result.plan.root.has_materialized_right());
388    }
389
390    #[test]
391    fn csg_cmp_costing_uses_a_singleton_index_probe_from_the_outer_group() {
392        let tree = RelationalJoinTree::join(
393            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
394            RelationalJoinTree::Relation(A),
395            RelationalJoinTree::Relation(B),
396        );
397        let problem = RelationalJoinRewriteProblem {
398            relations: vec![relation(A, 1), indexed_relation(B, 1_000, A)],
399            initial_tree: tree,
400            post_join_filter: None,
401        };
402        let result = enumerate_relational_csg_cmp_joins(
403            &problem,
404            &RequiredProperties::default(),
405            RelationalJoinEnumerationConfig::default(),
406        )
407        .unwrap();
408
409        assert_eq!(
410            result.plan.cost(),
411            PlanCost {
412                estimated_rows: 1,
413                cost: 14,
414            }
415        );
416        let RelationalCsgCmpPlanNode::Join { left, right, .. } = result.plan.root else {
417            panic!("expected costed join root");
418        };
419        assert_eq!(left.bindings(), A.into());
420        let RelationalCsgCmpPlanNode::Relation { access_path, .. } = *right else {
421            panic!("expected singleton probe on the right");
422        };
423        assert_eq!(access_path.descriptor.name, "by_outer");
424    }
425
426    #[test]
427    fn csg_cmp_probe_prefers_a_cheaper_scan_over_a_longer_equality_prefix() {
428        let tree = RelationalJoinTree::join(
429            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
430            RelationalJoinTree::Relation(A),
431            RelationalJoinTree::Relation(B),
432        );
433        let mut inner = relation(B, 10);
434        inner.access_paths.push(RelationalJoinAccessPath::probe(
435            RelationalAccessPathDescriptor {
436                kind: RelationalAccessPathKind::Index,
437                name: "by_outer".to_string(),
438                index_columns: vec!["outer_id".to_string()],
439                access_columns: BTreeSet::from(["outer_id".to_string()]),
440                equality_prefix_len: 1,
441                order_prefix_len: 0,
442                exclusive_range: false,
443                reverse_order: false,
444                unique_point: false,
445                covering: false,
446                requires_row_fetch: true,
447                estimated_rows: 10,
448            },
449            A.into(),
450        ));
451        let problem = RelationalJoinRewriteProblem {
452            relations: vec![relation(A, 1), inner],
453            initial_tree: tree,
454            post_join_filter: None,
455        };
456
457        let result = enumerate_relational_csg_cmp_joins(
458            &problem,
459            &RequiredProperties::default(),
460            RelationalJoinEnumerationConfig::default(),
461        )
462        .unwrap();
463
464        let RelationalCsgCmpPlanNode::Join { right, .. } = result.plan.root else {
465            panic!("expected a join root");
466        };
467        let RelationalCsgCmpPlanNode::Relation { access_path, .. } = *right else {
468            panic!("expected a singleton probe");
469        };
470        assert_eq!(access_path.descriptor.name, "__full_scan");
471    }
472
473    #[test]
474    fn multi_relation_hyperedge_waits_for_its_complete_endpoint() {
475        let lower = RelationalJoinTree::join(
476            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
477            RelationalJoinTree::Relation(A),
478            RelationalJoinTree::Relation(B),
479        );
480        let tree = RelationalJoinTree::join(
481            operator(2, RelationalJoinOperatorKind::Inner, &[A, B, C]),
482            lower,
483            RelationalJoinTree::Relation(C),
484        );
485        let result = enumerate_relational_csg_cmp_joins(
486            &problem(&[A, B, C], tree, None),
487            &RequiredProperties::default(),
488            RelationalJoinEnumerationConfig::default(),
489        )
490        .unwrap();
491
492        assert!(result
493            .alternatives
494            .iter()
495            .filter(|alternative| alternative.operator_id == RelationalJoinOperatorId::new(2))
496            .all(|alternative| {
497                alternative.left_bindings == BindingSet::from([A, B])
498                    || alternative.right_bindings == BindingSet::from([A, B])
499            }));
500    }
501
502    #[test]
503    fn csg_cmp_never_inserts_the_illegal_outer_join_association() {
504        let lower = RelationalJoinTree::join(
505            operator(1, RelationalJoinOperatorKind::LeftOuter, &[A, B]),
506            RelationalJoinTree::Relation(A),
507            RelationalJoinTree::Relation(B),
508        );
509        let tree = RelationalJoinTree::join(
510            operator(2, RelationalJoinOperatorKind::Inner, &[B, C]),
511            lower,
512            RelationalJoinTree::Relation(C),
513        );
514        let result = enumerate_relational_csg_cmp_joins(
515            &problem(&[A, B, C], tree, None),
516            &RequiredProperties::default(),
517            RelationalJoinEnumerationConfig::default(),
518        )
519        .unwrap();
520
521        assert!(!result.alternatives.iter().any(|alternative| {
522            alternative.operator_id == RelationalJoinOperatorId::new(2)
523                && binding_union(&alternative.left_bindings, &alternative.right_bindings)
524                    == BindingSet::from([B, C])
525        }));
526    }
527
528    #[test]
529    fn null_rejection_opens_the_inner_hyperedge_only_after_outer_simplification() {
530        let lower = RelationalJoinTree::join(
531            operator(1, RelationalJoinOperatorKind::LeftOuter, &[A, B]),
532            RelationalJoinTree::Relation(A),
533            RelationalJoinTree::Relation(B),
534        );
535        let tree = RelationalJoinTree::join(
536            operator(2, RelationalJoinOperatorKind::Inner, &[B, C]),
537            lower,
538            RelationalJoinTree::Relation(C),
539        );
540        let filter = BoundPredicate::IsNotNull(value(B));
541        let result = enumerate_relational_csg_cmp_joins(
542            &problem(&[A, B, C], tree, Some(filter)),
543            &RequiredProperties::default(),
544            RelationalJoinEnumerationConfig::default(),
545        )
546        .unwrap();
547
548        assert!(result.alternatives.iter().any(|alternative| {
549            alternative.operator_id == RelationalJoinOperatorId::new(2)
550                && binding_union(&alternative.left_bindings, &alternative.right_bindings)
551                    == BindingSet::from([B, C])
552        }));
553    }
554}