dash_platform_queries/documents/
chained_document_query.rs1use 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#[derive(Debug, Clone, PartialEq, dash_platform_macros::Mockable)]
36#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
37pub struct ChainedDocumentQuery {
38 pub inner: DocumentQuery,
40 pub join_property: String,
50 pub outer_document_type_name: String,
52}
53
54impl ChainedDocumentQuery {
55 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 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 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 Ok((Some(chained), mtd.clone(), proof.clone()))
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 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 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}