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}