Skip to main content

dash_platform_queries/documents/
composite_document_query.rs

1//! Composite document queries — the client half of "a page plus the
2//! sub-queries derived from it", answered as ONE merged proof.
3//!
4//! Attach sub-queries directly to [`DocumentQuery`] with an explicit
5//! page limit, then fetch the result as [`CompositeDocuments`].
6//! Each [`CompositeSubQuery`] is a by-id join, an indexed lookup, a
7//! grouped count, or an independent sibling, whose `IN` clause the
8//! server derives from the proven page (or an earlier documents
9//! sub-query) — the request never names the derived values, so the
10//! responding node cannot steer them. The verifier bootstraps the page
11//! from the merged proof and re-derives every sub-query with the same
12//! builders, so a substituted, omitted or injected sub-result fails
13//! verification. See `drive::query::composite_document_query`
14//! for the shape rules and the trust model.
15
16use crate::documents::document_query::{
17    order_clause_to_proto, where_clause_to_proto, DocumentQuery,
18};
19use crate::error::Error;
20use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::{
21    sub_query, SubQuery as ProtoSubQuery,
22};
23use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata};
24use dapi_grpc::platform::VersionedGrpcResponse;
25use dash_context_provider::ContextProvider;
26use dpp::dashcore::Network;
27use dpp::data_contract::accessors::v0::DataContractV0Getters;
28use dpp::data_contract::DataContract;
29use dpp::version::PlatformVersion;
30use dpp::ProtocolError;
31use drive::config::DEFAULT_QUERY_LIMIT;
32use drive::error::query::QuerySyntaxError;
33use drive::query::{
34    BindingSource, DriveDocumentQuery, DriveSubQuery, OrderClause, SelectProjection,
35    SubQueryBinding, SubQueryKind, WhereClause, MAX_SUB_QUERIES,
36};
37use drive_proof_verifier::{
38    verify_composite_documents_tenderdash_proof, CompositeDocuments, FromProof,
39};
40use std::sync::Arc;
41
42/// Whose proven documents a sub-query's values are read from.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
45pub enum CompositeBindingSource {
46    /// The page.
47    Page,
48    /// An earlier documents sub-query, by its position in
49    /// [`DocumentQuery::sub_queries`].
50    SubQuery(usize),
51}
52
53/// The derived clause of a sub-query: `<field> IN <values>`, where the
54/// values are read off the source's proven documents.
55#[derive(Debug, Clone, PartialEq, Eq)]
56#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
57pub struct CompositeBinding {
58    /// Whose documents supply the values.
59    pub source: CompositeBindingSource,
60    /// The source property read off each document: `$id`, `$ownerId`,
61    /// or an identifier-typed property (dotted paths reach nested
62    /// properties). Documents without it contribute nothing.
63    pub source_property: String,
64    /// The sub-query field receiving the `IN` clause. `$id` makes the
65    /// sub-query a by-id JOIN (the source property must then declare
66    /// `refersTo: permanentDocument` targeting the sub-query's type);
67    /// otherwise `$ownerId` or an indexed property (a LOOKUP).
68    pub field: String,
69}
70
71/// What a sub-query returns.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
74pub enum CompositeSubQueryKind {
75    /// The matching documents.
76    Documents,
77    /// One count per derived value, read from the countable index
78    /// covering the fixed clauses plus the bound field.
79    Count,
80}
81
82/// One sub-query of a composite request.
83#[derive(Debug, Clone, PartialEq)]
84#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
85pub struct CompositeSubQuery {
86    /// The contract the sub-query targets — the page's, or any other
87    /// (profiles keyed by owner, names keyed by identity).
88    pub data_contract: Arc<DataContract>,
89    /// The document type queried.
90    pub document_type_name: String,
91    /// Documents or counts.
92    pub kind: CompositeSubQueryKind,
93    /// The FIXED clauses — everything but the derived `IN`, which must
94    /// not be named here.
95    pub where_clauses: Vec<WhereClause>,
96    /// Ordering (documents only). Every component of the merged proof
97    /// walks in the page's direction: a bound field missing from here is
98    /// appended in that direction by the node and the verifier alike, and
99    /// an ordering that disagrees with the page's direction is refused
100    /// (turning a limited lookup around would change the rows it returns).
101    pub order_by_clauses: Vec<OrderClause>,
102    /// Required for a documents lookup on a non-unique index: it caps the
103    /// rows the lookup returns in total, in walk order, like an ordinary
104    /// `IN` query's limit. Forbidden for a lookup already bounded by its
105    /// values, a by-id join and a count.
106    pub limit: Option<u32>,
107    /// The derived clause, or `None` for a sibling: an independent
108    /// documents query proven under the same root.
109    pub binding: Option<CompositeBinding>,
110}
111
112impl CompositeSubQuery {
113    fn new(
114        data_contract: Arc<DataContract>,
115        document_type_name: &str,
116        kind: CompositeSubQueryKind,
117    ) -> Result<Self, Error> {
118        data_contract
119            .document_type_for_name(document_type_name)
120            .map_err(|e| Error::Protocol(ProtocolError::DataContractError(e)))?;
121        Ok(Self {
122            data_contract,
123            document_type_name: document_type_name.to_string(),
124            kind,
125            where_clauses: Vec::new(),
126            order_by_clauses: Vec::new(),
127            limit: None,
128            binding: None,
129        })
130    }
131
132    /// A documents sub-query against `document_type_name` of
133    /// `data_contract`. Unbound until [`Self::bound_to`] (a sibling
134    /// otherwise).
135    pub fn documents<C: Into<Arc<DataContract>>>(
136        data_contract: C,
137        document_type_name: &str,
138    ) -> Result<Self, Error> {
139        Self::new(
140            data_contract.into(),
141            document_type_name,
142            CompositeSubQueryKind::Documents,
143        )
144    }
145
146    /// A count sub-query against `document_type_name` of
147    /// `data_contract`. Must be bound.
148    pub fn count<C: Into<Arc<DataContract>>>(
149        data_contract: C,
150        document_type_name: &str,
151    ) -> Result<Self, Error> {
152        Self::new(
153            data_contract.into(),
154            document_type_name,
155            CompositeSubQueryKind::Count,
156        )
157    }
158
159    /// Bind `field` to the `source_property` values of `source`'s
160    /// proven documents.
161    pub fn bound_to(
162        mut self,
163        source: CompositeBindingSource,
164        source_property: impl Into<String>,
165        field: impl Into<String>,
166    ) -> Self {
167        self.binding = Some(CompositeBinding {
168            source,
169            source_property: source_property.into(),
170            field: field.into(),
171        });
172        self
173    }
174
175    /// Bind `field` to the `source_property` values of the page's
176    /// proven documents.
177    pub fn bound_to_page(
178        self,
179        source_property: impl Into<String>,
180        field: impl Into<String>,
181    ) -> Self {
182        self.bound_to(CompositeBindingSource::Page, source_property, field)
183    }
184
185    /// Add a fixed `where` clause.
186    pub fn with_where(mut self, clause: WhereClause) -> Self {
187        self.where_clauses.push(clause);
188        self
189    }
190
191    /// Add an `order_by` clause (documents only).
192    pub fn with_order_by(mut self, clause: OrderClause) -> Self {
193        self.order_by_clauses.push(clause);
194        self
195    }
196
197    /// Set the total row limit of a documents lookup.
198    pub fn with_limit(mut self, limit: u32) -> Self {
199        self.limit = Some(limit);
200        self
201    }
202}
203
204impl From<&DriveSubQuery<'_>> for CompositeSubQuery {
205    fn from(sub: &DriveSubQuery<'_>) -> Self {
206        use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
207        Self {
208            data_contract: Arc::new(sub.contract.clone()),
209            document_type_name: sub.document_type.name().to_string(),
210            kind: match sub.kind {
211                SubQueryKind::Documents => CompositeSubQueryKind::Documents,
212                SubQueryKind::Count => CompositeSubQueryKind::Count,
213            },
214            where_clauses: sub.where_clauses.clone(),
215            order_by_clauses: sub.order_by.clone(),
216            limit: sub.limit.map(u32::from),
217            binding: sub.binding.as_ref().map(|binding| CompositeBinding {
218                source: match binding.source {
219                    BindingSource::Page => CompositeBindingSource::Page,
220                    BindingSource::SubQuery(index) => CompositeBindingSource::SubQuery(index),
221                },
222                source_property: binding.source_property.clone(),
223                field: binding.field.clone(),
224            }),
225        }
226    }
227}
228
229impl DocumentQuery {
230    /// Append a sub-query derived from this page or an earlier sub-query.
231    /// Fetch the result as [`CompositeDocuments`].
232    pub fn with_sub_query(mut self, sub_query: CompositeSubQuery) -> Self {
233        self.sub_queries.push(sub_query);
234        self
235    }
236
237    /// Replace the sub-queries. An empty list makes this an ordinary query.
238    pub fn with_sub_queries(mut self, sub_queries: Vec<CompositeSubQuery>) -> Self {
239        self.sub_queries = sub_queries;
240        self
241    }
242
243    /// Check the composite-only shape before encoding or building Drive queries.
244    pub(super) fn check_composite_shape(&self) -> Result<(), Error> {
245        if self.sub_queries.is_empty() || self.sub_queries.len() > MAX_SUB_QUERIES {
246            return Err(Error::Config(format!(
247                "a composite document query requires between 1 and {MAX_SUB_QUERIES} sub-queries"
248            )));
249        }
250        check_page_shape(self)?;
251        for (index, sub) in self.sub_queries.iter().enumerate() {
252            if let Some(limit) = sub.limit {
253                if limit == 0 || limit > u32::from(DEFAULT_QUERY_LIMIT) {
254                    return Err(Error::Drive(drive::error::Error::Query(
255                        QuerySyntaxError::InvalidLimit(format!(
256                            "sub-query {index}: limit must be in [1, {DEFAULT_QUERY_LIMIT}], got {limit}"
257                        )),
258                    )));
259                }
260            }
261            if let Some(CompositeBinding {
262                source: CompositeBindingSource::SubQuery(source),
263                ..
264            }) = &sub.binding
265            {
266                if *source >= index
267                    || self.sub_queries[*source].kind != CompositeSubQueryKind::Documents
268                {
269                    return Err(Error::Config(format!(
270                        "sub-query {index}: a binding must name an earlier documents sub-query"
271                    )));
272                }
273            }
274        }
275        Ok(())
276    }
277}
278
279/// The page-side shape rules shared by the wire encoder and the drive
280/// conversion: an explicit limit, a documents projection, and nothing
281/// the composite surface cannot express.
282fn check_page_shape(page: &DocumentQuery) -> Result<(), Error> {
283    if page.limit == 0 || page.limit > u32::from(DEFAULT_QUERY_LIMIT) {
284        return Err(Error::Config(format!(
285            "a composite document query requires an explicit page limit between 1 and {DEFAULT_QUERY_LIMIT}: it \
286             bounds every derived sub-query clause, so there is no server-default sentinel"
287        )));
288    }
289    if page.select != SelectProjection::documents() {
290        return Err(Error::Config(
291            "a composite page supports the DOCUMENTS projection only".to_string(),
292        ));
293    }
294    if !page.time_range_clauses.is_empty()
295        || page.start.is_some()
296        || page.offset.is_some()
297        || !page.group_by.is_empty()
298        || !page.having.is_empty()
299    {
300        return Err(Error::Config(
301            "a composite page supports where/order_by/limit only: no time-range \
302             selections, cursors, offsets, group_by, or having (paginate with a range \
303             clause on the page's ordering property)"
304                .to_string(),
305        ));
306    }
307    Ok(())
308}
309
310/// Encode sub-queries after [`DocumentQuery::check_composite_shape`] has
311/// bounded their count, binding indices, and limits.
312pub(super) fn sub_queries_to_proto(
313    sub_queries: Vec<CompositeSubQuery>,
314) -> Result<Vec<ProtoSubQuery>, Error> {
315    sub_queries
316        .into_iter()
317        .map(|sub_query| {
318            let CompositeSubQuery {
319                data_contract,
320                document_type_name,
321                kind,
322                where_clauses,
323                order_by_clauses,
324                limit,
325                binding,
326            } = sub_query;
327            let kind = match kind {
328                CompositeSubQueryKind::Documents => sub_query::Kind::Documents,
329                CompositeSubQueryKind::Count => sub_query::Kind::Count,
330            };
331            Ok(ProtoSubQuery {
332                // Always explicit: the server treats an empty id as
333                // "the page's contract", but naming it costs 32
334                // bytes and removes a shape the verifier would
335                // otherwise have to mirror.
336                data_contract_id: data_contract.id().to_vec(),
337                document_type: document_type_name,
338                where_clauses: where_clauses
339                    .into_iter()
340                    .map(where_clause_to_proto)
341                    .collect::<Result<Vec<_>, _>>()?,
342                order_by: order_by_clauses
343                    .into_iter()
344                    .map(order_clause_to_proto)
345                    .collect(),
346                limit,
347                kind: kind as i32,
348                bind: binding.map(|binding| sub_query::Binding {
349                    source: match binding.source {
350                        CompositeBindingSource::Page => 0,
351                        CompositeBindingSource::SubQuery(index) => index as u32 + 1,
352                    },
353                    source_property: binding.source_property,
354                    field: binding.field,
355                }),
356            })
357        })
358        .collect::<Result<Vec<_>, Error>>()
359}
360
361/// Borrow sub-queries after [`DocumentQuery::check_composite_shape`] has
362/// validated limits before narrowing them to Drive's `u16`.
363pub(super) fn drive_sub_queries<'a>(
364    request: &'a DocumentQuery,
365) -> Result<Vec<DriveSubQuery<'a>>, Error> {
366    request
367        .sub_queries
368        .iter()
369        .map(|sub_query| {
370            let contract: &'a DataContract = &sub_query.data_contract;
371            let document_type = contract
372                .document_type_for_name(&sub_query.document_type_name)
373                .map_err(|e| Error::Protocol(ProtocolError::DataContractError(e)))?;
374            Ok(DriveSubQuery {
375                contract,
376                document_type,
377                kind: match sub_query.kind {
378                    CompositeSubQueryKind::Documents => SubQueryKind::Documents,
379                    CompositeSubQueryKind::Count => SubQueryKind::Count,
380                },
381                where_clauses: sub_query.where_clauses.clone(),
382                order_by: sub_query.order_by_clauses.clone(),
383                limit: sub_query.limit.map(|limit| limit as u16),
384                binding: sub_query.binding.as_ref().map(|binding| SubQueryBinding {
385                    source: match binding.source {
386                        CompositeBindingSource::Page => BindingSource::Page,
387                        CompositeBindingSource::SubQuery(index) => BindingSource::SubQuery(index),
388                    },
389                    source_property: binding.source_property.clone(),
390                    field: binding.field.clone(),
391                }),
392            })
393        })
394        .collect::<Result<Vec<_>, Error>>()
395}
396
397impl FromProof<DocumentQuery> for CompositeDocuments {
398    type Request = DocumentQuery;
399    type Response = GetDocumentsResponse;
400
401    fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
402        request: I,
403        response: O,
404        _network: Network,
405        platform_version: &PlatformVersion,
406        provider: &'a dyn ContextProvider,
407    ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
408    where
409        Self: 'a,
410    {
411        let request: Self::Request = request.into();
412        request
413            .check_composite_shape()
414            .map_err(|e| drive_proof_verifier::Error::RequestError {
415                error: e.to_string(),
416            })?;
417        let response: Self::Response = response.into();
418
419        let query: DriveDocumentQuery = (&request).try_into().map_err(|e: Error| {
420            drive_proof_verifier::Error::RequestError {
421                error: e.to_string(),
422            }
423        })?;
424
425        // The standard envelope carries the single MERGED proof, and
426        // the proof alone is enough: the verifier bootstraps the page
427        // from it via a subset pass and re-derives the rest.
428        let proof = response
429            .proof()
430            .or(Err(drive_proof_verifier::Error::NoProofInResult))?;
431        let mtd = response
432            .metadata()
433            .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?;
434
435        let (_root_hash, composite) = verify_composite_documents_tenderdash_proof(
436            &query,
437            proof,
438            mtd,
439            platform_version,
440            provider,
441        )?;
442
443        // An empty page is a valid, proven "nothing here" — surface it
444        // as Some(empty) rather than None so callers can tell it apart
445        // from a missing object.
446        Ok((Some(composite), mtd.clone(), proof.clone()))
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    //! Offline tests for the composite client surface: the V1
453    //! request-wire encoding, the page-shape rejections, and the
454    //! rich→drive conversion + shared shape validation against the
455    //! yappr-feed fixture. Proof verification is exercised end to end
456    //! in rs-drive's `composite_query_e2e_tests` and rs-drive-abci's
457    //! composite dispatch and trust-boundary tests, where a populated
458    //! Drive exists.
459
460    use super::*;
461    use dapi_grpc::platform::v0::get_documents_request::Version as RequestVersion;
462    use dapi_grpc::platform::v0::GetDocumentsRequest;
463    use dpp::platform_value::Value;
464    use dpp::tests::json_document::json_document_to_contract;
465    use dpp::version::TryFromPlatformVersioned;
466    use drive::query::WhereOperator;
467
468    const FEED_CONTRACT_PATH: &str =
469        "../rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json";
470    const DASHPAY_CONTRACT_PATH: &str =
471        "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json";
472
473    fn platform_version() -> &'static PlatformVersion {
474        PlatformVersion::latest()
475    }
476
477    fn contract(path: &str) -> Arc<DataContract> {
478        Arc::new(
479            json_document_to_contract(path, false, platform_version())
480                .expect("expected to parse the fixture contract"),
481        )
482    }
483
484    /// The feed card composition: `dash` posts, their like counts, the
485    /// posts they quote, and their authors' dashpay profiles.
486    fn feed_page(limit: u32) -> DocumentQuery {
487        let feed = contract(FEED_CONTRACT_PATH);
488        let dashpay = contract(DASHPAY_CONTRACT_PATH);
489        let page = DocumentQuery::new(feed.clone(), "post")
490            .expect("post doctype exists")
491            .with_where(WhereClause {
492                field: "hashtag".to_string(),
493                operator: WhereOperator::Equal,
494                value: Value::Text("dash".to_string()),
495            })
496            .with_limit(limit);
497        page.with_sub_query(
498            CompositeSubQuery::count(feed.clone(), "like")
499                .expect("like doctype exists")
500                .bound_to_page("$id", "postId"),
501        )
502        .with_sub_query(
503            CompositeSubQuery::documents(feed, "post")
504                .expect("post doctype exists")
505                .bound_to_page("quotedPostId", "$id"),
506        )
507        .with_sub_query(
508            CompositeSubQuery::documents(dashpay, "profile")
509                .expect("profile doctype exists")
510                .bound_to_page("$ownerId", "$ownerId"),
511        )
512    }
513
514    #[test]
515    fn encodes_the_v1_wire_shape() {
516        let query = feed_page(10);
517        let dashpay_id = query.sub_queries[2].data_contract.id().to_vec();
518        let request = GetDocumentsRequest::try_from_platform_versioned(query, platform_version())
519            .expect("encodes");
520        let Some(RequestVersion::V1(v1)) = request.version else {
521            panic!("expected a V1 request");
522        };
523        assert_eq!(v1.document_type, "post");
524        assert_eq!(v1.limit, Some(10));
525        assert!(v1.prove, "composite fetch always proves");
526        assert!(v1.chained.is_none(), "composite and chained are exclusive");
527        assert_eq!(v1.where_clauses.len(), 1);
528        assert_eq!(v1.sub_queries.len(), 3);
529
530        let counts = &v1.sub_queries[0];
531        assert_eq!(counts.document_type, "like");
532        assert_eq!(counts.kind, sub_query::Kind::Count as i32);
533        assert_eq!(counts.limit, None);
534        let bind = counts.bind.as_ref().expect("bound");
535        assert_eq!(bind.source, 0, "the page is source 0");
536        assert_eq!(bind.source_property, "$id");
537        assert_eq!(bind.field, "postId");
538
539        let quoted = &v1.sub_queries[1];
540        assert_eq!(quoted.kind, sub_query::Kind::Documents as i32);
541        assert_eq!(quoted.bind.as_ref().expect("bound").field, "$id");
542
543        let profiles = &v1.sub_queries[2];
544        assert_eq!(profiles.data_contract_id, dashpay_id);
545        assert_eq!(profiles.document_type, "profile");
546    }
547
548    #[test]
549    fn numbers_sub_query_sources_from_one() {
550        let feed = contract(FEED_CONTRACT_PATH);
551        let query = feed_page(10).with_sub_query(
552            CompositeSubQuery::count(feed, "like")
553                .expect("like doctype exists")
554                .bound_to(CompositeBindingSource::SubQuery(1), "$id", "postId"),
555        );
556        let request = GetDocumentsRequest::try_from_platform_versioned(query, platform_version())
557            .expect("encodes");
558        let Some(RequestVersion::V1(v1)) = request.version else {
559            panic!("expected a V1 request");
560        };
561        assert_eq!(
562            v1.sub_queries[3].bind.as_ref().expect("bound").source,
563            2,
564            "sub-query 1 is wire source 2"
565        );
566    }
567
568    #[test]
569    fn should_preserve_the_full_composition_through_drive_conversion() {
570        let feed = contract(FEED_CONTRACT_PATH);
571        let query = feed_page(10).with_sub_query(
572            CompositeSubQuery::documents(feed, "repost")
573                .expect("repost doctype exists")
574                .bound_to(CompositeBindingSource::SubQuery(1), "$id", "postId")
575                .with_where(WhereClause {
576                    field: "hashtag".to_string(),
577                    operator: WhereOperator::Equal,
578                    value: Value::Text("dash".to_string()),
579                })
580                .with_order_by(OrderClause {
581                    field: "postId".to_string(),
582                    ascending: true,
583                })
584                .with_limit(7),
585        );
586        let drive_query: DriveDocumentQuery = (&query).try_into().expect("converts");
587        for restored in [
588            DocumentQuery::try_from(&drive_query),
589            DocumentQuery::try_from(drive_query.clone()),
590            DocumentQuery::new_with_drive_query(&drive_query),
591        ] {
592            assert_eq!(restored.expect("preserves the composition"), query);
593        }
594    }
595
596    #[test]
597    fn should_reject_compositions_on_v0_without_affecting_ordinary_queries() {
598        let mut v0 = platform_version().clone();
599        v0.drive_abci.query.document_query.default_current_version = 0;
600        let query = feed_page(10);
601        let refused = GetDocumentsRequest::try_from_platform_versioned(query.clone(), &v0);
602        assert!(matches!(refused, Err(Error::Config(message)) if message.contains("V1")));
603
604        let ordinary = query.with_sub_queries(vec![]);
605        let request = GetDocumentsRequest::try_from_platform_versioned(ordinary.clone(), &v0)
606            .expect("ordinary queries still encode as V0");
607        assert!(matches!(request.version, Some(RequestVersion::V0(_))));
608        let request =
609            GetDocumentsRequest::try_from_platform_versioned(ordinary, platform_version())
610                .expect("ordinary queries still encode as V1");
611        let Some(RequestVersion::V1(v1)) = request.version else {
612            panic!("expected V1");
613        };
614        assert!(v1.sub_queries.is_empty());
615    }
616
617    #[test]
618    fn should_enforce_sub_query_limits_before_encoding_or_conversion() {
619        let query = feed_page(10);
620        let sub = query.sub_queries[0].clone();
621        let maximum = query
622            .clone()
623            .with_sub_queries(vec![sub.clone(); MAX_SUB_QUERIES]);
624        GetDocumentsRequest::try_from_platform_versioned(maximum.clone(), platform_version())
625            .expect("the maximum sub-query count encodes");
626        DriveDocumentQuery::try_from(&maximum).expect("the maximum sub-query count converts");
627
628        let excessive = query.with_sub_queries(vec![sub; MAX_SUB_QUERIES + 1]);
629        assert!(GetDocumentsRequest::try_from_platform_versioned(
630            excessive.clone(),
631            platform_version()
632        )
633        .is_err());
634        assert!(DriveDocumentQuery::try_from(&excessive).is_err());
635
636        for limit in [0, 101, u32::MAX] {
637            let mut query = feed_page(10);
638            query.sub_queries[2].limit = Some(limit);
639            assert!(GetDocumentsRequest::try_from_platform_versioned(
640                query.clone(),
641                platform_version()
642            )
643            .is_err());
644            assert!(DriveDocumentQuery::try_from(&query).is_err());
645        }
646    }
647
648    #[test]
649    fn should_reject_invalid_binding_sources_before_encoding_or_conversion() {
650        // Source 0 is a count, source 2 is the sub-query itself, and a
651        // maximal index must not wrap when mapped to the wire's u32.
652        for source in [0, 2, 3, usize::MAX] {
653            let mut query = feed_page(10);
654            query.sub_queries[2].binding.as_mut().expect("bound").source =
655                CompositeBindingSource::SubQuery(source);
656            assert!(GetDocumentsRequest::try_from_platform_versioned(
657                query.clone(),
658                platform_version()
659            )
660            .is_err());
661            assert!(DriveDocumentQuery::try_from(&query).is_err());
662        }
663    }
664
665    #[cfg(feature = "mocks")]
666    #[test]
667    fn should_preserve_mock_compositions_and_read_older_ordinary_queries() {
668        let query = feed_page(10);
669        let encoded = serde_json::to_value(&query).expect("serializes");
670        let restored: DocumentQuery =
671            serde_json::from_value(encoded.clone()).expect("deserializes");
672        assert_eq!(restored, query);
673
674        let mut legacy = encoded;
675        legacy
676            .as_object_mut()
677            .expect("query object")
678            .remove("sub_queries");
679        let restored: DocumentQuery = serde_json::from_value(legacy).expect("reads older vectors");
680        assert_eq!(restored, query.with_sub_queries(vec![]));
681    }
682
683    #[test]
684    fn should_reject_invalid_page_limits() {
685        for limit in [0, 101, u32::MAX] {
686            let query = feed_page(limit);
687            let refused =
688                GetDocumentsRequest::try_from_platform_versioned(query.clone(), platform_version());
689            assert!(
690                matches!(refused, Err(Error::Config(_))),
691                "an invalid page limit must be refused, got {refused:?}"
692            );
693            assert!(DriveDocumentQuery::try_from(&query).is_err());
694        }
695    }
696
697    #[test]
698    fn refuses_unsupported_page_features() {
699        let mut query = feed_page(10);
700        query.offset = Some(4);
701        let refused = GetDocumentsRequest::try_from_platform_versioned(query, platform_version());
702        assert!(
703            matches!(refused, Err(Error::Config(_))),
704            "a page offset must be refused, got {refused:?}"
705        );
706    }
707
708    #[test]
709    fn converts_to_a_valid_drive_query() {
710        let query = feed_page(10);
711        let drive_query: DriveDocumentQuery =
712            (&query).try_into().expect("converts to a drive query");
713        drive_query
714            .validate_composite(platform_version())
715            .expect("the feed card shape validates");
716        assert_eq!(drive_query.limit, Some(10));
717        assert_eq!(drive_query.sub_queries.len(), 3);
718        assert_eq!(drive_query.sub_queries[0].kind, SubQueryKind::Count);
719        assert_eq!(
720            drive_query.sub_queries[2]
721                .binding
722                .as_ref()
723                .expect("bound")
724                .source,
725            BindingSource::Page
726        );
727    }
728
729    #[test]
730    fn conversion_refuses_an_out_of_range_sub_query_limit() {
731        let feed = contract(FEED_CONTRACT_PATH);
732        let query = feed_page(10).with_sub_query(
733            CompositeSubQuery::documents(feed, "repost")
734                .expect("repost doctype exists")
735                .bound_to_page("$id", "postId")
736                .with_limit(101),
737        );
738        let refused: Result<DriveDocumentQuery, _> = (&query).try_into();
739        assert!(
740            matches!(refused, Err(Error::Drive(_))),
741            "a sub-query limit above the server maximum must be refused, got {refused:?}"
742        );
743    }
744
745    #[test]
746    fn conversion_surfaces_shape_errors() {
747        let feed = contract(FEED_CONTRACT_PATH);
748        let query = feed_page(10).with_sub_query(
749            CompositeSubQuery::documents(feed, "post")
750                .expect("post doctype exists")
751                .bound_to_page("hashtag", "$id"),
752        );
753        let drive_query: DriveDocumentQuery =
754            (&query).try_into().expect("conversion itself succeeds");
755        assert!(
756            drive_query.validate_composite(platform_version()).is_err(),
757            "a by-id join off a non-refersTo property must fail validation"
758        );
759    }
760}