Skip to main content

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}