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