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