Skip to main content

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}