drive/query/chained_document_query/mod.rs
1//! Chained document queries: a provable semi-join.
2//!
3//! `SELECT * FROM post WHERE $id IN (SELECT postId FROM like WHERE
4//! $ownerId = <me>)` — the INNER query runs against an indexOnly document
5//! type and projects a `refersTo: permanentDocument` property (the JOIN
6//! property); its proven values are reinjected as the OUTER query's
7//! primary keys. Both halves are proven as ONE merged grovedb proof —
8//! `prove_query_many` merges the limited inner query with the derived
9//! outer by-ids query (grovedb merge slot 2 lifts the inner limit into
10//! a per-instance branch limit), so a single root binds the whole
11//! composition by construction; the surrounding tenderdash layer then
12//! binds that root to the quorum-signed app hash (see
13//! `rs-drive-proof-verifier`).
14//!
15//! There is no separate chained query type: a chained query is a
16//! [`DriveDocumentQuery`] — the inner half — whose
17//! [`sub_queries`](DriveDocumentQuery::sub_queries) carry exactly one
18//! by-id join bound to it, the shape
19//! [`DriveDocumentQuery::with_by_id_join`] builds (the same shape the
20//! composite surface generalizes). This module holds the chained
21//! behaviour of `DriveDocumentQuery`: shape validation, join-value
22//! derivation, the outer by-ids builder, proof merging, and the
23//! server-side executors behind `Drive::query_chained_documents` /
24//! `query_chained_documents_with_proof` (the verifier half lives in
25//! `verify::chained_document`).
26//!
27//! Soundness never rests on the server's join: the verifier re-derives
28//! the outer query from the INNER proof's results
29//! ([`DriveDocumentQuery::chained_join_values`] →
30//! [`DriveDocumentQuery::derive_chained_outer_query`], the same functions
31//! the server executes), so a server cannot substitute, omit, or inject
32//! outer documents. Because the join property's `refersTo` targets a
33//! `permanentDocument` type (non-deletable, enforced at write time),
34//! every proven join value MUST resolve to a document — a missing outer
35//! document is an invalid proof, not an absence.
36//!
37//! Guardrails (v1): the inner query must resolve to an indexOnly index
38//! that carries the join property (as terminal or prefix property, so
39//! every synthesized projection provably carries its value); the join
40//! edge must be a same-contract `refersTo: permanentDocument` whose
41//! target is the outer type; the inner limit is required (it is what
42//! bounds the outer fan-out); the outer half takes no clauses, no
43//! limit and no cursor — it is purely the derived by-ids fetch, and
44//! pagination lives on the inner query alone.
45
46use crate::error::drive::DriveError;
47use crate::error::query::QuerySyntaxError;
48use crate::error::Error;
49use crate::query::{
50 BindingSource, DriveDocumentQuery, InternalClauses, SubQueryBinding, SubQueryKind, WhereClause,
51 WhereOperator,
52};
53use dpp::data_contract::accessors::v0::DataContractV0Getters;
54use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters};
55use dpp::data_contract::document_type::{
56 DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef,
57};
58use dpp::data_contract::DataContract;
59use dpp::document::{Document, DocumentV0Getters};
60use dpp::identifier::Identifier;
61use dpp::platform_value::Value;
62use dpp::version::PlatformVersion;
63
64/// The most join values one chained query can carry — the derived
65/// outer query is a single `$id IN [...]` clause, and `in` clauses
66/// admit at most 100 values (`WhereClause::in_values`).
67/// [`DriveDocumentQuery::validate_chained`] caps the inner limit here so
68/// every reachable page fits, and
69/// [`DriveDocumentQuery::chained_proof_path_queries`] enforces it on the
70/// (untrusted, verifier-supplied) join-value list itself.
71pub const MAX_CHAINED_JOIN_VALUES: usize = 100;
72
73/// The materialized result of a chained query, in inner-proof order.
74#[derive(Debug, Default)]
75pub struct ChainedDocumentsResult {
76 /// The inner projections (synthesized indexOnly documents), exactly
77 /// as the inner query alone would return them — the caller reads its
78 /// pagination cursor (the last join value) from here.
79 pub inner_documents: Vec<Document>,
80 /// The referenced outer documents, ordered by FIRST APPEARANCE of
81 /// their id in `inner_documents` (deduplicated).
82 pub outer_documents: Vec<Document>,
83}
84
85impl<'a> DriveDocumentQuery<'a> {
86 /// The join edge of a chained query. There is no separate chained
87 /// query type: a chained query is this query (the inner half) whose
88 /// [`sub_queries`](Self::sub_queries) carry EXACTLY ONE by-id join
89 /// bound to it — the shape [`Self::with_by_id_join`] builds. Returns
90 /// the join's source property (the inner property whose proven values
91 /// become the outer `$id`s) and the outer document type with its
92 /// contract; refuses any other sub-query shape.
93 pub(crate) fn chained_join(
94 &self,
95 ) -> Result<(&str, DocumentTypeRef<'a>, &'a DataContract), Error> {
96 let unsupported =
97 |message: &str| Error::Query(QuerySyntaxError::Unsupported(message.to_string()));
98 let [join] = self.sub_queries.as_slice() else {
99 return Err(unsupported(
100 "a chained query carries exactly one sub-query: the by-id join whose source \
101 property's proven values become the outer `$id`s (build it with \
102 with_by_id_join); a query with more sub-queries belongs on the composite \
103 surface",
104 ));
105 };
106 let Some(SubQueryBinding {
107 source: BindingSource::Page,
108 source_property,
109 field,
110 }) = &join.binding
111 else {
112 return Err(unsupported(
113 "a chained query's sub-query must be bound to the inner query itself",
114 ));
115 };
116 if field.as_str() != dpp::document::property_names::ID {
117 return Err(unsupported(
118 "a chained query's sub-query must be a by-id join (bound field `$id`); other \
119 bindings live on the composite surface",
120 ));
121 }
122 if join.kind != SubQueryKind::Documents {
123 return Err(unsupported(
124 "a chained join returns documents; counts live on the composite surface",
125 ));
126 }
127 if !join.where_clauses.is_empty() || !join.order_by.is_empty() || join.limit.is_some() {
128 return Err(unsupported(
129 "a chained by-id join takes no fixed clauses, no ordering and no limit: the \
130 outer half is purely the derived by-ids fetch, complete by set equality",
131 ));
132 }
133 Ok((source_property.as_str(), join.document_type, join.contract))
134 }
135
136 /// Validates the chained shape: this query as the inner indexOnly
137 /// half plus the single by-id join its
138 /// [`sub_queries`](Self::sub_queries) carry (see
139 /// [`Self::chained_join`]). Called by the server before executing and
140 /// by the verifier before verifying, so an invalid spec fails
141 /// identically on both sides.
142 pub fn validate_chained(&self, platform_version: &PlatformVersion) -> Result<(), Error> {
143 let unsupported = |message: String| Error::Query(QuerySyntaxError::Unsupported(message));
144
145 let (join_property, outer_document_type, outer_contract) = self.chained_join()?;
146 // Chained joins are same-contract (v1): the join sub-query's
147 // contract must be the inner query's own.
148 if outer_contract.id() != self.contract.id() {
149 return Err(unsupported(
150 "chained document queries support same-contract joins only: the join \
151 sub-query targets another contract"
152 .to_string(),
153 ));
154 }
155 if !self.document_type.index_only() {
156 return Err(unsupported(
157 "chained document queries require an indexOnly inner document type: only \
158 indexOnly projections prove their values positionally"
159 .to_string(),
160 ));
161 }
162 if outer_document_type.index_only() {
163 return Err(unsupported(
164 "the outer document type of a chained query cannot be indexOnly: outer \
165 documents are fetched by id from primary storage, which indexOnly types \
166 do not have"
167 .to_string(),
168 ));
169 }
170 match self.limit {
171 None => {
172 return Err(unsupported(
173 "chained document queries require an explicit limit on the inner query: \
174 the inner page size is what bounds the derived outer query"
175 .to_string(),
176 ));
177 }
178 Some(limit) if limit as usize > MAX_CHAINED_JOIN_VALUES => {
179 return Err(unsupported(format!(
180 "a chained inner limit of {} exceeds {}: the derived outer query is a \
181 single `$id IN` clause, which admits at most that many values",
182 limit, MAX_CHAINED_JOIN_VALUES,
183 )));
184 }
185 Some(_) => {}
186 }
187 if self.offset.is_some() {
188 return Err(unsupported(
189 "chained document queries do not support an inner offset; paginate with a \
190 range clause on the join property"
191 .to_string(),
192 ));
193 }
194
195 // The join property must be a same-contract permanentDocument
196 // reference targeting the outer type. `refersTo` writes are
197 // existence-validated and permanentDocument targets can never be
198 // deleted, so every proven join value MUST resolve — which is
199 // what lets the verifier treat a missing outer document as an
200 // invalid proof instead of needing absence proofs.
201 let Some(join_document_property) =
202 self.document_type.flattened_properties().get(join_property)
203 else {
204 return Err(unsupported(format!(
205 "chained query join property \"{}\" does not name a property of inner \
206 document type \"{}\"",
207 join_property,
208 self.document_type.name(),
209 )));
210 };
211 match &join_document_property.property_type {
212 DocumentPropertyType::IdentifierWithReference(
213 DocumentPropertyReferenceTarget::PermanentDocument {
214 contract_id,
215 document_type_name,
216 ..
217 },
218 ) => {
219 if let Some(referenced_contract_id) = contract_id {
220 if *referenced_contract_id != self.contract.id() {
221 return Err(unsupported(
222 "chained document queries support same-contract joins only: \
223 the join property's refersTo names another contract"
224 .to_string(),
225 ));
226 }
227 }
228 if document_type_name != outer_document_type.name() {
229 return Err(unsupported(format!(
230 "chained query outer document type \"{}\" does not match the join \
231 property's refersTo target \"{}\"",
232 outer_document_type.name(),
233 document_type_name,
234 )));
235 }
236 }
237 _ => {
238 return Err(unsupported(format!(
239 "chained query join property \"{}\" must carry a `refersTo: \
240 permanentDocument` declaration: only a permanent-document reference \
241 guarantees every proven join value resolves to an outer document",
242 join_property,
243 )));
244 }
245 }
246
247 // The resolved index must carry the join property, so every
248 // synthesized inner projection provably carries its value.
249 let index = self.index_only_query_index(platform_version)?;
250 let index_carries_join_property = index.terminal.as_deref() == Some(join_property)
251 || index
252 .properties
253 .iter()
254 .any(|property| property.name == join_property);
255 if !index_carries_join_property {
256 return Err(unsupported(format!(
257 "the inner query resolves to index \"{}\", which does not carry the join \
258 property \"{}\"; constrain the query so an index carrying it serves it",
259 index.name, join_property,
260 )));
261 }
262
263 Ok(())
264 }
265
266 /// Extracts the join values from the inner documents in their proof
267 /// order, deduplicated to first appearance. ONE extraction both the
268 /// server and the verifier run — the single-builder rule that keeps
269 /// the derived outer query identical on both sides.
270 pub fn chained_join_values(
271 &self,
272 inner_documents: &[Document],
273 ) -> Result<Vec<Identifier>, Error> {
274 use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper;
275
276 let (join_property, _, _) = self.chained_join()?;
277 let mut seen: std::collections::BTreeSet<Identifier> = std::collections::BTreeSet::new();
278 let mut join_values = Vec::with_capacity(inner_documents.len());
279 for document in inner_documents {
280 // Path-aware read: `validate_chained` admits any property
281 // `flattened_properties()` names — dotted (nested) keys
282 // included — and the synthesis builder stores those nested
283 // (`insert_at_path`), so a flat `.get` would miss them.
284 let value = document
285 .properties()
286 .get_optional_at_path(join_property)
287 .ok()
288 .flatten()
289 .ok_or(Error::Drive(DriveError::CorruptedCodeExecution(
290 "an inner projection is missing the join property: validate_chained() \
291 guarantees the resolved index carries it",
292 )))?;
293 let identifier = value.to_identifier().map_err(|_| {
294 Error::Drive(DriveError::CorruptedCodeExecution(
295 "a chained join property must decode as an identifier: the parser \
296 only admits identifier-typed refersTo properties",
297 ))
298 })?;
299 if seen.insert(identifier) {
300 join_values.push(identifier);
301 }
302 }
303 Ok(join_values)
304 }
305
306 /// The derived outer query: a pure by-ids fetch of the join values
307 /// from the outer type's primary storage. No clauses, no limit, no
308 /// cursor — completeness is set-equality against `join_values`,
309 /// checked by the verifier.
310 pub fn derive_chained_outer_query(
311 &self,
312 join_values: &[Identifier],
313 ) -> Result<DriveDocumentQuery<'a>, Error> {
314 let (_, outer_document_type, outer_contract) = self.chained_join()?;
315 // Canonical value order: byte-ascending. Grove sorts query keys
316 // internally either way; sorting here keeps the built query —
317 // and therefore the proof — byte-identical between the server
318 // and a verifier that extracted the ids in any order.
319 let mut ids: Vec<Identifier> = join_values.to_vec();
320 ids.sort();
321 Ok(DriveDocumentQuery {
322 contract: outer_contract,
323 document_type: outer_document_type,
324 internal_clauses: InternalClauses {
325 primary_key_in_clause: Some(WhereClause {
326 field: dpp::document::property_names::ID.to_string(),
327 operator: WhereOperator::In,
328 value: Value::Array(
329 ids.into_iter()
330 .map(|id| Value::Identifier(id.to_buffer()))
331 .collect(),
332 ),
333 }),
334 primary_key_equal_clause: None,
335 in_clauses: Vec::new(),
336 range_clause: None,
337 equal_clauses: Default::default(),
338 },
339 offset: None,
340 limit: None,
341 order_by: Default::default(),
342 start_at: None,
343 start_at_included: false,
344 block_time_ms: None,
345 resolved_time_ranges: Vec::new(),
346 sub_queries: vec![],
347 })
348 }
349
350 /// Reorders the outer documents (returned in key order by the by-ids
351 /// query) into first-appearance join order, and enforces EXACT set
352 /// equality between the proven outer ids and the derived join
353 /// values — both directions. Shared by the server (where a mismatch
354 /// is corrupted state: permanentDocument references cannot dangle)
355 /// and the verifier (where it is an invalid proof).
356 pub fn assemble_chained_outer_documents(
357 &self,
358 join_values: &[Identifier],
359 outer_documents: Vec<Document>,
360 ) -> Result<Vec<Document>, Error> {
361 use std::collections::BTreeMap;
362 let mut by_id: BTreeMap<Identifier, Document> = BTreeMap::new();
363 for document in outer_documents {
364 let id = document.id();
365 if by_id.insert(id, document).is_some() {
366 return Err(Error::Proof(
367 crate::error::proof::ProofError::CorruptedProof(format!(
368 "chained outer results carry document {} twice",
369 id
370 )),
371 ));
372 }
373 }
374 let mut ordered = Vec::with_capacity(join_values.len());
375 for join_value in join_values {
376 let document = by_id.remove(join_value).ok_or_else(|| {
377 Error::Proof(crate::error::proof::ProofError::CorruptedProof(format!(
378 "chained outer results are missing referenced document {}: a \
379 permanentDocument reference cannot dangle, so the outer half does \
380 not prove the derived query",
381 join_value
382 )))
383 })?;
384 ordered.push(document);
385 }
386 if let Some((extra_id, _)) = by_id.into_iter().next() {
387 return Err(Error::Proof(
388 crate::error::proof::ProofError::CorruptedProof(format!(
389 "chained outer results carry document {} that no proven join value \
390 references",
391 extra_id
392 )),
393 ));
394 }
395 Ok(ordered)
396 }
397
398 /// The component path queries the chained proof covers: the inner
399 /// query's own path query, plus — for a non-empty join — the outer
400 /// by-ids path query derived from `join_values`. ONE builder both
401 /// the prover (`prove_query_many` merges these) and the verifier
402 /// (`PathQuery::merge` on the same inputs at the same grove
403 /// version) call, so the merged query is byte-identical on both
404 /// sides. Grovedb's merge lifts the inner query's global
405 /// `SizedQuery::limit` into its branch's per-instance
406 /// `Query::limit`, which is exact here: the branch instance
407 /// executes once.
408 pub fn chained_proof_path_queries(
409 &self,
410 join_values: &[Identifier],
411 platform_version: &PlatformVersion,
412 ) -> Result<Vec<grovedb::PathQuery>, Error> {
413 // `join_values` may be an UNTRUSTED verifier-side hint; cap it
414 // before deriving, so an oversized list fails here with a clear
415 // message instead of deep in the `in`-clause lowering. An
416 // honest list cannot exceed this: it is deduplicated from an
417 // inner page whose limit `validate_chained` bounds to the same
418 // cap.
419 if join_values.len() > MAX_CHAINED_JOIN_VALUES {
420 return Err(Error::Query(QuerySyntaxError::Unsupported(format!(
421 "{} chained join values exceed the {} an outer `$id IN` clause admits",
422 join_values.len(),
423 MAX_CHAINED_JOIN_VALUES,
424 ))));
425 }
426 let inner = self.construct_path_query(None, platform_version)?;
427 if join_values.is_empty() {
428 return Ok(vec![inner]);
429 }
430 let outer = self
431 .derive_chained_outer_query(join_values)?
432 .construct_path_query(None, platform_version)?;
433 Ok(vec![inner, outer])
434 }
435}
436
437#[cfg(feature = "server")]
438impl DriveDocumentQuery<'_> {
439 /// Executes the chained query without proofs.
440 pub(crate) fn execute_chained_no_proof_internal(
441 &self,
442 drive: &crate::drive::Drive,
443 transaction: grovedb::TransactionArg,
444 drive_operations: &mut Vec<crate::fees::op::LowLevelDriveOperation>,
445 platform_version: &PlatformVersion,
446 ) -> Result<ChainedDocumentsResult, Error> {
447 use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0;
448
449 self.validate_chained(platform_version)?;
450
451 let (inner_documents, _skipped) = self.execute_index_only_documents_no_proof_internal(
452 drive,
453 transaction,
454 drive_operations,
455 platform_version,
456 )?;
457 let join_values = self.chained_join_values(&inner_documents)?;
458 if join_values.is_empty() {
459 return Ok(ChainedDocumentsResult {
460 inner_documents,
461 outer_documents: Vec::new(),
462 });
463 }
464
465 let outer_query = self.derive_chained_outer_query(&join_values)?;
466 let outer_document_type = outer_query.document_type;
467 let (serialized_outer, _outer_skipped) = outer_query
468 .execute_raw_results_no_proof_internal(
469 drive,
470 transaction,
471 drive_operations,
472 platform_version,
473 )?;
474 let outer_documents = serialized_outer
475 .into_iter()
476 .map(|serialized| {
477 Document::from_bytes(serialized.as_slice(), outer_document_type, platform_version)
478 .map_err(|e| Error::Protocol(Box::new(e)))
479 })
480 .collect::<Result<Vec<Document>, Error>>()?;
481 let outer_documents =
482 self.assemble_chained_outer_documents(&join_values, outer_documents)?;
483
484 Ok(ChainedDocumentsResult {
485 inner_documents,
486 outer_documents,
487 })
488 }
489
490 /// Executes the chained query AND generates its single merged
491 /// proof.
492 ///
493 /// The inner page and the derived outer by-ids fetch are proven as
494 /// ONE grovedb proof: [`Self::chained_proof_path_queries`] builds the
495 /// component path queries and `prove_query_many` merges them
496 /// (grovedb merge slot 2 LIFTS the inner query's global limit into
497 /// its merged branch's per-instance `Query::limit` — semantically
498 /// exact, since the branch executes once). One proof means one root
499 /// by construction.
500 ///
501 /// The materialize pass (which produces the join values the outer
502 /// component derives from) and the prove pass still both read
503 /// committed state — grovedb proves committed state only — so the
504 /// sequence is BRACKETED by root-hash reads and retried if a block
505 /// commit interleaved; otherwise the proof's inner branch could
506 /// disagree with the outer branch derived from the stale
507 /// materialization, and every verifier would reject the
508 /// composition.
509 ///
510 /// Returns the proof and the materialized INNER projections (the
511 /// join values, and with them the caller's response hint and
512 /// pagination cursor, derive from these). The outer documents are
513 /// deliberately NOT materialized here — the proof pass covers them,
514 /// so reading their bodies a second time would double the state
515 /// reads for data the proved response never carries inline.
516 pub(crate) fn execute_chained_with_proof_internal(
517 &self,
518 drive: &crate::drive::Drive,
519 drive_operations: &mut Vec<crate::fees::op::LowLevelDriveOperation>,
520 platform_version: &PlatformVersion,
521 ) -> Result<(Vec<u8>, Vec<Document>), Error> {
522 self.validate_chained(platform_version)?;
523
524 // Block commits are seconds apart while an attempt is
525 // milliseconds, so a bracket collision is rare and two in a row
526 // vanishingly so; three attempts is generosity, not need.
527 const MAX_ATTEMPTS: usize = 3;
528 for _ in 0..MAX_ATTEMPTS {
529 let root_before = drive
530 .grove
531 .root_hash(None, &platform_version.drive.grove_version)
532 .unwrap()?;
533
534 // Materialize the INNER half only — the join values the
535 // outer component derives from live in its projections.
536 let (inner_documents, _skipped) = self.execute_index_only_documents_no_proof_internal(
537 drive,
538 None,
539 drive_operations,
540 platform_version,
541 )?;
542 let join_values = self.chained_join_values(&inner_documents)?;
543
544 let path_queries = self.chained_proof_path_queries(&join_values, platform_version)?;
545 let path_query_refs: Vec<&grovedb::PathQuery> = path_queries.iter().collect();
546 let proof = drive
547 .grove
548 .prove_query_many(path_query_refs, None, &platform_version.drive.grove_version)
549 .unwrap()?;
550
551 let root_after = drive
552 .grove
553 .root_hash(None, &platform_version.drive.grove_version)
554 .unwrap()?;
555 if root_before != root_after {
556 continue;
557 }
558
559 return Ok((proof, inner_documents));
560 }
561 Err(Error::Drive(DriveError::NotSupported(
562 "chained proof generation raced a block commit on every attempt; \
563 transient — retry the request",
564 )))
565 }
566}