drive/query/canonicalize.rs
1//! Shared where-clause validation + canonicalization for the aggregate
2//! query surfaces (count / sum / average / joint count-and-sum) and
3//! their SDK proof verifiers.
4//!
5//! Lives outside the per-surface dispatcher modules because the shape
6//! contract must be identical on every route: the server dispatchers
7//! canonicalize before mode detection, and the proof verifiers must run
8//! the very same canonicalization before *their* mode detection or a
9//! proof the server produced for the canonical shape is rejected
10//! client-side (the count dispatcher promises callers "the bounded form
11//! and the pre-merged form get equivalent mode detection" — that promise
12//! only holds if verifiers canonicalize too).
13
14use crate::error::query::QuerySyntaxError;
15use crate::error::Error;
16use crate::query::drive_document_count_query::DriveDocumentCountQuery;
17use crate::query::{WhereClause, WhereOperator};
18use dpp::data_contract::document_type::DocumentTypeRef;
19use dpp::version::PlatformVersion;
20
21/// Key the clauses of a count, sum, average, ranked or having request that read
22/// the elements of a typed array (protocol version 14) as the index levels of
23/// the array are keyed: `contains` as an equality on one element's key,
24/// `containsAny` as an `in` over several. The aggregate surfaces then serve
25/// them as any equality or `in`, each element a group of its own. Every other
26/// clause passes through unchanged.
27///
28/// A clause on a typed array must be one of the two, with values that key as
29/// its elements, and a clause reading elements must name a typed array
30/// ([`WhereClause::validate_array_element_clause`]): the marker is gone once a
31/// clause is keyed, so this is where the field's type is judged.
32/// `grouped_by_in` says whether the request is grouped by its `In` field
33/// alone (a `containsAny` routes as one): only then, and without a range
34/// clause, does it answer per element when it reads several
35/// ([`answers_per_element`]). A total over several elements would count a
36/// document once per element it holds, so it is refused. The ranked and
37/// having surfaces and a count sub-query read one element at most, and pass
38/// `false`.
39pub fn key_array_element_clauses(
40 clauses: Vec<WhereClause>,
41 document_type: DocumentTypeRef,
42 grouped_by_in: bool,
43 platform_version: &PlatformVersion,
44) -> Result<Vec<WhereClause>, Error> {
45 let several_elements_allowed = answers_per_element(grouped_by_in, &clauses);
46 clauses
47 .into_iter()
48 .map(|mut clause| {
49 clause.validate_array_element_clause(document_type, platform_version)?;
50 if !clause.operator.reads_array_elements() {
51 return Ok(clause);
52 }
53 if clause.operator == WhereOperator::ContainsAny && !several_elements_allowed {
54 return Err(Error::Query(QuerySyntaxError::Unsupported(format!(
55 "containsAny on \"{}\" is only answered grouped by \"{}\" alone, one total \
56 per element, and without a range clause: a total over several elements \
57 would count a document once per element it holds; read each element \
58 with contains otherwise",
59 clause.field, clause.field
60 ))));
61 }
62 clause.operator = clause.operator.as_key_operator();
63 Ok(clause)
64 })
65 .collect()
66}
67
68/// Whether a count, sum or average request reading several elements of a
69/// typed array (`containsAny`) answers one total per element: grouped by the
70/// `In` field alone (`containsAny` routes as one, so it is grouped by the
71/// array), with no range clause, over which the surfaces fold a group's
72/// totals. Applied by [`key_array_element_clauses`] to the clauses it keys,
73/// for the aggregate dispatchers and the SDK proof verifiers alike, so a
74/// verifier refuses exactly the totals the server refuses.
75fn answers_per_element(grouped_by_in: bool, where_clauses: &[WhereClause]) -> bool {
76 grouped_by_in
77 && !where_clauses
78 .iter()
79 .any(|clause| DriveDocumentCountQuery::is_range_operator(clause.operator))
80}
81
82/// Run the system-wide where-clause validator on a structured
83/// `Vec<WhereClause>` and canonicalize same-field range pairs into
84/// their `between*` form. Single source of truth for the aggregate
85/// shape contract; called by the count / sum / average / joint
86/// dispatchers, the legacy CBOR-decoded count entry, and the SDK
87/// count / sum / average proof verifiers.
88///
89/// The validator (`WhereClause::group_clauses`) rejects:
90/// - Duplicate `Equal` clauses on the same field
91/// (`DuplicateNonGroupableClauseSameField`).
92/// - Multiple `In` clauses (`MultipleInClauses`) — rejected here: the
93/// shared grammar accepts them for protocol version 14+ document
94/// queries, but the aggregate surfaces do not.
95/// - Multiple non-groupable range clauses (`MultipleRangeClauses`).
96/// - Equality + `In` on the same field, range + equality/In on the
97/// same field (`DuplicateNonGroupableClauseSameField` /
98/// `InvalidWhereClauseComponents`).
99///
100/// Without this validation, downstream
101/// [`DriveDocumentCountQuery::find_countable_index_for_where_clauses`]
102/// collapses repeated fields into a `BTreeSet` and
103/// [`DriveDocumentCountQuery::point_lookup_count_path_query`]
104/// resolves each index property with a single `.find(...)` — both
105/// of which silently pick the first clause on a duplicated field
106/// and return a count for an arbitrarily reduced query rather than
107/// rejecting the malformed request.
108///
109/// **Exception**: `MultipleRangeClauses` is intentionally tolerated
110/// here. The regular-query parser rejects two ranges on different
111/// fields wholesale (its callers expect
112/// `(equal_clauses, in_clause, range_clause)` triples), but the
113/// count-query path accepts the carrier-aggregate shape
114/// (`outer_range + inner_ACOR_range` on different fields, e.g.
115/// G8). Structural validation for that shape lives in
116/// [`DriveDocumentCountQuery::detect_mode`] (which knows about
117/// `CountMode::GroupByRange`-with-two-ranges and routes to
118/// `DocumentCountMode::RangeAggregateCarrierProof`); replicating
119/// it here would be redundant.
120///
121/// After validation, [`merge_same_field_range_pairs`] collapses
122/// `[field > A, field < B]` (and analogous pairs with `>=` / `<=`)
123/// into the canonical `between*` operator that
124/// [`DriveDocumentCountQuery::range_clause_to_query_item`] knows
125/// how to convert into a single `QueryItem`. The regular-query
126/// parser does the same merge before its grouped-triple
127/// validation; for aggregate queries we do it explicitly here so
128/// callers can pass either the bounded form (e.g.
129/// `[brand > A, brand < B]`) or the pre-merged form (e.g.
130/// `[brand BetweenExcludeBounds [A, B]]`) and get equivalent
131/// mode detection downstream. Without this merge, G8a's natural
132/// wire shape (four range clauses, two per field) would slip past
133/// the catch-`MultipleRangeClauses` block above and then get
134/// rejected by `detect_mode`'s `range_count > 1` structural check.
135pub fn validate_and_canonicalize_where_clauses(
136 clauses: Vec<WhereClause>,
137 platform_version: &PlatformVersion,
138) -> Result<Vec<WhereClause>, Error> {
139 match WhereClause::group_clauses(&clauses, platform_version) {
140 // Multiple `In` clauses are a document-query-only shape (protocol
141 // version 14+); the aggregate surfaces keep rejecting them since
142 // their mode detection and index pickers assume a single `In`.
143 Ok((_, _, in_clauses)) if in_clauses.len() > 1 => {
144 return Err(Error::Query(QuerySyntaxError::MultipleInClauses(
145 "aggregate queries support at most one in clause",
146 )));
147 }
148 Ok(_) => {}
149 Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(_))) => {}
150 Err(e) => return Err(e),
151 }
152 merge_same_field_range_pairs(clauses)
153}
154
155/// Collapse `[field > A, field < B]` (and analogous pairs with
156/// `>=` / `<=`) into a single `field between* [A, B]` clause per
157/// field. Equality / In clauses pass through unchanged.
158///
159/// Returns an error if a field has more than two range clauses
160/// (structurally meaningless — a third bound would either
161/// contradict an existing one or be redundant) or if the pair
162/// isn't one lower-bound + one upper-bound (e.g. two `>` on the
163/// same field).
164fn merge_same_field_range_pairs(clauses: Vec<WhereClause>) -> Result<Vec<WhereClause>, Error> {
165 use crate::query::conditions::WhereOperator::{
166 Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, GreaterThan,
167 GreaterThanOrEquals, LessThan, LessThanOrEquals,
168 };
169 use std::collections::BTreeMap;
170
171 let mut by_field: BTreeMap<String, Vec<WhereClause>> = BTreeMap::new();
172 let mut non_range: Vec<WhereClause> = Vec::new();
173 for wc in clauses {
174 if DriveDocumentCountQuery::is_range_operator(wc.operator) {
175 by_field.entry(wc.field.clone()).or_default().push(wc);
176 } else {
177 non_range.push(wc);
178 }
179 }
180 let mut result = non_range;
181 for (field, mut ranges) in by_field {
182 match ranges.len() {
183 0 => {}
184 1 => result.push(ranges.remove(0)),
185 2 => {
186 let (mut lower, mut upper): (Option<WhereClause>, Option<WhereClause>) =
187 (None, None);
188 for r in ranges {
189 match r.operator {
190 GreaterThan | GreaterThanOrEquals => {
191 if lower.is_some() {
192 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
193 "two lower-bound range clauses on the same field cannot be \
194 merged; combine via `between*` or remove the redundant clause",
195 )));
196 }
197 lower = Some(r);
198 }
199 LessThan | LessThanOrEquals => {
200 if upper.is_some() {
201 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
202 "two upper-bound range clauses on the same field cannot be \
203 merged; combine via `between*` or remove the redundant clause",
204 )));
205 }
206 upper = Some(r);
207 }
208 _ => {
209 // The other range operators (Between*,
210 // StartsWith) are themselves bounded
211 // already; a second range clause on the
212 // same field is structurally redundant.
213 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
214 "cannot pair a `between*`/`startsWith` range clause with \
215 another range on the same field; use the pre-merged form",
216 )));
217 }
218 }
219 }
220 let lower = lower.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses(
221 "two range clauses on the same field require one lower bound (> or >=) \
222 and one upper bound (< or <=)",
223 )))?;
224 let upper = upper.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses(
225 "two range clauses on the same field require one lower bound (> or >=) \
226 and one upper bound (< or <=)",
227 )))?;
228 let merged_op = match (
229 lower.operator == GreaterThanOrEquals,
230 upper.operator == LessThanOrEquals,
231 ) {
232 (true, true) => Between, // [a, b]
233 (false, false) => BetweenExcludeBounds, // (a, b)
234 (true, false) => BetweenExcludeRight, // [a, b)
235 (false, true) => BetweenExcludeLeft, // (a, b]
236 };
237 result.push(WhereClause {
238 field,
239 operator: merged_op,
240 value: dpp::platform_value::Value::Array(vec![lower.value, upper.value]),
241 });
242 }
243 _ => {
244 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
245 "more than two range clauses on the same field are not supported; a \
246 bounded range needs exactly one lower bound and one upper bound",
247 )));
248 }
249 }
250 }
251 Ok(result)
252}