Skip to main content

dash_platform_queries/documents/
chained_document_query.rs

1//! Chained document queries — the client half of the provable semi-join:
2//! `SELECT * FROM <outer> WHERE $id IN (SELECT <join_property> FROM
3//! <inner> WHERE …)`.
4//!
5//! The inner half is an ordinary [`DocumentQuery`] against an indexOnly
6//! document type; the request carries no outer clauses at all — the
7//! server derives the outer by-ids query from the inner results, and the
8//! verifier re-derives it from the PROVEN inner results, so the join can
9//! never be steered by the responding node. See
10//! `drive::query::chained_document_query` for the trust model.
11
12use crate::documents::document_query::DocumentQuery;
13use crate::error::Error;
14use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::ChainedJoin;
15use dapi_grpc::platform::v0::get_documents_request::Version as RequestVersion;
16use dapi_grpc::platform::v0::{GetDocumentsRequest, GetDocumentsResponse, Proof, ResponseMetadata};
17use dapi_grpc::platform::VersionedGrpcResponse;
18use dash_context_provider::ContextProvider;
19use dpp::dashcore::Network;
20use dpp::data_contract::accessors::v0::DataContractV0Getters;
21use dpp::version::{PlatformVersion, TryFromPlatformVersioned};
22use dpp::ProtocolError;
23use drive::query::DriveDocumentQuery;
24use drive_proof_verifier::{
25    verify_chained_documents_tenderdash_proof, ChainedDocuments, FromProof,
26};
27
28/// A chained document query: the inner [`DocumentQuery`] plus the join
29/// edge. The outer half has no clauses by design — it is derived.
30///
31/// The inner query MUST carry an explicit non-zero limit (it bounds the
32/// derived outer query; there is no server-default sentinel on this
33/// surface) and must resolve, server-side, to an indexOnly index
34/// carrying `join_property`.
35#[derive(Debug, Clone, PartialEq, dash_platform_macros::Mockable)]
36#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
37pub struct ChainedDocumentQuery {
38    /// The inner query (the subselect).
39    pub inner: DocumentQuery,
40    /// The inner property whose proven values become the outer `$id`s.
41    /// Must carry a same-contract `refersTo: permanentDocument`
42    /// declaration targeting `outer_document_type_name`.
43    pub join_property: String,
44    /// The outer (joined) document type — the `refersTo` target.
45    pub outer_document_type_name: String,
46}
47
48impl ChainedDocumentQuery {
49    /// A chained query joining `inner`'s `join_property` values onto
50    /// documents of `outer_document_type_name`.
51    pub fn new(
52        inner: DocumentQuery,
53        join_property: impl Into<String>,
54        outer_document_type_name: impl Into<String>,
55    ) -> Self {
56        Self {
57            inner,
58            join_property: join_property.into(),
59            outer_document_type_name: outer_document_type_name.into(),
60        }
61    }
62}
63
64impl TryFromPlatformVersioned<ChainedDocumentQuery> for GetDocumentsRequest {
65    type Error = Error;
66
67    fn try_from_platform_versioned(
68        value: ChainedDocumentQuery,
69        platform_version: &PlatformVersion,
70    ) -> Result<Self, Self::Error> {
71        let ChainedDocumentQuery {
72            inner,
73            join_property,
74            outer_document_type_name,
75        } = value;
76
77        inner
78            .ensure_no_sub_queries()
79            .map_err(|e| Error::Config(e.to_string()))?;
80        if inner.limit == 0 {
81            return Err(Error::Config(
82                "a chained document query requires an explicit non-zero inner limit: it \
83                 bounds the derived outer query, so there is no server-default sentinel"
84                    .to_string(),
85            ));
86        }
87        if !inner.time_range_clauses.is_empty()
88            || inner.start.is_some()
89            || inner.offset.is_some()
90            || !inner.group_by.is_empty()
91            || !inner.having.is_empty()
92        {
93            return Err(Error::Config(
94                "a chained inner query supports where/order_by/limit only: no time-range \
95                 selections, cursors, offsets, group_by, or having (paginate with a range \
96                 clause on the join property)"
97                    .to_string(),
98            ));
99        }
100
101        // The chained surface rides the typed V1 wire: encode the
102        // inner query through the standard versioned encoder, then
103        // attach the join spec. A network still on the V0 (CBOR) wire
104        // cannot express the field, so refuse rather than silently
105        // sending a plain documents query.
106        let mut request =
107            GetDocumentsRequest::try_from_platform_versioned(inner, platform_version)?;
108        match request.version.as_mut() {
109            Some(RequestVersion::V1(v1)) => {
110                v1.chained = Some(ChainedJoin {
111                    join_property,
112                    outer_document_type: outer_document_type_name,
113                });
114            }
115            _ => {
116                return Err(Error::Config(
117                    "chained document queries require the V1 documents wire (Platform \
118                     v3.1+); this network's protocol version encodes V0"
119                        .to_string(),
120                ));
121            }
122        }
123        Ok(request)
124    }
125}
126
127impl<'a> TryFrom<&'a ChainedDocumentQuery> for DriveDocumentQuery<'a> {
128    type Error = Error;
129
130    fn try_from(request: &'a ChainedDocumentQuery) -> Result<Self, Self::Error> {
131        request
132            .inner
133            .ensure_no_sub_queries()
134            .map_err(|e| Error::Config(e.to_string()))?;
135        let inner: DriveDocumentQuery<'a> = (&request.inner).try_into()?;
136        let outer_document_type = request
137            .inner
138            .data_contract
139            .document_type_for_name(&request.outer_document_type_name)
140            .map_err(|e| Error::Protocol(ProtocolError::DataContractError(e)))?;
141        Ok(inner.with_by_id_join(request.join_property.clone(), outer_document_type))
142    }
143}
144
145impl FromProof<ChainedDocumentQuery> for ChainedDocuments {
146    type Request = ChainedDocumentQuery;
147    type Response = GetDocumentsResponse;
148
149    fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
150        request: I,
151        response: O,
152        _network: Network,
153        platform_version: &PlatformVersion,
154        provider: &'a dyn ContextProvider,
155    ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
156    where
157        Self: 'a,
158    {
159        let request: Self::Request = request.into();
160        let response: Self::Response = response.into();
161
162        let query: DriveDocumentQuery = (&request).try_into().map_err(|e: Error| {
163            drive_proof_verifier::Error::RequestError {
164                error: e.to_string(),
165            }
166        })?;
167
168        // The standard envelope carries the single MERGED proof, and
169        // the proof alone is enough: the verifier bootstraps the join
170        // values from it via a subset pass.
171        let proof = response
172            .proof()
173            .or(Err(drive_proof_verifier::Error::NoProofInResult))?;
174        let mtd = response
175            .metadata()
176            .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?;
177
178        let (_root_hash, chained) = verify_chained_documents_tenderdash_proof(
179            &query,
180            proof,
181            mtd,
182            platform_version,
183            provider,
184        )?;
185
186        // An empty inner page is a valid, proven "you have nothing
187        // here" — surface it as Some(empty) rather than None so callers
188        // can tell it apart from a missing object.
189        Ok((Some(chained), mtd.clone(), proof.clone()))
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    //! Offline tests for the chained client surface: the V1
196    //! request-wire encoding (typed clauses, no CBOR), the
197    //! unsupported-feature rejections, and the rich→drive conversion +
198    //! shared shape validation against the yappr-likes fixture. Proof
199    //! verification is exercised end-to-end in rs-drive's
200    //! `chained_query_e2e_tests` and rs-drive-abci's v1 chained
201    //! dispatch tests, where a populated Drive exists.
202
203    use super::*;
204    use dpp::data_contract::DataContract;
205    use dpp::platform_value::Value;
206    use dpp::tests::json_document::json_document_to_contract;
207    use drive::query::{
208        BindingSource, DriveSubQuery, SubQueryBinding, SubQueryKind, WhereClause, WhereOperator,
209    };
210    use std::sync::Arc;
211
212    const YAPPR_CONTRACT_PATH: &str =
213        "../rs-drive/tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json";
214    const OWNER: [u8; 32] = [0x11; 32];
215
216    fn platform_version() -> &'static PlatformVersion {
217        PlatformVersion::latest()
218    }
219
220    fn yappr_contract() -> Arc<DataContract> {
221        Arc::new(
222            json_document_to_contract(YAPPR_CONTRACT_PATH, false, platform_version())
223                .expect("expected to parse the yappr-likes contract"),
224        )
225    }
226
227    fn posts_i_liked(limit: u32) -> ChainedDocumentQuery {
228        let inner = DocumentQuery::new(yappr_contract(), "like")
229            .expect("like doctype exists")
230            .with_where(WhereClause {
231                field: "$ownerId".to_string(),
232                operator: WhereOperator::Equal,
233                value: Value::Identifier(OWNER),
234            })
235            .with_limit(limit);
236        ChainedDocumentQuery::new(inner, "postId", "post")
237    }
238
239    #[test]
240    fn encodes_the_v1_wire_shape() {
241        let request =
242            GetDocumentsRequest::try_from_platform_versioned(posts_i_liked(10), platform_version())
243                .expect("encodes");
244        let Some(RequestVersion::V1(v1)) = request.version else {
245            panic!("expected a V1 request");
246        };
247        assert_eq!(v1.document_type, "like");
248        assert_eq!(v1.limit, Some(10));
249        assert!(v1.prove, "chained fetch always proves");
250        assert!(v1.order_by.is_empty());
251        // Typed clauses on the wire — no CBOR anywhere on this surface.
252        assert_eq!(v1.where_clauses.len(), 1);
253        assert_eq!(v1.where_clauses[0].field, "$ownerId");
254        let chained = v1.chained.expect("the join spec rides the request");
255        assert_eq!(chained.join_property, "postId");
256        assert_eq!(chained.outer_document_type, "post");
257    }
258
259    #[test]
260    fn requires_an_inner_limit() {
261        let refused =
262            GetDocumentsRequest::try_from_platform_versioned(posts_i_liked(0), platform_version());
263        assert!(
264            matches!(refused, Err(Error::Config(_))),
265            "a zero inner limit must be refused, got {refused:?}"
266        );
267    }
268
269    #[test]
270    fn refuses_unsupported_inner_features() {
271        let mut query = posts_i_liked(10);
272        query.inner.group_by = vec!["hashtag".to_string()];
273        let refused = GetDocumentsRequest::try_from_platform_versioned(query, platform_version());
274        assert!(
275            matches!(refused, Err(Error::Config(_))),
276            "an inner group_by must be refused, got {refused:?}"
277        );
278    }
279
280    #[test]
281    fn converts_to_a_valid_drive_query() {
282        let query = posts_i_liked(10);
283        let drive_query: DriveDocumentQuery =
284            (&query).try_into().expect("converts to a drive query");
285        drive_query
286            .validate_chained(platform_version())
287            .expect("the byLiker shape validates");
288        assert_eq!(
289            drive_query.sub_queries[0]
290                .binding
291                .as_ref()
292                .expect("the join is bound")
293                .source_property,
294            "postId"
295        );
296        assert_eq!(drive_query.limit, Some(10));
297    }
298
299    #[test]
300    fn should_reject_sub_queries_inside_a_chained_inner_query() {
301        use crate::documents::composite_document_query::CompositeSubQuery;
302
303        let mut query = posts_i_liked(10);
304        query.inner.sub_queries.push(
305            CompositeSubQuery::documents(query.inner.data_contract.clone(), "post")
306                .expect("post doctype exists")
307                .bound_to_page("postId", "$id"),
308        );
309        let refused =
310            GetDocumentsRequest::try_from_platform_versioned(query.clone(), platform_version());
311        assert!(matches!(refused, Err(Error::Config(message)) if message.contains("sub-queries")));
312        let refused = DriveDocumentQuery::try_from(&query);
313        assert!(matches!(refused, Err(Error::Config(message)) if message.contains("sub-queries")));
314    }
315
316    fn assert_conversions_preserve_sub_queries(query: &DriveDocumentQuery) {
317        for result in [
318            DocumentQuery::try_from(query),
319            DocumentQuery::try_from(query.clone()),
320            DocumentQuery::new_with_drive_query(query),
321        ] {
322            let sdk_query = result.expect("conversion preserves sub-queries");
323            let restored: DriveDocumentQuery = (&sdk_query).try_into().expect("converts back");
324            assert_eq!(&restored, query);
325            let request =
326                GetDocumentsRequest::try_from_platform_versioned(sdk_query, platform_version())
327                    .expect("the composition encodes");
328            let Some(RequestVersion::V1(v1)) = request.version else {
329                panic!("expected V1");
330            };
331            assert_eq!(v1.sub_queries.len(), query.sub_queries.len());
332        }
333    }
334
335    #[test]
336    fn should_preserve_a_drive_join_during_query_conversion() {
337        let query = posts_i_liked(10);
338        let drive_query: DriveDocumentQuery = (&query).try_into().expect("drive query");
339        drive_query
340            .validate_chained(platform_version())
341            .expect("valid chained shape");
342        assert_conversions_preserve_sub_queries(&drive_query);
343    }
344
345    #[test]
346    fn should_preserve_a_composite_count_during_query_conversion() {
347        let query = posts_i_liked(10);
348        let page: DriveDocumentQuery = (&query.inner).try_into().expect("drive page");
349        let count = DriveSubQuery {
350            contract: page.contract,
351            document_type: page.document_type,
352            kind: SubQueryKind::Count,
353            where_clauses: vec![],
354            order_by: vec![],
355            limit: None,
356            binding: Some(SubQueryBinding {
357                source: BindingSource::Page,
358                source_property: "postId".into(),
359                field: "postId".into(),
360            }),
361        };
362        let composite = page.with_sub_queries(vec![count]);
363        composite
364            .validate_composite(platform_version())
365            .expect("valid count composition");
366        assert_conversions_preserve_sub_queries(&composite);
367    }
368
369    #[test]
370    fn should_preserve_plain_drive_query_conversion() {
371        let query = posts_i_liked(10).inner;
372        let drive_query: DriveDocumentQuery = (&query).try_into().expect("drive page");
373        for result in [
374            DocumentQuery::try_from(&drive_query),
375            DocumentQuery::try_from(drive_query.clone()),
376            DocumentQuery::new_with_drive_query(&drive_query),
377        ] {
378            assert_eq!(result.expect("plain conversion succeeds"), query);
379        }
380    }
381
382    #[test]
383    fn conversion_surfaces_shape_errors() {
384        let query = ChainedDocumentQuery::new(
385            DocumentQuery::new(yappr_contract(), "like")
386                .expect("like doctype exists")
387                .with_limit(10),
388            "hashtag",
389            "post",
390        );
391        let drive_query: DriveDocumentQuery =
392            (&query).try_into().expect("conversion itself succeeds");
393        let refused = drive_query.validate_chained(platform_version());
394        assert!(
395            refused.is_err(),
396            "a non-refersTo join property must fail validation"
397        );
398    }
399}