drive/query/drive_document_count_query/drive_dispatcher.rs
1//! Top-level dispatcher for the unified `GetDocumentsCount` request.
2//!
3//! Owns the whole pipeline: CBOR-decode → mode detection →
4//! per-mode executor (see [`super::executors`]) → response
5//! wrapping. The drive-abci handler builds a
6//! [`DocumentCountRequest`] and calls
7//! [`Drive::execute_document_count_request`]; everything past
8//! contract lookup lives in drive.
9//!
10//! Both `DocumentCountRequest` and `DocumentCountResponse` are
11//! the ABI for this dispatcher — they're public so drive-abci can
12//! name the input/output types without reaching into the
13//! executor surface.
14//!
15//! Module is gated `feature = "server"` via the parent's
16//! `pub mod drive_dispatcher;` declaration.
17
18use super::super::conditions::WhereClause;
19use super::super::ordering::OrderClause;
20use super::execute_range_count::RangeCountOptions;
21use super::{DocumentCountMode, DriveDocumentCountQuery, SplitCountEntry};
22use crate::drive::Drive;
23use crate::error::query::QuerySyntaxError;
24use crate::error::Error;
25use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
26use dpp::data_contract::document_type::DocumentTypeRef;
27use dpp::version::PlatformVersion;
28use grovedb::TransactionArg;
29
30// `impl Drive { ... per-mode executors ... }` lives in
31// [`super::executors`] — it's a deliberate physical split between
32// "dispatcher routes" (this file) and "executors execute" (sibling).
33// All per-mode executor methods this file calls
34// (`execute_document_count_total_no_proof` etc.) are reachable via
35// the shared `Drive` type from there.
36
37/// All inputs required for the unified document-count entry point
38/// [`Drive::execute_document_count_request`]. Built by the gRPC
39/// handler from a `GetDocumentsRequestV1` after wire-decoding +
40/// contract lookup; drive owns everything past this point including
41/// mode-detection-from-clauses, index picking, and per-mode dispatch.
42///
43/// `where_clauses` and `order_clauses` arrive already structured —
44/// the v1 ABCI handler converts proto `repeated WhereClause` /
45/// `repeated OrderClause` upstream; benches and tests that want a
46/// `Value`-shape fixture call [`where_clauses_from_value`] /
47/// [`order_clauses_from_value`] to parse before constructing the
48/// request. The dispatcher entry point runs
49/// [`validate_and_canonicalize_where_clauses`] on the input so
50/// shape-validation rejection (duplicate equal, multiple In, …)
51/// and the `> AND <` → `between*` canonicalization happen
52/// regardless of upstream path.
53pub struct DocumentCountRequest<'a> {
54 /// Live contract (already loaded by the handler).
55 pub contract: &'a dpp::data_contract::DataContract,
56 /// Resolved document type within `contract`.
57 pub document_type: DocumentTypeRef<'a>,
58 /// Structured `where` clauses. The dispatcher runs the same
59 /// [`WhereClause::group_clauses`] validator + same-field
60 /// range-pair merge the regular document-query path runs (see
61 /// [`validate_and_canonicalize_where_clauses`]'s docstring for
62 /// the catalog of rejections this enables and the In/range +
63 /// `between*` canonicalization rules) before mode detection.
64 pub where_clauses: Vec<WhereClause>,
65 /// Structured `order_by` clauses. The first clause's direction
66 /// governs split-mode entry ordering (per-`In`-value /
67 /// per-distinct-value-in-range) and, on the
68 /// `RangeDistinctProof` prove path, is part of the path-query
69 /// bytes the SDK reconstructs to verify the proof.
70 /// `PointLookupProof` and the no-proof `Total` / `PerInValue`
71 /// paths don't read order_by. Empty list → ascending default
72 /// for split-mode response ordering.
73 pub order_clauses: Vec<OrderClause>,
74 /// SQL-shaped output mode — the caller's `(select, group_by)`
75 /// contract resolved into one of four shapes (Aggregate,
76 /// GroupByIn, GroupByRange, GroupByCompound). The dispatcher
77 /// uses this to distinguish e.g. "aggregate count with In
78 /// fan-out" (which does NOT accept `limit`) from "per-In-value
79 /// entries" (which does) — they're otherwise indistinguishable
80 /// from the where clauses alone. See [`CountMode`] for the
81 /// per-variant where-clause and `limit` invariants.
82 pub mode: super::CountMode,
83 /// Limit cap from the request. Callers SHOULD pre-clamp against
84 /// their server-side `max_query_limit` policy, but Drive also
85 /// enforces a defense-in-depth clamp before forwarding to the
86 /// distinct-mode walk: an `Option::None` here is normalized to
87 /// `drive_config.default_query_limit` and any `Some(value)` is
88 /// reduced to `drive_config.max_query_limit` if larger. After
89 /// dispatch, the limit forwarded to
90 /// [`RangeCountOptions::limit`] is always `Some(_)` ≤ system cap.
91 pub limit: Option<u32>,
92 /// Whether to produce a proof (vs. raw counts).
93 pub prove: bool,
94 /// Drive-side query config — only consumed by the materialize-and-
95 /// count fallback.
96 pub drive_config: &'a crate::config::DriveConfig,
97}
98
99/// Output shape of [`Drive::execute_document_count_request`]. Three
100/// variants mirror the proto's `CountResults.variant` oneof (for
101/// no-proof responses) plus the outer `Proof` arm:
102///
103/// - `Aggregate(u64)` — total-count modes (`Total` and
104/// `RangeNoProof` under [`super::CountMode::Aggregate`]). The abci
105/// handler maps this to `CountResults.aggregate_count`.
106/// - `Entries(Vec<SplitCountEntry>)` — per-key modes (`PerInValue`
107/// and `RangeNoProof` under [`super::CountMode::GroupByRange`] /
108/// [`super::CountMode::GroupByCompound`]). The abci handler maps
109/// this to `CountResults.entries`.
110/// - `Proof(Vec<u8>)` — grovedb proof bytes the client verifies via
111/// either `verify_aggregate_count_query` (for `RangeProof`),
112/// `verify_distinct_count_proof` (for `RangeDistinctProof`), or
113/// the `DriveDocumentQuery` proof verifier (for
114/// `PointLookupProof`).
115#[derive(Debug, Clone)]
116pub enum DocumentCountResponse {
117 /// Single aggregate count — total across the matching set.
118 Aggregate(u64),
119 /// Per-key entries.
120 Entries(Vec<SplitCountEntry>),
121 /// Grovedb proof bytes.
122 Proof(Vec<u8>),
123}
124
125/// Parse the decoded `where` value into structured [`WhereClause`]s.
126///
127/// Mirrors the per-clause loop the regular `query_documents_v0`
128/// handler delegates to `DriveDocumentQuery::from_decomposed_values`:
129/// the abci layer just CBOR-decodes the wire bytes into a `Value` and
130/// hands the raw value down. Drive owns the parsing so a future
131/// per-clause validation (e.g. forbidding operators in distinct mode)
132/// can live next to the executors instead of being scattered across
133/// abci handlers.
134///
135/// `Value::Null` (empty `where` field) → no clauses. Any other shape
136/// must be an outer array of inner arrays-of-components.
137///
138/// After component parsing, the resulting clause list is run through
139/// [`WhereClause::group_clauses`] — the same validator the regular
140/// document-query path uses — to reject malformed shapes the count
141/// path otherwise silently reduces:
142///
143/// - Duplicate `Equal` clauses on the same field
144/// (`DuplicateNonGroupableClauseSameField`).
145/// - Multiple `In` clauses (`MultipleInClauses`).
146/// - Multiple non-groupable range clauses (`MultipleRangeClauses`).
147/// - Equality + `In` on the same field, range + equality/In on the
148/// same field (`DuplicateNonGroupableClauseSameField` /
149/// `InvalidWhereClauseComponents`).
150///
151/// Without this validation, downstream
152/// [`DriveDocumentCountQuery::find_countable_index_for_where_clauses`]
153/// collapses repeated fields into a `BTreeSet` and
154/// [`DriveDocumentCountQuery::point_lookup_count_path_query`]
155/// resolves each index property with a single `.find(...)` — both
156/// of which silently pick the first clause on a duplicated field
157/// and return a count for an arbitrarily reduced query rather than
158/// rejecting the malformed request. `group_clauses` is the single
159/// source of truth for what shapes the query stack as a whole
160/// accepts; running it here aligns the count endpoint with the
161/// regular document-query path's rejection contract.
162///
163/// Only the validation side-effect is consumed — the dispatcher
164/// continues to operate on the parsed `Vec<WhereClause>` directly,
165/// since the count-specific mode detection and index pickers
166/// expect a flat list, not the equal-clauses/in-clause/range-clause
167/// triple that `group_clauses` returns. (The regular query path's
168/// `InternalClauses::extract_from_clauses` uses the triple; the
169/// count path doesn't.)
170pub fn where_clauses_from_value(
171 value: &dpp::platform_value::Value,
172) -> Result<Vec<WhereClause>, Error> {
173 let clauses: Vec<WhereClause> = match value {
174 dpp::platform_value::Value::Null => Vec::new(),
175 dpp::platform_value::Value::Array(clauses) => clauses
176 .iter()
177 .map(|wc| match wc {
178 dpp::platform_value::Value::Array(components) => {
179 WhereClause::from_components(components)
180 }
181 _ => Err(Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
182 "where clause must be an array".to_string(),
183 ))),
184 })
185 .collect::<Result<Vec<_>, _>>()?,
186 _ => {
187 return Err(Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
188 "where clause must be an array".to_string(),
189 )));
190 }
191 };
192
193 validate_and_canonicalize_where_clauses(clauses)
194}
195
196/// Run the system-wide where-clause validator on a structured
197/// `Vec<WhereClause>` and canonicalize same-field range pairs into
198/// their `between*` form. Single source of truth for the
199/// count-endpoint shape contract; called both from the legacy
200/// CBOR-decoded entry [`where_clauses_from_value`] and from the
201/// dispatcher's typed entry, [`Drive::execute_document_count_request`].
202///
203/// The validator (`WhereClause::group_clauses`) rejects:
204/// - Duplicate `Equal` clauses on the same field
205/// (`DuplicateNonGroupableClauseSameField`).
206/// - Multiple `In` clauses (`MultipleInClauses`).
207/// - Multiple non-groupable range clauses (`MultipleRangeClauses`).
208/// - Equality + `In` on the same field, range + equality/In on the
209/// same field (`DuplicateNonGroupableClauseSameField` /
210/// `InvalidWhereClauseComponents`).
211///
212/// Without this validation, downstream
213/// [`DriveDocumentCountQuery::find_countable_index_for_where_clauses`]
214/// collapses repeated fields into a `BTreeSet` and
215/// [`DriveDocumentCountQuery::point_lookup_count_path_query`]
216/// resolves each index property with a single `.find(...)` — both
217/// of which silently pick the first clause on a duplicated field
218/// and return a count for an arbitrarily reduced query rather than
219/// rejecting the malformed request.
220///
221/// **Exception**: `MultipleRangeClauses` is intentionally tolerated
222/// here. The regular-query parser rejects two ranges on different
223/// fields wholesale (its callers expect
224/// `(equal_clauses, in_clause, range_clause)` triples), but the
225/// count-query path accepts the carrier-aggregate shape
226/// (`outer_range + inner_ACOR_range` on different fields, e.g.
227/// G8). Structural validation for that shape lives in
228/// [`DriveDocumentCountQuery::detect_mode`] (which knows about
229/// `CountMode::GroupByRange`-with-two-ranges and routes to
230/// `DocumentCountMode::RangeAggregateCarrierProof`); replicating
231/// it here would be redundant.
232///
233/// After validation, [`merge_same_field_range_pairs`] collapses
234/// `[field > A, field < B]` (and analogous pairs with `>=` / `<=`)
235/// into the canonical `between*` operator that
236/// [`DriveDocumentCountQuery::range_clause_to_query_item`] knows
237/// how to convert into a single `QueryItem`. The regular-query
238/// parser does the same merge before its grouped-triple
239/// validation; for count queries we do it explicitly here so
240/// callers can pass either the bounded form (e.g.
241/// `[brand > A, brand < B]`) or the pre-merged form (e.g.
242/// `[brand BetweenExcludeBounds [A, B]]`) and get equivalent
243/// mode detection downstream. Without this merge, G8a's natural
244/// wire shape (four range clauses, two per field) would slip past
245/// the catch-`MultipleRangeClauses` block above and then get
246/// rejected by `detect_mode`'s `range_count > 1` structural check.
247pub fn validate_and_canonicalize_where_clauses(
248 clauses: Vec<WhereClause>,
249) -> Result<Vec<WhereClause>, Error> {
250 match WhereClause::group_clauses(&clauses) {
251 Ok(_) => {}
252 Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(_))) => {}
253 Err(e) => return Err(e),
254 }
255 merge_same_field_range_pairs(clauses)
256}
257
258/// Collapse `[field > A, field < B]` (and analogous pairs with
259/// `>=` / `<=`) into a single `field between* [A, B]` clause per
260/// field. Equality / In clauses pass through unchanged.
261///
262/// Returns an error if a field has more than two range clauses
263/// (structurally meaningless — a third bound would either
264/// contradict an existing one or be redundant) or if the pair
265/// isn't one lower-bound + one upper-bound (e.g. two `>` on the
266/// same field).
267fn merge_same_field_range_pairs(clauses: Vec<WhereClause>) -> Result<Vec<WhereClause>, Error> {
268 use crate::query::conditions::WhereOperator::{
269 Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, GreaterThan,
270 GreaterThanOrEquals, LessThan, LessThanOrEquals,
271 };
272 use std::collections::BTreeMap;
273
274 let mut by_field: BTreeMap<String, Vec<WhereClause>> = BTreeMap::new();
275 let mut non_range: Vec<WhereClause> = Vec::new();
276 for wc in clauses {
277 if DriveDocumentCountQuery::is_range_operator(wc.operator) {
278 by_field.entry(wc.field.clone()).or_default().push(wc);
279 } else {
280 non_range.push(wc);
281 }
282 }
283 let mut result = non_range;
284 for (field, mut ranges) in by_field {
285 match ranges.len() {
286 0 => {}
287 1 => result.push(ranges.remove(0)),
288 2 => {
289 let (mut lower, mut upper): (Option<WhereClause>, Option<WhereClause>) =
290 (None, None);
291 for r in ranges {
292 match r.operator {
293 GreaterThan | GreaterThanOrEquals => {
294 if lower.is_some() {
295 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
296 "two lower-bound range clauses on the same field cannot be \
297 merged; combine via `between*` or remove the redundant clause",
298 )));
299 }
300 lower = Some(r);
301 }
302 LessThan | LessThanOrEquals => {
303 if upper.is_some() {
304 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
305 "two upper-bound range clauses on the same field cannot be \
306 merged; combine via `between*` or remove the redundant clause",
307 )));
308 }
309 upper = Some(r);
310 }
311 _ => {
312 // The other range operators (Between*,
313 // StartsWith) are themselves bounded
314 // already; a second range clause on the
315 // same field is structurally redundant.
316 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
317 "cannot pair a `between*`/`startsWith` range clause with \
318 another range on the same field; use the pre-merged form",
319 )));
320 }
321 }
322 }
323 let lower = lower.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses(
324 "two range clauses on the same field require one lower bound (> or >=) \
325 and one upper bound (< or <=)",
326 )))?;
327 let upper = upper.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses(
328 "two range clauses on the same field require one lower bound (> or >=) \
329 and one upper bound (< or <=)",
330 )))?;
331 let merged_op = match (
332 lower.operator == GreaterThanOrEquals,
333 upper.operator == LessThanOrEquals,
334 ) {
335 (true, true) => Between, // [a, b]
336 (false, false) => BetweenExcludeBounds, // (a, b)
337 (true, false) => BetweenExcludeRight, // [a, b)
338 (false, true) => BetweenExcludeLeft, // (a, b]
339 };
340 result.push(WhereClause {
341 field,
342 operator: merged_op,
343 value: dpp::platform_value::Value::Array(vec![lower.value, upper.value]),
344 });
345 }
346 _ => {
347 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
348 "more than two range clauses on the same field are not supported; a \
349 bounded range needs exactly one lower bound and one upper bound",
350 )));
351 }
352 }
353 }
354 Ok(result)
355}
356
357/// Parse the decoded `order_by` value into structured [`OrderClause`]s.
358///
359/// Same shape as [`where_clauses_from_value`] for `order_by`:
360/// `Value::Null` (empty `order_by` field on the wire) → no clauses;
361/// any other shape must be an outer array of `[field, direction]`
362/// inner arrays. Direction is `"asc"` / `"desc"` per
363/// `OrderClause::from_components`.
364pub fn order_clauses_from_value(
365 value: &dpp::platform_value::Value,
366) -> Result<Vec<OrderClause>, Error> {
367 match value {
368 dpp::platform_value::Value::Null => Ok(Vec::new()),
369 dpp::platform_value::Value::Array(clauses) => clauses
370 .iter()
371 .map(|oc| match oc {
372 dpp::platform_value::Value::Array(components) => {
373 // `OrderClause::from_components` returns
374 // `grovedb::Error`; wrap as drive's query-syntax
375 // error so the dispatcher's error contract stays
376 // uniform with the where-clause parser above.
377 OrderClause::from_components(components).map_err(|_e| {
378 Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
379 "order_by clause must have [field, \"asc\"|\"desc\"] shape".to_string(),
380 ))
381 })
382 }
383 _ => Err(Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
384 "order_by clause must be an array".to_string(),
385 ))),
386 })
387 .collect(),
388 _ => Err(Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
389 "order_by clause must be an array".to_string(),
390 ))),
391 }
392}
393
394impl Drive {
395 /// Single entry point for the unified `GetDocumentsCount` request.
396 ///
397 /// Owns the whole pipeline:
398 /// 1. [`DriveDocumentCountQuery::detect_mode`] classifies the
399 /// query shape from the where clauses + flags.
400 /// 2. The matching `Drive::execute_document_count_*` per-mode
401 /// method picks an index and runs the executor.
402 /// 3. The result is wrapped in [`DocumentCountResponse`] —
403 /// `Counts(...)` for no-proof modes, `Proof(...)` for proof
404 /// modes.
405 ///
406 /// Errors:
407 /// - Mode-detection failures (multiple range clauses, range +
408 /// `In`, distinct on prove path, …) come back as
409 /// `Error::Query(QuerySyntaxError::InvalidWhereClauseComponents)`.
410 /// - "No covering index" failures come back as
411 /// `Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty)`.
412 /// - All other failures (grovedb, cost calculation, …) surface
413 /// as their native `Error` variants.
414 ///
415 /// The handler maps both `Error::Query(...)` cases to its own
416 /// `QueryError::Query(...)` variant uniformly.
417 pub fn execute_document_count_request(
418 &self,
419 request: DocumentCountRequest,
420 transaction: TransactionArg,
421 platform_version: &PlatformVersion,
422 ) -> Result<DocumentCountResponse, Error> {
423 use dpp::data_contract::accessors::v0::DataContractV0Getters;
424
425 // Validate + canonicalize the structured `where_clauses` —
426 // same rejections the regular document-query path runs,
427 // applied here so the count endpoint's shape contract is
428 // independent of whether the caller arrived via the CBOR-
429 // shaped legacy path or the v1 typed-proto path. See
430 // [`validate_and_canonicalize_where_clauses`]'s docstring
431 // for the catalog of rejections / canonicalization rules.
432 let where_clauses = validate_and_canonicalize_where_clauses(request.where_clauses)?;
433 let order_clauses = request.order_clauses;
434
435 // Split-mode entry direction is whatever the first orderBy
436 // clause specifies. Empty orderBy → ascending default. Used
437 // by per-`In`-value, distinct-range no-proof, and
438 // distinct-range prove paths; the `PointLookupProof` and
439 // flat `Total` paths don't read it.
440 let order_by_ascending = order_clauses.first().map(|c| c.ascending).unwrap_or(true);
441
442 let mode = DriveDocumentCountQuery::detect_mode_versioned(
443 &where_clauses,
444 request.mode,
445 request.prove,
446 platform_version,
447 )?;
448
449 let contract_id = request.contract.id_ref().to_buffer();
450 let document_type_name = request.document_type.name().to_string();
451
452 match mode {
453 DocumentCountMode::Total => {
454 // Total mode → single aggregate. The executor returns
455 // at most one entry (with empty key); collapse to
456 // `Aggregate(count)` here so the response is a u64
457 // with no per-key wrapping. Empty result (indexed
458 // path doesn't exist yet) → `Aggregate(0)`.
459 let entries = self.execute_document_count_total_no_proof(
460 contract_id,
461 request.document_type,
462 document_type_name,
463 where_clauses,
464 transaction,
465 platform_version,
466 )?;
467 let total = entries.first().and_then(|e| e.count).unwrap_or(0);
468 Ok(DocumentCountResponse::Aggregate(total))
469 }
470 DocumentCountMode::PerInValue => {
471 // |In| ≤ 100 is the structural bound; failsafe cap
472 // keeps behavior independent of `default_query_limit`.
473 // See [`super::MAX_LIMIT_AS_FAILSAFE`].
474 let options = RangeCountOptions {
475 distinct: false, // ignored by PerInValue executor
476 limit: Some(super::MAX_LIMIT_AS_FAILSAFE),
477 order_by_ascending,
478 };
479 Ok(DocumentCountResponse::Entries(
480 self.execute_document_count_per_in_value_no_proof(
481 contract_id,
482 request.document_type,
483 document_type_name,
484 where_clauses,
485 options,
486 transaction,
487 platform_version,
488 )?,
489 ))
490 }
491 DocumentCountMode::RangeNoProof => {
492 // Aggregate → failsafe cap (per-In fan-out bounded by
493 // |In| ≤ 100); distinct walk → caller's limit with
494 // `default_query_limit` fallback since range is
495 // genuinely unbounded.
496 let effective_limit = if request.mode.is_aggregate() {
497 super::MAX_LIMIT_AS_FAILSAFE
498 } else {
499 request
500 .limit
501 .unwrap_or(request.drive_config.default_query_limit as u32)
502 .min(request.drive_config.max_query_limit as u32)
503 };
504 let options = RangeCountOptions {
505 distinct: request.mode.requires_distinct_walk(),
506 limit: Some(effective_limit),
507 order_by_ascending,
508 };
509 let entries = self.execute_document_count_range_no_proof(
510 contract_id,
511 request.document_type,
512 document_type_name,
513 where_clauses,
514 options,
515 transaction,
516 platform_version,
517 )?;
518 if request.mode.is_aggregate() {
519 // Aggregate mode: executor returns a single
520 // empty-key entry containing the sum (or empty
521 // vec if the path doesn't exist). Collapse to
522 // `Aggregate`.
523 let total = entries.first().and_then(|e| e.count).unwrap_or(0);
524 Ok(DocumentCountResponse::Aggregate(total))
525 } else {
526 Ok(DocumentCountResponse::Entries(entries))
527 }
528 }
529 DocumentCountMode::RangeProof => Ok(DocumentCountResponse::Proof(
530 self.execute_document_count_range_proof(
531 contract_id,
532 request.document_type,
533 document_type_name,
534 where_clauses,
535 transaction,
536 platform_version,
537 )?,
538 )),
539 DocumentCountMode::RangeDistinctProof => {
540 // Validate-don't-clamp limit policy on the prove
541 // path: client-side proof reconstruction needs the
542 // exact same limit value the server applied to the
543 // path query (so the merk-root recomputation
544 // matches). Silent clamping would invisibly break
545 // verification on any request with `limit >
546 // max_query_limit`.
547 //
548 // **Limit fallback uses `crate::config::DEFAULT_QUERY_LIMIT`
549 // (the compile-time constant), NOT
550 // `drive_config.default_query_limit` (the
551 // operator-tunable runtime value).** The SDK verifier
552 // can't know an operator's tuned config, so any
553 // operator who tuned `default_query_limit` away from
554 // `DEFAULT_QUERY_LIMIT` would produce proofs whose
555 // `SizedQuery::limit` byte-differs from the
556 // verifier's reconstruction — silent verify failure
557 // on a consensus-adjacent path. Anchoring the
558 // fallback to the shared compile-time constant
559 // removes that operator-tunable degree of freedom
560 // from proof bytes entirely; the runtime
561 // `default_query_limit` continues to govern no-proof
562 // dispatch paths where there's no verifier to match.
563 // `max_query_limit` still gates the request as a
564 // DoS-protection knob (proofs never cross the
565 // operator-set ceiling, but the ceiling itself doesn't
566 // affect proof bytes — it only decides whether the
567 // request gets served).
568 let effective_limit = request
569 .limit
570 .unwrap_or(crate::config::DEFAULT_QUERY_LIMIT as u32);
571 if effective_limit > request.drive_config.max_query_limit as u32 {
572 return Err(Error::Query(QuerySyntaxError::InvalidLimit(format!(
573 "limit {} exceeds max_query_limit {} on the prove + \
574 distinct-walk path (GROUP BY a range field); reduce the \
575 requested limit or use prove = false",
576 effective_limit, request.drive_config.max_query_limit
577 ))));
578 }
579 let limit_u16 = effective_limit as u16;
580 // Default to ascending if the request didn't specify
581 // — matches the no-proof default. The verifier reads
582 // the same field to reconstruct the matching path
583 // query (see SDK's `FromProof<DocumentQuery>` impl
584 // for `DocumentSplitCounts`); both sides MUST land
585 // on the same `left_to_right` value or the merk-root
586 // recomputation fails.
587 let left_to_right = order_by_ascending;
588 Ok(DocumentCountResponse::Proof(
589 self.execute_document_count_range_distinct_proof(
590 contract_id,
591 request.document_type,
592 document_type_name,
593 where_clauses,
594 limit_u16,
595 left_to_right,
596 transaction,
597 platform_version,
598 )?,
599 ))
600 }
601 DocumentCountMode::PointLookupProof => Ok(DocumentCountResponse::Proof(
602 self.execute_document_count_point_lookup_proof(
603 contract_id,
604 request.document_type,
605 document_type_name,
606 where_clauses,
607 transaction,
608 platform_version,
609 )?,
610 )),
611 DocumentCountMode::RangeAggregateCarrierProof => {
612 // Validate-don't-clamp limit policy on the prove path
613 // (same rationale as `RangeDistinctProof` above): the
614 // verifier reconstructs the SizedQuery's `limit` byte-
615 // identically, so silent clamping would invisibly
616 // break verification.
617 //
618 // Two shape-dependent rules apply here:
619 //
620 // - **In-outer carrier (G7):** the caller's `|In|`
621 // already bounds the result. `SizedQuery::limit`
622 // stays `None`; if the caller passed a non-`None`
623 // `limit`, reject — there's no use case for a sub-
624 // `|In|` limit on this path, and accepting it would
625 // silently change which In-branches appear in the
626 // proof.
627 //
628 // - **Range-outer carrier (G8):** the platform
629 // enforces a max outer-walk cap of
630 // [`super::MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT`]
631 // on how many outer-range matches the carrier walks.
632 // Caller may pass a smaller `limit` to truncate the
633 // walk further; passing a larger one is rejected.
634 // If the caller passes `None`, the platform default
635 // (the cap itself) is used.
636 let has_outer_range = where_clauses
637 .iter()
638 .filter(|wc| DriveDocumentCountQuery::is_range_operator(wc.operator))
639 .count()
640 == 2;
641 let effective_limit = if has_outer_range {
642 match request.limit {
643 None => Some(super::MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT),
644 Some(n) => {
645 if n > super::MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT as u32 {
646 return Err(Error::Query(QuerySyntaxError::InvalidLimit(format!(
647 "carrier-aggregate range-outer queries (e.g. \
648 `outer_range_field > X AND inner_acor_field > \
649 Y` with `group_by = [outer_range_field]`) cap \
650 the outer walk at {} entries (compile-time \
651 constant `MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT`); \
652 got limit = {}. Pass a value ≤ {} or omit \
653 `limit` to use the default.",
654 super::MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT,
655 n,
656 super::MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT,
657 ))));
658 }
659 if n == 0 {
660 return Err(Error::Query(QuerySyntaxError::InvalidLimit(
661 "carrier-aggregate range-outer queries require limit \
662 ≥ 1; got limit = 0"
663 .to_string(),
664 )));
665 }
666 Some(n as u16)
667 }
668 }
669 } else {
670 if let Some(n) = request.limit {
671 return Err(Error::Query(QuerySyntaxError::InvalidLimit(format!(
672 "carrier-aggregate In-outer queries (e.g. `outer_in_field IN \
673 [...] AND inner_acor_field > Y` with `group_by = \
674 [outer_in_field]`) don't accept `limit` — the In array's \
675 length already bounds the result. Got limit = {n}.",
676 ))));
677 }
678 None
679 };
680 // Outer-walk direction: ascending by default (the
681 // grovedb invariant for serialized-key carriers), or
682 // descending when the caller's `order_by` first
683 // clause is `desc`. Carried byte-identically through
684 // `Query::left_to_right` so the verifier rebuilds the
685 // exact same `PathQuery` — same load-bearing pattern
686 // as the `RangeDistinctProof` arm above.
687 let left_to_right = order_by_ascending;
688 Ok(DocumentCountResponse::Proof(
689 self.execute_document_count_range_aggregate_carrier_proof(
690 contract_id,
691 request.document_type,
692 document_type_name,
693 where_clauses,
694 effective_limit,
695 left_to_right,
696 transaction,
697 platform_version,
698 )?,
699 ))
700 }
701 }
702 }
703}