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