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)]
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
141pub 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
155pub 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
184pub 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
207pub 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}