Skip to main content

linkrs_storage/cursor/
predicates.rs

1use crate::cursor::column_batch::ColumnValues;
2use std::sync::Arc;
3
4/// Predicate understood by native index cursors.
5#[derive(Debug, Clone, PartialEq)]
6pub enum IndexPredicate {
7    Equal(linkrs_core::Value),
8    Range {
9        lower: Option<linkrs_core::Value>,
10        upper: Option<linkrs_core::Value>,
11        include_lower: bool,
12        include_upper: bool,
13    },
14    Prefix(linkrs_core::Value),
15    All,
16}
17
18/// A single-column comparison predicate pushed from the query layer into a
19/// physical scan.
20///
21/// This is the whitelist of filter conjuncts the planner can push into the
22/// storage layer.  A list of predicates forms a conjunction (every predicate
23/// must match).  Rows with a missing property never match, mirroring the
24/// query engine's NULL semantics where comparisons against NULL are false.
25/// The original filter expression still runs on top of the scan, so the
26/// pushdown is a pure pre-filter and can never change results.
27#[derive(Debug, Clone, PartialEq)]
28pub enum ScanPredicate {
29    /// `column = value`
30    ColumnEqual {
31        column: Arc<str>,
32        value: linkrs_core::Value,
33    },
34    /// `column` bounded by constants (either bound may be absent).
35    ColumnRange {
36        column: Arc<str>,
37        lower: Option<linkrs_core::Value>,
38        upper: Option<linkrs_core::Value>,
39        include_lower: bool,
40        include_upper: bool,
41    },
42}
43
44impl ScanPredicate {
45    /// Whether the predicate matches the given property set.
46    ///
47    /// Properties are a `(name, value)` slice in projection order.  A
48    /// missing column (or any non-scalar comparison) never matches.
49    pub fn matches(&self, props: &[(std::sync::Arc<str>, linkrs_core::Value)]) -> bool {
50        let Some(value) = props
51            .iter()
52            .find(|(name, _)| name.as_ref() == self.column())
53            .map(|(_, v)| v)
54        else {
55            return false;
56        };
57        match self {
58            ScanPredicate::ColumnEqual {
59                value: expected, ..
60            } => compare_scalar(value, expected) == std::cmp::Ordering::Equal,
61            ScanPredicate::ColumnRange {
62                lower,
63                upper,
64                include_lower,
65                include_upper,
66                ..
67            } => {
68                if let Some(lower) = lower {
69                    let ord = compare_scalar(value, lower);
70                    let passes = if *include_lower {
71                        ord != std::cmp::Ordering::Less
72                    } else {
73                        ord == std::cmp::Ordering::Greater
74                    };
75                    if !passes {
76                        return false;
77                    }
78                }
79                if let Some(upper) = upper {
80                    let ord = compare_scalar(value, upper);
81                    let passes = if *include_upper {
82                        ord != std::cmp::Ordering::Greater
83                    } else {
84                        ord == std::cmp::Ordering::Less
85                    };
86                    if !passes {
87                        return false;
88                    }
89                }
90                true
91            }
92        }
93    }
94
95    /// The property column this predicate compares.
96    pub fn column(&self) -> &str {
97        match self {
98            ScanPredicate::ColumnEqual { column, .. } => column,
99            ScanPredicate::ColumnRange { column, .. } => column,
100        }
101    }
102
103    pub fn column_arc(&self) -> Arc<str> {
104        match self {
105            ScanPredicate::ColumnEqual { column, .. } => column.clone(),
106            ScanPredicate::ColumnRange { column, .. } => column.clone(),
107        }
108    }
109
110    /// Merge pushed predicates into one value range per referenced column.
111    ///
112    /// The result is the conjunction of all predicates on that column:
113    /// bounds only tighten. Columns compared with mixed orderings collapse
114    /// into a single `[lower, upper]` interval; an equality contributes an
115    /// empty interval.
116    pub fn merged_ranges(predicates: &[ScanPredicate]) -> Vec<PredicateRange> {
117        let mut ranges: Vec<PredicateRange> = Vec::new();
118        for predicate in predicates {
119            let (lower, include_lower, upper, include_upper) = match predicate {
120                ScanPredicate::ColumnEqual { value, .. } => {
121                    (Some(value.clone()), true, Some(value.clone()), true)
122                }
123                ScanPredicate::ColumnRange {
124                    lower,
125                    include_lower,
126                    upper,
127                    include_upper,
128                    ..
129                } => (lower.clone(), *include_lower, upper.clone(), *include_upper),
130            };
131            let column = predicate.column_arc();
132            match ranges.iter_mut().find(|r| r.column == column) {
133                Some(range) => range.intersect(lower, include_lower, upper, include_upper),
134                None => ranges.push(PredicateRange {
135                    column,
136                    lower,
137                    include_lower,
138                    upper,
139                    include_upper,
140                }),
141            }
142        }
143        ranges
144    }
145}
146
147/// A merged value range over one column, derived from pushed scan
148/// predicates. Used for zone-map pruning: a storage chunk whose min/max
149/// bounds cannot overlap this range cannot contain matching rows.
150#[derive(Debug, Clone)]
151pub struct PredicateRange {
152    pub column: Arc<str>,
153    pub lower: Option<linkrs_core::Value>,
154    pub include_lower: bool,
155    pub upper: Option<linkrs_core::Value>,
156    pub include_upper: bool,
157}
158
159impl PredicateRange {
160    /// Tighten this range with another conjunctive bound set.
161    fn intersect(
162        &mut self,
163        lower: Option<linkrs_core::Value>,
164        include_lower: bool,
165        upper: Option<linkrs_core::Value>,
166        include_upper: bool,
167    ) {
168        if let (_, Some(new_lower)) = (&self.lower, &lower) {
169            let replace = match &self.lower {
170                Some(old) => match compare_scalar(old, new_lower) {
171                    std::cmp::Ordering::Equal => self.include_lower && !include_lower,
172                    std::cmp::Ordering::Less => true,
173                    std::cmp::Ordering::Greater => false,
174                },
175                None => true,
176            };
177            if replace {
178                self.lower = Some(new_lower.clone());
179                self.include_lower = include_lower;
180            }
181        }
182        if let (_, Some(new_upper)) = (&self.upper, &upper) {
183            let replace = match &self.upper {
184                Some(old) => match compare_scalar(old, new_upper) {
185                    std::cmp::Ordering::Equal => self.include_upper && !include_upper,
186                    std::cmp::Ordering::Greater => true,
187                    std::cmp::Ordering::Less => false,
188                },
189                None => true,
190            };
191            if replace {
192                self.upper = Some(new_upper.clone());
193                self.include_upper = include_upper;
194            }
195        }
196    }
197
198    /// Whether a chunk holding values in `[min, max]` may contain rows
199    /// matching this range. Conservative: returns `true` unless the chunk
200    /// provably lies entirely outside.
201    pub fn overlaps(&self, min: &linkrs_core::Value, max: &linkrs_core::Value) -> bool {
202        if let Some(ref lower) = self.lower {
203            // Chunk max must reach the query lower bound.
204            let ord = compare_scalar(max, lower);
205            let reaches = if self.include_lower {
206                ord != std::cmp::Ordering::Less
207            } else {
208                ord == std::cmp::Ordering::Greater
209            };
210            if !reaches {
211                return false;
212            }
213        }
214        if let Some(ref upper) = self.upper {
215            // Chunk min must not exceed the query upper bound.
216            let ord = compare_scalar(min, upper);
217            let under = if self.include_upper {
218                ord != std::cmp::Ordering::Greater
219            } else {
220                ord == std::cmp::Ordering::Less
221            };
222            if !under {
223                return false;
224            }
225        }
226        true
227    }
228
229    /// Length of the equality probe when this range is a closed point
230    /// (`lower == upper`, both inclusive). Used for length pre-pruning of
231    /// complex values: a chunk whose length interval excludes the probe
232    /// length cannot contain a match. Returns `None` for open ranges and
233    /// for values without a prunable length.
234    pub fn equality_len(&self) -> Option<usize> {
235        if !self.include_lower || !self.include_upper {
236            return None;
237        }
238        let lower = self.lower.as_ref()?;
239        let upper = self.upper.as_ref()?;
240        if compare_scalar(lower, upper) != std::cmp::Ordering::Equal {
241            return None;
242        }
243        crate::vertex::column_store::complex_len(lower)
244    }
245
246    /// Scalar-leaf interval of the equality probe, if closed point over a
247    /// leaf-carrying complex value. A chunk whose leaf interval is disjoint
248    /// cannot contain a match even when outer lengths coincide. Uses the
249    /// same flattening as chunk summaries, so exactly equal pairs share
250    /// the interval.
251    pub fn equality_leaf_range(&self) -> Option<(linkrs_core::Value, linkrs_core::Value)> {
252        if !self.include_lower || !self.include_upper {
253            return None;
254        }
255        let lower = self.lower.as_ref()?;
256        let upper = self.upper.as_ref()?;
257        if compare_scalar(lower, upper) != std::cmp::Ordering::Equal {
258            return None;
259        }
260        crate::vertex::column_store::complex_leaf_range(lower)
261    }
262
263    /// Key fingerprint of the equality probe, if it carries keyed content.
264    /// Returns `None` for keyless probes so the bloom gate stays disabled.
265    pub fn equality_key_fp(&self) -> Option<u64> {
266        if !self.include_lower || !self.include_upper {
267            return None;
268        }
269        let lower = self.lower.as_ref()?;
270        let upper = self.upper.as_ref()?;
271        if compare_scalar(lower, upper) != std::cmp::Ordering::Equal {
272            return None;
273        }
274        let fp = crate::vertex::column_store::complex_key_fp(lower);
275        (fp != 0).then_some(fp)
276    }
277}
278
279impl ScanPredicate {
280    /// Columnar variant of [`matches`](Self::matches): evaluate against one
281    /// [`ColumnValues`] at row `idx`.  A null value never matches, mirroring
282    /// the row-based NULL semantics.
283    pub fn matches_column(&self, column: &ColumnValues, idx: usize) -> bool {
284        let Some(value) = column.value_at(idx) else {
285            return false;
286        };
287        self.matches_scalar(&value)
288    }
289
290    /// Evaluate the predicate against a single decoded value.
291    ///
292    /// Column-scan pushdown entry point: the caller already selected the
293    /// predicate column, so no property-set lookup happens here. A null
294    /// value never reaches this method; callers return false for nulls to
295    /// keep bitmap null semantics.
296    pub fn matches_value(&self, value: &linkrs_core::Value) -> bool {
297        self.matches_scalar(value)
298    }
299
300    /// Evaluate the predicate against a single decoded value.
301    fn matches_scalar(&self, value: &linkrs_core::Value) -> bool {
302        match self {
303            ScanPredicate::ColumnEqual {
304                value: expected, ..
305            } => compare_scalar(value, expected) == std::cmp::Ordering::Equal,
306            ScanPredicate::ColumnRange {
307                lower,
308                upper,
309                include_lower,
310                include_upper,
311                ..
312            } => {
313                if let Some(lower) = lower {
314                    let ord = compare_scalar(value, lower);
315                    let passes = if *include_lower {
316                        ord != std::cmp::Ordering::Less
317                    } else {
318                        ord == std::cmp::Ordering::Greater
319                    };
320                    if !passes {
321                        return false;
322                    }
323                }
324                if let Some(upper) = upper {
325                    let ord = compare_scalar(value, upper);
326                    let passes = if *include_upper {
327                        ord != std::cmp::Ordering::Greater
328                    } else {
329                        ord == std::cmp::Ordering::Less
330                    };
331                    if !passes {
332                        return false;
333                    }
334                }
335                true
336            }
337        }
338    }
339}
340
341/// Compare two scalar values for a pushed predicate.
342///
343/// Integer kinds are compared exactly as `i64`; any numeric pair involving a
344/// float is compared as `f64` (mirroring the query engine's typed batch
345/// evaluation); everything else falls back to `Value` ordering.
346///
347/// Shared by segment-statistics widening so bounds merging uses exactly the
348/// same ordering as pruning decisions.
349pub fn compare_stat_values(a: &linkrs_core::Value, b: &linkrs_core::Value) -> std::cmp::Ordering {
350    compare_scalar(a, b)
351}
352
353fn compare_scalar(a: &linkrs_core::Value, b: &linkrs_core::Value) -> std::cmp::Ordering {
354    match (as_i64(a), as_i64(b)) {
355        (Some(x), Some(y)) => x.cmp(&y),
356        _ => match (as_f64(a), as_f64(b)) {
357            (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
358            _ => linkrs_core::Value::cmp(a, b),
359        },
360    }
361}
362
363fn as_i64(value: &linkrs_core::Value) -> Option<i64> {
364    match value {
365        linkrs_core::Value::SmallInt(v) => Some(*v as i64),
366        linkrs_core::Value::Int(v) => Some(*v as i64),
367        linkrs_core::Value::BigInt(v) => Some(*v),
368        _ => None,
369    }
370}
371
372fn as_f64(value: &linkrs_core::Value) -> Option<f64> {
373    match value {
374        linkrs_core::Value::Float(v) => Some(*v as f64),
375        linkrs_core::Value::Double(v) => Some(*v),
376        _ => None,
377    }
378}