Skip to main content

drive/query/drive_document_count_query/
mode_detection.rs

1//! Mode detection + operator classification for the count query.
2//!
3//! Pure functions on the where-clause shape + request flags — no
4//! Drive, no contract, no indexes. Used both server-side (to pick an
5//! executor) and verifier-side (to validate the request before
6//! attempting verification).
7
8use super::super::conditions::{WhereClause, WhereOperator};
9use super::{CountMode, DocumentCountMode, DriveDocumentCountQuery};
10#[cfg(any(feature = "server", feature = "verify"))]
11use crate::error::query::QuerySyntaxError;
12
13impl DriveDocumentCountQuery<'_> {
14    /// Returns `true` if the where-clause operator is one the count fast path
15    /// can serve via point-lookups in a CountTree.
16    ///
17    /// Today that's `Equal` (one path) and `In` (cartesian fork over the listed
18    /// values). Range operators (`>`, `<`, `Between*`, `StartsWith`) need a
19    /// boundary walk that the current PathQuery infrastructure cannot express;
20    /// callers detect those via [`Self::has_unsupported_operator`] and surface
21    /// an error instead of silently returning a wrong count.
22    ///
23    /// `pub(super)` so the sibling [`index_picker`](super::index_picker) module
24    /// can call it from `Self::is_indexable_for_count`; not part of the public
25    /// API.
26    pub(super) fn is_indexable_for_count(op: WhereOperator) -> bool {
27        matches!(op, WhereOperator::Equal | WhereOperator::In)
28    }
29
30    /// Returns `true` if `op` is a range operator that can be served by a
31    /// `range_countable` index walking the property-name `ProvableCountTree`'s
32    /// children. The non-prefix portion of a range count query carries
33    /// exactly one range operator on the index's last property.
34    pub fn is_range_operator(op: WhereOperator) -> bool {
35        matches!(
36            op,
37            WhereOperator::GreaterThan
38                | WhereOperator::GreaterThanOrEquals
39                | WhereOperator::LessThan
40                | WhereOperator::LessThanOrEquals
41                | WhereOperator::Between
42                | WhereOperator::BetweenExcludeBounds
43                | WhereOperator::BetweenExcludeLeft
44                | WhereOperator::BetweenExcludeRight
45                | WhereOperator::StartsWith
46        )
47    }
48
49    /// Returns `true` if any where clause uses an operator the count fast path
50    /// cannot serve. Callers should treat this as a query-rejection signal.
51    pub fn has_unsupported_operator(where_clauses: &[WhereClause]) -> bool {
52        where_clauses
53            .iter()
54            .any(|wc| !Self::is_indexable_for_count(wc.operator))
55    }
56
57    /// Classify a count query's mode from its where clauses + request flags.
58    ///
59    /// This is the protocol-version-agnostic shape detection that
60    /// decides which executor (one of the six
61    /// [`DocumentCountMode`] variants — `Total` / `PerInValue` /
62    /// `RangeNoProof` / `RangeProof` / `RangeDistinctProof` /
63    /// `PointLookupProof`) the request maps to. The returned
64    /// `DocumentCountMode` discriminates among the dispatcher's
65    /// match arms; concrete pagination / index-picker inputs flow
66    /// through the call sites separately.
67    ///
68    /// All validation that depends only on the where clauses + flags
69    /// (multiple range clauses, range mixed with `In`, distinct mode on
70    /// the prove path, distinct mode without a range clause, etc.) is
71    /// done here and surfaces as
72    /// [`QuerySyntaxError::InvalidWhereClauseComponents`]. Validation
73    /// that depends on the contract's index set (no covering index)
74    /// stays at the call site since it requires the
75    /// `&BTreeMap<String, Index>`.
76    #[cfg(any(feature = "server", feature = "verify"))]
77    pub fn detect_mode(
78        where_clauses: &[WhereClause],
79        mode: CountMode,
80        prove: bool,
81    ) -> Result<DocumentCountMode, QuerySyntaxError> {
82        // The caller-supplied `CountMode` is the SQL-shape contract
83        // (Aggregate / GroupByIn / GroupByRange / GroupByCompound).
84        // Translate it back to the "distinct walk required?" boolean
85        // the per-mode tuple match below was written against. Pure
86        // mechanical mapping: the distinct walk is what the two
87        // range-grouped variants need.
88        let distinct = mode.requires_distinct_walk();
89        // Reject any operator that's neither an indexable point operator
90        // (Equal/In) nor a range operator. Defense-in-depth: the request
91        // shape forbids these elsewhere, but folding the check in here
92        // keeps the mode-detection contract self-contained.
93        //
94        // `startsWith` IS in `is_range_operator` and routes through the
95        // same `Range(a..b)` path as `betweenExcludeRight` — the
96        // half-open upper bound is computed by byte-incrementing the
97        // serialized prefix's last byte (see `range_clause_to_query_item`,
98        // mirroring `conditions.rs:1129`'s normal-docs encoding).
99        for wc in where_clauses {
100            if !Self::is_indexable_for_count(wc.operator) && !Self::is_range_operator(wc.operator) {
101                return Err(QuerySyntaxError::InvalidWhereClauseComponents(
102                    "count query supports only `==`, `in`, and range operators",
103                ));
104            }
105        }
106
107        let range_count = where_clauses
108            .iter()
109            .filter(|wc| Self::is_range_operator(wc.operator))
110            .count();
111        let in_count = where_clauses
112            .iter()
113            .filter(|wc| wc.operator == WhereOperator::In)
114            .count();
115
116        // `range_count > 1` is rejected for every shape EXCEPT the
117        // carrier-ACOR with outer range (G8): when the caller asks
118        // for `GroupByRange` + `prove = true` and there are exactly
119        // two range clauses on different fields (one on the index's
120        // first property — the carrier — and one on the terminator —
121        // the inner ACOR), grovedb's carrier-subquery composition
122        // ([PR #663](https://github.com/dashpay/grovedb/pull/663)
123        // shipped the carrier shape; [PR #664](https://github.com/dashpay/grovedb/pull/664)
124        // permits `SizedQuery::limit` on it) makes this a well-formed
125        // single-proof shape. The two ranges must be on distinct
126        // fields — combining two-sided ranges on the same field still
127        // routes through `between*` as before.
128        if range_count > 1 {
129            let two_ranges_on_distinct_fields = where_clauses
130                .iter()
131                .filter(|wc| Self::is_range_operator(wc.operator))
132                .map(|wc| wc.field.as_str())
133                .collect::<std::collections::BTreeSet<_>>()
134                .len()
135                == range_count;
136            if !(prove
137                && matches!(mode, CountMode::GroupByRange)
138                && range_count == 2
139                && in_count == 0
140                && two_ranges_on_distinct_fields)
141            {
142                return Err(QuerySyntaxError::InvalidWhereClauseComponents(
143                    "count query supports at most one range where-clause; combine \
144                     two-sided ranges via `between*` instead of separate `>` / `<` \
145                     clauses, or use `group_by = [outer_range_field]` with `prove = \
146                     true` for the carrier-aggregate shape with one outer range and \
147                     one inner ACOR range on a different field",
148                ));
149            }
150        }
151        if in_count > 1 {
152            return Err(QuerySyntaxError::InvalidWhereClauseComponents(
153                "count query supports at most one `in` where-clause; the In carries \
154                 the split property and only one split dimension is supported per request",
155            ));
156        }
157
158        let has_range = range_count == 1;
159        let has_in = in_count == 1;
160
161        // `range + In` on the prove path used to be rejected
162        // wholesale because grovedb's `AggregateCountOnRange`
163        // primitive wraps a single inner range and historically
164        // couldn't cartesian-fork. As of grovedb PR #663 it CAN
165        // appear under outer `Keys` via the carrier-subquery
166        // composition, so the rejection is now conditional:
167        // - `CountMode::GroupByIn` (single-field group_by on the
168        //   In field): routes to the new
169        //   `DocumentCountMode::RangeAggregateCarrierProof` —
170        //   produces one (in_key, u64) pair per In branch via a
171        //   carrier-ACOR PathQuery (see
172        //   [`super::path_query::carrier_aggregate_count_path_query`]).
173        // - `CountMode::GroupByRange` / `GroupByCompound` (distinct
174        //   modes): route to `RangeDistinctProof` as before.
175        // - `CountMode::Aggregate` (no group_by): still rejected.
176        //   With no group_by the caller asks for a single summed
177        //   count across all In branches, but the carrier-ACOR
178        //   primitive returns one count per branch — collapsing
179        //   that back to a single sum at the verifier requires
180        //   trusting the SDK to add them, which is exactly what
181        //   the prove path is supposed to avoid (the verifier
182        //   should get the consensus-committed answer, not
183        //   compute a derived one).
184        if has_range && has_in && prove && !distinct && !matches!(mode, CountMode::GroupByIn) {
185            return Err(QuerySyntaxError::InvalidWhereClauseComponents(
186                "range count queries with an `in` clause are not supported on the \
187                 aggregate prove path; use `group_by = [in_field]` for the carrier \
188                 per-In-aggregate proof (one u64 per branch), `group_by = [in_field, \
189                 range_field]` for the compound distinct-prove path (per-distinct-\
190                 value entries), or `prove = false` for the no-proof variant",
191            ));
192        }
193
194        if distinct && !has_range && range_count != 2 {
195            return Err(QuerySyntaxError::InvalidWhereClauseComponents(
196                "GROUP BY on a range field requires a range where-clause; the \
197                 range field must appear in `where` for the distinct walk to \
198                 have a window to iterate over",
199            ));
200        }
201
202        // G8 short-circuit: `GroupByRange + prove` with exactly two
203        // range clauses on distinct fields routes to the carrier
204        // ACOR shape. One range becomes the outer dimension of the
205        // carrier walk; the other range becomes the inner ACOR
206        // target. `SizedQuery::limit` caps the outer walk (per
207        // [grovedb PR #664](https://github.com/dashpay/grovedb/pull/664)).
208        // The validity of "which range is the outer / which is the
209        // inner" depends on the picked index — checked in the
210        // path-query builder against the index's property order.
211        if prove && matches!(mode, CountMode::GroupByRange) && range_count == 2 && in_count == 0 {
212            return Ok(DocumentCountMode::RangeAggregateCarrierProof);
213        }
214
215        Ok(match (has_range, has_in, prove, distinct) {
216            // Range + prove + distinct (with or without In on
217            // prefix): per-distinct-value counts come from a
218            // regular range proof against the property-name
219            // `ProvableCountTree`. With In on prefix the path
220            // query uses grovedb's subquery primitive to
221            // cartesian-fork; the verifier walks the same
222            // compound shape.
223            (true, _, true, true) => DocumentCountMode::RangeDistinctProof,
224            // Range + prove + summed (no In): `AggregateCountOnRange`
225            // collapse — single u64 verified out. The In case is
226            // rejected above.
227            (true, false, true, false) => DocumentCountMode::RangeProof,
228            // Range + no-proof: the executor uses the same
229            // `distinct_count_path_query` builder; In on prefix
230            // forks via grovedb subquery at execution time. Sum
231            // vs. distinct comes from `RangeCountOptions.distinct`
232            // applied to the merged result.
233            (true, _, false, _) => DocumentCountMode::RangeNoProof,
234            (false, true, false, _) => DocumentCountMode::PerInValue,
235            // `In` + `prove = true` (no range): route to the
236            // CountTree-element proof path. The shared
237            // `point_lookup_count_path_query` builder emits one
238            // `Element::CountTree` per matched In branch (via
239            // outer `Key`s + `[0]` subquery); the SDK's
240            // `verify_point_lookup_count_proof` extracts
241            // `count_value_or_default()` from each verified
242            // element, and the `FromProof<DocumentQuery>` impl
243            // for `DocumentSplitCounts` returns them as
244            // per-In-value entries. Proof size is O(|In values|
245            // × log n) — no document materialization, no
246            // `u16::MAX` cap on matching docs.
247            (false, true, true, _) => DocumentCountMode::PointLookupProof,
248            // No range, no In, `prove = true`: same CountTree-
249            // element proof shape — either the documents_countable
250            // primary-key CountTree fast path (empty where) or
251            // a single per-branch CountTree element for an
252            // Equal-only fully-covered query.
253            (false, false, true, _) => DocumentCountMode::PointLookupProof,
254            (false, false, false, _) => DocumentCountMode::Total,
255            // Range + In + prove + !distinct: the GroupByIn case
256            // routes to the new carrier-ACOR proof shape (grovedb
257            // PR #663); the Aggregate case is rejected above.
258            (true, true, true, false) if matches!(mode, CountMode::GroupByIn) => {
259                DocumentCountMode::RangeAggregateCarrierProof
260            }
261            (true, true, true, false) => {
262                unreachable!("range + In + prove + Aggregate is rejected before the dispatch match")
263            }
264        })
265    }
266}