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}