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