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}