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