drive/query/drive_document_ranked_query/index_picker.rs
1//! Covering-index picker for the ranked query, plus the shared
2//! prefix-value encoding.
3//!
4//! Pure functions on the document type's index map plus the
5//! `(group property, equality pins, axis, aggregate field)` tuple
6//! [`super::mode_detection`] resolved. No Drive, no proof — the server
7//! and the SDK verifier both call these so they land on the same index
8//! (and therefore the same grove path) for the same request.
9
10use super::{DocumentRankedMode, DriveDocumentRankedQuery, PrefixPin, RankedAxis};
11use crate::error::query::QuerySyntaxError;
12use crate::error::Error;
13use crate::query::{index_admissible_for_resolved_time_range, ResolvedTimeRange};
14use dpp::data_contract::document_type::methods::DocumentTypeV0Methods;
15use dpp::data_contract::document_type::{DocumentTypeRef, Index};
16use dpp::version::PlatformVersion;
17use std::collections::BTreeMap;
18
19/// Find the index that can serve `axis` ranking grouped by
20/// `group_by_property` with the given equality pins, aggregating
21/// `aggregate_field`.
22///
23/// An index qualifies when **all** of:
24///
25/// - one of its **ranked levels** for `axis` is `group_by_property`,
26/// with exactly one pin per property before it. For the boolean axes
27/// the ranked level is the index's **last** property; a prefix-level
28/// `rankedCountable: { at }` hosts a Count secondary at the `at`
29/// property as well, ranking its values by whole-subtree count — an
30/// index may declare both, and the (group property, pin count) pair
31/// singles out which level a request addresses. At a prefix level the
32/// properties *after* `at` never appear in the request at all (they
33/// are interior to the subtrees being counted);
34/// - every property **before** the ranked level is pinned: each appears
35/// (by name) among `equality_pin_fields`. Lengths matching plus the
36/// pins being distinct (enforced upstream by
37/// [`super::mode_detection::prefix_pins_from_where_clauses`]) makes
38/// this set equality, so no pin is left over either;
39/// - it declares the ranking keyword for `axis`
40/// ([`RankedAxis::required_index_keyword`]);
41/// - for [`RankedAxis::Sum`] / [`RankedAxis::Avg`], its `summable`
42/// property is exactly `aggregate_field`. Both axes are derived from
43/// the same running sum the index maintains (`Avg` is that sum over the
44/// group's count), so summing a *different* field than the one the
45/// index accumulates would silently answer about the wrong property.
46/// A prefix-level Count ranking excludes both (the grammar rejects the
47/// combination), so those arms never see an `at` index;
48/// - it is admissible for the request's resolved time-range selections
49/// ([`index_admissible_for_resolved_time_range`]): a request whose
50/// leading pin was produced by `IN_TIME_RANGE` resolution may only be
51/// served by the index bucketing that field with exactly that grid,
52/// and a raw request never by a bucketed index — either mismatch
53/// would be a validly-proven wrong answer. The resolved bucket-start
54/// pin then descends the grid-qualified first level like any other
55/// leading-property pin, into that window's own per-prefix secondary.
56///
57/// With no pins this degenerates to the original single-property rule —
58/// or, for an index ranked at its FIRST property, to the global group
59/// ranking ("top hashtags by total likes" with nothing pinned). A
60/// partial pin (some but not all leading properties) matches nothing —
61/// the per-prefix secondary lives under one value tree per leading
62/// property, so there is no subtree an unpinned prefix could address —
63/// and callers turn the `None` into a loud
64/// [`crate::error::query::QuerySyntaxError`] naming what is missing.
65///
66/// Returns `None` when nothing qualifies; callers turn that into
67/// [`crate::error::query::QuerySyntaxError::WhereClauseOnNonIndexedProperty`]
68/// with a message naming the missing keyword.
69///
70/// At most one index can qualify for a given `(group property, pins,
71/// axis, field)` tuple — rs-dpp rejects two indexes over the same
72/// property set on one document type — so "first match wins" is not a
73/// tie-break in practice. Should that ever change, the `BTreeMap`
74/// iteration order (index name, ascending) keeps the choice
75/// deterministic, which is what prover/verifier agreement actually
76/// requires: both sides run this same function over the same contract
77/// and must land on the same grove path.
78///
79/// Note that axis availability is decided from the index's `ranked_*`
80/// flags, **not** from the element variant the write path laid down: a
81/// `rankedCountable` index that also declares `rangeSummable` is stored
82/// as a `ProvableCountProvableSumIndexedTree` carrying only the Count
83/// axis, so the element variant alone would over-report what is rankable.
84pub fn find_ranked_index_for_axis<'b>(
85 indexes: &'b BTreeMap<String, Index>,
86 group_by_property: &str,
87 equality_pin_fields: &[String],
88 axis: RankedAxis,
89 aggregate_field: &str,
90 resolved_time_ranges: &[ResolvedTimeRange],
91) -> Option<&'b Index> {
92 indexes.values().find(|index| {
93 // Bucketed and raw indexes are never interchangeable, and one
94 // grid's index never serves another grid's resolution — the same
95 // provenance rule every other aggregate picker applies. This is
96 // what keeps a raw request off bucketed indexes AND routes a
97 // resolved request to exactly the grid it was resolved against.
98 if !index_admissible_for_resolved_time_range(index, resolved_time_ranges) {
99 return false;
100 }
101 // The positions whose levels host this axis's secondaries — for
102 // the Count axis every `at` level plus the terminal when the
103 // boolean is on (any subset of an index's levels may rank); empty
104 // when the index does not declare the axis (or aggregates a
105 // different field than requested).
106 let candidate_positions: Vec<usize> = match axis {
107 RankedAxis::Count => index
108 .ranked_countable_at
109 .iter()
110 .filter_map(|at| index.properties.iter().position(|p| &p.name == at))
111 .chain(
112 index
113 .ranked_countable
114 .then(|| index.properties.len().checked_sub(1))
115 .flatten(),
116 )
117 .collect(),
118 RankedAxis::Sum => (index.ranked_summable
119 && index.summable.as_deref() == Some(aggregate_field))
120 .then(|| index.properties.len().checked_sub(1))
121 .flatten()
122 .into_iter()
123 .collect(),
124 RankedAxis::Avg => (index.ranked_averageable
125 && index.summable.as_deref() == Some(aggregate_field))
126 .then(|| index.properties.len().checked_sub(1))
127 .flatten()
128 .into_iter()
129 .collect(),
130 };
131 // A candidate matches when its property is the grouping property
132 // and every property before it is pinned exactly once (length
133 // equality + distinct pins ⇒ set equality). At most one candidate
134 // can match a given request: the two levels are distinct
135 // positions, and the pin count singles one out.
136 candidate_positions.into_iter().any(|ranked_position| {
137 let Some(ranked_property) = index.properties.get(ranked_position) else {
138 return false;
139 };
140 let leading = &index.properties[..ranked_position];
141 ranked_property.name == group_by_property
142 && leading.len() == equality_pin_fields.len()
143 && leading
144 .iter()
145 .all(|property| equality_pin_fields.iter().any(|f| f == &property.name))
146 })
147 })
148}
149
150/// [`find_ranked_index_for_axis`] driven straight from a resolved
151/// [`DocumentRankedMode`] — the shape every caller actually has.
152pub fn find_ranked_index_for_mode<'b>(
153 indexes: &'b BTreeMap<String, Index>,
154 mode: &DocumentRankedMode,
155 resolved_time_ranges: &[ResolvedTimeRange],
156) -> Option<&'b Index> {
157 let pin_fields: Vec<String> = mode
158 .prefix_pins
159 .iter()
160 .map(|pin| pin.field.clone())
161 .collect();
162 find_ranked_index_for_axis(
163 indexes,
164 &mode.group_by_property,
165 &pin_fields,
166 mode.axis,
167 &mode.aggregate_field,
168 resolved_time_ranges,
169 )
170}
171
172/// Resolve a validated [`DocumentRankedMode`] against a document type's
173/// indexes into the executable [`DriveDocumentRankedQuery`]: pick the
174/// covering index, encode the prefix pins into prefix **branches** (one
175/// branch for all-`==` pins, one branch per element of the single
176/// permitted `IN`), and assemble the query.
177///
178/// This is the **one** resolution path — the server's executors and the
179/// SDK's proof helpers both call it, which is what guarantees a proof
180/// and an unproven read (and the client's verification) are about the
181/// same subtree.
182///
183/// `indexes` is threaded in separately rather than read off
184/// `document_type` here because
185/// [`DocumentTypeV0Getters::indexes`](dpp::data_contract::document_type::accessors::DocumentTypeV0Getters::indexes)
186/// borrows its receiver — taking the map from the caller lets the
187/// returned query's `&'a Index` outlive this frame. Callers pass
188/// `document_type.indexes()`.
189///
190/// The main failure is "no index covers this", reported with the exact
191/// contract keyword (and, for pinned requests, the exact index shape)
192/// the request needs, so the caller can act on it without reading the
193/// schema spec.
194pub fn resolve_ranked_query_for_mode<'a>(
195 contract_id: [u8; 32],
196 document_type: DocumentTypeRef<'a>,
197 document_type_name: String,
198 indexes: &'a BTreeMap<String, Index>,
199 mode: &DocumentRankedMode,
200 resolved_time_ranges: &[ResolvedTimeRange],
201 platform_version: &PlatformVersion,
202) -> Result<DriveDocumentRankedQuery<'a>, Error> {
203 let index =
204 find_ranked_index_for_mode(indexes, mode, resolved_time_ranges).ok_or_else(|| {
205 Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty(
206 no_covering_index_message(
207 "ranked",
208 mode.axis,
209 &mode.group_by_property,
210 &mode.prefix_pins,
211 &mode.aggregate_field,
212 ),
213 ))
214 })?;
215 let prefix_branches =
216 encode_prefix_branches(document_type, index, &mode.prefix_pins, platform_version)?;
217 Ok(DriveDocumentRankedQuery {
218 document_type,
219 contract_id,
220 document_type_name,
221 index,
222 prefix_branches,
223 axis: mode.axis,
224 descending: mode.descending,
225 k: mode.k,
226 offset: mode.offset,
227 })
228}
229
230/// The "no index covers this request" rejection text, shared by the
231/// ranked and having-range resolutions (and the SDK's mirrors of them)
232/// so a rejected request reads identically everywhere. Names the exact
233/// index the request needs: property list (pins first, in request
234/// order, then the grouping property), ranking keyword, and `summable`
235/// field where applicable.
236pub fn no_covering_index_message(
237 surface: &str,
238 axis: RankedAxis,
239 group_by_property: &str,
240 prefix_pins: &[PrefixPin],
241 aggregate_field: &str,
242) -> String {
243 let pin_fields = || {
244 prefix_pins
245 .iter()
246 .map(|pin| pin.field.as_str())
247 .collect::<Vec<_>>()
248 .join(", ")
249 };
250 let index_shape = if prefix_pins.is_empty() {
251 format!("a single-property index on `{group_by_property}`")
252 } else {
253 format!(
254 "a compound index on [{}, {group_by_property}] (every leading property pinned \
255 by an equality or `IN` `where` clause, the trailing property grouped over)",
256 pin_fields()
257 )
258 };
259 format!(
260 "no ranked index covers `group_by = [{group_by_property}]`{} on the {axis:?} axis \
261 for this {surface} query: the document type needs {index_shape} declaring `{}`{}",
262 if prefix_pins.is_empty() {
263 String::new()
264 } else {
265 format!(" with pins on [{}]", pin_fields())
266 },
267 axis.required_index_keyword(),
268 if aggregate_field.is_empty() {
269 String::new()
270 } else {
271 format!(" with `summable: \"{aggregate_field}\"`")
272 }
273 )
274}
275
276/// Encode the resolved prefix pins into **branches** — one
277/// `Vec<Vec<u8>>` of prefix path segments per branch, in index-property
278/// order (the same order and encoding the write path used to key those
279/// prefix value trees). A request with only `==` pins yields exactly
280/// one branch; the (at most one) `IN` pin yields one branch per
281/// element.
282///
283/// This is part of the prover/verifier agreement: server executors and
284/// the SDK's proof helpers both come through here, so a pinned value
285/// can only ever name one subtree — and a branch *set* only ever one
286/// ordered subtree list — identically on both sides. Branch order is
287/// canonical: ascending by encoded segment bytes, independent of the
288/// caller's element order (which also makes `null`, the empty segment,
289/// sort first deterministically).
290///
291/// `index` must have been picked by [`find_ranked_index_for_axis`]
292/// against these same pins — every leading property is then guaranteed
293/// a pin. A value the property's type cannot encode is a caller error
294/// naming the property; two `IN` elements that encode to the same
295/// segment (two spellings of one value) are one branch and are rejected
296/// as a duplicate rather than walked twice.
297pub fn encode_prefix_branches(
298 document_type: DocumentTypeRef,
299 index: &Index,
300 prefix_pins: &[PrefixPin],
301 platform_version: &PlatformVersion,
302) -> Result<Vec<Vec<Vec<u8>>>, Error> {
303 // The pinnable properties end at the ranked level the pin count
304 // addresses (an index may host secondaries at both its `at` property
305 // and its terminal — the pin count singles one out; properties past
306 // it are interior to the counted subtrees and never pinned).
307 let (leading, _) = super::path::ranked_level_split(index, prefix_pins.len())?;
308 // Enforced BEFORE any encoding: the ceiling bounds every downstream
309 // cost (encode, sort, clone, walk, proof size), so an oversized pin
310 // must not buy that work first. The post-product branch count check
311 // below stays as a backstop.
312 if prefix_pins
313 .iter()
314 .any(|pin| pin.values.len() > super::MAX_PREFIX_IN_BRANCHES)
315 {
316 return Err(Error::Query(
317 QuerySyntaxError::InvalidWhereClauseComponents(
318 "an `IN` prefix pin fans out into more branches than the ranked surface serves \
319 — narrow the element list or issue several requests",
320 ),
321 ));
322 }
323 let per_property: Vec<Vec<Vec<u8>>> = leading
324 .iter()
325 .map(|property| {
326 let pin = prefix_pins
327 .iter()
328 .find(|pin| pin.field == property.name)
329 .ok_or_else(|| {
330 Error::Query(QuerySyntaxError::InvalidWhereClauseComponents(
331 "internal resolution mismatch: the picked compound ranked index has \
332 a leading property with no pin — the index picker and the prefix \
333 encoder disagreed on the pins",
334 ))
335 })?;
336 let mut encoded = pin
337 .values
338 .iter()
339 .map(|value| {
340 // A null pin addresses the subtree the write walkers
341 // create for an **absent** value: they encode it as
342 // `get_raw_for_document_type(..).unwrap_or_default()`
343 // — an empty path segment — for user and system
344 // properties alike. Null must short-circuit here
345 // because the system-property encoders (`$updatedAt`,
346 // `$creatorId`, …) reject null before any encoding
347 // happens, which would make the stored empty-segment
348 // prefix unaddressable.
349 if value.is_null() {
350 return Ok(Vec::new());
351 }
352 document_type
353 .serialize_value_for_key(&property.name, value, platform_version)
354 .map_err(|e| {
355 Error::Query(QuerySyntaxError::InvalidParameter(format!(
356 "the pin on `{}` does not encode as that property's \
357 index key: {e}",
358 property.name
359 )))
360 })
361 })
362 .collect::<Result<Vec<_>, Error>>()?;
363 if encoded.len() > 1 {
364 encoded.sort();
365 if encoded.windows(2).any(|pair| pair[0] == pair[1]) {
366 return Err(Error::Query(
367 QuerySyntaxError::InvalidWhereClauseComponents(
368 "an `IN` pin's elements encode to the same index key: two \
369 spellings of one value are one prefix branch — deduplicate \
370 the element list",
371 ),
372 ));
373 }
374 }
375 Ok(encoded)
376 })
377 .collect::<Result<Vec<_>, Error>>()?;
378
379 // Defense in depth at the shared choke point: the grammar enforces
380 // both invariants upstream, but this function is `pub` and the
381 // prover/verifier agreement hangs off it, so a mis-built pin set
382 // must fail here rather than collapse to zero branches (a
383 // downstream panic) or fan out into an unbounded cartesian product
384 // (which would also break the one-varying-position assumption
385 // `in_key` and the merge order rely on).
386 if per_property.iter().any(|candidates| candidates.is_empty()) {
387 return Err(Error::Query(
388 QuerySyntaxError::InvalidWhereClauseComponents(
389 "internal resolution mismatch: a prefix pin carries no values",
390 ),
391 ));
392 }
393 if per_property
394 .iter()
395 .filter(|candidates| candidates.len() > 1)
396 .count()
397 > 1
398 {
399 return Err(Error::Query(
400 QuerySyntaxError::InvalidWhereClauseComponents(
401 "internal resolution mismatch: more than one branching pin — the grammar \
402 admits at most one `IN` across the prefix properties",
403 ),
404 ));
405 }
406
407 // A single `null` pin encodes as the empty path segment; the branched
408 // proof grammar (`PathQuery::new_branched_axis`) cannot address an
409 // empty segment in the shared prefix or suffix, so a null `==` pin
410 // combined with an `IN` would serve the unproved read and fail the
411 // prove — the exact proved/unproved divergence this surface forbids.
412 // Rejected for any non-branching pin position, conservatively: issue
413 // one request per `IN` element to combine null pins with multiple
414 // prefixes. `null` as an ELEMENT of the `IN` itself stays legal — it
415 // is a branch key, which the envelope addresses and authenticates
416 // like any other.
417 let has_branching_pin = per_property.iter().any(|candidates| candidates.len() > 1);
418 if has_branching_pin
419 && per_property
420 .iter()
421 .any(|candidates| candidates.len() == 1 && candidates[0].is_empty())
422 {
423 return Err(Error::Query(
424 QuerySyntaxError::InvalidWhereClauseComponents(
425 "an `IN` prefix pin cannot be combined with a `null` pin: null addresses the \
426 absent-value prefix through an empty path segment, which the branched proof \
427 cannot express — issue one request per `IN` element instead",
428 ),
429 ));
430 }
431
432 // The grammar admits at most one multi-value pin, so this product
433 // is |IN| branches (or exactly one), already in canonical order
434 // because the only varying position was sorted above.
435 let mut branches: Vec<Vec<Vec<u8>>> = vec![Vec::with_capacity(leading.len())];
436 for candidates in per_property {
437 branches = branches
438 .into_iter()
439 .flat_map(|prefix| {
440 candidates.iter().map(move |segment| {
441 let mut branch = prefix.clone();
442 branch.push(segment.clone());
443 branch
444 })
445 })
446 .collect();
447 }
448 // The documented hard ceiling on branch fan-out, enforced at the
449 // shared choke point too: this function is `pub`, and everything
450 // downstream (encoding, sorting, per-branch walks, proof size) is
451 // linear in the branch count.
452 if branches.len() > super::MAX_PREFIX_IN_BRANCHES {
453 return Err(Error::Query(
454 QuerySyntaxError::InvalidWhereClauseComponents(
455 "an `IN` prefix pin fans out into more branches than the ranked surface serves \
456 — narrow the element list or issue several requests",
457 ),
458 ));
459 }
460 Ok(branches)
461}