drive/query/drive_document_count_query/index_picker.rs
1//! Index pickers for the count query.
2//!
3//! Pure functions on the document type's index map + where clauses;
4//! no Drive, no proof. Picks a covering index for a given query
5//! shape, returning `None` if no index can serve the query.
6
7use super::super::conditions::WhereClause;
8use super::DriveDocumentCountQuery;
9use crate::query::index_admissible_for_resolved_time_range;
10use crate::query::ResolvedTimeRange;
11use dpp::data_contract::document_type::Index;
12use std::collections::{BTreeMap, BTreeSet};
13
14impl DriveDocumentCountQuery<'_> {
15 /// Finds a `countable: true` index whose properties **exactly match** the
16 /// indexable (Equal/In) where-clause fields — every index property has a
17 /// corresponding clause AND every clause's field appears in the index.
18 ///
19 /// Exact coverage is the contract for both no-proof and prove count
20 /// paths: a countable index counts exactly what it indexes, and queries
21 /// against partially-covered indexes are rejected with a clear error
22 /// directing the caller at the index-design fix. This avoids the
23 /// product-of-uncovered-branching-factors walk that a prefix-match
24 /// approach would silently fall through to, and keeps the storage's
25 /// "count maintained only at the terminal level" trade-off intact (no
26 /// need to maintain counts at intermediate index levels just to serve
27 /// partial-coverage queries cheaply).
28 ///
29 /// Returns `None` if:
30 /// - Any where clause uses an operator other than `Equal` / `In`.
31 /// - The set of indexable where-clause fields doesn't exactly equal the
32 /// set of properties of any single `countable: true` index.
33 ///
34 /// For the `documents_countable: true` case (total count with no where
35 /// clauses), the dispatcher reads the document-type primary-key tree's
36 /// CountTree directly — that path doesn't use this picker because no
37 /// index is involved.
38 ///
39 /// `resolved_time_ranges` names the fields whose equality clause was
40 /// produced by `IN_TIME_RANGE` resolution (see
41 /// [`crate::query::resolve_time_range_bucket_clause`]); it gates which
42 /// indexes are candidates at all — see
43 /// [`index_admissible_for_resolved_time_range`].
44 pub fn find_countable_index_for_where_clauses<'b>(
45 indexes: &'b BTreeMap<String, Index>,
46 where_clauses: &[WhereClause],
47 resolved_time_ranges: &[ResolvedTimeRange],
48 ) -> Option<&'b Index> {
49 if Self::has_unsupported_operator(where_clauses) {
50 return None;
51 }
52
53 let indexable_fields: BTreeSet<&str> = where_clauses
54 .iter()
55 .filter(|wc| Self::is_indexable_for_count(wc.operator))
56 .map(|wc| wc.field.as_str())
57 .collect();
58
59 // Need a clause for every property of the index, so empty
60 // `indexable_fields` only matches an empty-properties index
61 // (which doesn't exist — indexes always have at least one
62 // property — so empty where clauses never match here).
63 if indexable_fields.is_empty() {
64 return None;
65 }
66
67 for index in indexes.values() {
68 // A time-range index holds one entry per bucket containing the
69 // document, keyed by bucket start: counting over it multi-counts
70 // every document unless the query pins a single bucket, and only
71 // a resolution-produced equality does that. Conversely a raw
72 // clause must never bind to bucket keys.
73 if !index_admissible_for_resolved_time_range(index, resolved_time_ranges) {
74 continue;
75 }
76 if !index.countable.is_countable() {
77 continue;
78 }
79 if index.properties.len() != indexable_fields.len() {
80 continue;
81 }
82 // Every index property must have a matching where-clause
83 // field. Because lengths match, this also implies every
84 // where-clause field appears in the index (no orphan
85 // clauses).
86 let all_covered = index
87 .properties
88 .iter()
89 .all(|prop| indexable_fields.contains(prop.name.as_str()));
90 if all_covered {
91 return Some(index);
92 }
93 }
94
95 None
96 }
97
98 /// Finds a `range_countable` index that can serve a range-count query.
99 ///
100 /// Match criteria:
101 /// - All `Equal`/`In` where-clause fields form a prefix of the index
102 /// properties.
103 /// - There is exactly one range-operator where-clause, on a property
104 /// that is the *last* property of the index (the IndexLevel
105 /// terminator). This is the property whose values get walked.
106 /// - The index has `range_countable = true` and `countable.is_countable()`.
107 ///
108 /// Returns `None` if no such index exists or if there's more than one
109 /// range operator in the where clauses (which would require nested range
110 /// walks the current model doesn't support). Pure point-lookup queries
111 /// (no range operator) should fall back to
112 /// [`Self::find_countable_index_for_where_clauses`].
113 ///
114 /// `resolved_time_ranges` gates the candidate set exactly as in
115 /// [`Self::find_countable_index_for_where_clauses`]. A resolved field
116 /// never arrives as a range clause — resolution always produces an
117 /// equality — so with a non-empty list the only bucketed index this can
118 /// return is one whose resolved equality is a prefix property and whose
119 /// range terminator is a different property. That is the intended shape:
120 /// a range over one property within a single time bucket.
121 pub fn find_range_countable_index_for_where_clauses<'b>(
122 indexes: &'b BTreeMap<String, Index>,
123 where_clauses: &[WhereClause],
124 resolved_time_ranges: &[ResolvedTimeRange],
125 ) -> Option<&'b Index> {
126 let range_clauses: Vec<&WhereClause> = where_clauses
127 .iter()
128 .filter(|wc| Self::is_range_operator(wc.operator))
129 .collect();
130 // Accept either:
131 // - 1 range clause (Q7 / G4 / G5 / G7 — the range is the
132 // terminator; prefix props use `==` or `In`).
133 // - 2 range clauses on distinct fields (G8 — outer range on
134 // an index prefix property, inner range on the terminator;
135 // the carrier-aggregate proof shape introduced by grovedb
136 // PR #664).
137 let (outer_range_field, terminator_range_clause) = match range_clauses.len() {
138 1 => (None, range_clauses[0]),
139 2 => {
140 // The two ranges must be on different fields — same-
141 // field two-sided ranges are flattened by the parser
142 // into `between*` and arrive with one clause.
143 if range_clauses[0].field == range_clauses[1].field {
144 return None;
145 }
146 // One of the two must be on an index's terminator; the
147 // other becomes the outer carrier dimension. We pick
148 // the terminator below by walking each candidate
149 // index's property order — defer choosing here.
150 (
151 Some((
152 range_clauses[0].field.as_str(),
153 range_clauses[1].field.as_str(),
154 )),
155 range_clauses[0], // placeholder, refined per-index below
156 )
157 }
158 _ => return None,
159 };
160
161 // Reject any operator that's neither indexable (Equal/In) nor a
162 // range operator — anything else has no defined count semantics.
163 if where_clauses.iter().any(|wc| {
164 !Self::is_indexable_for_count(wc.operator) && !Self::is_range_operator(wc.operator)
165 }) {
166 return None;
167 }
168
169 let prefix_fields: BTreeSet<&str> = where_clauses
170 .iter()
171 .filter(|wc| Self::is_indexable_for_count(wc.operator))
172 .map(|wc| wc.field.as_str())
173 .collect();
174
175 for index in indexes.values() {
176 // Same admissibility rule as the point-lookup picker: bucketed
177 // indexes store one entry per containing bucket, so only a query
178 // pinned to a single bucket by a resolution-produced equality may
179 // walk them, and raw clauses may never bind to bucket keys.
180 if !index_admissible_for_resolved_time_range(index, resolved_time_ranges) {
181 continue;
182 }
183 if !index.range_countable || !index.countable.is_countable() {
184 continue;
185 }
186
187 // For the two-range case, the terminator's field must be
188 // one of the two range fields, and the other range field
189 // must be the index's first property (the carrier
190 // dimension).
191 if let Some((field_a, field_b)) = outer_range_field {
192 let terminator = index.properties.last()?;
193 let first = index.properties.first()?;
194 // Determine which range field is the terminator.
195 let (outer_field, _terminator_field) = if terminator.name == field_a {
196 (field_b, field_a)
197 } else if terminator.name == field_b {
198 (field_a, field_b)
199 } else {
200 continue;
201 };
202 if first.name != outer_field {
203 continue;
204 }
205 // Any Equal/In prefix clauses must sit between the
206 // first (outer-range) and last (terminator-range)
207 // properties. For the widget contract there are no
208 // such middle properties on byBrandColor, but the
209 // builder handles the general case.
210 let intermediate_props = &index.properties[1..index.properties.len() - 1];
211 let mut intermediate_props_ok = true;
212 for prop in intermediate_props {
213 if !prefix_fields.contains(prop.name.as_str()) {
214 intermediate_props_ok = false;
215 break;
216 }
217 }
218 // Strict-coverage check, mirroring sum's picker: every
219 // Equal/In prefix field must appear in the index's
220 // intermediate properties. Without this
221 // `intermediate_props.len() == prefix_fields.len()` guard,
222 // a query with extra prefix fields would silently pick an
223 // index that *doesn't* cover them — the carrier path-query
224 // builder iterates only index properties, so the uncovered
225 // clause would simply be dropped and the per-group counts
226 // would span all its values (an over-broad result that
227 // even verifies, since the verifier rebuilds the same
228 // path query from the same picker).
229 if intermediate_props_ok && intermediate_props.len() == prefix_fields.len() {
230 return Some(index);
231 }
232 continue;
233 }
234
235 // Single-range case (the original logic): prefix matches
236 // must come first, followed by the range property as the
237 // LAST element.
238 let mut prefix_len = 0usize;
239 for prop in &index.properties {
240 if prefix_fields.contains(prop.name.as_str()) {
241 prefix_len += 1;
242 } else {
243 break;
244 }
245 }
246 if prefix_len < prefix_fields.len() {
247 continue;
248 }
249 if prefix_len + 1 != index.properties.len() {
250 // Range property must be the terminator (last property).
251 continue;
252 }
253 let range_prop = &index.properties[prefix_len];
254 if range_prop.name == terminator_range_clause.field {
255 return Some(index);
256 }
257 }
258
259 None
260 }
261}