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