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;
18use dpp::version::PlatformVersion;
19
20/// Run the system-wide where-clause validator on a structured
21/// `Vec<WhereClause>` and canonicalize same-field range pairs into
22/// their `between*` form. Single source of truth for the aggregate
23/// shape contract; called by the count / sum / average / joint
24/// dispatchers, the legacy CBOR-decoded count entry, and the SDK
25/// count / sum / average proof verifiers.
26///
27/// The validator (`WhereClause::group_clauses`) rejects:
28/// - Duplicate `Equal` clauses on the same field
29/// (`DuplicateNonGroupableClauseSameField`).
30/// - Multiple `In` clauses (`MultipleInClauses`) — rejected here: the
31/// shared grammar accepts them for protocol version 14+ document
32/// queries, but the aggregate surfaces do not.
33/// - Multiple non-groupable range clauses (`MultipleRangeClauses`).
34/// - Equality + `In` on the same field, range + equality/In on the
35/// same field (`DuplicateNonGroupableClauseSameField` /
36/// `InvalidWhereClauseComponents`).
37///
38/// Without this validation, downstream
39/// [`DriveDocumentCountQuery::find_countable_index_for_where_clauses`]
40/// collapses repeated fields into a `BTreeSet` and
41/// [`DriveDocumentCountQuery::point_lookup_count_path_query`]
42/// resolves each index property with a single `.find(...)` — both
43/// of which silently pick the first clause on a duplicated field
44/// and return a count for an arbitrarily reduced query rather than
45/// rejecting the malformed request.
46///
47/// **Exception**: `MultipleRangeClauses` is intentionally tolerated
48/// here. The regular-query parser rejects two ranges on different
49/// fields wholesale (its callers expect
50/// `(equal_clauses, in_clause, range_clause)` triples), but the
51/// count-query path accepts the carrier-aggregate shape
52/// (`outer_range + inner_ACOR_range` on different fields, e.g.
53/// G8). Structural validation for that shape lives in
54/// [`DriveDocumentCountQuery::detect_mode`] (which knows about
55/// `CountMode::GroupByRange`-with-two-ranges and routes to
56/// `DocumentCountMode::RangeAggregateCarrierProof`); replicating
57/// it here would be redundant.
58///
59/// After validation, [`merge_same_field_range_pairs`] collapses
60/// `[field > A, field < B]` (and analogous pairs with `>=` / `<=`)
61/// into the canonical `between*` operator that
62/// [`DriveDocumentCountQuery::range_clause_to_query_item`] knows
63/// how to convert into a single `QueryItem`. The regular-query
64/// parser does the same merge before its grouped-triple
65/// validation; for aggregate queries we do it explicitly here so
66/// callers can pass either the bounded form (e.g.
67/// `[brand > A, brand < B]`) or the pre-merged form (e.g.
68/// `[brand BetweenExcludeBounds [A, B]]`) and get equivalent
69/// mode detection downstream. Without this merge, G8a's natural
70/// wire shape (four range clauses, two per field) would slip past
71/// the catch-`MultipleRangeClauses` block above and then get
72/// rejected by `detect_mode`'s `range_count > 1` structural check.
73pub fn validate_and_canonicalize_where_clauses(
74 clauses: Vec<WhereClause>,
75 platform_version: &PlatformVersion,
76) -> Result<Vec<WhereClause>, Error> {
77 match WhereClause::group_clauses(&clauses, platform_version) {
78 // Multiple `In` clauses are a document-query-only shape (protocol
79 // version 14+); the aggregate surfaces keep rejecting them since
80 // their mode detection and index pickers assume a single `In`.
81 Ok((_, _, in_clauses)) if in_clauses.len() > 1 => {
82 return Err(Error::Query(QuerySyntaxError::MultipleInClauses(
83 "aggregate queries support at most one in clause",
84 )));
85 }
86 Ok(_) => {}
87 Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(_))) => {}
88 Err(e) => return Err(e),
89 }
90 merge_same_field_range_pairs(clauses)
91}
92
93/// Collapse `[field > A, field < B]` (and analogous pairs with
94/// `>=` / `<=`) into a single `field between* [A, B]` clause per
95/// field. Equality / In clauses pass through unchanged.
96///
97/// Returns an error if a field has more than two range clauses
98/// (structurally meaningless — a third bound would either
99/// contradict an existing one or be redundant) or if the pair
100/// isn't one lower-bound + one upper-bound (e.g. two `>` on the
101/// same field).
102fn merge_same_field_range_pairs(clauses: Vec<WhereClause>) -> Result<Vec<WhereClause>, Error> {
103 use crate::query::conditions::WhereOperator::{
104 Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, GreaterThan,
105 GreaterThanOrEquals, LessThan, LessThanOrEquals,
106 };
107 use std::collections::BTreeMap;
108
109 let mut by_field: BTreeMap<String, Vec<WhereClause>> = BTreeMap::new();
110 let mut non_range: Vec<WhereClause> = Vec::new();
111 for wc in clauses {
112 if DriveDocumentCountQuery::is_range_operator(wc.operator) {
113 by_field.entry(wc.field.clone()).or_default().push(wc);
114 } else {
115 non_range.push(wc);
116 }
117 }
118 let mut result = non_range;
119 for (field, mut ranges) in by_field {
120 match ranges.len() {
121 0 => {}
122 1 => result.push(ranges.remove(0)),
123 2 => {
124 let (mut lower, mut upper): (Option<WhereClause>, Option<WhereClause>) =
125 (None, None);
126 for r in ranges {
127 match r.operator {
128 GreaterThan | GreaterThanOrEquals => {
129 if lower.is_some() {
130 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
131 "two lower-bound range clauses on the same field cannot be \
132 merged; combine via `between*` or remove the redundant clause",
133 )));
134 }
135 lower = Some(r);
136 }
137 LessThan | LessThanOrEquals => {
138 if upper.is_some() {
139 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
140 "two upper-bound range clauses on the same field cannot be \
141 merged; combine via `between*` or remove the redundant clause",
142 )));
143 }
144 upper = Some(r);
145 }
146 _ => {
147 // The other range operators (Between*,
148 // StartsWith) are themselves bounded
149 // already; a second range clause on the
150 // same field is structurally redundant.
151 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
152 "cannot pair a `between*`/`startsWith` range clause with \
153 another range on the same field; use the pre-merged form",
154 )));
155 }
156 }
157 }
158 let lower = lower.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses(
159 "two range clauses on the same field require one lower bound (> or >=) \
160 and one upper bound (< or <=)",
161 )))?;
162 let upper = upper.ok_or(Error::Query(QuerySyntaxError::MultipleRangeClauses(
163 "two range clauses on the same field require one lower bound (> or >=) \
164 and one upper bound (< or <=)",
165 )))?;
166 let merged_op = match (
167 lower.operator == GreaterThanOrEquals,
168 upper.operator == LessThanOrEquals,
169 ) {
170 (true, true) => Between, // [a, b]
171 (false, false) => BetweenExcludeBounds, // (a, b)
172 (true, false) => BetweenExcludeRight, // [a, b)
173 (false, true) => BetweenExcludeLeft, // (a, b]
174 };
175 result.push(WhereClause {
176 field,
177 operator: merged_op,
178 value: dpp::platform_value::Value::Array(vec![lower.value, upper.value]),
179 });
180 }
181 _ => {
182 return Err(Error::Query(QuerySyntaxError::MultipleRangeClauses(
183 "more than two range clauses on the same field are not supported; a \
184 bounded range needs exactly one lower bound and one upper bound",
185 )));
186 }
187 }
188 }
189 Ok(result)
190}