lance_select/result.rs
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Interval-shaped wrappers around a row-address mask returned by a
5//! scalar-index expression evaluation.
6//!
7//! Each result describes a closed interval `[lower, upper]` in the
8//! lattice of subsets:
9//!
10//! * `lower` — rows the index *guarantees* are in the answer.
11//! * `upper` — rows that *might* be in the answer; rows outside `upper`
12//! are guaranteed not in the answer.
13//!
14//! The three pre-existing "shapes" map onto degenerate intervals:
15//!
16//! | Old variant | Interval form |
17//! |-------------|----------------------------------------|
18//! | `Exact(m)` | `{lower: m, upper: m}` |
19//! | `AtMost(m)` | `{lower: allow_nothing(), upper: m}` |
20//! | `AtLeast(m)`| `{lower: m, upper: all_rows()}` |
21//!
22//! Use [`IndexExprResult::exact`] / [`IndexExprResult::at_most`] /
23//! [`IndexExprResult::at_least`] to construct those shapes, and the
24//! matching [`IndexExprResult::is_exact`] etc. predicates to inspect
25//! them. Intervals that are neither (the "Refined" case — a non-empty
26//! `lower` strictly inside a non-universe `upper`) arise from indices
27//! that can distinguish guaranteed-match from candidate-match rows
28//! within a single search (e.g. a zone map answering `IS NOT NULL`).
29//!
30//! The boolean algebra (`Not` / `BitAnd` / `BitOr`) is elementwise on
31//! the endpoints:
32//!
33//! ```text
34//! !{l, u} = {!u, !l}
35//! {l1, u1} & {l2, u2} = {l1 & l2, u1 & u2}
36//! {l1, u1} | {l2, u2} = {l1 | l2, u1 | u2}
37//! ```
38//!
39//! This works for both the post-`drop_nulls` form ([`IndexExprResult`],
40//! backed by [`RowAddrMask`]) and the during-evaluation form
41//! ([`NullableIndexExprResult`], backed by [`NullableRowAddrMask`]) —
42//! the per-endpoint algebra already implements two-valued and SQL
43//! three-valued logic correctly inside each mask type.
44
45use std::sync::{Arc, LazyLock};
46
47use arrow_array::{Array, RecordBatch, UInt32Array, builder::BinaryBuilder};
48use arrow_schema::{DataType, Field, Schema, SchemaRef};
49use roaring::RoaringBitmap;
50
51use lance_core::{Error, Result};
52
53use crate::mask::{NullableRowAddrMask, RowAddrMask, RowSetOps};
54
55/// Result of an index search before NULL rows are dropped. Each endpoint
56/// is a [`NullableRowAddrMask`] carrying SQL three-valued logic info.
57#[derive(Debug, Clone)]
58pub struct NullableIndexExprResult {
59 /// Rows the index *guarantees* are TRUE.
60 pub lower: NullableRowAddrMask,
61 /// Rows that may be TRUE. Rows outside `upper` are guaranteed to be
62 /// FALSE / NULL (and so not in a `WHERE` answer set).
63 pub upper: NullableRowAddrMask,
64 // O(1) cache for is_exact(). Set by constructors and propagated
65 // elementwise through the boolean algebra.
66 exact: bool,
67}
68
69impl NullableIndexExprResult {
70 /// Precise result — every row in `mask` is in the answer and every
71 /// row outside is not. Equivalent to the old `Exact` variant.
72 pub fn exact(mask: NullableRowAddrMask) -> Self {
73 Self {
74 lower: mask.clone(),
75 upper: mask,
76 exact: true,
77 }
78 }
79
80 /// Upper-bound-only result — rows outside `mask` are guaranteed not
81 /// to match; rows inside may match and require a recheck.
82 /// Equivalent to the old `AtMost` variant.
83 pub fn at_most(mask: NullableRowAddrMask) -> Self {
84 Self {
85 lower: NullableRowAddrMask::allow_nothing(),
86 upper: mask,
87 exact: false,
88 }
89 }
90
91 /// Lower-bound-only result — rows in `mask` are guaranteed to match;
92 /// rows outside may match too and require a recheck. Equivalent to
93 /// the old `AtLeast` variant.
94 pub fn at_least(mask: NullableRowAddrMask) -> Self {
95 Self {
96 lower: mask,
97 upper: NullableRowAddrMask::all_rows(),
98 exact: false,
99 }
100 }
101
102 /// Construct an interval result from lower/upper bounds.
103 ///
104 /// `lower` rows are guaranteed TRUE and `upper` rows may be TRUE, with
105 /// NULL state preserved at both endpoints. Equal endpoints are canonicalized
106 /// to [`Self::exact`] so `is_exact()` remains structural.
107 pub fn new(lower: NullableRowAddrMask, upper: NullableRowAddrMask) -> Self {
108 if lower == upper {
109 Self::exact(lower)
110 } else {
111 Self {
112 lower,
113 upper,
114 exact: false,
115 }
116 }
117 }
118
119 /// True if the result is exact — the answer is precisely the lower
120 /// (== upper) mask.
121 ///
122 /// This is a **structural** check on the canonical form produced by
123 /// the constructors / algebra: an `Exact(m)` built with
124 /// [`Self::exact`] holds equal masks, and elementwise `&` / `|` / `!`
125 /// preserve that. It is not a semantic emptiness test — a
126 /// hand-constructed `IndexExprResult` whose endpoints are
127 /// representationally distinct but semantically equal (e.g.
128 /// `AllowList(universe)` vs `BlockList(empty)`) will report
129 /// `is_exact() == false`. All in-tree code paths construct results
130 /// through the canonical builders, so this is sound in practice.
131 ///
132 /// The three shape predicates are not mutually exclusive — see the
133 /// note on [`Self::is_at_least`] for the precedence convention.
134 pub fn is_exact(&self) -> bool {
135 self.exact
136 }
137
138 /// True if `lower` matches no rows (canonical `AllowList(∅)`) — the
139 /// index gives only an upper bound on the answer.
140 ///
141 /// Like [`Self::is_exact`], this is a structural check on the
142 /// canonical form. See that doc for the caveat.
143 pub fn is_at_most(&self) -> bool {
144 matches!(&self.lower, NullableRowAddrMask::AllowList(set) if set.is_empty())
145 }
146
147 /// True if `upper` covers every row (canonical `BlockList(∅)`) — the
148 /// index gives only a lower bound on the answer.
149 ///
150 /// **Precedence convention** for consumers branching on shape: check
151 /// [`Self::is_exact`] *first* (Exact-of-empty satisfies both
152 /// `is_exact` and `is_at_most`; Exact-of-universe satisfies both
153 /// `is_exact` and `is_at_least`); then `is_at_least`; finally treat
154 /// the residual as `is_at_most` or Refined. The branches in
155 /// `filtered_read::apply_index_to_fragment` follow this order.
156 pub fn is_at_least(&self) -> bool {
157 matches!(&self.upper, NullableRowAddrMask::BlockList(set) if set.is_empty())
158 }
159
160 /// Project NULL rows out of the result.
161 ///
162 /// Under a `WHERE` clause NULL is treated as FALSE, so `drop_nulls`
163 /// folds NULL rows out of the answer at each endpoint.
164 pub fn drop_nulls(self) -> IndexExprResult {
165 IndexExprResult {
166 lower: self.lower.drop_nulls(),
167 upper: self.upper.drop_nulls(),
168 exact: self.exact,
169 }
170 }
171}
172
173impl std::ops::Not for NullableIndexExprResult {
174 type Output = Self;
175
176 fn not(self) -> Self {
177 Self {
178 lower: !self.upper,
179 upper: !self.lower,
180 exact: self.exact,
181 }
182 }
183}
184
185impl std::ops::BitAnd<Self> for NullableIndexExprResult {
186 type Output = Self;
187
188 fn bitand(self, rhs: Self) -> Self {
189 Self {
190 lower: self.lower & rhs.lower,
191 upper: self.upper & rhs.upper,
192 exact: self.exact && rhs.exact,
193 }
194 }
195}
196
197impl std::ops::BitOr<Self> for NullableIndexExprResult {
198 type Output = Self;
199
200 fn bitor(self, rhs: Self) -> Self {
201 Self {
202 lower: self.lower | rhs.lower,
203 upper: self.upper | rhs.upper,
204 exact: self.exact && rhs.exact,
205 }
206 }
207}
208
209/// Result of an index search after NULL rows have been dropped. This is
210/// what the read planner consumes.
211#[derive(Debug, Clone)]
212pub struct IndexExprResult {
213 /// Rows the index *guarantees* are in the answer.
214 pub lower: RowAddrMask,
215 /// Rows that may be in the answer. Rows outside `upper` are
216 /// guaranteed not in the answer.
217 pub upper: RowAddrMask,
218 // O(1) cache for is_exact(). Set by constructors and propagated
219 // elementwise through the boolean algebra.
220 exact: bool,
221}
222
223impl IndexExprResult {
224 /// Precise result — every row in `mask` is in the answer and every
225 /// row outside is not. Equivalent to the old `Exact` variant.
226 pub fn exact(mask: RowAddrMask) -> Self {
227 Self {
228 lower: mask.clone(),
229 upper: mask,
230 exact: true,
231 }
232 }
233
234 /// Upper-bound-only result. Equivalent to the old `AtMost` variant.
235 pub fn at_most(mask: RowAddrMask) -> Self {
236 Self {
237 lower: RowAddrMask::allow_nothing(),
238 upper: mask,
239 exact: false,
240 }
241 }
242
243 /// Lower-bound-only result. Equivalent to the old `AtLeast` variant.
244 pub fn at_least(mask: RowAddrMask) -> Self {
245 Self {
246 lower: mask,
247 upper: RowAddrMask::all_rows(),
248 exact: false,
249 }
250 }
251
252 /// Construct a refined interval result — `lower` rows are guaranteed
253 /// matches and `upper` rows are candidates, with `lower ⊆ upper`.
254 ///
255 /// Use [`Self::exact`] / [`Self::at_most`] / [`Self::at_least`] for
256 /// the three degenerate shapes; this constructor is only needed when
257 /// both endpoints are non-trivial.
258 pub fn new(lower: RowAddrMask, upper: RowAddrMask) -> Self {
259 Self {
260 lower,
261 upper,
262 exact: false,
263 }
264 }
265
266 /// True if the result is exact — the answer is precisely the lower
267 /// (== upper) mask. See [`NullableIndexExprResult::is_exact`] for the
268 /// structural-form caveat and the precedence convention shared with
269 /// [`Self::is_at_most`] / [`Self::is_at_least`].
270 pub fn is_exact(&self) -> bool {
271 self.exact
272 }
273
274 /// True if `lower` matches no rows (canonical `AllowList(∅)`) — the
275 /// index gives only an upper bound on the answer. See
276 /// [`NullableIndexExprResult::is_exact`] for caveats.
277 pub fn is_at_most(&self) -> bool {
278 matches!(&self.lower, RowAddrMask::AllowList(set) if set.is_empty())
279 }
280
281 /// True if `upper` covers every row (canonical `BlockList(∅)`) — the
282 /// index gives only a lower bound on the answer. See
283 /// [`NullableIndexExprResult::is_at_least`] for the precedence
284 /// convention consumers should follow.
285 pub fn is_at_least(&self) -> bool {
286 matches!(&self.upper, RowAddrMask::BlockList(set) if set.is_empty())
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::{NullableRowAddrSet, RowAddrTreeMap};
294
295 fn allow(rows: &[u64]) -> NullableRowAddrMask {
296 NullableRowAddrMask::AllowList(NullableRowAddrSet::new(
297 RowAddrTreeMap::from_iter(rows.iter().copied()),
298 RowAddrTreeMap::new(),
299 ))
300 }
301
302 #[test]
303 fn nullable_index_expr_result_new_canonicalizes_exact() {
304 let lower = allow(&[1, 2]);
305 let result = NullableIndexExprResult::new(lower.clone(), lower.clone());
306
307 assert!(result.is_exact());
308 assert_eq!(result.lower, lower);
309 assert_eq!(result.upper, lower);
310 }
311
312 #[test]
313 fn nullable_index_expr_result_new_preserves_interval() {
314 let lower = allow(&[1, 2]);
315 let upper = allow(&[1, 2, 3]);
316 let result = NullableIndexExprResult::new(lower.clone(), upper.clone());
317
318 assert!(!result.is_exact());
319 assert_eq!(result.lower, lower);
320 assert_eq!(result.upper, upper);
321 }
322}
323
324impl std::ops::Not for IndexExprResult {
325 type Output = Self;
326
327 fn not(self) -> Self {
328 Self {
329 lower: !self.upper,
330 upper: !self.lower,
331 exact: self.exact,
332 }
333 }
334}
335
336impl std::ops::BitAnd<Self> for IndexExprResult {
337 type Output = Self;
338
339 fn bitand(self, rhs: Self) -> Self {
340 Self {
341 lower: self.lower & rhs.lower,
342 upper: self.upper & rhs.upper,
343 exact: self.exact && rhs.exact,
344 }
345 }
346}
347
348impl std::ops::BitOr<Self> for IndexExprResult {
349 type Output = Self;
350
351 fn bitor(self, rhs: Self) -> Self {
352 Self {
353 lower: self.lower | rhs.lower,
354 upper: self.upper | rhs.upper,
355 exact: self.exact && rhs.exact,
356 }
357 }
358}
359
360static TWO_MASK_RESULT_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
361 Arc::new(Schema::new(vec![
362 Field::new("lower", DataType::Binary, true),
363 Field::new("upper", DataType::Binary, true),
364 Field::new("fragments_covered", DataType::Binary, true),
365 ]))
366});
367
368static THREE_VARIANT_RESULT_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
369 Arc::new(Schema::new(vec![
370 Field::new("result".to_string(), DataType::Binary, true),
371 Field::new("discriminant".to_string(), DataType::UInt32, true),
372 Field::new("fragments_covered".to_string(), DataType::Binary, true),
373 ]))
374});
375
376#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
377pub enum IndexExprResultWireFormat {
378 ThreeVariant, // A legacy format that used AtMost/AtLeast/Exact variants
379 #[default]
380 TwoMask, // The two-mask format with upper and lower
381}
382
383impl IndexExprResultWireFormat {
384 pub fn schema(&self) -> &SchemaRef {
385 match self {
386 Self::ThreeVariant => &THREE_VARIANT_RESULT_SCHEMA,
387 Self::TwoMask => &TWO_MASK_RESULT_SCHEMA,
388 }
389 }
390}
391
392impl IndexExprResult {
393 /// Serialize into the `INDEX_EXPR_RESULT_SCHEMA` record-batch layout used to
394 /// hand scalar-index results to the read planner.
395 #[tracing::instrument(skip_all)]
396 fn serialize_standard(&self, fragments_covered: &RoaringBitmap) -> Result<RecordBatch> {
397 let lower_arr = self.lower.into_arrow()?;
398 let upper_arr = if self.is_exact() {
399 let mut b = BinaryBuilder::new();
400 b.append_null();
401 b.append_null();
402 b.finish()
403 } else {
404 self.upper.into_arrow()?
405 };
406 let mut frags_builder = BinaryBuilder::new();
407 let mut frags_bytes = Vec::with_capacity(fragments_covered.serialized_size());
408 fragments_covered.serialize_into(&mut frags_bytes)?;
409 frags_builder.append_value(frags_bytes);
410 frags_builder.append_null();
411 Ok(RecordBatch::try_new(
412 TWO_MASK_RESULT_SCHEMA.clone(),
413 vec![
414 Arc::new(lower_arr),
415 Arc::new(upper_arr),
416 Arc::new(frags_builder.finish()) as Arc<dyn Array>,
417 ],
418 )?)
419 }
420
421 /// Serialize into the legacy three-variant record-batch layout.
422 ///
423 /// Refined intervals (a non-empty `lower` strictly inside a non-universe `upper`)
424 /// cannot be represented in the legacy encoding and are degraded to `AtMost(upper)`.
425 fn serialize_three_variant(&self, fragments_covered: &RoaringBitmap) -> Result<RecordBatch> {
426 let (mask, discriminant) = if self.is_exact() {
427 (&self.lower, 0u32)
428 } else if self.is_at_most() {
429 (&self.upper, 1)
430 } else if self.is_at_least() {
431 (&self.lower, 2)
432 } else {
433 tracing::warn!(
434 "Legacy serialization of refined index-expr result: degrading to AtMost(upper); \
435 answer will remain correct but query will be more expensive"
436 );
437 (&self.upper, 1)
438 };
439 let mask_arr = mask.into_arrow()?;
440 let discriminant_arr =
441 Arc::new(UInt32Array::from(vec![discriminant, discriminant])) as Arc<dyn Array>;
442 let mut frags_builder = BinaryBuilder::new();
443 let mut frags_bytes = Vec::with_capacity(fragments_covered.serialized_size());
444 fragments_covered.serialize_into(&mut frags_bytes)?;
445 frags_builder.append_value(frags_bytes);
446 frags_builder.append_null();
447 Ok(RecordBatch::try_new(
448 THREE_VARIANT_RESULT_SCHEMA.clone(),
449 vec![
450 Arc::new(mask_arr),
451 discriminant_arr,
452 Arc::new(frags_builder.finish()) as Arc<dyn Array>,
453 ],
454 )?)
455 }
456
457 pub fn serialize(
458 &self,
459 fragments_covered: &RoaringBitmap,
460 format: IndexExprResultWireFormat,
461 ) -> Result<RecordBatch> {
462 match format {
463 IndexExprResultWireFormat::ThreeVariant => {
464 self.serialize_three_variant(fragments_covered)
465 }
466 IndexExprResultWireFormat::TwoMask => self.serialize_standard(fragments_covered),
467 }
468 }
469
470 /// Deserialize from a record batch produced by [`Self::serialize`].
471 pub fn deserialize(batch: &RecordBatch) -> Result<(Self, RoaringBitmap)> {
472 use arrow_array::cast::AsArray;
473
474 if batch.num_rows() != 2 {
475 return Err(Error::invalid_input_source(
476 format!(
477 "Expected a batch with exactly 2 rows but there are {} rows",
478 batch.num_rows()
479 )
480 .into(),
481 ));
482 }
483 if batch.num_columns() != 3 {
484 return Err(Error::invalid_input_source(
485 format!(
486 "Expected a batch with exactly three columns but there are {} columns",
487 batch.num_columns()
488 )
489 .into(),
490 ));
491 }
492
493 let first_col_name = batch.schema().field(0).name().clone();
494 let index_result = if first_col_name == "lower" {
495 let lower = RowAddrMask::from_arrow(batch.column(0).as_binary())?;
496 let upper_col = batch.column(1).as_binary::<i32>();
497 if upper_col.is_null(0) && upper_col.is_null(1) {
498 // Null upper column is the serialized form of an exact result.
499 Self::exact(lower)
500 } else {
501 let upper = RowAddrMask::from_arrow(upper_col)?;
502 Self {
503 lower,
504 upper,
505 exact: false,
506 }
507 }
508 } else if first_col_name == "result" {
509 let row_addr_mask = RowAddrMask::from_arrow(batch.column(0).as_binary())?;
510 let match_type = batch
511 .column(1)
512 .as_primitive::<arrow_array::types::UInt32Type>()
513 .values()[0];
514 if match_type == 0 {
515 Self::exact(row_addr_mask)
516 } else if match_type == 1 {
517 Self::at_most(row_addr_mask)
518 } else if match_type == 2 {
519 Self::at_least(row_addr_mask)
520 } else {
521 return Err(Error::internal(format!(
522 "Unexpected match type: {match_type}"
523 )));
524 }
525 } else {
526 return Err(Error::internal(format!(
527 "Unexpected column name: {first_col_name}"
528 )));
529 };
530
531 let frags_col = batch.column(2).as_binary::<i32>();
532 let fragments = RoaringBitmap::deserialize_from(frags_col.value(0))?;
533
534 Ok((index_result, fragments))
535 }
536}