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 strict-coverage pickers:
4//! - [`find_summable_index_for_where_clauses`]: returns the `summable: "<prop>"`
5//!   index whose properties *exactly* match the Equal/In where-clause fields
6//!   and whose summed property equals the request's `sum_property`. None on
7//!   miss (no partial coverage).
8//! - [`find_range_summable_index_for_where_clauses`]: returns the
9//!   `rangeSummable: true` index whose Equal/In prefix covers the
10//!   non-range clauses AND whose last property is the range
11//!   terminator. None on miss.
12//!
13//! Reject-on-miss is the load-bearing contract: callers landing in
14//! "no covering index" return `WhereClauseOnNonIndexedProperty` so the
15//! prover and verifier reject the same set of inputs (same as count).
16
17use crate::query::drive_document_sum_query::{is_indexable_for_sum, is_range_operator};
18use crate::query::ResolvedTimeRange;
19use crate::query::{index_admissible_for_resolved_time_range, WhereClause, WhereOperator};
20use dpp::data_contract::document_type::Index;
21use std::collections::{BTreeMap, BTreeSet};
22
23/// Find a `summable: "<prop>"` index whose properties exactly cover
24/// the Equal/In where-clause fields AND whose summed property name
25/// equals the request's `sum_property`.
26///
27/// Mirror of count's `find_countable_index_for_where_clauses` with the
28/// additional `summable == Some(sum_property)` predicate on top of the
29/// strict-coverage match.
30///
31/// `resolved_time_ranges` names the fields whose equality clause was
32/// produced by `IN_TIME_RANGE` resolution (see
33/// [`crate::query::resolve_time_range_bucket_clause`]) and gates which indexes
34/// are candidates — see [`index_admissible_for_resolved_time_range`].
35pub fn find_summable_index_for_where_clauses<'b>(
36    indexes: &'b BTreeMap<String, Index>,
37    where_clauses: &[WhereClause],
38    sum_property: &str,
39    resolved_time_ranges: &[ResolvedTimeRange],
40) -> Option<&'b Index> {
41    // Defense-in-depth: any non-indexable operator immediately disqualifies
42    // — the sum point-lookup path can only serve Equal/In.
43    if where_clauses
44        .iter()
45        .any(|wc| !is_indexable_for_sum(wc.operator))
46    {
47        return None;
48    }
49
50    let indexable_fields: BTreeSet<&str> = where_clauses
51        .iter()
52        .filter(|wc| matches!(wc.operator, WhereOperator::Equal | WhereOperator::In))
53        .map(|wc| wc.field.as_str())
54        .collect();
55
56    if indexable_fields.is_empty() {
57        return None;
58    }
59
60    for index in indexes.values() {
61        // A time-range index holds one entry per bucket containing the
62        // document, keyed by bucket start: summing over it double-counts
63        // every document unless the query pins a single bucket, and only a
64        // resolution-produced equality does that. Conversely a raw clause
65        // must never bind to bucket keys.
66        if !index_admissible_for_resolved_time_range(index, resolved_time_ranges) {
67            continue;
68        }
69        // Skip if not summable OR if summable property doesn't match.
70        match &index.summable {
71            Some(prop) if prop == sum_property => {}
72            _ => continue,
73        }
74        if index.properties.len() != indexable_fields.len() {
75            continue;
76        }
77        let all_covered = index
78            .properties
79            .iter()
80            .all(|prop| indexable_fields.contains(prop.name.as_str()));
81        if all_covered {
82            return Some(index);
83        }
84    }
85
86    None
87}
88
89/// Find a `rangeSummable: true` index whose properties cover the
90/// non-range Equal/In clauses as a prefix AND whose last property is
91/// the range terminator. The summed property must match
92/// `sum_property`.
93///
94/// Mirror of count's `find_range_countable_index_for_where_clauses`.
95///
96/// `resolved_time_ranges` gates the candidate set exactly as in
97/// [`find_summable_index_for_where_clauses`]. A resolved field never arrives
98/// as a range clause — resolution always produces an equality — so with a
99/// non-empty list the only bucketed index this can return is one whose
100/// resolved equality is a prefix property and whose range terminator is a
101/// different property. That is the intended shape: a range over one property
102/// within a single time bucket.
103pub fn find_range_summable_index_for_where_clauses<'b>(
104    indexes: &'b BTreeMap<String, Index>,
105    where_clauses: &[WhereClause],
106    sum_property: &str,
107    resolved_time_ranges: &[ResolvedTimeRange],
108) -> Option<&'b Index> {
109    let range_clauses: Vec<&WhereClause> = where_clauses
110        .iter()
111        .filter(|wc| is_range_operator(wc.operator))
112        .collect();
113    let (outer_range_field, terminator_range_clause) = match range_clauses.len() {
114        1 => (None, range_clauses[0]),
115        2 => {
116            // Same-field two-sided ranges are flattened into `between*`
117            // and arrive as one clause; reject if same-field anyway.
118            if range_clauses[0].field == range_clauses[1].field {
119                return None;
120            }
121            (
122                Some((
123                    range_clauses[0].field.as_str(),
124                    range_clauses[1].field.as_str(),
125                )),
126                range_clauses[0],
127            )
128        }
129        _ => return None,
130    };
131
132    // Reject any operator that's neither indexable (Equal/In) nor a
133    // range operator — anything else has no defined sum semantics.
134    if where_clauses
135        .iter()
136        .any(|wc| !is_indexable_for_sum(wc.operator) && !is_range_operator(wc.operator))
137    {
138        return None;
139    }
140
141    let prefix_fields: BTreeSet<&str> = where_clauses
142        .iter()
143        .filter(|wc| matches!(wc.operator, WhereOperator::Equal | WhereOperator::In))
144        .map(|wc| wc.field.as_str())
145        .collect();
146
147    for index in indexes.values() {
148        // Same admissibility rule as the point-lookup picker: bucketed
149        // indexes store one entry per containing bucket, so only a query
150        // pinned to a single bucket by a resolution-produced equality may
151        // walk them, and raw clauses may never bind to bucket keys.
152        if !index_admissible_for_resolved_time_range(index, resolved_time_ranges) {
153            continue;
154        }
155        if !index.range_summable {
156            continue;
157        }
158        // `range_summable: true` requires `summable: Some(_)` per the DPP
159        // schema; verify it matches the caller's sum_property.
160        match &index.summable {
161            Some(prop) if prop == sum_property => {}
162            _ => continue,
163        }
164
165        if let Some((field_a, field_b)) = outer_range_field {
166            let terminator = index.properties.last()?;
167            let first = index.properties.first()?;
168            let (outer_field, _terminator_field) = if terminator.name == field_a {
169                (field_b, field_a)
170            } else if terminator.name == field_b {
171                (field_a, field_b)
172            } else {
173                continue;
174            };
175            if first.name != outer_field {
176                continue;
177            }
178            let intermediate_props = &index.properties[1..index.properties.len() - 1];
179            let mut intermediate_props_ok = true;
180            for prop in intermediate_props {
181                if !prefix_fields.contains(prop.name.as_str()) {
182                    intermediate_props_ok = false;
183                    break;
184                }
185            }
186            // Strict-coverage check: every Equal/In prefix field must
187            // appear in the index's intermediate properties. Without
188            // this `intermediate_props.len() == prefix_fields.len()`
189            // guard, a query with extra prefix fields would silently
190            // pick an index that *doesn't* cover them, producing an
191            // over-broad result.
192            if intermediate_props_ok && intermediate_props.len() == prefix_fields.len() {
193                return Some(index);
194            }
195            continue;
196        }
197
198        // Single-range case.
199        let mut prefix_len = 0usize;
200        for prop in &index.properties {
201            if prefix_fields.contains(prop.name.as_str()) {
202                prefix_len += 1;
203            } else {
204                break;
205            }
206        }
207        if prefix_len < prefix_fields.len() {
208            continue;
209        }
210        if prefix_len + 1 != index.properties.len() {
211            continue;
212        }
213        let range_prop = &index.properties[prefix_len];
214        if range_prop.name == terminator_range_clause.field {
215            return Some(index);
216        }
217    }
218
219    None
220}