Skip to main content

drive/query/drive_document_sum_query/
index_picker.rs

1//! Sum-index pickers. Parallels count's `index_picker.rs`.
2//!
3//! Two pickers:
4//! - [`find_summable_index_for_where_clauses`]: returns the index whose summed
5//!   value equals the request's `sum_property` and whose properties the
6//!   Equal/In where-clause fields *exactly* match, or, on a
7//!   `summableOffCountIndex` index, cover as a prefix it can read whole: a
8//!   prefix reaching its sum chain (the deepest pin's value tree) or every
9//!   property but the last (the last property's tree). None on miss.
10//! - [`find_range_summable_index_for_where_clauses`]: returns the
11//!   `rangeSummable: true` index whose Equal/In prefix covers the
12//!   non-range clauses AND whose last property is the range
13//!   terminator. None on miss.
14//!
15//! Reject-on-miss is the load-bearing contract: callers landing in
16//! "no covering index" return `WhereClauseOnNonIndexedProperty` so the
17//! prover and verifier reject the same set of inputs (same as count).
18
19use crate::query::drive_document_sum_query::{is_indexable_for_sum, is_range_operator};
20use crate::query::ResolvedTimeRange;
21use crate::query::{
22    index_admissible_for_query, pins_reach_chain, SkipIfAbsentBinding, WhereClause, WhereOperator,
23};
24use dpp::data_contract::document_type::Index;
25use std::collections::{BTreeMap, BTreeSet};
26
27/// Find an index whose summed value equals the request's `sum_property` and
28/// whose properties the Equal/In where-clause fields exactly cover, or, on a
29/// `summableOffCountIndex` index, cover as a prefix it reads whole (see
30/// `find_summable_index_accepted_by`).
31///
32/// Mirror of count's `find_countable_index_for_where_clauses` with the
33/// additional summed-value predicate on top of the coverage match.
34///
35/// `resolved_time_ranges` names the fields whose equality clause was
36/// produced by `IN_TIME_RANGE` resolution (see
37/// [`crate::query::resolve_time_range_bucket_clause`]) and gates which indexes
38/// are candidates — see [`index_admissible_for_resolved_time_range`](crate::query::index_admissible_for_resolved_time_range).
39pub fn find_summable_index_for_where_clauses<'b>(
40    indexes: &'b BTreeMap<String, Index>,
41    where_clauses: &[WhereClause],
42    sum_property: &str,
43    resolved_time_ranges: &[ResolvedTimeRange],
44) -> Option<&'b Index> {
45    find_summable_index_accepted_by(
46        indexes,
47        where_clauses,
48        sum_property,
49        resolved_time_ranges,
50        |_| true,
51    )
52}
53
54/// [`find_summable_index_for_where_clauses`] for an average or count-and-sum
55/// read: the picked index's read element must also carry a count
56/// ([`summable_point_lookup_carries_counts`]). A `summableOffCountIndex` index
57/// that cannot answer is passed over, so it never hides, by name order, one
58/// that can. A regular index is picked first and judged after, as before
59/// protocol version 14, so a query over regular indexes keeps resolving to
60/// the index released verifiers rebuild.
61pub fn find_summable_index_with_counts_for_where_clauses<'b>(
62    indexes: &'b BTreeMap<String, Index>,
63    where_clauses: &[WhereClause],
64    sum_property: &str,
65    resolved_time_ranges: &[ResolvedTimeRange],
66) -> Option<&'b Index> {
67    find_summable_index_accepted_by(
68        indexes,
69        where_clauses,
70        sum_property,
71        resolved_time_ranges,
72        |index| {
73            !index.is_summable_off_count_index()
74                || summable_point_lookup_carries_counts(index, where_clauses)
75        },
76    )
77    // A counter index was judged in the loop already (`accepts`).
78    .filter(|index| {
79        index.is_summable_off_count_index()
80            || summable_point_lookup_carries_counts(index, where_clauses)
81    })
82}
83
84/// The first index, in name order, that a point sum over `where_clauses`
85/// reads and that `accepts`: exactly covering, else through its last
86/// property's tree ([`prefix_to_last_sum_reads`]), else through a sum chain
87/// (the deepest pin's value tree), in the order count's picker and the sum
88/// builder try them. The order is the same but not the test: an average needs
89/// the last tree to carry counts, so `prefix_to_last_sum_reads` refuses an
90/// index whose last tree does not count (not `rangeCountable`) when its pins
91/// reach a sum chain, which count's picker accepts. A `count(*)` and a sum
92/// with the same pins may then read different indexes; both totals are the
93/// counters' sums under the pins.
94fn find_summable_index_accepted_by<'b>(
95    indexes: &'b BTreeMap<String, Index>,
96    where_clauses: &[WhereClause],
97    sum_property: &str,
98    resolved_time_ranges: &[ResolvedTimeRange],
99    accepts: impl Fn(&Index) -> bool,
100) -> Option<&'b Index> {
101    // A skip index serves only a query binding every skip property
102    // ([`index_admissible_for_skip_if_absent`](crate::query::index_admissible_for_skip_if_absent)): a prefix match may stop
103    // above a deep one.
104    let skip_bindings = SkipIfAbsentBinding::for_where_clauses(where_clauses);
105    // Defense-in-depth: any non-indexable operator immediately disqualifies
106    // — the sum point-lookup path can only serve Equal/In.
107    if where_clauses
108        .iter()
109        .any(|wc| !is_indexable_for_sum(wc.operator))
110    {
111        return None;
112    }
113
114    let indexable_fields: BTreeSet<&str> = where_clauses
115        .iter()
116        .filter(|wc| matches!(wc.operator, WhereOperator::Equal | WhereOperator::In))
117        .map(|wc| wc.field.as_str())
118        .collect();
119
120    if indexable_fields.is_empty() {
121        return None;
122    }
123
124    for index in indexes.values() {
125        // A time-range index holds one entry per bucket containing the
126        // document, keyed by bucket start: summing over it double-counts
127        // every document unless the query pins a single bucket, and only a
128        // resolution-produced equality does that. Conversely a raw clause
129        // must never bind to bucket keys.
130        if !index_admissible_for_query(index, resolved_time_ranges, &skip_bindings) {
131            continue;
132        }
133        // Skip if not summable OR if summable property doesn't match. A
134        // `summableOffCountIndex` index is addressed by its source index's
135        // name, its counters holding that index's entry counts.
136        if index.summed_value_name() != Some(sum_property) {
137            continue;
138        }
139        if index.properties.len() != indexable_fields.len() {
140            continue;
141        }
142        let all_covered = index
143            .properties
144            .iter()
145            .all(|prop| indexable_fields.contains(prop.name.as_str()));
146        if all_covered && accepts(index) {
147            return Some(index);
148        }
149    }
150
151    // The partial-cover forms, prefix-to-last first as in count's picker:
152    // the leading `pin_depth` properties of an index that `form` admits at
153    // that depth.
154    //
155    // Prefix-to-last form, the sum counterpart of count's: a
156    // `summableOffCountIndex` index pinned on every property but its last
157    // reads the tree of its last property, which sums the counters below the
158    // pins ([`prefix_to_last_sum_reads`]), the element a `count(*)` with the
159    // same pins reads when both pick that index (see the function doc).
160    //
161    // Sum-chain value-tree form, the sum counterpart of count's at-chain
162    // fallback: on a `summableOffCountIndex` index ranking by sum or average
163    // at an earlier level, every value tree from the shallowest such level
164    // down sums its whole subtree, so contiguous pins landing at or below
165    // that level are servable by reading the deepest pin's value tree
166    // element. Pins landing above it stay rejected: those levels are plain
167    // trees.
168    let pin_depth = indexable_fields.len();
169    let first_leading_cover = |form: &dyn Fn(&Index, usize) -> bool| {
170        indexes.values().find(|index| {
171            index_admissible_for_query(index, resolved_time_ranges, &skip_bindings)
172                && index.summed_value_name() == Some(sum_property)
173                && form(index, pin_depth)
174                && index.properties[..pin_depth]
175                    .iter()
176                    .all(|prop| indexable_fields.contains(prop.name.as_str()))
177                && accepts(index)
178        })
179    };
180    first_leading_cover(&prefix_to_last_sum_reads).or_else(|| {
181        first_leading_cover(&|index, depth| {
182            pins_reach_chain(index, depth, index.shallowest_sum_chain_position())
183        })
184    })
185}
186
187/// Whether a point sum over `index` with its first `pin_depth` properties
188/// pinned reads the tree of its last property, the prefix-to-last form: a
189/// `summableOffCountIndex` index pinned on every property but its last, the
190/// last unranked (a ranked one is an indexed tree, which grovedb's query
191/// dispatch refuses to return). That tree sums the counters below the pins,
192/// the source's entries, and when the index is `rangeCountable` it counts
193/// them too, the groups: it then answers even pins that reach the sum chain,
194/// as count's form does, so an average reads the groups a sum-only chain's
195/// value trees do not count. Without `rangeCountable`, pins reaching the sum
196/// chain read the deepest pin's value tree instead. The picker and the path
197/// builder both ask this, so they agree on the form.
198///
199/// Only a `summableOffCountIndex` index, which only meta-schema v3 (protocol
200/// version 14) admits, takes this form: every other index reads as before.
201pub(crate) fn prefix_to_last_sum_reads(index: &Index, pin_depth: usize) -> bool {
202    index.is_summable_off_count_index()
203        && !index.ranks_its_last_property()
204        && pin_depth >= 1
205        && pin_depth + 1 == index.properties.len()
206        && (index.range_countable
207            || !pins_reach_chain(index, pin_depth, index.shallowest_sum_chain_position()))
208}
209
210/// Whether the element a sum point lookup on `index` reads for
211/// `where_clauses` also carries a count, as an average or count-and-sum read
212/// needs: the index is countable, and the read lands on its terminal (every
213/// property pinned), on a value tree of its count chain, or, in the
214/// prefix-to-last form, on its last property's tree when that tree counts
215/// (`rangeCountable`). A sum-chain level that carries no count holds
216/// `SumTree`s, whose count would read as one.
217fn summable_point_lookup_carries_counts(index: &Index, where_clauses: &[WhereClause]) -> bool {
218    if !index.countable.is_countable() {
219        return false;
220    }
221    let pin_depth = index
222        .properties
223        .iter()
224        .take_while(|prop| where_clauses.iter().any(|wc| wc.field == prop.name))
225        .count();
226    pin_depth == index.properties.len()
227        || pins_reach_chain(index, pin_depth, index.shallowest_count_chain_position())
228        || (index.range_countable && prefix_to_last_sum_reads(index, pin_depth))
229}
230
231/// Find a `rangeSummable: true` index whose properties cover the
232/// non-range Equal/In clauses as a prefix AND whose last property is
233/// the range terminator. The summed property must match
234/// `sum_property`.
235///
236/// Mirror of count's `find_range_countable_index_for_where_clauses`.
237///
238/// `resolved_time_ranges` gates the candidate set exactly as in
239/// [`find_summable_index_for_where_clauses`]. A resolved field never arrives
240/// as a range clause — resolution always produces an equality — so with a
241/// non-empty list the only bucketed index this can return is one whose
242/// resolved equality is a prefix property and whose range terminator is a
243/// different property. That is the intended shape: a range over one property
244/// within a single time bucket.
245pub fn find_range_summable_index_for_where_clauses<'b>(
246    indexes: &'b BTreeMap<String, Index>,
247    where_clauses: &[WhereClause],
248    sum_property: &str,
249    resolved_time_ranges: &[ResolvedTimeRange],
250) -> Option<&'b Index> {
251    find_range_summable_index_accepted_by(
252        indexes,
253        where_clauses,
254        sum_property,
255        resolved_time_ranges,
256        |_| true,
257    )
258}
259
260/// [`find_range_summable_index_for_where_clauses`] for a range average or
261/// count-and-sum read: the picked index must also be `rangeCountable`. A
262/// `summableOffCountIndex` index that is not is passed over, so it never
263/// hides, by name order, one that is. A regular index is picked first and
264/// judged after, as before protocol version 14, so a query over regular
265/// indexes keeps resolving to the index released verifiers rebuild.
266pub fn find_range_summable_index_with_counts_for_where_clauses<'b>(
267    indexes: &'b BTreeMap<String, Index>,
268    where_clauses: &[WhereClause],
269    sum_property: &str,
270    resolved_time_ranges: &[ResolvedTimeRange],
271) -> Option<&'b Index> {
272    find_range_summable_index_accepted_by(
273        indexes,
274        where_clauses,
275        sum_property,
276        resolved_time_ranges,
277        |index| !index.is_summable_off_count_index() || index.range_countable,
278    )
279    .filter(|index| index.range_countable)
280}
281
282/// The first index, in name order, that a range sum over `where_clauses`
283/// reads and that `accepts`.
284fn find_range_summable_index_accepted_by<'b>(
285    indexes: &'b BTreeMap<String, Index>,
286    where_clauses: &[WhereClause],
287    sum_property: &str,
288    resolved_time_ranges: &[ResolvedTimeRange],
289    accepts: impl Fn(&Index) -> bool,
290) -> Option<&'b Index> {
291    // A skip index serves only a query binding every skip property
292    // ([`index_admissible_for_skip_if_absent`](crate::query::index_admissible_for_skip_if_absent)): a prefix match may stop
293    // above a deep one.
294    let skip_bindings = SkipIfAbsentBinding::for_where_clauses(where_clauses);
295    let range_clauses: Vec<&WhereClause> = where_clauses
296        .iter()
297        .filter(|wc| is_range_operator(wc.operator))
298        .collect();
299    let (outer_range_field, terminator_range_clause) = match range_clauses.len() {
300        1 => (None, range_clauses[0]),
301        2 => {
302            // Same-field two-sided ranges are flattened into `between*`
303            // and arrive as one clause; reject if same-field anyway.
304            if range_clauses[0].field == range_clauses[1].field {
305                return None;
306            }
307            (
308                Some((
309                    range_clauses[0].field.as_str(),
310                    range_clauses[1].field.as_str(),
311                )),
312                range_clauses[0],
313            )
314        }
315        _ => return None,
316    };
317
318    // Reject any operator that's neither indexable (Equal/In) nor a
319    // range operator — anything else has no defined sum semantics.
320    if where_clauses
321        .iter()
322        .any(|wc| !is_indexable_for_sum(wc.operator) && !is_range_operator(wc.operator))
323    {
324        return None;
325    }
326
327    let prefix_fields: BTreeSet<&str> = where_clauses
328        .iter()
329        .filter(|wc| matches!(wc.operator, WhereOperator::Equal | WhereOperator::In))
330        .map(|wc| wc.field.as_str())
331        .collect();
332
333    for index in indexes.values() {
334        // Same admissibility rule as the point-lookup picker: bucketed
335        // indexes store one entry per containing bucket, so only a query
336        // pinned to a single bucket by a resolution-produced equality may
337        // walk them, and raw clauses may never bind to bucket keys.
338        if !index_admissible_for_query(index, resolved_time_ranges, &skip_bindings) {
339            continue;
340        }
341        if !index.range_summable {
342            continue;
343        }
344        // `range_summable: true` requires `summable: Some(_)` (or a
345        // `summableOffCountIndex` source) per the DPP schema; verify it
346        // matches the caller's sum_property.
347        if index.summed_value_name() != Some(sum_property) {
348            continue;
349        }
350
351        if let Some((field_a, field_b)) = outer_range_field {
352            let terminator = index.properties.last()?;
353            let first = index.properties.first()?;
354            let (outer_field, _terminator_field) = if terminator.name == field_a {
355                (field_b, field_a)
356            } else if terminator.name == field_b {
357                (field_a, field_b)
358            } else {
359                continue;
360            };
361            if first.name != outer_field {
362                continue;
363            }
364            let intermediate_props = &index.properties[1..index.properties.len() - 1];
365            let mut intermediate_props_ok = true;
366            for prop in intermediate_props {
367                if !prefix_fields.contains(prop.name.as_str()) {
368                    intermediate_props_ok = false;
369                    break;
370                }
371            }
372            // Strict-coverage check: every Equal/In prefix field must
373            // appear in the index's intermediate properties. Without
374            // this `intermediate_props.len() == prefix_fields.len()`
375            // guard, a query with extra prefix fields would silently
376            // pick an index that *doesn't* cover them, producing an
377            // over-broad result.
378            if intermediate_props_ok
379                && intermediate_props.len() == prefix_fields.len()
380                && accepts(index)
381            {
382                return Some(index);
383            }
384            continue;
385        }
386
387        // Single-range case.
388        let mut prefix_len = 0usize;
389        for prop in &index.properties {
390            if prefix_fields.contains(prop.name.as_str()) {
391                prefix_len += 1;
392            } else {
393                break;
394            }
395        }
396        if prefix_len < prefix_fields.len() {
397            continue;
398        }
399        if prefix_len + 1 != index.properties.len() {
400            continue;
401        }
402        let range_prop = &index.properties[prefix_len];
403        if range_prop.name == terminator_range_clause.field && accepts(index) {
404            return Some(index);
405        }
406    }
407
408    None
409}