1use 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#[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
119pub 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
133pub 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
162pub 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
185pub 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}