1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
45pub enum CompositeBindingSource {
46 Page,
48 SubQuery(usize),
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
57pub struct CompositeBinding {
58 pub source: CompositeBindingSource,
60 pub source_property: String,
64 pub field: String,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
80pub enum CompositeSubQueryKind {
81 Documents,
83 Count,
86}
87
88#[derive(Debug, Clone, PartialEq)]
90#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
91pub struct CompositeSubQuery {
92 pub data_contract: Arc<DataContract>,
95 pub document_type_name: String,
97 pub kind: CompositeSubQueryKind,
99 pub where_clauses: Vec<WhereClause>,
102 pub order_by_clauses: Vec<OrderClause>,
108 pub limit: Option<u32>,
113 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 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 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 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 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 pub fn with_where(mut self, clause: WhereClause) -> Self {
193 self.where_clauses.push(clause);
194 self
195 }
196
197 pub fn with_order_by(mut self, clause: OrderClause) -> Self {
199 self.order_by_clauses.push(clause);
200 self
201 }
202
203 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 pub fn with_sub_query(mut self, sub_query: CompositeSubQuery) -> Self {
239 self.sub_queries.push(sub_query);
240 self
241 }
242
243 pub fn with_sub_queries(mut self, sub_queries: Vec<CompositeSubQuery>) -> Self {
245 self.sub_queries = sub_queries;
246 self
247 }
248
249 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
285fn 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
317pub(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 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
368pub(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
404fn 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 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 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 Ok((Some(composite), mtd.clone(), proof.clone()))
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 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 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 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 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}