Skip to main content

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