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}