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::{CountMode, DocumentCountMode, DriveDocumentCountQuery, SplitCountEntry};
22use crate::drive::Drive;
23use crate::error::query::QuerySyntaxError;
24use crate::error::Error;
25use crate::query::ResolvedTimeRange;
26// Shared with the sum / average / joint dispatchers and the SDK proof
27// verifiers — see `crate::query::canonicalize` for the shape contract.
28use crate::query::{
29 key_array_element_clauses, validate_and_canonicalize_where_clauses,
30 validate_resolved_time_range_clause_shapes,
31};
32use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
33use dpp::data_contract::document_type::DocumentTypeRef;
34use dpp::version::PlatformVersion;
35use grovedb::TransactionArg;
36
37// `impl Drive { ... per-mode executors ... }` lives in
38// [`super::executors`] — it's a deliberate physical split between
39// "dispatcher routes" (this file) and "executors execute" (sibling).
40// All per-mode executor methods this file calls
41// (`execute_document_count_total_no_proof` etc.) are reachable via
42// the shared `Drive` type from there.
43
44/// All inputs required for the unified document-count entry point
45/// [`Drive::execute_document_count_request`]. Built by the gRPC
46/// handler from a `GetDocumentsRequestV1` after wire-decoding +
47/// contract lookup; drive owns everything past this point including
48/// mode-detection-from-clauses, index picking, and per-mode dispatch.
49///
50/// `where_clauses` and `order_clauses` arrive already structured —
51/// the v1 ABCI handler converts proto `repeated WhereClause` /
52/// `repeated OrderClause` upstream; benches and tests that want a
53/// `Value`-shape fixture call [`where_clauses_from_value`] /
54/// [`order_clauses_from_value`] to parse before constructing the
55/// request. The dispatcher entry point runs
56/// [`validate_and_canonicalize_where_clauses`] on the input so
57/// shape-validation rejection (duplicate equal, multiple In, …)
58/// and the `> AND <` → `between*` canonicalization happen
59/// regardless of upstream path.
60pub struct DocumentCountRequest<'a> {
61 /// Live contract (already loaded by the handler).
62 pub contract: &'a dpp::data_contract::DataContract,
63 /// Resolved document type within `contract`.
64 pub document_type: DocumentTypeRef<'a>,
65 /// Structured `where` clauses. The dispatcher runs the same
66 /// [`WhereClause::group_clauses`] validator + same-field
67 /// range-pair merge the regular document-query path runs (see
68 /// [`validate_and_canonicalize_where_clauses`]'s docstring for
69 /// the catalog of rejections this enables and the In/range +
70 /// `between*` canonicalization rules) before mode detection.
71 pub where_clauses: Vec<WhereClause>,
72 /// The fields among `where_clauses` whose equality clause was produced by
73 /// `IN_TIME_RANGE` resolution rather than written by the caller. Same
74 /// contract and same purpose as
75 /// [`crate::query::DriveDocumentQuery::resolved_time_ranges`]:
76 /// it is what gates which indexes the count pickers may select.
77 pub resolved_time_ranges: Vec<ResolvedTimeRange>,
78 /// Structured `order_by` clauses. The first clause's direction
79 /// governs split-mode entry ordering (per-`In`-value /
80 /// per-distinct-value-in-range) and, on the
81 /// `RangeDistinctProof` prove path, is part of the path-query
82 /// bytes the SDK reconstructs to verify the proof.
83 /// `PointLookupProof` and the no-proof `Total` / `PerInValue`
84 /// paths don't read order_by. Empty list → ascending default
85 /// for split-mode response ordering.
86 pub order_clauses: Vec<OrderClause>,
87 /// SQL-shaped output mode — the caller's `(select, group_by)`
88 /// contract resolved into one of four shapes (Aggregate,
89 /// GroupByIn, GroupByRange, GroupByCompound). The dispatcher
90 /// uses this to distinguish e.g. "aggregate count with In
91 /// fan-out" (which does NOT accept `limit`) from "per-In-value
92 /// entries" (which does) — they're otherwise indistinguishable
93 /// from the where clauses alone. See [`CountMode`] for the
94 /// per-variant where-clause and `limit` invariants.
95 pub mode: super::CountMode,
96 /// Limit cap from the request. Callers SHOULD pre-clamp against
97 /// their server-side `max_query_limit` policy, but Drive also
98 /// enforces a defense-in-depth clamp before forwarding to the
99 /// distinct-mode walk: an `Option::None` here is normalized to
100 /// `drive_config.default_query_limit` and any `Some(value)` is
101 /// reduced to `drive_config.max_query_limit` if larger. After
102 /// dispatch, the limit forwarded to
103 /// [`RangeCountOptions::limit`] is always `Some(_)` ≤ system cap.
104 pub limit: Option<u32>,
105 /// Whether to produce a proof (vs. raw counts).
106 pub prove: bool,
107 /// Drive-side query config — only consumed by the materialize-and-
108 /// count fallback.
109 pub drive_config: &'a crate::config::DriveConfig,
110}
111
112/// Output shape of [`Drive::execute_document_count_request`]. Three
113/// variants mirror the proto's `CountResults.variant` oneof (for
114/// no-proof responses) plus the outer `Proof` arm:
115///
116/// - `Aggregate(u64)` — total-count modes (`Total` and
117/// `RangeNoProof` under [`super::CountMode::Aggregate`]). The abci
118/// handler maps this to `CountResults.aggregate_count`.
119/// - `Entries(Vec<SplitCountEntry>)` — per-key modes (`PerInValue`
120/// and `RangeNoProof` under [`super::CountMode::GroupByRange`] /
121/// [`super::CountMode::GroupByCompound`]). The abci handler maps
122/// this to `CountResults.entries`.
123/// - `Proof(Vec<u8>)` — grovedb proof bytes the client verifies via
124/// either `verify_aggregate_count_query` (for `RangeProof`),
125/// `verify_distinct_count_proof` (for `RangeDistinctProof`), or
126/// the `DriveDocumentQuery` proof verifier (for
127/// `PointLookupProof`).
128#[derive(Debug, Clone)]
129pub enum DocumentCountResponse {
130 /// Single aggregate count — total across the matching set.
131 Aggregate(u64),
132 /// Per-key entries.
133 Entries(Vec<SplitCountEntry>),
134 /// Grovedb proof bytes.
135 Proof(Vec<u8>),
136}
137
138/// Parse the decoded `where` value into structured [`WhereClause`]s.
139///
140/// Mirrors the per-clause loop the regular `query_documents_v0`
141/// handler delegates to `DriveDocumentQuery::from_decomposed_values`:
142/// the abci layer just CBOR-decodes the wire bytes into a `Value` and
143/// hands the raw value down. Drive owns the parsing so a future
144/// per-clause validation (e.g. forbidding operators in distinct mode)
145/// can live next to the executors instead of being scattered across
146/// abci handlers.
147///
148/// `Value::Null` (empty `where` field) → no clauses. Any other shape
149/// must be an outer array of inner arrays-of-components.
150///
151/// After component parsing, the resulting clause list is run through
152/// [`WhereClause::group_clauses`] — the same validator the regular
153/// document-query path uses — to reject malformed shapes the count
154/// path otherwise silently reduces:
155///
156/// - Duplicate `Equal` clauses on the same field
157/// (`DuplicateNonGroupableClauseSameField`).
158/// - Multiple `In` clauses (`MultipleInClauses`) — rejected here: the
159/// shared grammar accepts them for protocol version 14+ document
160/// queries, but the aggregate surfaces do not.
161/// - Multiple non-groupable range clauses (`MultipleRangeClauses`).
162/// - Equality + `In` on the same field, range + equality/In on the
163/// same field (`DuplicateNonGroupableClauseSameField` /
164/// `InvalidWhereClauseComponents`).
165///
166/// Without this validation, downstream
167/// [`DriveDocumentCountQuery::find_countable_index_for_where_clauses`]
168/// collapses repeated fields into a `BTreeSet` and
169/// [`DriveDocumentCountQuery::point_lookup_count_path_query`]
170/// resolves each index property with a single `.find(...)` — both
171/// of which silently pick the first clause on a duplicated field
172/// and return a count for an arbitrarily reduced query rather than
173/// rejecting the malformed request. `group_clauses` is the single
174/// source of truth for what shapes the query stack as a whole
175/// accepts; running it here aligns the count endpoint with the
176/// regular document-query path's rejection contract.
177///
178/// Only the validation side-effect is consumed — the dispatcher
179/// continues to operate on the parsed `Vec<WhereClause>` directly,
180/// since the count-specific mode detection and index pickers
181/// expect a flat list, not the equal-clauses/in-clause/range-clause
182/// triple that `group_clauses` returns. (The regular query path's
183/// `InternalClauses::extract_from_clauses` uses the triple; the
184/// count path doesn't.)
185pub fn where_clauses_from_value(
186 value: &dpp::platform_value::Value,
187 platform_version: &PlatformVersion,
188) -> Result<Vec<WhereClause>, Error> {
189 let clauses: Vec<WhereClause> = match value {
190 dpp::platform_value::Value::Null => Vec::new(),
191 dpp::platform_value::Value::Array(clauses) => clauses
192 .iter()
193 .map(|wc| match wc {
194 dpp::platform_value::Value::Array(components) => {
195 WhereClause::from_components(components)
196 }
197 _ => Err(Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
198 "where clause must be an array".to_string(),
199 ))),
200 })
201 .collect::<Result<Vec<_>, _>>()?,
202 _ => {
203 return Err(Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
204 "where clause must be an array".to_string(),
205 )));
206 }
207 };
208
209 validate_and_canonicalize_where_clauses(clauses, platform_version)
210}
211
212/// Parse the decoded `order_by` value into structured [`OrderClause`]s.
213///
214/// Same shape as [`where_clauses_from_value`] for `order_by`:
215/// `Value::Null` (empty `order_by` field on the wire) → no clauses;
216/// any other shape must be an outer array of `[field, direction]`
217/// inner arrays. Direction is `"asc"` / `"desc"` per
218/// `OrderClause::from_components`.
219pub fn order_clauses_from_value(
220 value: &dpp::platform_value::Value,
221) -> Result<Vec<OrderClause>, Error> {
222 match value {
223 dpp::platform_value::Value::Null => Ok(Vec::new()),
224 dpp::platform_value::Value::Array(clauses) => clauses
225 .iter()
226 .map(|oc| match oc {
227 dpp::platform_value::Value::Array(components) => {
228 // `OrderClause::from_components` returns
229 // `grovedb::Error`; wrap as drive's query-syntax
230 // error so the dispatcher's error contract stays
231 // uniform with the where-clause parser above.
232 OrderClause::from_components(components).map_err(|_e| {
233 Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
234 "order_by clause must have [field, \"asc\"|\"desc\"] shape".to_string(),
235 ))
236 })
237 }
238 _ => Err(Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
239 "order_by clause must be an array".to_string(),
240 ))),
241 })
242 .collect(),
243 _ => Err(Error::Query(QuerySyntaxError::InvalidFormatWhereClause(
244 "order_by clause must be an array".to_string(),
245 ))),
246 }
247}
248
249impl Drive {
250 /// Single entry point for the unified `GetDocumentsCount` request.
251 ///
252 /// Owns the whole pipeline:
253 /// 1. [`DriveDocumentCountQuery::detect_mode`] classifies the
254 /// query shape from the where clauses + flags.
255 /// 2. The matching `Drive::execute_document_count_*` per-mode
256 /// method picks an index and runs the executor.
257 /// 3. The result is wrapped in [`DocumentCountResponse`] —
258 /// `Counts(...)` for no-proof modes, `Proof(...)` for proof
259 /// modes.
260 ///
261 /// Errors:
262 /// - Mode-detection failures (multiple range clauses, range +
263 /// `In`, distinct on prove path, …) come back as
264 /// `Error::Query(QuerySyntaxError::InvalidWhereClauseComponents)`.
265 /// - "No covering index" failures come back as
266 /// `Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty)`.
267 /// - All other failures (grovedb, cost calculation, …) surface
268 /// as their native `Error` variants.
269 ///
270 /// The handler maps both `Error::Query(...)` cases to its own
271 /// `QueryError::Query(...)` variant uniformly.
272 pub fn execute_document_count_request(
273 &self,
274 request: DocumentCountRequest,
275 transaction: TransactionArg,
276 platform_version: &PlatformVersion,
277 ) -> Result<DocumentCountResponse, Error> {
278 use dpp::data_contract::accessors::v0::DataContractV0Getters;
279
280 // Validate + canonicalize the structured `where_clauses` —
281 // same rejections the regular document-query path runs,
282 // applied here so the count endpoint's shape contract is
283 // independent of whether the caller arrived via the CBOR-
284 // shaped legacy path or the v1 typed-proto path. See
285 // [`validate_and_canonicalize_where_clauses`]'s docstring
286 // for the catalog of rejections / canonicalization rules.
287 // A typed array's elements are keyed first: several only when the
288 // request answers per element (see [`key_array_element_clauses`]).
289 let where_clauses = key_array_element_clauses(
290 request.where_clauses,
291 request.document_type,
292 matches!(request.mode, CountMode::GroupByIn),
293 platform_version,
294 )?;
295 let where_clauses =
296 validate_and_canonicalize_where_clauses(where_clauses, platform_version)?;
297 let resolved_time_ranges = request.resolved_time_ranges;
298 validate_resolved_time_range_clause_shapes(&where_clauses, &resolved_time_ranges)?;
299 let order_clauses = request.order_clauses;
300
301 // Split-mode entry direction is whatever the first orderBy
302 // clause specifies. Empty orderBy → ascending default. Used
303 // by per-`In`-value, distinct-range no-proof, and
304 // distinct-range prove paths; the `PointLookupProof` and
305 // flat `Total` paths don't read it.
306 let order_by_ascending = order_clauses.first().map(|c| c.ascending).unwrap_or(true);
307
308 let mode = DriveDocumentCountQuery::detect_mode_versioned(
309 &where_clauses,
310 request.mode,
311 request.prove,
312 platform_version,
313 )?;
314
315 let contract_id = request.contract.id_ref().to_buffer();
316 let document_type_name = request.document_type.name().to_string();
317
318 match mode {
319 DocumentCountMode::Total => {
320 // Total mode → single aggregate. The executor returns
321 // at most one entry (with empty key); collapse to
322 // `Aggregate(count)` here so the response is a u64
323 // with no per-key wrapping. Empty result (indexed
324 // path doesn't exist yet) → `Aggregate(0)`.
325 let entries = self.execute_document_count_total_no_proof(
326 contract_id,
327 request.document_type,
328 document_type_name,
329 where_clauses,
330 &resolved_time_ranges,
331 transaction,
332 platform_version,
333 )?;
334 let total = entries.first().and_then(|e| e.count).unwrap_or(0);
335 Ok(DocumentCountResponse::Aggregate(total))
336 }
337 DocumentCountMode::PerInValue => {
338 // |In| ≤ 100 is the structural bound; failsafe cap
339 // keeps behavior independent of `default_query_limit`.
340 // See [`super::MAX_LIMIT_AS_FAILSAFE`].
341 let options = RangeCountOptions {
342 distinct: false, // ignored by PerInValue executor
343 limit: Some(super::MAX_LIMIT_AS_FAILSAFE),
344 order_by_ascending,
345 };
346 Ok(DocumentCountResponse::Entries(
347 self.execute_document_count_per_in_value_no_proof(
348 contract_id,
349 request.document_type,
350 document_type_name,
351 where_clauses,
352 &resolved_time_ranges,
353 options,
354 transaction,
355 platform_version,
356 )?,
357 ))
358 }
359 DocumentCountMode::RangeNoProof => {
360 // Aggregate → failsafe cap (per-In fan-out bounded by
361 // |In| ≤ 100); distinct walk → caller's limit with
362 // `default_query_limit` fallback since range is
363 // genuinely unbounded.
364 let effective_limit = if request.mode.is_aggregate() {
365 super::MAX_LIMIT_AS_FAILSAFE
366 } else {
367 let effective_limit = request
368 .limit
369 .unwrap_or(request.drive_config.default_query_limit as u32)
370 .min(request.drive_config.max_query_limit as u32);
371 // Fail closed instead of walking storage with a
372 // zero bound (grovedb treats `limit: 0` as "return
373 // nothing", which would masquerade as an empty
374 // result set). Mirrors the sum-side
375 // `effective_no_proof_distinct_limit` policy.
376 if effective_limit == 0 {
377 return Err(Error::Query(QuerySyntaxError::InvalidLimit(
378 "effective distinct COUNT limit must be greater than zero".to_string(),
379 )));
380 }
381 effective_limit
382 };
383 let options = RangeCountOptions {
384 distinct: request.mode.requires_distinct_walk(),
385 limit: Some(effective_limit),
386 order_by_ascending,
387 };
388 let entries = self.execute_document_count_range_no_proof(
389 contract_id,
390 request.document_type,
391 document_type_name,
392 where_clauses,
393 &resolved_time_ranges,
394 options,
395 transaction,
396 platform_version,
397 )?;
398 if request.mode.is_aggregate() {
399 // Aggregate mode: executor returns a single
400 // empty-key entry containing the sum (or empty
401 // vec if the path doesn't exist). Collapse to
402 // `Aggregate`.
403 let total = entries.first().and_then(|e| e.count).unwrap_or(0);
404 Ok(DocumentCountResponse::Aggregate(total))
405 } else {
406 Ok(DocumentCountResponse::Entries(entries))
407 }
408 }
409 DocumentCountMode::RangeProof => Ok(DocumentCountResponse::Proof(
410 self.execute_document_count_range_proof(
411 contract_id,
412 request.document_type,
413 document_type_name,
414 where_clauses,
415 &resolved_time_ranges,
416 transaction,
417 platform_version,
418 )?,
419 )),
420 DocumentCountMode::RangeDistinctProof => {
421 // Validate-don't-clamp limit policy on the prove
422 // path: client-side proof reconstruction needs the
423 // exact same limit value the server applied to the
424 // path query (so the merk-root recomputation
425 // matches). Silent clamping would invisibly break
426 // verification on any request with `limit >
427 // max_query_limit`.
428 //
429 // **Limit fallback uses `crate::config::DEFAULT_QUERY_LIMIT`
430 // (the compile-time constant), NOT
431 // `drive_config.default_query_limit` (the
432 // operator-tunable runtime value).** The SDK verifier
433 // can't know an operator's tuned config, so any
434 // operator who tuned `default_query_limit` away from
435 // `DEFAULT_QUERY_LIMIT` would produce proofs whose
436 // `SizedQuery::limit` byte-differs from the
437 // verifier's reconstruction — silent verify failure
438 // on a consensus-adjacent path. Anchoring the
439 // fallback to the shared compile-time constant
440 // removes that operator-tunable degree of freedom
441 // from proof bytes entirely; the runtime
442 // `default_query_limit` continues to govern no-proof
443 // dispatch paths where there's no verifier to match.
444 // `max_query_limit` still gates the request as a
445 // DoS-protection knob (proofs never cross the
446 // operator-set ceiling, but the ceiling itself doesn't
447 // affect proof bytes — it only decides whether the
448 // request gets served).
449 let effective_limit = request
450 .limit
451 .unwrap_or(crate::config::DEFAULT_QUERY_LIMIT as u32);
452 if effective_limit > request.drive_config.max_query_limit as u32 {
453 return Err(Error::Query(QuerySyntaxError::InvalidLimit(format!(
454 "limit {} exceeds max_query_limit {} on the prove + \
455 distinct-walk path (GROUP BY a range field); reduce the \
456 requested limit or use prove = false",
457 effective_limit, request.drive_config.max_query_limit
458 ))));
459 }
460 let limit_u16 = effective_limit as u16;
461 // Default to ascending if the request didn't specify
462 // — matches the no-proof default. The verifier reads
463 // the same field to reconstruct the matching path
464 // query (see SDK's `FromProof<DocumentQuery>` impl
465 // for `DocumentSplitCounts`); both sides MUST land
466 // on the same `left_to_right` value or the merk-root
467 // recomputation fails.
468 let left_to_right = order_by_ascending;
469 Ok(DocumentCountResponse::Proof(
470 self.execute_document_count_range_distinct_proof(
471 contract_id,
472 request.document_type,
473 document_type_name,
474 where_clauses,
475 &resolved_time_ranges,
476 limit_u16,
477 left_to_right,
478 transaction,
479 platform_version,
480 )?,
481 ))
482 }
483 DocumentCountMode::PointLookupProof => Ok(DocumentCountResponse::Proof(
484 self.execute_document_count_point_lookup_proof(
485 contract_id,
486 request.document_type,
487 document_type_name,
488 where_clauses,
489 &resolved_time_ranges,
490 transaction,
491 platform_version,
492 )?,
493 )),
494 DocumentCountMode::RangeAggregateCarrierProof => {
495 // Validate-don't-clamp limit policy on the prove path
496 // (same rationale as `RangeDistinctProof` above): the
497 // verifier reconstructs the SizedQuery's `limit` byte-
498 // identically, so silent clamping would invisibly
499 // break verification.
500 //
501 // Two shape-dependent rules apply here:
502 //
503 // - **In-outer carrier (G7):** the caller's `|In|`
504 // already bounds the result. `SizedQuery::limit`
505 // stays `None`; if the caller passed a non-`None`
506 // `limit`, reject — there's no use case for a sub-
507 // `|In|` limit on this path, and accepting it would
508 // silently change which In-branches appear in the
509 // proof.
510 //
511 // - **Range-outer carrier (G8):** the platform
512 // enforces a max outer-walk cap of
513 // [`super::MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT`]
514 // on how many outer-range matches the carrier walks.
515 // Caller may pass a smaller `limit` to truncate the
516 // walk further; passing a larger one is rejected.
517 // If the caller passes `None`, the platform default
518 // (the cap itself) is used.
519 let effective_limit = DriveDocumentCountQuery::carrier_aggregate_count_limit(
520 &where_clauses,
521 request.limit,
522 )?;
523 // Outer-walk direction: ascending by default (the
524 // grovedb invariant for serialized-key carriers), or
525 // descending when the caller's `order_by` first
526 // clause is `desc`. Carried byte-identically through
527 // `Query::left_to_right` so the verifier rebuilds the
528 // exact same `PathQuery` — same load-bearing pattern
529 // as the `RangeDistinctProof` arm above.
530 let left_to_right = order_by_ascending;
531 Ok(DocumentCountResponse::Proof(
532 self.execute_document_count_range_aggregate_carrier_proof(
533 contract_id,
534 request.document_type,
535 document_type_name,
536 where_clauses,
537 &resolved_time_ranges,
538 effective_limit,
539 left_to_right,
540 transaction,
541 platform_version,
542 )?,
543 ))
544 }
545 }
546 }
547}