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