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                    range: None,
277                    reverse_order: false,
278                    unique_point: false,
279                    covering: false,
280                    requires_row_fetch: false,
281                    estimated_rows: rows,
282                },
283            )],
284        }
285    }
286
287    fn indexed_relation(
288        binding: BindingId,
289        rows: usize,
290        required: BindingId,
291    ) -> RelationalJoinRelation {
292        let mut relation = relation(binding, rows);
293        relation.access_paths.push(RelationalJoinAccessPath::probe(
294            RelationalAccessPathDescriptor {
295                kind: RelationalAccessPathKind::Index,
296                name: "by_outer".to_string(),
297                index_columns: vec!["id".to_string()],
298                access_columns: BTreeSet::from(["id".to_string()]),
299                equality_prefix_len: 1,
300                order_prefix_len: 0,
301                exclusive_range: false,
302                range: None,
303                reverse_order: false,
304                unique_point: true,
305                covering: false,
306                requires_row_fetch: true,
307                estimated_rows: 1,
308            },
309            required.into(),
310        ));
311        relation
312    }
313
314    fn problem(
315        relations: &[BindingId],
316        tree: RelationalJoinTree,
317        filter: Option<BoundPredicate>,
318    ) -> RelationalJoinRewriteProblem {
319        RelationalJoinRewriteProblem {
320            relations: relations
321                .iter()
322                .enumerate()
323                .map(|(index, binding)| relation(*binding, (index + 1) * 10))
324                .collect(),
325            initial_tree: tree,
326            post_join_filter: filter,
327        }
328    }
329
330    #[test]
331    fn csg_cmp_enumerates_bushy_hypergraph_partitions_into_one_semantic_group() {
332        let left = RelationalJoinTree::join(
333            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
334            RelationalJoinTree::Relation(A),
335            RelationalJoinTree::Relation(B),
336        );
337        let right = RelationalJoinTree::join(
338            operator(2, RelationalJoinOperatorKind::Inner, &[C, D]),
339            RelationalJoinTree::Relation(C),
340            RelationalJoinTree::Relation(D),
341        );
342        let tree = RelationalJoinTree::join(
343            operator(3, RelationalJoinOperatorKind::Inner, &[B, C]),
344            left,
345            right,
346        );
347        let result = enumerate_relational_csg_cmp_joins(
348            &problem(&[A, B, C, D], tree, None),
349            &RequiredProperties::default(),
350            RelationalJoinEnumerationConfig::default(),
351        )
352        .unwrap();
353
354        assert_eq!(result.plan.root.bindings(), BindingSet::from([A, B, C, D]));
355        assert!(result.root_alternatives > 1);
356        assert!(result.alternatives.iter().any(|alternative| {
357            alternative.operator_id == RelationalJoinOperatorId::new(3)
358                && alternative.left_bindings == BindingSet::from([A, B])
359                && alternative.right_bindings == BindingSet::from([C, D])
360        }));
361    }
362
363    #[test]
364    fn csg_cmp_probe_only_policy_excludes_unbounded_materialization() {
365        let left = RelationalJoinTree::join(
366            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
367            RelationalJoinTree::Relation(A),
368            RelationalJoinTree::Relation(B),
369        );
370        let right = RelationalJoinTree::join(
371            operator(2, RelationalJoinOperatorKind::Inner, &[C, D]),
372            RelationalJoinTree::Relation(C),
373            RelationalJoinTree::Relation(D),
374        );
375        let tree = RelationalJoinTree::join(
376            operator(3, RelationalJoinOperatorKind::Inner, &[B, C]),
377            left,
378            right,
379        );
380
381        let result = enumerate_relational_csg_cmp_joins_with_right_input_policy(
382            &problem(&[A, B, C, D], tree, None),
383            &RequiredProperties::default(),
384            RelationalJoinEnumerationConfig::default(),
385            RelationalCsgCmpRightInputPolicy::ProbeOnly,
386        )
387        .expect("probe-only CSG-CMP can choose a left-deep alternative");
388
389        assert!(!result.plan.root.has_materialized_right());
390    }
391
392    #[test]
393    fn csg_cmp_costing_uses_a_singleton_index_probe_from_the_outer_group() {
394        let tree = RelationalJoinTree::join(
395            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
396            RelationalJoinTree::Relation(A),
397            RelationalJoinTree::Relation(B),
398        );
399        let problem = RelationalJoinRewriteProblem {
400            relations: vec![relation(A, 1), indexed_relation(B, 1_000, A)],
401            initial_tree: tree,
402            post_join_filter: None,
403        };
404        let result = enumerate_relational_csg_cmp_joins(
405            &problem,
406            &RequiredProperties::default(),
407            RelationalJoinEnumerationConfig::default(),
408        )
409        .unwrap();
410
411        assert_eq!(
412            result.plan.cost(),
413            PlanCost {
414                estimated_rows: 1,
415                cost: 14,
416            }
417        );
418        let RelationalCsgCmpPlanNode::Join { left, right, .. } = result.plan.root else {
419            panic!("expected costed join root");
420        };
421        assert_eq!(left.bindings(), A.into());
422        let RelationalCsgCmpPlanNode::Relation { access_path, .. } = *right else {
423            panic!("expected singleton probe on the right");
424        };
425        assert_eq!(access_path.descriptor.name, "by_outer");
426    }
427
428    #[test]
429    fn csg_cmp_probe_prefers_a_cheaper_scan_over_a_longer_equality_prefix() {
430        let tree = RelationalJoinTree::join(
431            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
432            RelationalJoinTree::Relation(A),
433            RelationalJoinTree::Relation(B),
434        );
435        let mut inner = relation(B, 10);
436        inner.access_paths.push(RelationalJoinAccessPath::probe(
437            RelationalAccessPathDescriptor {
438                kind: RelationalAccessPathKind::Index,
439                name: "by_outer".to_string(),
440                index_columns: vec!["outer_id".to_string()],
441                access_columns: BTreeSet::from(["outer_id".to_string()]),
442                equality_prefix_len: 1,
443                order_prefix_len: 0,
444                exclusive_range: false,
445                range: None,
446                reverse_order: false,
447                unique_point: false,
448                covering: false,
449                requires_row_fetch: true,
450                estimated_rows: 10,
451            },
452            A.into(),
453        ));
454        let problem = RelationalJoinRewriteProblem {
455            relations: vec![relation(A, 1), inner],
456            initial_tree: tree,
457            post_join_filter: None,
458        };
459
460        let result = enumerate_relational_csg_cmp_joins(
461            &problem,
462            &RequiredProperties::default(),
463            RelationalJoinEnumerationConfig::default(),
464        )
465        .unwrap();
466
467        let RelationalCsgCmpPlanNode::Join { right, .. } = result.plan.root else {
468            panic!("expected a join root");
469        };
470        let RelationalCsgCmpPlanNode::Relation { access_path, .. } = *right else {
471            panic!("expected a singleton probe");
472        };
473        assert_eq!(access_path.descriptor.name, "__full_scan");
474    }
475
476    #[test]
477    fn multi_relation_hyperedge_waits_for_its_complete_endpoint() {
478        let lower = RelationalJoinTree::join(
479            operator(1, RelationalJoinOperatorKind::Inner, &[A, B]),
480            RelationalJoinTree::Relation(A),
481            RelationalJoinTree::Relation(B),
482        );
483        let tree = RelationalJoinTree::join(
484            operator(2, RelationalJoinOperatorKind::Inner, &[A, B, C]),
485            lower,
486            RelationalJoinTree::Relation(C),
487        );
488        let result = enumerate_relational_csg_cmp_joins(
489            &problem(&[A, B, C], tree, None),
490            &RequiredProperties::default(),
491            RelationalJoinEnumerationConfig::default(),
492        )
493        .unwrap();
494
495        assert!(result
496            .alternatives
497            .iter()
498            .filter(|alternative| alternative.operator_id == RelationalJoinOperatorId::new(2))
499            .all(|alternative| {
500                alternative.left_bindings == BindingSet::from([A, B])
501                    || alternative.right_bindings == BindingSet::from([A, B])
502            }));
503    }
504
505    #[test]
506    fn csg_cmp_never_inserts_the_illegal_outer_join_association() {
507        let lower = RelationalJoinTree::join(
508            operator(1, RelationalJoinOperatorKind::LeftOuter, &[A, B]),
509            RelationalJoinTree::Relation(A),
510            RelationalJoinTree::Relation(B),
511        );
512        let tree = RelationalJoinTree::join(
513            operator(2, RelationalJoinOperatorKind::Inner, &[B, C]),
514            lower,
515            RelationalJoinTree::Relation(C),
516        );
517        let result = enumerate_relational_csg_cmp_joins(
518            &problem(&[A, B, C], tree, None),
519            &RequiredProperties::default(),
520            RelationalJoinEnumerationConfig::default(),
521        )
522        .unwrap();
523
524        assert!(!result.alternatives.iter().any(|alternative| {
525            alternative.operator_id == RelationalJoinOperatorId::new(2)
526                && binding_union(&alternative.left_bindings, &alternative.right_bindings)
527                    == BindingSet::from([B, C])
528        }));
529    }
530
531    #[test]
532    fn null_rejection_opens_the_inner_hyperedge_only_after_outer_simplification() {
533        let lower = RelationalJoinTree::join(
534            operator(1, RelationalJoinOperatorKind::LeftOuter, &[A, B]),
535            RelationalJoinTree::Relation(A),
536            RelationalJoinTree::Relation(B),
537        );
538        let tree = RelationalJoinTree::join(
539            operator(2, RelationalJoinOperatorKind::Inner, &[B, C]),
540            lower,
541            RelationalJoinTree::Relation(C),
542        );
543        let filter = BoundPredicate::IsNotNull(value(B));
544        let result = enumerate_relational_csg_cmp_joins(
545            &problem(&[A, B, C], tree, Some(filter)),
546            &RequiredProperties::default(),
547            RelationalJoinEnumerationConfig::default(),
548        )
549        .unwrap();
550
551        assert!(result.alternatives.iter().any(|alternative| {
552            alternative.operator_id == RelationalJoinOperatorId::new(2)
553                && binding_union(&alternative.left_bindings, &alternative.right_bindings)
554                    == BindingSet::from([B, C])
555        }));
556    }
557}