Skip to main content

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}