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::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}