Skip to main content

hawdb_optimizer_relational/
relational.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
15use crate::{estimate_relational_access_path_cost_with_context, RelationalAccessCostContext};
16use std::collections::BTreeSet;
17
18#[cfg(test)]
19#[path = "relational/cost_tests.rs"]
20mod cost_tests;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum RelationalAccessPathKind {
24    FullScan,
25    PrimaryKey,
26    Index,
27}
28
29/// Conservative row-work estimate for an index nested-loop join.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct RelationalNestedLoopJoinCost {
32    pub outer_rows: usize,
33    pub inner_rows_per_outer: usize,
34    pub estimated_work: usize,
35}
36
37/// A conjunctive range on the first index column after the equality prefix.
38/// Bound values remain in the bound execution plan, not diagnostic metadata.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RelationalRangeAccess {
41    pub column: String,
42    pub lower_inclusive: Option<bool>,
43    pub upper_inclusive: Option<bool>,
44    pub empty: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RelationalAccessPathDescriptor {
49    pub kind: RelationalAccessPathKind,
50    pub name: String,
51    pub index_columns: Vec<String>,
52    pub access_columns: BTreeSet<String>,
53    pub equality_prefix_len: usize,
54    pub order_prefix_len: usize,
55    pub exclusive_range: bool,
56    pub range: Option<RelationalRangeAccess>,
57    pub reverse_order: bool,
58    pub unique_point: bool,
59    pub covering: bool,
60    pub requires_row_fetch: bool,
61    pub estimated_rows: usize,
62}
63
64impl RelationalAccessPathDescriptor {
65    pub fn validate(&self) -> Result<(), &'static str> {
66        if self.name.is_empty() {
67            return Err("relational access path name must not be empty");
68        }
69        if self.kind != RelationalAccessPathKind::FullScan && self.index_columns.is_empty() {
70            return Err("relational index access path must contain index columns");
71        }
72        if self.equality_prefix_len > self.index_columns.len() {
73            return Err("relational equality prefix exceeds index columns");
74        }
75        if self
76            .equality_prefix_len
77            .saturating_add(self.order_prefix_len)
78            > self.index_columns.len()
79        {
80            return Err("relational order prefix exceeds remaining index columns");
81        }
82        if (self.exclusive_range || self.reverse_order)
83            && (self.kind != RelationalAccessPathKind::Index || self.order_prefix_len == 0)
84        {
85            return Err("relational range metadata requires an ordered index access");
86        }
87        if let Some(range) = &self.range
88            && (self.kind != RelationalAccessPathKind::Index
89                || self.index_columns.get(self.equality_prefix_len) != Some(&range.column)
90                || (!range.empty
91                    && range.lower_inclusive.is_none()
92                    && range.upper_inclusive.is_none()))
93        {
94            return Err("relational range must constrain the next index column");
95        }
96        let expected_access_columns = self.index_columns[..self.equality_prefix_len]
97            .iter()
98            .cloned()
99            .collect::<BTreeSet<_>>();
100        if self.access_columns != expected_access_columns {
101            return Err("relational access columns must equal the constrained leading prefix");
102        }
103        if self.unique_point
104            && (self.kind == RelationalAccessPathKind::FullScan
105                || self.equality_prefix_len != self.index_columns.len())
106        {
107            return Err("relational unique point access must constrain the complete key");
108        }
109        if self.covering && self.requires_row_fetch {
110            return Err("covering relational access must not require a row fetch");
111        }
112        Ok(())
113    }
114
115    fn dominates(&self, other: &Self, context: RelationalAccessCostContext) -> bool {
116        let access_is_superset = self.access_columns.is_superset(&other.access_columns);
117        let no_worse = access_is_superset
118            && self.equality_prefix_len >= other.equality_prefix_len
119            && self.order_prefix_len >= other.order_prefix_len
120            && (self.exclusive_range || !other.exclusive_range)
121            && (self.range.is_some() || other.range.is_none())
122            && (self.unique_point || !other.unique_point)
123            && (self.covering || !other.covering)
124            && (!self.requires_row_fetch || other.requires_row_fetch)
125            && estimate_relational_access_path_cost_with_context(self, context).cost
126                <= estimate_relational_access_path_cost_with_context(other, context).cost
127            && self.estimated_rows <= other.estimated_rows;
128        let strictly_better = self.access_columns != other.access_columns
129            || self.equality_prefix_len > other.equality_prefix_len
130            || self.order_prefix_len > other.order_prefix_len
131            || (self.exclusive_range && !other.exclusive_range)
132            || (self.range.is_some() && other.range.is_none())
133            || (self.unique_point && !other.unique_point)
134            || (self.covering && !other.covering)
135            || (!self.requires_row_fetch && other.requires_row_fetch)
136            || self.estimated_rows < other.estimated_rows;
137        no_worse && strictly_better
138    }
139}
140
141/// Retains the Pareto frontier of relational access paths.
142///
143/// Paths using different, non-superset predicate columns remain incomparable.
144/// This prevents a locally attractive index from pruning the only useful path
145/// for a different conjunction shape before costing.
146pub fn skyline_prune_relational_access_paths(
147    candidates: impl IntoIterator<Item = RelationalAccessPathDescriptor>,
148) -> Result<Vec<RelationalAccessPathDescriptor>, &'static str> {
149    skyline_prune_relational_access_paths_with_context(
150        candidates,
151        RelationalAccessCostContext::default(),
152    )
153}
154
155/// Computes the frontier with the same immutable relation context used for
156/// final selection. The legacy entrypoint retains descriptor-only costs.
157pub fn skyline_prune_relational_access_paths_with_context(
158    candidates: impl IntoIterator<Item = RelationalAccessPathDescriptor>,
159    context: RelationalAccessCostContext,
160) -> Result<Vec<RelationalAccessPathDescriptor>, &'static str> {
161    let mut frontier = Vec::<RelationalAccessPathDescriptor>::new();
162    for mut candidate in candidates {
163        candidate.estimated_rows = candidate.estimated_rows.max(1);
164        candidate.validate()?;
165        if frontier
166            .iter()
167            .any(|existing| existing.dominates(&candidate, context))
168        {
169            continue;
170        }
171        frontier.retain(|existing| !candidate.dominates(existing, context));
172        frontier.push(candidate);
173    }
174    frontier.sort_by(|left, right| left.name.cmp(&right.name));
175    Ok(frontier)
176}
177
178pub fn select_relational_access_path(
179    candidates: impl IntoIterator<Item = RelationalAccessPathDescriptor>,
180) -> Result<Option<RelationalAccessPathDescriptor>, &'static str> {
181    select_relational_access_path_with_context(candidates, RelationalAccessCostContext::default())
182}
183
184/// Selects an access after context-aware skyline pruning. Candidate costs are
185/// comparable only when this context belongs to their common row source.
186pub fn select_relational_access_path_with_context(
187    candidates: impl IntoIterator<Item = RelationalAccessPathDescriptor>,
188    context: RelationalAccessCostContext,
189) -> Result<Option<RelationalAccessPathDescriptor>, &'static str> {
190    let frontier = skyline_prune_relational_access_paths_with_context(candidates, context)?;
191    Ok(frontier.into_iter().min_by(|left, right| {
192        estimate_relational_access_path_cost_with_context(left, context)
193            .cost
194            .cmp(&estimate_relational_access_path_cost_with_context(right, context).cost)
195            .then_with(|| left.estimated_rows.cmp(&right.estimated_rows))
196            .then_with(|| right.unique_point.cmp(&left.unique_point))
197            .then_with(|| right.equality_prefix_len.cmp(&left.equality_prefix_len))
198            .then_with(|| right.order_prefix_len.cmp(&left.order_prefix_len))
199            .then_with(|| right.exclusive_range.cmp(&left.exclusive_range))
200            .then_with(|| right.covering.cmp(&left.covering))
201            .then_with(|| left.requires_row_fetch.cmp(&right.requires_row_fetch))
202            .then_with(|| left.index_columns.len().cmp(&right.index_columns.len()))
203            .then_with(|| left.name.cmp(&right.name))
204    }))
205}
206
207/// Estimates row visits for an index nested-loop join.
208///
209/// The planning boundary is responsible for supplying a conservative
210/// per-outer-row estimate in the inner descriptor. This keeps the join cost
211/// independent of where that estimate came from (fresh statistics or a
212/// table-cardinality fallback).
213pub fn estimate_relational_nested_loop_join_cost(
214    outer: &RelationalAccessPathDescriptor,
215    inner: &RelationalAccessPathDescriptor,
216) -> RelationalNestedLoopJoinCost {
217    let outer_rows = outer.estimated_rows.max(1);
218    let inner_rows_per_outer = inner.estimated_rows.max(1);
219    RelationalNestedLoopJoinCost {
220        outer_rows,
221        inner_rows_per_outer,
222        estimated_work: outer_rows.saturating_add(outer_rows.saturating_mul(inner_rows_per_outer)),
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn path(
231        name: &str,
232        columns: &[&str],
233        equality_prefix_len: usize,
234        unique_point: bool,
235        estimated_rows: usize,
236    ) -> RelationalAccessPathDescriptor {
237        RelationalAccessPathDescriptor {
238            kind: RelationalAccessPathKind::Index,
239            name: name.to_string(),
240            index_columns: columns.iter().map(|column| (*column).to_string()).collect(),
241            access_columns: columns[..equality_prefix_len]
242                .iter()
243                .map(|column| (*column).to_string())
244                .collect(),
245            equality_prefix_len,
246            order_prefix_len: 0,
247            exclusive_range: false,
248            range: None,
249            reverse_order: false,
250            unique_point,
251            covering: false,
252            requires_row_fetch: true,
253            estimated_rows,
254        }
255    }
256
257    #[test]
258    fn composite_prefix_dominates_a_shorter_access_condition() {
259        let frontier = skyline_prune_relational_access_paths([
260            path("idx_a", &["a"], 1, false, 100),
261            path("idx_a_b", &["a", "b"], 2, false, 10),
262        ])
263        .unwrap();
264
265        assert_eq!(frontier.len(), 1);
266        assert_eq!(frontier[0].name, "idx_a_b");
267    }
268
269    #[test]
270    fn different_predicate_columns_remain_incomparable() {
271        let frontier = skyline_prune_relational_access_paths([
272            path("idx_a", &["a"], 1, false, 10),
273            path("idx_b", &["b"], 1, false, 5),
274        ])
275        .unwrap();
276
277        assert_eq!(
278            frontier
279                .iter()
280                .map(|path| path.name.as_str())
281                .collect::<Vec<_>>(),
282            ["idx_a", "idx_b"]
283        );
284    }
285
286    #[test]
287    fn complete_unique_composite_key_dominates_unique_prefix_when_both_are_bound() {
288        let frontier = skyline_prune_relational_access_paths([
289            path("idx_f", &["f"], 1, true, 1),
290            path("idx_f_g", &["f", "g"], 2, true, 1),
291        ])
292        .unwrap();
293
294        assert_eq!(frontier.len(), 1);
295        assert_eq!(frontier[0].name, "idx_f_g");
296    }
297
298    #[test]
299    fn final_selection_is_deterministic_across_incomparable_paths() {
300        let selected = select_relational_access_path([
301            path("idx_a", &["a"], 1, false, 10),
302            path("idx_b", &["b"], 1, false, 5),
303        ])
304        .unwrap()
305        .unwrap();
306
307        assert_eq!(selected.name, "idx_b");
308    }
309
310    #[test]
311    fn ordered_suffix_must_fit_after_the_equality_prefix() {
312        let mut candidate = path("idx_a_b", &["a", "b"], 1, false, 10);
313        candidate.order_prefix_len = 2;
314
315        assert_eq!(
316            candidate.validate(),
317            Err("relational order prefix exceeds remaining index columns")
318        );
319    }
320
321    #[test]
322    fn zero_row_estimates_are_normalized_before_skyline_comparison() {
323        let frontier =
324            skyline_prune_relational_access_paths([path("idx_empty", &["id"], 1, true, 0)])
325                .unwrap();
326
327        assert_eq!(frontier[0].estimated_rows, 1);
328    }
329
330    #[test]
331    fn nested_loop_cost_prefers_a_selective_outer_with_an_indexed_probe() {
332        let full_outer = RelationalAccessPathDescriptor {
333            kind: RelationalAccessPathKind::FullScan,
334            name: "__full_scan".to_string(),
335            index_columns: Vec::new(),
336            access_columns: BTreeSet::new(),
337            equality_prefix_len: 0,
338            order_prefix_len: 0,
339            exclusive_range: false,
340            range: None,
341            reverse_order: false,
342            unique_point: false,
343            covering: false,
344            requires_row_fetch: false,
345            estimated_rows: 100,
346        };
347        let unique_probe = path("documents_pk", &["id"], 1, true, 1);
348        let unique_outer = path("documents_owner", &["owner_kind", "owner_id"], 2, true, 1);
349        let indexed_probe = path(
350            "chunks_document",
351            &["document_id", "ordinal"],
352            1,
353            false,
354            100,
355        );
356
357        let syntax_order = estimate_relational_nested_loop_join_cost(&full_outer, &unique_probe);
358        let reordered = estimate_relational_nested_loop_join_cost(&unique_outer, &indexed_probe);
359
360        assert_eq!(syntax_order.estimated_work, 200);
361        assert_eq!(reordered.estimated_work, 101);
362    }
363
364    #[test]
365    fn nested_loop_cost_saturates_for_untrusted_cardinality_inputs() {
366        let outer = path("outer", &["id"], 1, false, usize::MAX);
367        let inner = path("inner", &["id"], 1, false, usize::MAX);
368
369        assert_eq!(
370            estimate_relational_nested_loop_join_cost(&outer, &inner).estimated_work,
371            usize::MAX
372        );
373    }
374}