dash_platform_queries/documents/document_having_entries.rs
1//! `FromProof` + `Fetch` for [`DocumentHavingEntries`] — the
2//! **having-range** (`GROUP BY … HAVING <aggregate> <op> <value>
3//! LIMIT n`) view of the unified `getDocuments` endpoint.
4//!
5//! A having-range query answers "which groups' aggregate falls inside a
6//! value bound?" — *hashtags with more than 100 posts* — in
7//! `O(log n + k)`, with a proof whose Merk range boundaries also attest
8//! **completeness**: a node cannot silently omit a matching group. It
9//! reads the same pre-sorted per-axis *secondary* Merk the ranked
10//! surface walks (grovedb PR #657), addressed by value bound instead of
11//! by rank.
12//!
13//! Per-request resolution (which axis, which bounds the operator
14//! translates to, which index covers them) lives in
15//! [`super::having_proof_helpers`]; this module is the thin
16//! `Fetch`-side wrapper.
17//!
18//! ## Request shape
19//!
20//! Exactly one aggregate `select`, exactly one `group_by` property,
21//! exactly one `having` clause **bounding the selected aggregate** with
22//! a contiguous-range operator (`=`, `>`, `>=`, `<`, `<=`, `BETWEEN*` —
23//! `!=` and `IN` are rejected), and a `LIMIT`. `ORDER BY` is optional:
24//! omitted means ascending by the aggregate; naming the selected
25//! aggregate sets the direction. `where` clauses are pins on a covering
26//! compound ranked index's leading properties (one per leading
27//! property, selecting which prefix's groups the bound reads) — absent
28//! for a single-property index. Each pin is an equality, except that
29//! **at most one** may be an `IN` of 2..=10 distinct elements: the
30//! bound fans out across one prefix branch per element and merges,
31//! entries carrying the encoded branch segment in `in_key` (unset on
32//! single-branch responses; a single-element `IN` normalizes to the
33//! equality pin; a `null` pin on another property cannot combine with
34//! the `IN`). No `offset`, no `start_at`.
35//!
36//! ## Contract prerequisites
37//!
38//! Same as the ranked surface: the index must opt in with
39//! `rankedCountable` / `rankedSummable` / `rankedAverageable`
40//! (meta-schema v3, **protocol version 14+**). The index may be
41//! single-property (`group_by` its property, no `where`) or compound
42//! (`group_by` its trailing property, pin every leading one — equality
43//! pins, at most one of them an `IN`).
44//! Against a pre-v14 node the request is refused with "HAVING clause
45//! is not yet implemented" — the intended activation gate.
46//!
47//! ## Reading the result
48//!
49//! Entries come back in axis order in the walk direction; **do not
50//! re-sort**. Fewer than `n` entries means fewer groups matched.
51//! **Exactly `n` may mean the match set was cut at the limit.**
52//! Tightening the bound past the last aggregate value seen continues
53//! past *distinct* values only: a cut inside a tie (several groups
54//! sharing the boundary aggregate) cannot be continued — the tied
55//! groups past the limit stay unreachable until a composite-key cursor
56//! exists — so size the limit above the widest expected tie. Averages
57//! are fixed-point integers, exact on this (proved) path; see the
58//! ranked module's notes, which apply verbatim.
59//!
60//! ## Example: hashtags with more than 100 posts
61//!
62//! `SELECT COUNT(*) GROUP BY hashtag HAVING $count > 100 ORDER BY $count DESC LIMIT 100`
63//!
64//! ```rust,ignore
65//! use dash_sdk::{Sdk, platform::{DataContract, DocumentQuery, Fetch, Identifier}};
66//! use dash_sdk::drive::query::{
67//! HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator,
68//! HavingRightOperand, SelectProjection,
69//! };
70//! use dash_sdk::platform::documents::document_query::RankingDirection;
71//! use dpp::platform_value::Value;
72//! use drive_proof_verifier::DocumentHavingEntries;
73//! use futures::executor::block_on;
74//!
75//! # const POSTS_CONTRACT_ID: [u8; 32] = [0; 32];
76//! let sdk = Sdk::new_mock();
77//! let contract = block_on(DataContract::fetch(&sdk, Identifier::new(POSTS_CONTRACT_ID)))
78//! .expect("fetch contract")
79//! .expect("contract exists");
80//!
81//! let query = DocumentQuery::new(contract, "post")
82//! .expect("document type exists")
83//! .with_select(SelectProjection::count_star())
84//! .with_group_by("hashtag")
85//! .with_having(vec![HavingClause {
86//! aggregate: HavingAggregate {
87//! function: HavingAggregateFunction::Count,
88//! field: String::new(),
89//! },
90//! operator: HavingOperator::GreaterThan,
91//! right: HavingRightOperand::Value(Value::U64(100)),
92//! }])
93//! .order_by_selected_aggregate(RankingDirection::Descending)
94//! .with_limit(100);
95//!
96//! let matching = block_on(DocumentHavingEntries::fetch(&sdk, query))
97//! .expect("fetch succeeds")
98//! .expect("a well-formed having query always answers");
99//!
100//! for entry in &matching.entries {
101//! let hashtag = String::from_utf8_lossy(&entry.key);
102//! println!("#{hashtag}: {} posts", entry.value.as_f64());
103//! }
104//! ```
105
106use crate::documents::document_query::DocumentQuery;
107use crate::documents::having_proof_helpers::{assert_having_shape, verify_having_query};
108use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata};
109use dash_context_provider::ContextProvider;
110use dpp::dashcore::Network;
111use dpp::version::PlatformVersion;
112use drive_proof_verifier::{DocumentHavingEntries, FromProof};
113
114impl FromProof<DocumentQuery> for DocumentHavingEntries {
115 type Request = DocumentQuery;
116 type Response = GetDocumentsResponse;
117
118 /// A request without a `having` clause is an ordinary grouped aggregate, which nodes
119 /// serve and the having verifier then refuses. Its pending time-range selections only
120 /// ever add equality pins, which the grammar cannot refuse a request for lacking, so
121 /// checking before they are resolved refuses nothing the verifier accepts.
122 fn check_request(
123 request: &Self::Request,
124 platform_version: &PlatformVersion,
125 ) -> Result<(), drive_proof_verifier::Error> {
126 request.ensure_no_sub_queries()?;
127 assert_having_shape(request, platform_version).map(|_| ())
128 }
129
130 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
131 request: I,
132 response: O,
133 _network: Network,
134 platform_version: &PlatformVersion,
135 provider: &'a dyn ContextProvider,
136 ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
137 where
138 Self: 'a,
139 {
140 let request: Self::Request = request.into();
141 let response: Self::Response = response.into();
142 // Same single-pass design as the ranked impl: the grammar check
143 // is the first step of resolution, inside the helper.
144 let (entries, mtd, proof) =
145 verify_having_query(request, response, platform_version, provider)?;
146 Ok((
147 entries.map(DocumentHavingEntries::from_verified),
148 mtd,
149 proof,
150 ))
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 //! Offline tests for the having client surface: the request→wire
157 //! encoding and the client-side grammar mirror. Proof verification
158 //! is exercised end-to-end in rs-drive's
159 //! `drive_document_having_query::tests` and rs-drive-abci's
160 //! `having_range_tests`, where a populated Drive exists.
161
162 use super::*;
163 use crate::documents::document_query::RankingDirection;
164 use crate::documents::having_proof_helpers::assert_having_shape;
165 use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::select as proto_select;
166 use dapi_grpc::platform::v0::get_documents_request::{
167 having_aggregate, having_clause, GetDocumentsRequestV1, Version as RequestVersion,
168 };
169 use dapi_grpc::platform::v0::GetDocumentsRequest;
170 use dpp::data_contract::DataContract;
171 use dpp::platform_value::Value;
172 use dpp::tests::fixtures::get_data_contract_fixture;
173 use dpp::version::TryFromPlatformVersioned;
174 use drive::query::{
175 AxisRangeBounds, HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator,
176 HavingRightOperand, SelectProjection,
177 };
178 use std::sync::Arc;
179
180 fn platform_version() -> &'static PlatformVersion {
181 PlatformVersion::latest()
182 }
183
184 fn contract() -> Arc<DataContract> {
185 Arc::new(
186 get_data_contract_fixture(None, 0, platform_version().protocol_version)
187 .data_contract_owned(),
188 )
189 }
190
191 fn count_over_100() -> HavingClause {
192 HavingClause {
193 aggregate: HavingAggregate {
194 function: HavingAggregateFunction::Count,
195 field: String::new(),
196 },
197 operator: HavingOperator::GreaterThan,
198 right: HavingRightOperand::Value(Value::U64(100)),
199 }
200 }
201
202 /// `SELECT COUNT(*) GROUP BY hashtag HAVING $count > 100 LIMIT 100`.
203 fn hashtags_over_100() -> DocumentQuery {
204 DocumentQuery::new(contract(), "niceDocument")
205 .expect("the fixture has this document type")
206 .with_select(SelectProjection::count_star())
207 .with_group_by("hashtag")
208 .with_having(vec![count_over_100()])
209 .with_limit(100)
210 }
211
212 fn v1_of(query: DocumentQuery) -> GetDocumentsRequestV1 {
213 let request = GetDocumentsRequest::try_from_platform_versioned(query, platform_version())
214 .expect("a having query encodes onto the V1 wire");
215 match request.version.expect("the encoder always sets a version") {
216 RequestVersion::V1(v1) => v1,
217 RequestVersion::V0(_) => {
218 panic!("a having query must encode onto the V1 wire; V0 has no `having` field")
219 }
220 }
221 }
222
223 /// The headline round-trip: the wire shape must be exactly what the
224 /// server's routing accepts — one select, one group_by, one having
225 /// clause, a limit, nothing else.
226 #[test]
227 fn having_query_encodes_the_expected_wire_shape() {
228 let v1 = v1_of(hashtags_over_100());
229
230 assert_eq!(v1.selects.len(), 1);
231 assert_eq!(v1.selects[0].function, proto_select::Function::Count as i32);
232 assert_eq!(v1.selects[0].field, "");
233 assert_eq!(v1.group_by, vec!["hashtag".to_string()]);
234
235 assert_eq!(v1.having.len(), 1, "exactly one having clause");
236 let clause = &v1.having[0];
237 let aggregate = clause.aggregate.as_ref().expect("aggregate is set");
238 assert_eq!(aggregate.function, having_aggregate::Function::Count as i32);
239 assert_eq!(aggregate.field, "");
240 assert_eq!(clause.operator, having_clause::Operator::GreaterThan as i32);
241 assert!(clause.right.is_some(), "the right operand rides the oneof");
242
243 assert_eq!(v1.limit, Some(100));
244 assert!(v1.where_clauses.is_empty());
245 assert!(
246 v1.order_by.is_empty(),
247 "order_by is optional and unset here"
248 );
249 assert_eq!(v1.offset, None);
250 assert!(v1.start.is_none());
251 assert!(v1.prove, "the Fetch path always requests a proof");
252 }
253
254 /// The client-side grammar must resolve the same bounds the server
255 /// (and therefore the prover) resolves — the bounds are rebuilt
256 /// into the proof's Merk query at verification time, so a client
257 /// that translated `> 100` differently could not verify an honest
258 /// proof.
259 #[test]
260 fn assert_having_shape_resolves_the_bounds() {
261 let mode = assert_having_shape(&hashtags_over_100(), platform_version())
262 .expect("the headline query is well-formed");
263 assert_eq!(
264 mode.bounds,
265 AxisRangeBounds::Count {
266 lo: 101,
267 hi: u64::MAX
268 }
269 );
270 assert!(!mode.descending, "no order_by means ascending");
271 assert_eq!(mode.limit, 100);
272 assert_eq!(mode.group_by_property, "hashtag");
273 }
274
275 /// An explicit descending ordering on the selected aggregate flips
276 /// the walk; biggest matching groups come first.
277 #[test]
278 fn ordering_by_the_aggregate_sets_the_direction() {
279 let query = hashtags_over_100().order_by_selected_aggregate(RankingDirection::Descending);
280 let mode = assert_having_shape(&query, platform_version())
281 .expect("having + ORDER BY the aggregate is well-formed");
282 assert!(mode.descending);
283 }
284
285 /// Every knob the range walk cannot honour is rejected client side,
286 /// before a round trip — mirroring the server's rejections.
287 #[test]
288 fn assert_having_shape_rejects_what_the_range_cannot_honour() {
289 let base = hashtags_over_100();
290
291 // No having at all: a plain grouped aggregate.
292 let mut no_having = base.clone();
293 no_having.having = Vec::new();
294 assert!(assert_having_shape(&no_having, platform_version()).is_err());
295
296 // Two clauses: implicit AND is a future capability.
297 let two = base
298 .clone()
299 .with_having(vec![count_over_100(), count_over_100()]);
300 assert!(assert_having_shape(&two, platform_version()).is_err());
301
302 // A clause on a different aggregate than the select.
303 let cross = base.clone().with_having(vec![HavingClause {
304 aggregate: HavingAggregate {
305 function: HavingAggregateFunction::Sum,
306 field: "amount".to_string(),
307 },
308 operator: HavingOperator::GreaterThan,
309 right: HavingRightOperand::Value(Value::I64(100)),
310 }]);
311 assert!(assert_having_shape(&cross, platform_version()).is_err());
312
313 // An offset: the range walk has no skip.
314 let with_offset = base.clone().with_offset(4);
315 assert!(assert_having_shape(&with_offset, platform_version()).is_err());
316
317 // Non-contiguous operators.
318 for operator in [HavingOperator::NotEqual, HavingOperator::In] {
319 let mut clause = count_over_100();
320 clause.operator = operator;
321 let query = base.clone().with_having(vec![clause]);
322 assert!(assert_having_shape(&query, platform_version()).is_err());
323 }
324 }
325
326 /// HAVING limits are a hard inclusive range, `1..=100`: `0` (the
327 /// unset sentinel) and anything above `MAX_HAVING_LIMIT` are
328 /// rejected client side rather than clamped, because the limit
329 /// bounds the coverage the verifier's rebuilt `PathQuery` demands
330 /// of the proof.
331 #[test]
332 fn limit_is_required_and_capped_client_side() {
333 for limit in [0u32, 101] {
334 let query = hashtags_over_100().with_limit(limit);
335 assert!(
336 assert_having_shape(&query, platform_version()).is_err(),
337 "LIMIT {limit} is outside 1..=100 and must be rejected, not clamped"
338 );
339 }
340 }
341}