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::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 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
119 request: I,
120 response: O,
121 _network: Network,
122 platform_version: &PlatformVersion,
123 provider: &'a dyn ContextProvider,
124 ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
125 where
126 Self: 'a,
127 {
128 let request: Self::Request = request.into();
129 let response: Self::Response = response.into();
130 // Same single-pass design as the ranked impl: the grammar check
131 // is the first step of resolution, inside the helper.
132 let (entries, mtd, proof) =
133 verify_having_query(request, response, platform_version, provider)?;
134 Ok((
135 entries.map(DocumentHavingEntries::from_verified),
136 mtd,
137 proof,
138 ))
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 //! Offline tests for the having client surface: the request→wire
145 //! encoding and the client-side grammar mirror. Proof verification
146 //! is exercised end-to-end in rs-drive's
147 //! `drive_document_having_query::tests` and rs-drive-abci's
148 //! `having_range_tests`, where a populated Drive exists.
149
150 use super::*;
151 use crate::documents::document_query::RankingDirection;
152 use crate::documents::having_proof_helpers::assert_having_shape;
153 use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::select as proto_select;
154 use dapi_grpc::platform::v0::get_documents_request::{
155 having_aggregate, having_clause, GetDocumentsRequestV1, Version as RequestVersion,
156 };
157 use dapi_grpc::platform::v0::GetDocumentsRequest;
158 use dpp::data_contract::DataContract;
159 use dpp::platform_value::Value;
160 use dpp::tests::fixtures::get_data_contract_fixture;
161 use dpp::version::TryFromPlatformVersioned;
162 use drive::query::{
163 AxisRangeBounds, HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator,
164 HavingRightOperand, SelectProjection,
165 };
166 use std::sync::Arc;
167
168 fn platform_version() -> &'static PlatformVersion {
169 PlatformVersion::latest()
170 }
171
172 fn contract() -> Arc<DataContract> {
173 Arc::new(
174 get_data_contract_fixture(None, 0, platform_version().protocol_version)
175 .data_contract_owned(),
176 )
177 }
178
179 fn count_over_100() -> HavingClause {
180 HavingClause {
181 aggregate: HavingAggregate {
182 function: HavingAggregateFunction::Count,
183 field: String::new(),
184 },
185 operator: HavingOperator::GreaterThan,
186 right: HavingRightOperand::Value(Value::U64(100)),
187 }
188 }
189
190 /// `SELECT COUNT(*) GROUP BY hashtag HAVING $count > 100 LIMIT 100`.
191 fn hashtags_over_100() -> DocumentQuery {
192 DocumentQuery::new(contract(), "niceDocument")
193 .expect("the fixture has this document type")
194 .with_select(SelectProjection::count_star())
195 .with_group_by("hashtag")
196 .with_having(vec![count_over_100()])
197 .with_limit(100)
198 }
199
200 fn v1_of(query: DocumentQuery) -> GetDocumentsRequestV1 {
201 let request = GetDocumentsRequest::try_from_platform_versioned(query, platform_version())
202 .expect("a having query encodes onto the V1 wire");
203 match request.version.expect("the encoder always sets a version") {
204 RequestVersion::V1(v1) => v1,
205 RequestVersion::V0(_) => {
206 panic!("a having query must encode onto the V1 wire; V0 has no `having` field")
207 }
208 }
209 }
210
211 /// The headline round-trip: the wire shape must be exactly what the
212 /// server's routing accepts — one select, one group_by, one having
213 /// clause, a limit, nothing else.
214 #[test]
215 fn having_query_encodes_the_expected_wire_shape() {
216 let v1 = v1_of(hashtags_over_100());
217
218 assert_eq!(v1.selects.len(), 1);
219 assert_eq!(v1.selects[0].function, proto_select::Function::Count as i32);
220 assert_eq!(v1.selects[0].field, "");
221 assert_eq!(v1.group_by, vec!["hashtag".to_string()]);
222
223 assert_eq!(v1.having.len(), 1, "exactly one having clause");
224 let clause = &v1.having[0];
225 let aggregate = clause.aggregate.as_ref().expect("aggregate is set");
226 assert_eq!(aggregate.function, having_aggregate::Function::Count as i32);
227 assert_eq!(aggregate.field, "");
228 assert_eq!(clause.operator, having_clause::Operator::GreaterThan as i32);
229 assert!(clause.right.is_some(), "the right operand rides the oneof");
230
231 assert_eq!(v1.limit, Some(100));
232 assert!(v1.where_clauses.is_empty());
233 assert!(
234 v1.order_by.is_empty(),
235 "order_by is optional and unset here"
236 );
237 assert_eq!(v1.offset, None);
238 assert!(v1.start.is_none());
239 assert!(v1.prove, "the Fetch path always requests a proof");
240 }
241
242 /// The client-side grammar must resolve the same bounds the server
243 /// (and therefore the prover) resolves — the bounds are rebuilt
244 /// into the proof's Merk query at verification time, so a client
245 /// that translated `> 100` differently could not verify an honest
246 /// proof.
247 #[test]
248 fn assert_having_shape_resolves_the_bounds() {
249 let mode = assert_having_shape(&hashtags_over_100(), platform_version())
250 .expect("the headline query is well-formed");
251 assert_eq!(
252 mode.bounds,
253 AxisRangeBounds::Count {
254 lo: 101,
255 hi: u64::MAX
256 }
257 );
258 assert!(!mode.descending, "no order_by means ascending");
259 assert_eq!(mode.limit, 100);
260 assert_eq!(mode.group_by_property, "hashtag");
261 }
262
263 /// An explicit descending ordering on the selected aggregate flips
264 /// the walk; biggest matching groups come first.
265 #[test]
266 fn ordering_by_the_aggregate_sets_the_direction() {
267 let query = hashtags_over_100().order_by_selected_aggregate(RankingDirection::Descending);
268 let mode = assert_having_shape(&query, platform_version())
269 .expect("having + ORDER BY the aggregate is well-formed");
270 assert!(mode.descending);
271 }
272
273 /// Every knob the range walk cannot honour is rejected client side,
274 /// before a round trip — mirroring the server's rejections.
275 #[test]
276 fn assert_having_shape_rejects_what_the_range_cannot_honour() {
277 let base = hashtags_over_100();
278
279 // No having at all: a plain grouped aggregate.
280 let mut no_having = base.clone();
281 no_having.having = Vec::new();
282 assert!(assert_having_shape(&no_having, platform_version()).is_err());
283
284 // Two clauses: implicit AND is a future capability.
285 let two = base
286 .clone()
287 .with_having(vec![count_over_100(), count_over_100()]);
288 assert!(assert_having_shape(&two, platform_version()).is_err());
289
290 // A clause on a different aggregate than the select.
291 let cross = base.clone().with_having(vec![HavingClause {
292 aggregate: HavingAggregate {
293 function: HavingAggregateFunction::Sum,
294 field: "amount".to_string(),
295 },
296 operator: HavingOperator::GreaterThan,
297 right: HavingRightOperand::Value(Value::I64(100)),
298 }]);
299 assert!(assert_having_shape(&cross, platform_version()).is_err());
300
301 // An offset: the range walk has no skip.
302 let with_offset = base.clone().with_offset(4);
303 assert!(assert_having_shape(&with_offset, platform_version()).is_err());
304
305 // Non-contiguous operators.
306 for operator in [HavingOperator::NotEqual, HavingOperator::In] {
307 let mut clause = count_over_100();
308 clause.operator = operator;
309 let query = base.clone().with_having(vec![clause]);
310 assert!(assert_having_shape(&query, platform_version()).is_err());
311 }
312 }
313
314 /// HAVING limits are a hard inclusive range, `1..=100`: `0` (the
315 /// unset sentinel) and anything above `MAX_HAVING_LIMIT` are
316 /// rejected client side rather than clamped, because the limit
317 /// bounds the coverage the verifier's rebuilt `PathQuery` demands
318 /// of the proof.
319 #[test]
320 fn limit_is_required_and_capped_client_side() {
321 for limit in [0u32, 101] {
322 let query = hashtags_over_100().with_limit(limit);
323 assert!(
324 assert_having_shape(&query, platform_version()).is_err(),
325 "LIMIT {limit} is outside 1..=100 and must be rejected, not clamped"
326 );
327 }
328 }
329}