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}