diff --git a/packages/dash-platform-queries/src/documents/chained_document_query.rs b/packages/dash-platform-queries/src/documents/chained_document_query.rs index ce46a4f5551..0e1e8d54d65 100644 --- a/packages/dash-platform-queries/src/documents/chained_document_query.rs +++ b/packages/dash-platform-queries/src/documents/chained_document_query.rs @@ -7,7 +7,7 @@ //! server derives the outer by-ids query from the inner results, and the //! verifier re-derives it from the PROVEN inner results, so the join can //! never be steered by the responding node. See -//! `drive::query::drive_chained_document_query` for the trust model. +//! `drive::query::chained_document_query` for the trust model. use crate::documents::document_query::DocumentQuery; use crate::error::Error; @@ -20,7 +20,6 @@ use dpp::dashcore::Network; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; use dpp::ProtocolError; -use drive::query::drive_chained_document_query::DriveChainedDocumentQuery; use drive::query::DriveDocumentQuery; use drive_proof_verifier::{ verify_chained_documents_tenderdash_proof, ChainedDocuments, FromProof, @@ -122,7 +121,7 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { } } -impl<'a> TryFrom<&'a ChainedDocumentQuery> for DriveChainedDocumentQuery<'a> { +impl<'a> TryFrom<&'a ChainedDocumentQuery> for DriveDocumentQuery<'a> { type Error = Error; fn try_from(request: &'a ChainedDocumentQuery) -> Result { @@ -132,11 +131,7 @@ impl<'a> TryFrom<&'a ChainedDocumentQuery> for DriveChainedDocumentQuery<'a> { .data_contract .document_type_for_name(&request.outer_document_type_name) .map_err(|e| Error::Protocol(ProtocolError::DataContractError(e)))?; - Ok(DriveChainedDocumentQuery { - inner, - join_property: request.join_property.clone(), - outer_document_type, - }) + Ok(inner.with_by_id_join(request.join_property.clone(), outer_document_type)) } } @@ -157,7 +152,7 @@ impl FromProof for ChainedDocuments { let request: Self::Request = request.into(); let response: Self::Response = response.into(); - let query: DriveChainedDocumentQuery = (&request).try_into().map_err(|e: Error| { + let query: DriveDocumentQuery = (&request).try_into().map_err(|e: Error| { drive_proof_verifier::Error::RequestError { error: e.to_string(), } @@ -202,7 +197,9 @@ mod tests { use dpp::data_contract::DataContract; use dpp::platform_value::Value; use dpp::tests::json_document::json_document_to_contract; - use drive::query::{WhereClause, WhereOperator}; + use drive::query::{ + BindingSource, DriveSubQuery, SubQueryBinding, SubQueryKind, WhereClause, WhereOperator, + }; use std::sync::Arc; const YAPPR_CONTRACT_PATH: &str = @@ -276,13 +273,80 @@ mod tests { #[test] fn converts_to_a_valid_drive_query() { let query = posts_i_liked(10); - let drive_query: DriveChainedDocumentQuery = + let drive_query: DriveDocumentQuery = (&query).try_into().expect("converts to a drive query"); drive_query - .validate(platform_version()) + .validate_chained(platform_version()) .expect("the byLiker shape validates"); - assert_eq!(drive_query.join_property, "postId"); - assert_eq!(drive_query.inner.limit, Some(10)); + assert_eq!( + drive_query.sub_queries[0] + .binding + .as_ref() + .expect("the join is bound") + .source_property, + "postId" + ); + assert_eq!(drive_query.limit, Some(10)); + } + + fn assert_plain_conversions_refuse(query: &DriveDocumentQuery) { + for result in [ + DocumentQuery::try_from(query), + DocumentQuery::try_from(query.clone()), + DocumentQuery::new_with_drive_query(query), + ] { + assert!( + matches!(&result, Err(Error::Config(message)) if message.contains("sub-queries")), + "a plain conversion must refuse the composition, got {result:?}" + ); + } + } + + #[test] + fn should_refuse_dropping_a_drive_join_during_plain_query_conversion() { + let query = posts_i_liked(10); + let drive_query: DriveDocumentQuery = (&query).try_into().expect("drive query"); + drive_query + .validate_chained(platform_version()) + .expect("valid chained shape"); + assert_plain_conversions_refuse(&drive_query); + } + + #[test] + fn should_refuse_dropping_a_composite_count_during_plain_query_conversion() { + let query = posts_i_liked(10); + let page: DriveDocumentQuery = (&query.inner).try_into().expect("drive page"); + let count = DriveSubQuery { + contract: page.contract, + document_type: page.document_type, + kind: SubQueryKind::Count, + where_clauses: vec![], + order_by: vec![], + limit: None, + binding: Some(SubQueryBinding { + source: BindingSource::Page, + source_property: "postId".into(), + field: "postId".into(), + }), + }; + let composite = page.with_sub_queries(vec![count]); + composite + .validate_composite(platform_version()) + .expect("valid count composition"); + assert_plain_conversions_refuse(&composite); + } + + #[test] + fn should_preserve_plain_drive_query_conversion() { + let query = posts_i_liked(10).inner; + let drive_query: DriveDocumentQuery = (&query).try_into().expect("drive page"); + for result in [ + DocumentQuery::try_from(&drive_query), + DocumentQuery::try_from(drive_query.clone()), + DocumentQuery::new_with_drive_query(&drive_query), + ] { + assert_eq!(result.expect("plain conversion succeeds"), query); + } } #[test] @@ -294,9 +358,9 @@ mod tests { "hashtag", "post", ); - let drive_query: DriveChainedDocumentQuery = + let drive_query: DriveDocumentQuery = (&query).try_into().expect("conversion itself succeeds"); - let refused = drive_query.validate(platform_version()); + let refused = drive_query.validate_chained(platform_version()); assert!( refused.is_err(), "a non-refersTo join property must fail validation" diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 97c880e1c58..5073b3a5739 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -217,6 +217,9 @@ impl DocumentQuery { /// Create new document query based on a [DriveDocumentQuery]. /// + /// Fails when the drive query carries sub-queries, which this plain + /// query cannot preserve — use the chained or composite surface. + /// /// Fails when the drive query carries time-range resolution provenance /// (`resolved_time_ranges`): the resolved bucket equality cannot be /// represented without it — see the `TryFrom` impl. Build the query @@ -973,6 +976,9 @@ fn encode_v0( impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery { type Error = crate::error::Error; + /// Refuses sub-queries: a plain `DocumentQuery` cannot carry their + /// selections through SDK request construction and proof verification. + /// /// Fallible by necessity: a drive query carrying `resolved_time_ranges` /// holds bucket-start equalities whose meaning lives in the provenance, /// and `DocumentQuery` has no field to carry it — the original @@ -982,6 +988,14 @@ impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery { /// contract then rejects the request, while a contract with a competing /// plain index returns a different — but validly proven — result. fn try_from(value: &'a DriveDocumentQuery<'a>) -> Result { + if !value.sub_queries.is_empty() { + return Err(Error::Config( + "a drive query carrying sub-queries cannot be converted to a plain \ + DocumentQuery: its sub-queries would be discarded. Use the chained or \ + composite query surface instead" + .to_string(), + )); + } if !value.resolved_time_ranges.is_empty() { return Err(Error::Config( "a drive query carrying time-range resolution provenance cannot be \ @@ -1031,7 +1045,7 @@ impl<'a> TryFrom> for DocumentQuery { type Error = crate::error::Error; /// By-value twin of the by-reference conversion above — same - /// provenance rejection, same rationale. + /// sub-query and provenance rejections, same rationale. fn try_from(value: DriveDocumentQuery<'a>) -> Result { DocumentQuery::try_from(&value) } @@ -1155,6 +1169,9 @@ impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> { // selections assign the fields they resolved onto the returned // query; everything else is a raw query. resolved_time_ranges: vec![], + // Composite sub-queries have no wire format yet: a query + // parsed from a request is always a plain page. + sub_queries: vec![], }; Ok(query) diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs index ba062b7f737..4c7c7b6b099 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_outwards_distribution/fetch_reward_shares_list_for_masternode/v0/mod.rs @@ -65,6 +65,7 @@ impl Platform { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs index ffe9cbe7b20..ba248980771 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v0/mod.rs @@ -249,6 +249,7 @@ pub(super) fn create_domain_data_trigger_v0( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation @@ -341,6 +342,7 @@ pub(super) fn create_domain_data_trigger_v0( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs index 17ee3e1b21c..2d190d5315d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dpns/v1/mod.rs @@ -242,6 +242,7 @@ pub(super) fn create_domain_data_trigger_v1( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // Diff vs `_v0` (parent-domain query): @@ -356,6 +357,7 @@ pub(super) fn create_domain_data_trigger_v1( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // Diff vs `_v0` (preorder query): same change as above. `_v0` diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs index 0f399d63ff9..c87fdedec0a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs @@ -78,6 +78,7 @@ pub(super) fn delete_withdrawal_data_trigger_v0( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs index 13d59b238f6..943c9f0f140 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v1/mod.rs @@ -72,6 +72,7 @@ pub(super) fn delete_withdrawal_data_trigger_v1( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // Diff vs `_v0` (withdrawal-document lookup): diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index b8f3b475887..2486f1574c4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -123,6 +123,7 @@ fn fetch_documents_for_transitions_knowing_contract_and_document_type_v0( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation @@ -184,6 +185,7 @@ fn fetch_documents_for_transitions_knowing_contract_and_document_type_v1( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // Diff vs `_v0`: epoch is `Some(...)` and the cost is billed via @@ -310,6 +312,7 @@ fn fetch_document_with_id_v0( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation @@ -373,6 +376,7 @@ fn fetch_document_with_id_v1( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // Diff vs `_v0`: epoch is `Some(...)` and the cost is billed via diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs index f6528d6afad..3ed94b01192 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs @@ -457,6 +457,7 @@ mod dpns_tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let documents = platform @@ -505,6 +506,7 @@ mod dpns_tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let documents = platform @@ -914,6 +916,7 @@ mod dpns_tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let documents = platform @@ -949,6 +952,7 @@ mod dpns_tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let documents = platform @@ -1183,6 +1187,7 @@ mod dpns_username_transfer_tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; platform @@ -1435,6 +1440,7 @@ mod dpns_username_transfer_tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; platform diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index 179da4d0162..992909894f1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -2601,6 +2601,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; query.internal_clauses.equal_clauses.insert( "contractId".to_string(), @@ -2984,6 +2985,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; query.internal_clauses.equal_clauses.insert( "contractId".to_string(), diff --git a/packages/rs-drive-abci/src/query/document_query/v0/mod.rs b/packages/rs-drive-abci/src/query/document_query/v0/mod.rs index bb245961d83..58a4d009fbe 100644 --- a/packages/rs-drive-abci/src/query/document_query/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v0/mod.rs @@ -655,6 +655,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let request = GetDocumentsRequestV0 { @@ -729,6 +730,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let request = GetDocumentsRequestV0 { @@ -815,6 +817,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let request = GetDocumentsRequestV0 { @@ -988,6 +991,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let where_clauses = serialize_vec_to_cbor( @@ -1156,6 +1160,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let where_clauses = serialize_vec_to_cbor( @@ -1312,6 +1317,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let mut where_clauses: Vec<_> = drive_document_query @@ -1479,6 +1485,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let mut where_clauses: Vec<_> = drive_document_query @@ -1663,6 +1670,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let mut where_clauses: Vec<_> = drive_document_query diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs index 6dd1fc701ec..2a589ad99c7 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/chained.rs @@ -34,7 +34,6 @@ use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::error::query::QuerySyntaxError; -use drive::query::drive_chained_document_query::DriveChainedDocumentQuery; use drive::query::DriveDocumentQuery; use drive::util::grove_operations::GroveDBToUse; @@ -160,14 +159,10 @@ impl Platform { platform_version, )); - let chained_query = DriveChainedDocumentQuery { - inner: inner_query, - join_property: chained.join_property, - outer_document_type: outer_type, - }; + let chained_query = inner_query.with_by_id_join(chained.join_property, outer_type); // Fail the shape checks as query errors (client-attributable), // before any execution. - match chained_query.validate(platform_version) { + match chained_query.validate_chained(platform_version) { Ok(()) => {} Err(drive::error::Error::Query(query_error)) => { return Ok(QueryValidationResult::new_with_error(QueryError::Query( @@ -231,14 +226,9 @@ impl Platform { }) .collect() }; - let inner_documents = serialize_all( - &outcome.result.inner_documents, - chained_query.inner.document_type, - )?; - let outer_documents = serialize_all( - &outcome.result.outer_documents, - chained_query.outer_document_type, - )?; + let inner_documents = + serialize_all(&outcome.result.inner_documents, chained_query.document_type)?; + let outer_documents = serialize_all(&outcome.result.outer_documents, outer_type)?; GetDocumentsResponseV1 { result: Some(get_documents_response_v1::Result::Data(ResultData { @@ -437,14 +427,14 @@ mod tests { start_at_included: true, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; - let chained = DriveChainedDocumentQuery { - inner, - join_property: "postId".to_string(), - outer_document_type: contract + let chained = inner.with_by_id_join( + "postId", + contract .document_type_for_name("post") .expect("post doctype"), - }; + ); let (_root_hash, verified) = chained .verify_chained_documents_proof(proof.grovedb_proof.as_slice(), version) .expect("chained proof verifies — the proof alone carries everything"); diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index 10fd4fe471d..f18fa22d1d1 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -6059,7 +6059,6 @@ mod chained_trust_boundary { use dpp::prelude::DataContract; use dpp::tests::json_document::json_document_to_contract; use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; - use drive::query::drive_chained_document_query::DriveChainedDocumentQuery; use drive::query::{InternalClauses, WhereClause, WhereOperator}; use drive_proof_verifier::{ChainedDocuments, FromProof}; use std::sync::Arc; @@ -6154,32 +6153,33 @@ mod chained_trust_boundary { let like_type = contract .document_type_for_name("like") .expect("like doctype"); - let chained = DriveChainedDocumentQuery { - inner: drive::query::DriveDocumentQuery { - contract, - document_type: like_type, - internal_clauses: InternalClauses::extract_from_clauses( - vec![WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(OWNER_1), - }], - platform_version(), - ) - .expect("clauses extract"), - offset: None, - limit: Some(10), - order_by: Default::default(), - start_at: None, - start_at_included: true, - block_time_ms: None, - resolved_time_ranges: vec![], - }, - join_property: "postId".to_string(), - outer_document_type: contract + let chained = drive::query::DriveDocumentQuery { + contract, + document_type: like_type, + internal_clauses: InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER_1), + }], + platform_version(), + ) + .expect("clauses extract"), + offset: None, + limit: Some(10), + order_by: Default::default(), + start_at: None, + start_at_included: true, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + } + .with_by_id_join( + "postId", + contract .document_type_for_name("post") .expect("post doctype"), - }; + ); let (proof, _inner_documents) = platform .drive .query_chained_documents_with_proof(&chained, platform_version()) diff --git a/packages/rs-drive-proof-verifier/src/proof/chained_document.rs b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs index afe9feba12e..1ccdb286621 100644 --- a/packages/rs-drive-proof-verifier/src/proof/chained_document.rs +++ b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs @@ -5,7 +5,7 @@ //! grovedb proof: the limited inner indexOnly page and the outer //! by-ids fetch derived from its values, merged by the server (grovedb //! lifts the inner limit into a per-instance branch limit). The -//! verifier ([`DriveChainedDocumentQuery::verify_chained_documents_proof`]) +//! verifier ([`DriveDocumentQuery::verify_chained_documents_proof`]) //! reconstructs the merged query from the response's UNTRUSTED //! join-value hint, verifies in one pass, and requires the proven //! outer documents to match the PROVEN inner join values exactly — a @@ -29,7 +29,7 @@ use dapi_grpc::platform::VersionedGrpcResponse; use dpp::dashcore::Network; use dpp::document::Document; use dpp::version::PlatformVersion; -use drive::query::drive_chained_document_query::DriveChainedDocumentQuery; +use drive::query::DriveDocumentQuery; use drive::verify::RootHash; /// The verified result of a chained document query, both halves in @@ -51,14 +51,17 @@ pub struct ChainedDocuments { /// The merk-level composition (bootstrap subset pass on the inner /// query, merged-query re-derivation, authoritative full verification, /// exact set equality against the PROVEN join values) lives in rs-drive's -/// [`DriveChainedDocumentQuery::verify_chained_documents_proof`]; this +/// [`DriveDocumentQuery::verify_chained_documents_proof`]; this /// wrapper adds the [`verify_tenderdash_proof`] binding — the root hash /// the proof commits to is only an attested fact once it is tied to the /// quorum-signed app hash, and this function exists so the composition /// can never be skipped by accident. /// +/// The query is the chained shape: the inner [`DriveDocumentQuery`] +/// carrying its single by-id join in `sub_queries` (see +/// `DriveDocumentQuery::with_by_id_join`). pub fn verify_chained_documents_proof( - query: &DriveChainedDocumentQuery, + query: &DriveDocumentQuery, proof: &Proof, mtd: &ResponseMetadata, platform_version: &PlatformVersion, @@ -81,7 +84,7 @@ pub fn verify_chained_documents_proof( impl<'dq, Q> FromProof for ChainedDocuments where - Q: TryInto> + Clone + 'dq, + Q: TryInto> + Clone + 'dq, Q::Error: std::fmt::Display, { type Request = Q; @@ -100,7 +103,7 @@ where let request: Self::Request = request.into(); let response: Self::Response = response.into(); - let query: DriveChainedDocumentQuery<'dq> = + let query: DriveDocumentQuery<'dq> = request .clone() .try_into() diff --git a/packages/rs-drive-proof-verifier/tests/vectors_documents.rs b/packages/rs-drive-proof-verifier/tests/vectors_documents.rs index 01e9ae063c0..780df174705 100644 --- a/packages/rs-drive-proof-verifier/tests/vectors_documents.rs +++ b/packages/rs-drive-proof-verifier/tests/vectors_documents.rs @@ -151,6 +151,7 @@ fn document_query<'a>(case: &Case, contract: &'a DataContract) -> DriveDocumentQ start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/chained_query_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/chained_query_e2e_tests.rs index d4d1d2c4a42..aeb95952c73 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/chained_query_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/chained_query_e2e_tests.rs @@ -12,7 +12,6 @@ use super::index_only_e2e_tests::{build_like, insert_like, platform_version, setup_likes}; use crate::error::Error; -use crate::query::drive_chained_document_query::DriveChainedDocumentQuery; use crate::query::{DriveDocumentQuery, OrderClause, WhereClause, WhereOperator}; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; @@ -119,6 +118,7 @@ fn my_likes_query<'a>( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } @@ -127,14 +127,13 @@ fn chained_posts_i_liked<'a>( owner: [u8; 32], after: Option<[u8; 32]>, limit: Option, -) -> DriveChainedDocumentQuery<'a> { - DriveChainedDocumentQuery { - inner: my_likes_query(contract, owner, after, limit), - join_property: "postId".to_string(), - outer_document_type: contract +) -> DriveDocumentQuery<'a> { + my_likes_query(contract, owner, after, limit).with_by_id_join( + "postId", + contract .document_type_for_name("post") .expect("post doctype exists"), - } + ) } /// The full round trip: the server's materialized result and the @@ -306,7 +305,11 @@ fn should_reject_invalid_chained_shapes() { // Join property without a refersTo declaration. let mut bad_join = chained_posts_i_liked(&contract, OWNER_1, None, Some(10)); - bad_join.join_property = "hashtag".to_string(); + bad_join.sub_queries[0] + .binding + .as_mut() + .expect("the join is bound") + .source_property = "hashtag".to_string(); assert!( matches!( drive.query_chained_documents(&bad_join, None, None, pv), @@ -318,7 +321,7 @@ fn should_reject_invalid_chained_shapes() { // Outer type that is not the refersTo target (and is itself // indexOnly, which is refused in its own right). let mut bad_outer = chained_posts_i_liked(&contract, OWNER_1, None, Some(10)); - bad_outer.outer_document_type = contract + bad_outer.sub_queries[0].document_type = contract .document_type_for_name("tip") .expect("tip doctype exists"); assert!( @@ -379,9 +382,9 @@ fn should_reject_an_inner_only_proof() { let chained = chained_posts_i_liked(&contract, OWNER_1, None, Some(10)); // What an old node would return: a proof of ONLY the inner query. - let inner_only_proof = chained - .inner - .clone() + let mut inner_alone = chained.clone(); + inner_alone.sub_queries = vec![]; + let inner_only_proof = inner_alone .execute_with_proof(&drive, None, None, pv) .expect("inner-only proof generates") .0; diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/composite_query_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/composite_query_e2e_tests.rs new file mode 100644 index 00000000000..51f48b29424 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/composite_query_e2e_tests.rs @@ -0,0 +1,1622 @@ +//! End-to-end coverage for **composite document queries**: a page of +//! posts plus everything a feed card renders for it — like and repost +//! counts, the quoted posts, the reposts, the authors' profiles (in +//! another contract), the quoted authors' profiles (derived from a +//! sub-query rather than the page), and the viewer's own likes — as ONE +//! merged proof against the `yappr-feed` fixture. +//! +//! Pinned here: no-proof/proof parity (the verifier's composed result +//! equals the server's materialized result), the empty-page shape, the +//! validation rejections, the fail-closed behaviour on a page-only proof +//! (what a node ignoring the sub-queries would serve), the dangling +//! reference refusal, and by-id routing when the page and a join share +//! the primary tree. + +use crate::error::Error; +use crate::query::{ + BindingSource, DriveDocumentQuery, DriveSubQuery, InternalClauses, OrderClause, + SubQueryBinding, SubQueryKind, SubQueryResult, WhereClause, WhereOperator, MAX_SUB_QUERIES, +}; +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::random_document::CreateRandomDocument; +use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; +use dpp::platform_value::{Identifier, Value}; +use dpp::prelude::DataContract; +use dpp::tests::json_document::json_document_to_contract; +use dpp::version::PlatformVersion; +use std::collections::BTreeMap; + +const FEED_CONTRACT: &str = "tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json"; +const DASHPAY_CONTRACT: &str = "tests/supporting_files/contract/dashpay/dashpay-contract.json"; + +const POST_A: [u8; 32] = [0xA1; 32]; +const POST_B: [u8; 32] = [0xB2; 32]; +const POST_C: [u8; 32] = [0xC3; 32]; +const POST_D: [u8; 32] = [0xD4; 32]; +const MISSING_POST: [u8; 32] = [0xE5; 32]; +const OWNER_1: [u8; 32] = [0x11; 32]; +const OWNER_2: [u8; 32] = [0x22; 32]; +const OWNER_3: [u8; 32] = [0x33; 32]; + +fn platform_version() -> &'static PlatformVersion { + PlatformVersion::latest() +} + +/// A drive with the feed contract and the dashpay contract (whose +/// `profile` type, keyed by `$ownerId`, plays the cross-contract lookup). +fn setup() -> (crate::drive::Drive, DataContract, DataContract) { + let drive = setup_drive_with_initial_state_structure(None); + let pv = platform_version(); + let mut contracts = Vec::new(); + for path in [FEED_CONTRACT, DASHPAY_CONTRACT] { + let contract = + json_document_to_contract(path, false, pv).expect("expected to parse the contract"); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + pv, + ) + .expect("expected to apply the contract"); + contracts.push(contract); + } + let dashpay = contracts.pop().expect("dashpay"); + let feed = contracts.pop().expect("feed"); + (drive, feed, dashpay) +} + +fn insert(drive: &crate::drive::Drive, contract: &DataContract, type_name: &str, doc: &Document) { + let document_type = contract + .document_type_for_name(type_name) + .expect("doctype exists"); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((doc, None)), + owner_id: None, + }, + contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version(), + None, + ) + .expect("insert document"); +} + +fn build(contract: &DataContract, type_name: &str, seed: u64) -> Document { + contract + .document_type_for_name(type_name) + .expect("doctype exists") + .random_document(Some(seed), platform_version()) + .expect("random document") +} + +fn insert_post( + drive: &crate::drive::Drive, + contract: &DataContract, + id: [u8; 32], + owner: [u8; 32], + hashtag: &str, + quoted: Option<[u8; 32]>, + seed: u64, +) { + let mut doc = build(contract, "post", seed); + let mut props = BTreeMap::new(); + props.insert("hashtag".to_string(), Value::Text(hashtag.to_string())); + props.insert("message".to_string(), Value::Text(format!("post {seed}"))); + if let Some(quoted) = quoted { + props.insert("quotedPostId".to_string(), Value::Identifier(quoted)); + } + doc.set_properties(props); + doc.set_id(Identifier::from(id)); + doc.set_owner_id(Identifier::from(owner)); + insert(drive, contract, "post", &doc); +} + +fn insert_like( + drive: &crate::drive::Drive, + contract: &DataContract, + owner: [u8; 32], + post: [u8; 32], + hashtag: &str, + seed: u64, +) { + let mut doc = build(contract, "like", seed); + let mut props = BTreeMap::new(); + props.insert("hashtag".to_string(), Value::Text(hashtag.to_string())); + props.insert("postId".to_string(), Value::Identifier(post)); + doc.set_properties(props); + doc.set_owner_id(Identifier::from(owner)); + insert(drive, contract, "like", &doc); +} + +fn insert_repost( + drive: &crate::drive::Drive, + contract: &DataContract, + owner: [u8; 32], + post: [u8; 32], + seed: u64, +) { + let mut doc = build(contract, "repost", seed); + let mut props = BTreeMap::new(); + props.insert("postId".to_string(), Value::Identifier(post)); + doc.set_properties(props); + doc.set_owner_id(Identifier::from(owner)); + insert(drive, contract, "repost", &doc); +} + +fn insert_profile( + drive: &crate::drive::Drive, + dashpay: &DataContract, + owner: [u8; 32], + display_name: &str, + seed: u64, +) { + let mut doc = build(dashpay, "profile", seed); + let mut props = BTreeMap::new(); + props.insert( + "displayName".to_string(), + Value::Text(display_name.to_string()), + ); + doc.set_properties(props); + doc.set_owner_id(Identifier::from(owner)); + insert(drive, dashpay, "profile", &doc); +} + +/// The feed fixture: three `dash` posts (the page), one `btc` post two +/// of them quote, likes, reposts and two profiles. +/// +/// | post | owner | tag | quotes | likes by | reposts by | +/// |------|-------|------|--------|---------------|----------------| +/// | A | 1 | dash | D | 1, 2 | 2 | +/// | B | 2 | dash | — | 1 | 1, 3 | +/// | C | 3 | dash | D | — | — | +/// | D | 3 | btc | — | 3 | — | +/// +/// Profiles exist for owners 1 and 3 only. +fn seed_feed(drive: &crate::drive::Drive, feed: &DataContract, dashpay: &DataContract) { + insert_post(drive, feed, POST_D, OWNER_3, "btc", None, 4); + insert_post(drive, feed, POST_A, OWNER_1, "dash", Some(POST_D), 1); + insert_post(drive, feed, POST_B, OWNER_2, "dash", None, 2); + insert_post(drive, feed, POST_C, OWNER_3, "dash", Some(POST_D), 3); + insert_like(drive, feed, OWNER_1, POST_A, "dash", 10); + insert_like(drive, feed, OWNER_2, POST_A, "dash", 11); + insert_like(drive, feed, OWNER_1, POST_B, "dash", 12); + insert_like(drive, feed, OWNER_3, POST_D, "btc", 13); + insert_repost(drive, feed, OWNER_2, POST_A, 20); + insert_repost(drive, feed, OWNER_1, POST_B, 21); + insert_repost(drive, feed, OWNER_3, POST_B, 22); + insert_profile(drive, dashpay, OWNER_1, "one", 30); + insert_profile(drive, dashpay, OWNER_3, "three", 31); +} + +fn page_by_hashtag<'a>( + contract: &'a DataContract, + hashtag: &str, + limit: Option, +) -> DriveDocumentQuery<'a> { + DriveDocumentQuery { + contract, + document_type: contract.document_type_for_name("post").expect("post"), + internal_clauses: InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(hashtag.to_string()), + }], + platform_version(), + ) + .expect("clauses extract"), + offset: None, + limit, + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + } +} + +fn bound<'a>( + contract: &'a DataContract, + type_name: &str, + kind: SubQueryKind, + source: BindingSource, + source_property: &str, + field: &str, + limit: Option, +) -> DriveSubQuery<'a> { + DriveSubQuery { + contract, + document_type: contract.document_type_for_name(type_name).expect("doctype"), + kind, + where_clauses: vec![], + order_by: vec![], + limit, + binding: Some(SubQueryBinding { + source, + source_property: source_property.to_string(), + field: field.to_string(), + }), + } +} + +/// Sub-query positions in [`feed_query`]. +const LIKE_COUNTS: usize = 0; +const QUOTED_POSTS: usize = 1; +const REPOSTS: usize = 2; +const AUTHOR_PROFILES: usize = 3; +const QUOTED_AUTHOR_PROFILES: usize = 4; +const VIEWER_LIKES: usize = 5; + +/// The whole feed composition: like counts, the quoted posts, the +/// reposts themselves (their count is a client-side length; a count on +/// the same `byPost` index would read the value trees the documents +/// lookup descends past), the authors' profiles, the quoted authors' +/// profiles. `viewer` adds the "which of these did I like" lookup on +/// the indexOnly `like` type — proof-path only, since its `byLiker` +/// projection does not cover every property and so cannot be +/// materialized into a non-proof response. +fn feed_query<'a>( + feed: &'a DataContract, + dashpay: &'a DataContract, + viewer: Option<[u8; 32]>, +) -> DriveDocumentQuery<'a> { + let mut sub_queries = vec![ + bound( + feed, + "like", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ), + bound( + feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "quotedPostId", + "$id", + None, + ), + bound( + feed, + "repost", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + Some(50), + ), + // Profiles are unique per owner: value-bounded, so no limit. + bound( + dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ), + bound( + dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::SubQuery(QUOTED_POSTS), + "$ownerId", + "$ownerId", + None, + ), + ]; + if let Some(viewer) = viewer { + // `byLiker` is `[$ownerId] → postId`: with the owner fixed, the + // terminal postId is unique per value — value-bounded, no limit. + let mut marks = bound( + feed, + "like", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + None, + ); + marks.where_clauses = vec![WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(viewer), + }]; + sub_queries.push(marks); + } + page_by_hashtag(feed, "dash", Some(10)).with_sub_queries(sub_queries) +} + +fn ids(documents: &[Document]) -> Vec<[u8; 32]> { + documents.iter().map(|d| d.id().to_buffer()).collect() +} + +fn counts(result: &SubQueryResult) -> BTreeMap<[u8; 32], u64> { + result + .counts() + .iter() + .map(|entry| { + let key: [u8; 32] = entry.key.as_slice().try_into().expect("identifier key"); + (key, entry.count.expect("present count")) + }) + .collect() +} + +fn post_ids_of(result: &SubQueryResult, property: &str) -> Vec<[u8; 32]> { + result + .documents() + .iter() + .map(|d| { + d.properties() + .get(property) + .expect("property present") + .to_identifier() + .expect("identifier") + .to_buffer() + }) + .collect() +} + +fn owner_ids(documents: &[Document]) -> Vec<[u8; 32]> { + documents.iter().map(|d| d.owner_id().to_buffer()).collect() +} + +#[test] +fn should_preserve_join_order_before_deriving_later_bindings() { + let (drive, feed, dashpay) = setup(); + insert_post(&drive, &feed, POST_C, OWNER_1, "btc", None, 3); + insert_post(&drive, &feed, POST_D, OWNER_3, "btc", None, 4); + insert_post(&drive, &feed, POST_A, OWNER_1, "dash", Some(POST_D), 1); + insert_post(&drive, &feed, POST_B, OWNER_2, "dash", Some(POST_C), 2); + insert_profile(&drive, &dashpay, OWNER_1, "one", 30); + insert_profile(&drive, &dashpay, OWNER_3, "three", 31); + let query = feed_query(&feed, &dashpay, None); + let pv = platform_version(); + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("materializes") + .result; + assert_eq!( + ids(materialized.sub_results[QUOTED_POSTS].documents()), + vec![POST_D, POST_C], + "the page references quoted posts in the opposite order to their ids" + ); + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("a feed with two quoted authors verifies"); + assert_eq!(verified.sub_results, materialized.sub_results); +} + +#[test] +fn should_route_counts_by_complete_positions_including_overlapping_queries() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let mut sub_queries: Vec<_> = [OWNER_1, OWNER_2, OWNER_3] + .into_iter() + .map(|owner| { + let mut count = bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ); + count.where_clauses.push(WhereClause { + field: "$ownerId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner), + }); + count + }) + .collect(); + // This count has a deeper base path, but shares terminals with the + // first and third counts. Both shallower selections still own them. + let mut owners_of_b = bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ); + owners_of_b.where_clauses.push(WhereClause { + field: "postId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(POST_B), + }); + sub_queries.push(owners_of_b); + let query = page_by_hashtag(&feed, "dash", Some(10)).with_sub_queries(sub_queries); + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("materializes") + .result; + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + for result in [&materialized, &verified] { + for (index, post) in [POST_B, POST_A, POST_B].into_iter().enumerate() { + assert_eq!(result.sub_results[index].counts().len(), 1); + assert_eq!( + counts(&result.sub_results[index]), + BTreeMap::from([(post, 1)]) + ); + } + assert_eq!(result.sub_results[3].counts().len(), 2); + assert_eq!( + counts(&result.sub_results[3]), + BTreeMap::from([(OWNER_1, 1), (OWNER_3, 1)]) + ); + } + assert_eq!(verified.sub_results, materialized.sub_results); +} + +#[test] +fn should_reject_conflicting_document_directions_even_when_the_page_is_empty() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let mut lookup = bound( + &feed, + "repost", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + Some(1), + ); + lookup.order_by.push(OrderClause { + field: "postId".into(), + ascending: false, + }); + let mut profiles = bound( + &dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ); + profiles.order_by.push(OrderClause { + field: "$ownerId".into(), + ascending: false, + }); + let sibling = DriveSubQuery { + contract: &feed, + document_type: feed.document_type_for_name("post").expect("post"), + kind: SubQueryKind::Documents, + where_clauses: vec![], + order_by: vec![OrderClause { + field: "$id".into(), + ascending: false, + }], + limit: Some(1), + binding: None, + }; + for sub_query in [lookup, profiles, sibling] { + for hashtag in ["dash", "empty"] { + let query = + page_by_hashtag(&feed, hashtag, Some(10)).with_sub_queries(vec![sub_query.clone()]); + for result in [ + drive + .query_composite_documents(&query, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&query, pv) + .map(|_| ()), + query.verify_composite_documents_proof(&[], pv).map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + assert!(result + .unwrap_err() + .to_string() + .contains("must match the page's direction")); + } + } + } +} + +#[test] +fn should_reject_count_tree_descents_but_allow_disjoint_count_selections() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + insert_repost(&drive, &feed, OWNER_3, POST_D, 23); + let pv = platform_version(); + let total = bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ); + let mut per_owner = total.clone(); + per_owner.where_clauses.push(WhereClause { + field: "$ownerId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER_1), + }); + let mut query = + page_by_hashtag(&feed, "dash", Some(10)).with_sub_queries(vec![total, per_owner]); + for result in [ + drive + .query_composite_documents(&query, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&query, pv) + .map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + let error = result.unwrap_err().to_string(); + assert!(error.contains("another component descends"), "{error}"); + } + + // The total now selects D's count tree, while the per-owner query + // descends through A/B/C. Their actual selections do not overlap. + query.sub_queries[0] + .binding + .as_mut() + .expect("bound") + .source_property = "quotedPostId".into(); + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("disjoint counts materialize") + .result; + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("disjoint counts prove"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("disjoint counts verify"); + assert_eq!( + counts(&verified.sub_results[0]), + BTreeMap::from([(POST_D, 1)]) + ); + assert_eq!( + counts(&verified.sub_results[1]), + BTreeMap::from([(POST_B, 1)]) + ); + assert_eq!(verified.sub_results, materialized.sub_results); +} + +#[test] +fn should_preserve_descending_documents_and_key_ordered_counts() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let mut page = page_by_hashtag(&feed, "dash", Some(3)); + page.internal_clauses = InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "$id".into(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Identifier(POST_A), + Value::Identifier(POST_B), + Value::Identifier(POST_C), + ]), + }], + pv, + ) + .expect("by-id page"); + page.order_by.insert( + "$id".into(), + OrderClause { + field: "$id".into(), + ascending: false, + }, + ); + // Keep the sibling on another type's primary tree so its limited + // branch cannot overlap the page or the by-id join. + let sibling = DriveSubQuery { + contract: &feed, + document_type: feed.document_type_for_name("repost").expect("repost"), + kind: SubQueryKind::Documents, + where_clauses: vec![], + order_by: vec![OrderClause { + field: "$id".into(), + ascending: false, + }], + limit: Some(1), + binding: None, + }; + let mut sub_queries = vec![ + bound( + &feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "quotedPostId", + "$id", + None, + ), + bound( + &feed, + "like", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ), + sibling, + ]; + // A later count also verifies that a descending sibling is a valid + // binding source and its limit is applied before deriving values. + sub_queries.push(bound( + &feed, + "like", + SubQueryKind::Count, + BindingSource::SubQuery(2), + "postId", + "postId", + None, + )); + let query = page.with_sub_queries(sub_queries); + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("materializes") + .result; + assert_eq!( + ids(&materialized.page_documents), + vec![POST_C, POST_B, POST_A] + ); + assert_eq!(materialized.sub_results[2].documents().len(), 1); + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + assert_eq!(verified.page_documents, materialized.page_documents); + assert_eq!(verified.sub_results, materialized.sub_results); + // The count bound to the sibling derived exactly the one post the + // sibling's limit left it: A carries two likes, B one. + let sibling_posts = post_ids_of(&verified.sub_results[2], "postId"); + assert_eq!(sibling_posts.len(), 1); + let expected_likes = if sibling_posts[0] == POST_A { 2 } else { 1 }; + assert_eq!( + counts(&verified.sub_results[3]), + BTreeMap::from([(sibling_posts[0], expected_likes)]), + "the sibling-bound count covers the sibling's single derived post" + ); +} + +/// The full round trip: the server's materialized result and the +/// verifier's composed result agree component for component. +#[test] +fn should_answer_the_feed_composition_with_proof_parity() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let query = feed_query(&feed, &dashpay, None); + + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("no-proof composite executes") + .result; + let (proof, page) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("composite proves"); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("the merged proof verifies"); + + // The page: the three `dash` posts, in index order. + assert_eq!( + ids(&materialized.page_documents), + vec![POST_A, POST_B, POST_C] + ); + assert_eq!(ids(&page), vec![POST_A, POST_B, POST_C]); + assert_eq!(verified.page_documents, materialized.page_documents); + + for result in [&materialized, &verified] { + assert_eq!( + counts(&result.sub_results[LIKE_COUNTS]), + BTreeMap::from([(POST_A, 2), (POST_B, 1)]), + "C has no like tree and D is off the page" + ); + assert_eq!( + ids(result.sub_results[QUOTED_POSTS].documents()), + vec![POST_D], + "A and C both quote D: one derived id, one document" + ); + assert_eq!( + post_ids_of(&result.sub_results[REPOSTS], "postId"), + vec![POST_A, POST_B, POST_B] + ); + assert_eq!( + owner_ids(result.sub_results[AUTHOR_PROFILES].documents()), + vec![OWNER_1, OWNER_3], + "owner 2 has no profile: a proven absence, not an error" + ); + assert_eq!( + owner_ids(result.sub_results[QUOTED_AUTHOR_PROFILES].documents()), + vec![OWNER_3], + "derived from the quoted-posts sub-query, not the page" + ); + } + assert_eq!(verified.sub_results, materialized.sub_results); +} + +/// The viewer's own likes ride the same proof as an indexOnly lookup +/// pinned on `$ownerId`: the synthesized projections carry the post ids +/// the viewer liked among the page. +#[test] +fn should_prove_the_viewers_marks_as_an_index_only_lookup() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let query = feed_query(&feed, &dashpay, Some(OWNER_1)); + + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("composite proves"); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("the merged proof verifies"); + + assert_eq!( + post_ids_of(&verified.sub_results[VIEWER_LIKES], "postId"), + vec![POST_A, POST_B] + ); + assert!(verified.sub_results[VIEWER_LIKES] + .documents() + .iter() + .all(|like| like.owner_id().to_buffer() == OWNER_1)); + + let query = feed_query(&feed, &dashpay, Some(OWNER_2)); + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("composite proves"); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + assert_eq!( + post_ids_of(&verified.sub_results[VIEWER_LIKES], "postId"), + vec![POST_A] + ); +} + +/// An empty page derives nothing: every sub-query is empty and the proof +/// is the page's alone. +#[test] +fn should_prove_an_empty_page_alone() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let mut query = feed_query(&feed, &dashpay, Some(OWNER_1)); + let sub_queries = std::mem::take(&mut query.sub_queries); + query = page_by_hashtag(&feed, "nothing", Some(10)).with_sub_queries(sub_queries); + + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("executes") + .result; + assert!(materialized.page_documents.is_empty()); + assert!(materialized + .sub_results + .iter() + .all(|result| result.documents().is_empty() && result.counts().is_empty())); + + let (proof, page) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + assert!(page.is_empty()); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + assert!(verified.page_documents.is_empty()); + assert_eq!(verified.sub_results.len(), query.sub_queries.len()); +} + +/// A proof covering only the page — what a node that ignores the +/// sub-queries would serve — cannot satisfy the merged query. +#[test] +fn should_refuse_a_page_only_proof() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let query = feed_query(&feed, &dashpay, None); + + let mut page_alone = query.clone(); + page_alone.sub_queries = vec![]; + let (page_only_proof, _cost) = page_alone + .execute_with_proof(&drive, None, None, pv) + .expect("the page alone proves"); + assert!( + query + .verify_composite_documents_proof(&page_only_proof, pv) + .is_err(), + "a page-only proof must fail the composite verification" + ); +} + +/// The plain (page-only) surfaces refuse a query carrying sub-queries +/// instead of silently proving or verifying the page alone — on the +/// verify side that silence would report the whole composition verified. +#[test] +fn should_refuse_composite_queries_on_plain_surfaces() { + let (drive, feed, dashpay) = setup(); + let pv = platform_version(); + let query = feed_query(&feed, &dashpay, None); + + let refused = drive + .query_documents(query.clone(), None, false, None, None) + .expect_err("plain query_documents must refuse sub-queries"); + assert!( + refused.to_string().contains("would silently ignore"), + "{refused}" + ); + let refused = query + .clone() + .execute_with_proof(&drive, None, None, pv) + .expect_err("the plain proof surface must refuse sub-queries"); + assert!( + refused.to_string().contains("would silently ignore"), + "{refused}" + ); + let refused = query + .verify_proof(&[], pv) + .expect_err("the plain verifier must refuse sub-queries"); + assert!( + refused.to_string().contains("would silently ignore"), + "{refused}" + ); +} + +/// A by-id join whose derived id has no document is an invalid proof +/// (and corrupted state on the server): a permanentDocument reference +/// cannot dangle. +#[test] +fn should_refuse_a_dangling_reference() { + let (drive, feed, _dashpay) = setup(); + insert_post( + &drive, + &feed, + POST_A, + OWNER_1, + "dash", + Some(MISSING_POST), + 1, + ); + let pv = platform_version(); + let query = page_by_hashtag(&feed, "dash", Some(10)).with_sub_queries(vec![bound( + &feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "quotedPostId", + "$id", + None, + )]); + + let refused = drive.query_composite_documents(&query, None, None, pv); + assert!( + matches!(refused, Err(Error::Proof(_))), + "expected the missing-document refusal, got {refused:?}" + ); + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("the proof itself generates"); + assert!( + query.verify_composite_documents_proof(&proof, pv).is_err(), + "the verifier must refuse a dangling reference" + ); +} + +/// When the page is itself a by-ids fetch and a join targets the same +/// type, both land in the primary tree: the page keeps its own ids, the +/// join keeps the derived ones. +#[test] +fn should_tell_a_by_ids_page_from_a_join_on_the_same_type() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let post_type = feed.document_type_for_name("post").expect("post"); + let page = DriveDocumentQuery { + contract: &feed, + document_type: post_type, + internal_clauses: InternalClauses { + primary_key_in_clause: Some(WhereClause { + field: "$id".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![Value::Identifier(POST_A), Value::Identifier(POST_B)]), + }), + primary_key_equal_clause: None, + in_clauses: vec![], + range_clause: None, + equal_clauses: Default::default(), + }, + offset: None, + limit: Some(2), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + let query = page.with_sub_queries(vec![bound( + &feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "quotedPostId", + "$id", + None, + )]); + + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("executes") + .result; + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + for result in [&materialized, &verified] { + assert_eq!(ids(&result.page_documents), vec![POST_A, POST_B]); + assert_eq!(ids(result.sub_results[0].documents()), vec![POST_D]); + } +} + +#[test] +fn should_reject_invalid_composite_shapes() { + let (drive, feed, dashpay) = setup(); + let pv = platform_version(); + let base = feed_query(&feed, &dashpay, Some(OWNER_1)); + let expect_unsupported = |query: DriveDocumentQuery, what: &str| { + let result = drive.query_composite_documents(&query, None, None, pv); + assert!( + matches!(result, Err(Error::Query(_))), + "{what}: expected a query rejection, got {result:?}" + ); + }; + + let mut no_limit = base.clone(); + no_limit.limit = None; + expect_unsupported(no_limit, "page without a limit"); + + let mut oversized = base.clone(); + oversized.limit = Some(101); + expect_unsupported(oversized, "page limit above the bound-value cap"); + + let mut none = base.clone(); + none.sub_queries.clear(); + expect_unsupported(none, "no sub-queries"); + + let mut too_many = base.clone(); + let extra = too_many.sub_queries[LIKE_COUNTS].clone(); + while too_many.sub_queries.len() <= MAX_SUB_QUERIES { + too_many.sub_queries.push(extra.clone()); + } + expect_unsupported(too_many, "more sub-queries than the cap"); + + let mut counted_with_limit = base.clone(); + counted_with_limit.sub_queries[LIKE_COUNTS].limit = Some(5); + expect_unsupported(counted_with_limit, "count with a limit"); + + let mut unbound_count = base.clone(); + unbound_count.sub_queries[LIKE_COUNTS].binding = None; + expect_unsupported(unbound_count, "unbound count"); + + let mut join_without_reference = base.clone(); + join_without_reference.sub_queries[QUOTED_POSTS] + .binding + .as_mut() + .expect("bound") + .source_property = "$ownerId".to_string(); + expect_unsupported( + join_without_reference, + "by-id join from a non-refersTo source", + ); + + let mut join_with_limit = base.clone(); + join_with_limit.sub_queries[QUOTED_POSTS].limit = Some(5); + expect_unsupported(join_with_limit, "by-id join with a limit"); + + let mut filtered_join = base.clone(); + filtered_join.sub_queries[QUOTED_POSTS] + .where_clauses + .push(WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }); + for result in [ + filtered_join + .sub_query_document_query( + &filtered_join.sub_queries[QUOTED_POSTS], + &[Identifier::from(POST_D)], + pv, + ) + .map(|_| ()), + drive + .query_composite_documents(&filtered_join, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&filtered_join, pv) + .map(|_| ()), + filtered_join + .verify_composite_documents_proof(&[], pv) + .map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + assert!(result.unwrap_err().to_string().contains("no fixed clauses")); + } + + let mut lookup_without_limit = base.clone(); + lookup_without_limit.sub_queries[REPOSTS].limit = None; + expect_unsupported(lookup_without_limit, "non-unique lookup without a limit"); + + let mut bounded_lookup_with_limit = base.clone(); + bounded_lookup_with_limit.sub_queries[AUTHOR_PROFILES].limit = Some(20); + expect_unsupported( + bounded_lookup_with_limit, + "value-bounded lookup with a limit", + ); + + let mut forward_binding = base.clone(); + forward_binding.sub_queries[LIKE_COUNTS] + .binding + .as_mut() + .expect("bound") + .source = BindingSource::SubQuery(QUOTED_POSTS); + expect_unsupported(forward_binding, "binding to a later sub-query"); + + let mut bound_to_a_count = base.clone(); + bound_to_a_count.sub_queries[QUOTED_AUTHOR_PROFILES] + .binding + .as_mut() + .expect("bound") + .source = BindingSource::SubQuery(LIKE_COUNTS); + expect_unsupported(bound_to_a_count, "binding to a count sub-query"); + + let mut fixed_on_bound_field = base.clone(); + fixed_on_bound_field.sub_queries[REPOSTS] + .where_clauses + .push(WhereClause { + field: "postId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(POST_A), + }); + expect_unsupported(fixed_on_bound_field, "fixed clause on the bound field"); + + let mut unknown_property = base.clone(); + unknown_property.sub_queries[LIKE_COUNTS] + .binding + .as_mut() + .expect("bound") + .source_property = "nope".to_string(); + expect_unsupported(unknown_property, "unknown source property"); + + let mut non_identifier_property = base.clone(); + non_identifier_property.sub_queries[LIKE_COUNTS] + .binding + .as_mut() + .expect("bound") + .source_property = "hashtag".to_string(); + expect_unsupported(non_identifier_property, "non-identifier source property"); + + let mut ordered_join = base.clone(); + ordered_join.sub_queries[QUOTED_POSTS].order_by = vec![OrderClause { + field: "hashtag".to_string(), + ascending: true, + }]; + expect_unsupported(ordered_join, "ordered by-id join"); + + let mut count_on_a_looked_up_index = base.clone(); + count_on_a_looked_up_index.sub_queries.push(bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + )); + expect_unsupported( + count_on_a_looked_up_index, + "count on the index a documents lookup reads through", + ); +} + +#[test] +fn should_check_count_and_document_descents_against_the_actual_bound_values() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + insert_repost(&drive, &feed, OWNER_3, POST_D, 23); + let pv = platform_version(); + let mut query = page_by_hashtag(&feed, "dash", Some(10)).with_sub_queries(vec![ + bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ), + DriveSubQuery { + contract: &feed, + document_type: feed.document_type_for_name("repost").expect("repost"), + kind: SubQueryKind::Documents, + where_clauses: vec![WhereClause { + field: "postId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(POST_B), + }], + order_by: vec![], + limit: None, + binding: Some(SubQueryBinding { + source: BindingSource::Page, + source_property: "$ownerId".into(), + field: "$ownerId".into(), + }), + }, + ]); + for result in [ + drive + .query_composite_documents(&query, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&query, pv) + .map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + let error = result.unwrap_err().to_string(); + assert!(error.contains("another component descends"), "{error}"); + } + + // Moving the document selection to D leaves the A/B/C count trees + // untouched, although the base paths still nest on the same index. + query.sub_queries[1].where_clauses[0].value = Value::Identifier(POST_D); + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("disjoint counts and documents materialize") + .result; + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("disjoint counts and documents prove"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("disjoint counts and documents verify"); + assert_eq!( + counts(&verified.sub_results[0]), + BTreeMap::from([(POST_A, 1), (POST_B, 2)]) + ); + assert_eq!( + post_ids_of(&verified.sub_results[1], "postId"), + vec![POST_D] + ); + assert_eq!(verified.sub_results, materialized.sub_results); +} + +/// A minimal request never conflicts with the page's direction: a +/// documents sub-query the caller left unordered on its bound field walks +/// the page's way, so a descending page with default lookups merges and +/// verifies, while an explicit ordering that disagrees is still refused. +#[test] +fn should_inherit_the_page_direction_for_unordered_lookups() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let descending_page = || { + let mut page = page_by_hashtag(&feed, "dash", Some(3)); + page.internal_clauses = InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "$id".into(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Identifier(POST_A), + Value::Identifier(POST_B), + Value::Identifier(POST_C), + ]), + }], + pv, + ) + .expect("by-id page"); + page.order_by.insert( + "$id".into(), + OrderClause { + field: "$id".into(), + ascending: false, + }, + ); + page + }; + let like_counts = || { + bound( + &feed, + "like", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ) + }; + let round_trip = |query: &DriveDocumentQuery, what: &str| { + let materialized = drive + .query_composite_documents(query, None, None, pv) + .unwrap_or_else(|e| panic!("{what} materializes: {e}")) + .result; + assert_eq!( + ids(&materialized.page_documents), + vec![POST_C, POST_B, POST_A] + ); + let (proof, _) = drive + .query_composite_documents_with_proof(query, pv) + .unwrap_or_else(|e| panic!("{what} proves: {e}")); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .unwrap_or_else(|e| panic!("{what} verifies: {e}")); + assert_eq!(verified.page_documents, materialized.page_documents); + assert_eq!(verified.sub_results, materialized.sub_results); + materialized + }; + + // The feed shape: cross-contract profiles, the viewer's marks (both + // value-bounded) and a count, none of them ordered by the caller. + let mut viewer_likes = bound( + &feed, + "like", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + None, + ); + viewer_likes.where_clauses = vec![WhereClause { + field: "$ownerId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER_1), + }]; + let feed_shape = descending_page().with_sub_queries(vec![ + bound( + &dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ), + viewer_likes, + like_counts(), + ]); + let result = round_trip(&feed_shape, "the descending feed shape"); + // The lookups inherited the page's direction: descending by their + // bound field. + assert_eq!( + owner_ids(result.sub_results[0].documents()), + vec![OWNER_3, OWNER_1], + "profiles walk owners descending" + ); + assert_eq!( + post_ids_of(&result.sub_results[1], "postId"), + vec![POST_B, POST_A], + "the viewer's likes, posts descending" + ); + assert_eq!( + counts(&result.sub_results[2]), + BTreeMap::from([(POST_A, 2), (POST_B, 1)]) + ); + + // A limited lookup under the page's own contract. Its limit caps the + // rows it returns in total, in walk order, like an ordinary `IN` + // query's: walking posts descending, the one row is B's. + let limited_lookup = descending_page().with_sub_queries(vec![ + bound( + &feed, + "repost", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + Some(1), + ), + like_counts(), + ]); + let result = round_trip(&limited_lookup, "the limited lookup"); + assert_eq!( + post_ids_of(&result.sub_results[0], "postId"), + vec![POST_B], + "the single repost row comes from the highest post id" + ); + + // Both at once: the cross-contract lookup lifts the merged root to the + // tree root, so the page's contract becomes a synthesized split that + // the limited lookup descends into. grovedb #851 gives that split the + // inputs' direction; before it, this descending composition was + // refused while its ascending twin merged. + let combined = descending_page().with_sub_queries(vec![ + bound( + &dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ), + bound( + &feed, + "repost", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + Some(1), + ), + like_counts(), + ]); + let result = round_trip(&combined, "the cross-contract shape with a limited lookup"); + assert_eq!( + owner_ids(result.sub_results[0].documents()), + vec![OWNER_3, OWNER_1] + ); + assert_eq!(post_ids_of(&result.sub_results[1], "postId"), vec![POST_B]); + + // An explicit ordering that disagrees with the page is still refused, + // on every entry point. + let mut conflicting = limited_lookup.clone(); + conflicting.sub_queries[0].order_by.push(OrderClause { + field: "postId".into(), + ascending: true, + }); + for result in [ + drive + .query_composite_documents(&conflicting, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&conflicting, pv) + .map(|_| ()), + conflicting + .verify_composite_documents_proof(&[], pv) + .map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + assert!(result.unwrap_err().to_string().contains("outer ordering")); + } +} + +#[test] +fn should_reject_zero_limits() { + let (drive, feed, dashpay) = setup(); + let pv = platform_version(); + let base = feed_query(&feed, &dashpay, None); + + let mut zero_page = base.clone(); + zero_page.limit = Some(0); + let refused = zero_page + .validate_composite(pv) + .expect_err("a zero page limit is refused"); + assert!(refused.to_string().contains("at least 1"), "{refused}"); + + let mut zero_lookup = base.clone(); + zero_lookup.sub_queries[REPOSTS].limit = Some(0); + let refused = zero_lookup + .validate_composite(pv) + .expect_err("a zero lookup limit is refused"); + assert!(refused.to_string().contains("at least 1"), "{refused}"); + + let mut zero_sibling = base; + zero_sibling.sub_queries.push(DriveSubQuery { + contract: &feed, + document_type: feed.document_type_for_name("repost").expect("repost"), + kind: SubQueryKind::Documents, + where_clauses: vec![], + order_by: vec![], + limit: Some(0), + binding: None, + }); + let refused = zero_sibling + .validate_composite(pv) + .expect_err("a zero sibling limit is refused"); + assert!(refused.to_string().contains("at least 1"), "{refused}"); + drop(drive); +} + +#[test] +fn should_reject_a_bound_field_that_is_not_identifier_typed() { + let (drive, feed, dashpay) = setup(); + let pv = platform_version(); + let mut query = feed_query(&feed, &dashpay, None); + // `hashtag` is a string: no derived identifier could ever match it. + query.sub_queries.push(bound( + &feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "hashtag", + Some(5), + )); + let refused = query + .validate_composite(pv) + .expect_err("a string bound field is refused"); + assert!( + refused.to_string().contains("not identifier-typed"), + "{refused}" + ); + drop(drive); +} + +/// A bound sub-query that derives nothing contributes no branch, so the +/// merged root is decided by the components that are always present. +/// A limited page with a sub-query below its own path would land at the +/// root on any page where the other bound sub-queries derive nothing; +/// that is refused up front rather than failing on such a page. +#[test] +fn should_reject_a_limited_page_a_sub_query_below_it_could_leave_at_the_merged_root() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + // The page walks `like` through [hashtag, postId] with the hashtag + // fixed; the lookup fixes the postId too and binds the terminal, so + // its path extends the page's. + let like_type = feed.document_type_for_name("like").expect("like"); + let page = DriveDocumentQuery { + contract: &feed, + document_type: like_type, + internal_clauses: InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }], + pv, + ) + .expect("clauses extract"), + offset: None, + limit: Some(10), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + let mut below_the_page = bound( + &feed, + "like", + SubQueryKind::Documents, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ); + below_the_page.where_clauses = vec![ + WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }, + WhereClause { + field: "postId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(POST_A), + }, + ]; + let query = page.with_sub_queries(vec![ + below_the_page, + // A bound sub-query elsewhere: present on some pages, absent + // on others, so the merged root moves with the data. + bound( + &feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "postId", + "$id", + None, + ), + ]); + let refused = query + .validate_composite(pv) + .expect_err("a limited page above a sub-query is refused"); + assert!( + refused.to_string().contains("lands at the merged root"), + "{refused}" + ); +} + +/// A `$id ==` page lowers with a limit of one whatever its limit says; +/// the proof query drops it like a `$id IN` page's, so the page and a +/// join on its type share the primary tree without a budget to lift. +#[test] +fn should_prove_a_single_id_page_with_a_join_on_the_same_type() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let mut page = page_by_hashtag(&feed, "dash", Some(1)); + page.internal_clauses = InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "$id".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(POST_A), + }], + pv, + ) + .expect("by-id page"); + let query = page.with_sub_queries(vec![bound( + &feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "quotedPostId", + "$id", + None, + )]); + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("materializes") + .result; + assert_eq!(ids(&materialized.page_documents), vec![POST_A]); + assert_eq!(ids(materialized.sub_results[0].documents()), vec![POST_D]); + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + assert_eq!(verified.page_documents, materialized.page_documents); + assert_eq!(verified.sub_results, materialized.sub_results); +} + +#[test] +fn should_reject_two_limited_lookups_on_one_index_path() { + let (drive, feed, dashpay) = setup(); + let pv = platform_version(); + let mut query = feed_query(&feed, &dashpay, None); + // A second limited repost lookup on `byPost`, bound to the quoted + // posts: budgets never blend, so the two could never be merged. + query.sub_queries.push(bound( + &feed, + "repost", + SubQueryKind::Documents, + BindingSource::SubQuery(QUOTED_POSTS), + "$id", + "postId", + Some(20), + )); + let refused = query + .validate_composite(pv) + .expect_err("two limited lookups on one index path are refused"); + assert!(refused.to_string().contains("carries a limit"), "{refused}"); + drop(drive); +} diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs index 960fdd4347c..9927ca81676 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs @@ -530,6 +530,7 @@ pub(super) fn likes_query<'a>( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } @@ -2404,6 +2405,7 @@ fn beat_synthesis_over_bucketed_index_is_refused() { phase_seconds: 0, }, }], + sub_queries: vec![], }; let error = drive .query_documents(query, None, false, None, None) @@ -2978,6 +2980,7 @@ fn pin_query<'a>( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs index e6ecb106050..a28ac2c257e 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs @@ -27,6 +27,7 @@ //! suite's fixture and assertion helpers. mod chained_query_e2e_tests; +mod composite_query_e2e_tests; mod countable_e2e_tests; mod index_only_e2e_tests; mod noncounted_sibling_e2e_tests; diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs index b649ced298c..d506d4d7d92 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v0/mod.rs @@ -186,6 +186,7 @@ impl Drive { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs index 2c3572be562..15a2dbcb01d 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v1/mod.rs @@ -459,6 +459,7 @@ impl Drive { start_at_included: false, block_time_ms: None, resolved_time_ranges, + sub_queries: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/drive/document/query/mod.rs b/packages/rs-drive/src/drive/document/query/mod.rs index 72b5dcab3e5..a34f54d0dba 100644 --- a/packages/rs-drive/src/drive/document/query/mod.rs +++ b/packages/rs-drive/src/drive/document/query/mod.rs @@ -5,6 +5,7 @@ mod fetch_document_history_query; mod query_chained_documents; +mod query_composite_documents; /// query of the vote state pub mod query_contested_documents_vote_state; mod query_documents; @@ -14,6 +15,7 @@ mod query_documents_with_flags; pub mod query_contested_documents_storage; pub use query_chained_documents::*; +pub use query_composite_documents::*; pub use query_documents::*; pub use query_documents_with_flags::*; diff --git a/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs b/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs index 919b758cea6..68fa1ab3ff3 100644 --- a/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs +++ b/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs @@ -3,7 +3,7 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; -use crate::query::drive_chained_document_query::DriveChainedDocumentQuery; +use crate::query::DriveDocumentQuery; use dpp::block::epoch::Epoch; use dpp::document::Document; use dpp::version::PlatformVersion; @@ -12,12 +12,15 @@ use grovedb::TransactionArg; pub use v0::QueryChainedDocumentsOutcomeV0; impl Drive { - /// Executes a chained document query (provable semi-join) without - /// proofs and returns the materialized halves plus the processing - /// cost (when an epoch is given). + /// Executes a chained document query — a [`DriveDocumentQuery`] inner + /// half carrying a single by-id join in its + /// [`sub_queries`](DriveDocumentQuery::sub_queries) (see + /// [`DriveDocumentQuery::with_by_id_join`]) — without proofs and + /// returns the materialized halves plus the processing cost (when an + /// epoch is given). pub fn query_chained_documents( &self, - query: &DriveChainedDocumentQuery, + query: &DriveDocumentQuery, epoch: Option<&Epoch>, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -44,7 +47,7 @@ impl Drive { /// by construction). Grovedb proves committed state only, so the /// materialize/prove sequence is bracketed by root-hash reads and /// retried when a block commit interleaves — see - /// [`DriveChainedDocumentQuery::execute_with_proof_internal`]. + /// [`DriveDocumentQuery::execute_chained_with_proof_internal`]. /// Shares the `query_chained_documents` version slot with the /// no-proof path (one surface, one version). /// Returns the merged proof plus the materialized INNER @@ -52,7 +55,7 @@ impl Drive { /// outer half is covered by the proof and not materialized. pub fn query_chained_documents_with_proof( &self, - query: &DriveChainedDocumentQuery, + query: &DriveDocumentQuery, platform_version: &PlatformVersion, ) -> Result<(Vec, Vec), Error> { match platform_version diff --git a/packages/rs-drive/src/drive/document/query/query_chained_documents/v0/mod.rs b/packages/rs-drive/src/drive/document/query/query_chained_documents/v0/mod.rs index e624aa31ac1..3eb4f2c3d60 100644 --- a/packages/rs-drive/src/drive/document/query/query_chained_documents/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/query/query_chained_documents/v0/mod.rs @@ -1,9 +1,7 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; -use crate::query::drive_chained_document_query::{ - ChainedDocumentsResult, DriveChainedDocumentQuery, -}; +use crate::query::{ChainedDocumentsResult, DriveDocumentQuery}; use dpp::block::epoch::Epoch; use dpp::version::PlatformVersion; use grovedb::TransactionArg; @@ -22,13 +20,13 @@ impl Drive { #[inline(always)] pub(super) fn query_chained_documents_v0( &self, - query: &DriveChainedDocumentQuery, + query: &DriveDocumentQuery, epoch: Option<&Epoch>, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { let mut drive_operations: Vec = vec![]; - let result = query.execute_no_proof_internal( + let result = query.execute_chained_no_proof_internal( self, transaction, &mut drive_operations, @@ -53,10 +51,10 @@ impl Drive { #[inline(always)] pub(super) fn query_chained_documents_with_proof_v0( &self, - query: &DriveChainedDocumentQuery, + query: &DriveDocumentQuery, platform_version: &PlatformVersion, ) -> Result<(Vec, Vec), Error> { let mut drive_operations: Vec = vec![]; - query.execute_with_proof_internal(self, &mut drive_operations, platform_version) + query.execute_chained_with_proof_internal(self, &mut drive_operations, platform_version) } } diff --git a/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs b/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs new file mode 100644 index 00000000000..2fd22b091b9 --- /dev/null +++ b/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs @@ -0,0 +1,75 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::DriveDocumentQuery; +use dpp::block::epoch::Epoch; +use dpp::document::Document; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +pub use v0::QueryCompositeDocumentsOutcomeV0; + +impl Drive { + /// Executes a composite document query — a [`DriveDocumentQuery`] + /// page carrying derived [`sub_queries`](DriveDocumentQuery::sub_queries) + /// — without proofs and returns the materialized results plus the + /// processing cost (when an epoch is given). + pub fn query_composite_documents( + &self, + query: &DriveDocumentQuery, + epoch: Option<&Epoch>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .document + .query + .query_composite_documents + { + 0 => self.query_composite_documents_v0(query, epoch, transaction, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "query_composite_documents".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Executes a composite document query AND generates its single + /// merged proof: the page and every derived sub-query merged by + /// `prove_query_many` — one proof, one root by construction. Grovedb + /// proves committed state only, so the materialize/prove sequence is + /// bracketed by root-hash reads and retried when a block commit + /// interleaves — see + /// [`DriveDocumentQuery::execute_composite_with_proof_internal`]. + /// Shares the `query_composite_documents` version slot with the + /// no-proof path (one surface, one version). + /// + /// Returns the merged proof plus the materialized page (the + /// caller's pagination cursor derives from it); the sub-query + /// results are covered by the proof and not materialized twice. + pub fn query_composite_documents_with_proof( + &self, + query: &DriveDocumentQuery, + platform_version: &PlatformVersion, + ) -> Result<(Vec, Vec), Error> { + match platform_version + .drive + .methods + .document + .query + .query_composite_documents + { + 0 => self.query_composite_documents_with_proof_v0(query, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "query_composite_documents_with_proof".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/query/query_composite_documents/v0/mod.rs b/packages/rs-drive/src/drive/document/query/query_composite_documents/v0/mod.rs new file mode 100644 index 00000000000..99bf2786502 --- /dev/null +++ b/packages/rs-drive/src/drive/document/query/query_composite_documents/v0/mod.rs @@ -0,0 +1,60 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::query::{CompositeDocumentsResult, DriveDocumentQuery}; +use dpp::block::epoch::Epoch; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +/// The outcome of a composite document query: the materialized results +/// and the processing cost. +#[derive(Debug, Default)] +pub struct QueryCompositeDocumentsOutcomeV0 { + /// The materialized page and sub-query results. + pub result: CompositeDocumentsResult, + /// The processing cost, when an epoch was given. + pub cost: u64, +} + +impl Drive { + #[inline(always)] + pub(super) fn query_composite_documents_v0( + &self, + query: &DriveDocumentQuery, + epoch: Option<&Epoch>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut drive_operations: Vec = vec![]; + let result = query.execute_composite_no_proof_internal( + self, + transaction, + &mut drive_operations, + platform_version, + )?; + let cost = if let Some(epoch) = epoch { + Drive::calculate_fee( + None, + Some(drive_operations), + epoch, + self.config.epochs_per_era, + platform_version, + None, + )? + .processing_fee + } else { + 0 + }; + Ok(QueryCompositeDocumentsOutcomeV0 { result, cost }) + } + + #[inline(always)] + pub(super) fn query_composite_documents_with_proof_v0( + &self, + query: &DriveDocumentQuery, + platform_version: &PlatformVersion, + ) -> Result<(Vec, Vec), Error> { + let mut drive_operations: Vec = vec![]; + query.execute_composite_with_proof_internal(self, &mut drive_operations, platform_version) + } +} diff --git a/packages/rs-drive/src/drive/document/query/query_documents/mod.rs b/packages/rs-drive/src/drive/document/query/query_documents/mod.rs index d7b9028bd9c..9cac53c6912 100644 --- a/packages/rs-drive/src/drive/document/query/query_documents/mod.rs +++ b/packages/rs-drive/src/drive/document/query/query_documents/mod.rs @@ -79,6 +79,7 @@ impl Drive { transaction: TransactionArg, protocol_version: Option, ) -> Result { + query.ensure_no_sub_queries("query_documents")?; let platform_version = PlatformVersion::get_version_or_current_or_latest(protocol_version)?; match platform_version diff --git a/packages/rs-drive/src/drive/document/query/query_documents_with_flags/mod.rs b/packages/rs-drive/src/drive/document/query/query_documents_with_flags/mod.rs index 2900d3c2f27..647cbc9bc28 100644 --- a/packages/rs-drive/src/drive/document/query/query_documents_with_flags/mod.rs +++ b/packages/rs-drive/src/drive/document/query/query_documents_with_flags/mod.rs @@ -80,6 +80,7 @@ impl Drive { transaction: TransactionArg, protocol_version: Option, ) -> Result { + query.ensure_no_sub_queries("query_documents_with_flags")?; let platform_version = PlatformVersion::get_version_or_current_or_latest(protocol_version)?; match platform_version diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs index 655fba900c6..b29b4a6d184 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/fetch_oldest_withdrawal_documents_by_status/v0/mod.rs @@ -83,6 +83,7 @@ impl Drive { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation @@ -130,6 +131,7 @@ impl Drive { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // Fetch all documents @@ -613,6 +615,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } }; diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs index 57fcf6ab630..7c5da87f4c1 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v0/mod.rs @@ -85,6 +85,7 @@ impl Drive { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs index 0f2e5b017e3..8136163345e 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs @@ -88,6 +88,7 @@ impl Drive { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // todo: deal with cost of this operation diff --git a/packages/rs-drive/src/query/drive_chained_document_query/mod.rs b/packages/rs-drive/src/query/chained_document_query/mod.rs similarity index 69% rename from packages/rs-drive/src/query/drive_chained_document_query/mod.rs rename to packages/rs-drive/src/query/chained_document_query/mod.rs index 305601b9f14..3c1c69d2321 100644 --- a/packages/rs-drive/src/query/drive_chained_document_query/mod.rs +++ b/packages/rs-drive/src/query/chained_document_query/mod.rs @@ -12,11 +12,24 @@ //! binds that root to the quorum-signed app hash (see //! `rs-drive-proof-verifier`). //! +//! There is no separate chained query type: a chained query is a +//! [`DriveDocumentQuery`] — the inner half — whose +//! [`sub_queries`](DriveDocumentQuery::sub_queries) carry exactly one +//! by-id join bound to it, the shape +//! [`DriveDocumentQuery::with_by_id_join`] builds (the same shape the +//! composite surface generalizes). This module holds the chained +//! behaviour of `DriveDocumentQuery`: shape validation, join-value +//! derivation, the outer by-ids builder, proof merging, and the +//! server-side executors behind `Drive::query_chained_documents` / +//! `query_chained_documents_with_proof` (the verifier half lives in +//! `verify::chained_document`). +//! //! Soundness never rests on the server's join: the verifier re-derives -//! the outer query from the INNER proof's results ([`Self::join_values`] -//! → [`Self::derive_outer_query`], the same functions the server -//! executes), so a server cannot substitute, omit, or inject outer -//! documents. Because the join property's `refersTo` targets a +//! the outer query from the INNER proof's results +//! ([`DriveDocumentQuery::chained_join_values`] → +//! [`DriveDocumentQuery::derive_chained_outer_query`], the same functions +//! the server executes), so a server cannot substitute, omit, or inject +//! outer documents. Because the join property's `refersTo` targets a //! `permanentDocument` type (non-deletable, enforced at write time), //! every proven join value MUST resolve to a document — a missing outer //! document is an invalid proof, not an absence. @@ -33,12 +46,16 @@ use crate::error::drive::DriveError; use crate::error::query::QuerySyntaxError; use crate::error::Error; -use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use crate::query::{ + BindingSource, DriveDocumentQuery, InternalClauses, SubQueryBinding, SubQueryKind, WhereClause, + WhereOperator, +}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, }; +use dpp::data_contract::DataContract; use dpp::document::{Document, DocumentV0Getters}; use dpp::identifier::Identifier; use dpp::platform_value::Value; @@ -46,36 +63,13 @@ use dpp::version::PlatformVersion; /// The most join values one chained query can carry — the derived /// outer query is a single `$id IN [...]` clause, and `in` clauses -/// admit at most 100 values (`WhereClause::in_values`). `validate` -/// caps the inner limit here so every reachable page fits, and -/// [`DriveChainedDocumentQuery::proof_path_queries`] enforces it on -/// the (untrusted, verifier-supplied) join-value list itself. +/// admit at most 100 values (`WhereClause::in_values`). +/// [`DriveDocumentQuery::validate_chained`] caps the inner limit here so +/// every reachable page fits, and +/// [`DriveDocumentQuery::chained_proof_path_queries`] enforces it on the +/// (untrusted, verifier-supplied) join-value list itself. pub const MAX_CHAINED_JOIN_VALUES: usize = 100; -/// A chained document query: an inner indexOnly query whose proven join -/// values become the outer query's primary keys. -/// -/// Construction contract: `outer_document_type` MUST be a document type -/// of `inner.contract` — build it via -/// [`DataContract::document_type_for_name`] on the same contract the -/// inner query was built from. [`Self::validate`] enforces everything -/// derivable from the types themselves. -/// -/// [`DataContract::document_type_for_name`]: -/// dpp::data_contract::accessors::v0::DataContractV0Getters::document_type_for_name -#[derive(Debug, Clone)] -pub struct DriveChainedDocumentQuery<'a> { - /// The inner query. Must target an indexOnly document type and - /// resolve to an index carrying [`Self::join_property`]. - pub inner: DriveDocumentQuery<'a>, - /// The inner property whose values feed the outer query's `$id`s. - /// Must carry a same-contract `refersTo: permanentDocument` - /// declaration targeting [`Self::outer_document_type`]. - pub join_property: String, - /// The outer document type — the `refersTo` target. - pub outer_document_type: DocumentTypeRef<'a>, -} - /// The materialized result of a chained query, in inner-proof order. #[derive(Debug, Default)] pub struct ChainedDocumentsResult { @@ -88,21 +82,84 @@ pub struct ChainedDocumentsResult { pub outer_documents: Vec, } -impl<'a> DriveChainedDocumentQuery<'a> { - /// Validates the chained shape. Called by the server before - /// executing and by the verifier before verifying, so an invalid - /// spec fails identically on both sides. - pub fn validate(&self, platform_version: &PlatformVersion) -> Result<(), Error> { +impl<'a> DriveDocumentQuery<'a> { + /// The join edge of a chained query. There is no separate chained + /// query type: a chained query is this query (the inner half) whose + /// [`sub_queries`](Self::sub_queries) carry EXACTLY ONE by-id join + /// bound to it — the shape [`Self::with_by_id_join`] builds. Returns + /// the join's source property (the inner property whose proven values + /// become the outer `$id`s) and the outer document type with its + /// contract; refuses any other sub-query shape. + pub(crate) fn chained_join( + &self, + ) -> Result<(&str, DocumentTypeRef<'a>, &'a DataContract), Error> { + let unsupported = + |message: &str| Error::Query(QuerySyntaxError::Unsupported(message.to_string())); + let [join] = self.sub_queries.as_slice() else { + return Err(unsupported( + "a chained query carries exactly one sub-query: the by-id join whose source \ + property's proven values become the outer `$id`s (build it with \ + with_by_id_join); a query with more sub-queries belongs on the composite \ + surface", + )); + }; + let Some(SubQueryBinding { + source: BindingSource::Page, + source_property, + field, + }) = &join.binding + else { + return Err(unsupported( + "a chained query's sub-query must be bound to the inner query itself", + )); + }; + if field.as_str() != dpp::document::property_names::ID { + return Err(unsupported( + "a chained query's sub-query must be a by-id join (bound field `$id`); other \ + bindings live on the composite surface", + )); + } + if join.kind != SubQueryKind::Documents { + return Err(unsupported( + "a chained join returns documents; counts live on the composite surface", + )); + } + if !join.where_clauses.is_empty() || !join.order_by.is_empty() || join.limit.is_some() { + return Err(unsupported( + "a chained by-id join takes no fixed clauses, no ordering and no limit: the \ + outer half is purely the derived by-ids fetch, complete by set equality", + )); + } + Ok((source_property.as_str(), join.document_type, join.contract)) + } + + /// Validates the chained shape: this query as the inner indexOnly + /// half plus the single by-id join its + /// [`sub_queries`](Self::sub_queries) carry (see + /// [`Self::chained_join`]). Called by the server before executing and + /// by the verifier before verifying, so an invalid spec fails + /// identically on both sides. + pub fn validate_chained(&self, platform_version: &PlatformVersion) -> Result<(), Error> { let unsupported = |message: String| Error::Query(QuerySyntaxError::Unsupported(message)); - if !self.inner.document_type.index_only() { + let (join_property, outer_document_type, outer_contract) = self.chained_join()?; + // Chained joins are same-contract (v1): the join sub-query's + // contract must be the inner query's own. + if outer_contract.id() != self.contract.id() { + return Err(unsupported( + "chained document queries support same-contract joins only: the join \ + sub-query targets another contract" + .to_string(), + )); + } + if !self.document_type.index_only() { return Err(unsupported( "chained document queries require an indexOnly inner document type: only \ indexOnly projections prove their values positionally" .to_string(), )); } - if self.outer_document_type.index_only() { + if outer_document_type.index_only() { return Err(unsupported( "the outer document type of a chained query cannot be indexOnly: outer \ documents are fetched by id from primary storage, which indexOnly types \ @@ -110,7 +167,7 @@ impl<'a> DriveChainedDocumentQuery<'a> { .to_string(), )); } - match self.inner.limit { + match self.limit { None => { return Err(unsupported( "chained document queries require an explicit limit on the inner query: \ @@ -127,7 +184,7 @@ impl<'a> DriveChainedDocumentQuery<'a> { } Some(_) => {} } - if self.inner.offset.is_some() { + if self.offset.is_some() { return Err(unsupported( "chained document queries do not support an inner offset; paginate with a \ range clause on the join property" @@ -141,17 +198,14 @@ impl<'a> DriveChainedDocumentQuery<'a> { // deleted, so every proven join value MUST resolve — which is // what lets the verifier treat a missing outer document as an // invalid proof instead of needing absence proofs. - let Some(join_document_property) = self - .inner - .document_type - .flattened_properties() - .get(self.join_property.as_str()) + let Some(join_document_property) = + self.document_type.flattened_properties().get(join_property) else { return Err(unsupported(format!( "chained query join property \"{}\" does not name a property of inner \ document type \"{}\"", - self.join_property, - self.inner.document_type.name(), + join_property, + self.document_type.name(), ))); }; match &join_document_property.property_type { @@ -163,7 +217,7 @@ impl<'a> DriveChainedDocumentQuery<'a> { }, ) => { if let Some(referenced_contract_id) = contract_id { - if *referenced_contract_id != self.inner.contract.id() { + if *referenced_contract_id != self.contract.id() { return Err(unsupported( "chained document queries support same-contract joins only: \ the join property's refersTo names another contract" @@ -171,11 +225,11 @@ impl<'a> DriveChainedDocumentQuery<'a> { )); } } - if document_type_name != self.outer_document_type.name() { + if document_type_name != outer_document_type.name() { return Err(unsupported(format!( "chained query outer document type \"{}\" does not match the join \ property's refersTo target \"{}\"", - self.outer_document_type.name(), + outer_document_type.name(), document_type_name, ))); } @@ -185,25 +239,24 @@ impl<'a> DriveChainedDocumentQuery<'a> { "chained query join property \"{}\" must carry a `refersTo: \ permanentDocument` declaration: only a permanent-document reference \ guarantees every proven join value resolves to an outer document", - self.join_property, + join_property, ))); } } // The resolved index must carry the join property, so every // synthesized inner projection provably carries its value. - let index = self.inner.index_only_query_index(platform_version)?; - let index_carries_join_property = index.terminal.as_deref() - == Some(self.join_property.as_str()) + let index = self.index_only_query_index(platform_version)?; + let index_carries_join_property = index.terminal.as_deref() == Some(join_property) || index .properties .iter() - .any(|property| property.name == self.join_property); + .any(|property| property.name == join_property); if !index_carries_join_property { return Err(unsupported(format!( "the inner query resolves to index \"{}\", which does not carry the join \ property \"{}\"; constrain the query so an index carrying it serves it", - index.name, self.join_property, + index.name, join_property, ))); } @@ -214,23 +267,27 @@ impl<'a> DriveChainedDocumentQuery<'a> { /// order, deduplicated to first appearance. ONE extraction both the /// server and the verifier run — the single-builder rule that keeps /// the derived outer query identical on both sides. - pub fn join_values(&self, inner_documents: &[Document]) -> Result, Error> { + pub fn chained_join_values( + &self, + inner_documents: &[Document], + ) -> Result, Error> { use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; + let (join_property, _, _) = self.chained_join()?; let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); let mut join_values = Vec::with_capacity(inner_documents.len()); for document in inner_documents { - // Path-aware read: `validate` admits any property + // Path-aware read: `validate_chained` admits any property // `flattened_properties()` names — dotted (nested) keys // included — and the synthesis builder stores those nested // (`insert_at_path`), so a flat `.get` would miss them. let value = document .properties() - .get_optional_at_path(self.join_property.as_str()) + .get_optional_at_path(join_property) .ok() .flatten() .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "an inner projection is missing the join property: validate() \ + "an inner projection is missing the join property: validate_chained() \ guarantees the resolved index carries it", )))?; let identifier = value.to_identifier().map_err(|_| { @@ -250,16 +307,20 @@ impl<'a> DriveChainedDocumentQuery<'a> { /// from the outer type's primary storage. No clauses, no limit, no /// cursor — completeness is set-equality against `join_values`, /// checked by the verifier. - pub fn derive_outer_query(&self, join_values: &[Identifier]) -> DriveDocumentQuery<'a> { + pub fn derive_chained_outer_query( + &self, + join_values: &[Identifier], + ) -> Result, Error> { + let (_, outer_document_type, outer_contract) = self.chained_join()?; // Canonical value order: byte-ascending. Grove sorts query keys // internally either way; sorting here keeps the built query — // and therefore the proof — byte-identical between the server // and a verifier that extracted the ids in any order. let mut ids: Vec = join_values.to_vec(); ids.sort(); - DriveDocumentQuery { - contract: self.inner.contract, - document_type: self.outer_document_type, + Ok(DriveDocumentQuery { + contract: outer_contract, + document_type: outer_document_type, internal_clauses: InternalClauses { primary_key_in_clause: Some(WhereClause { field: dpp::document::property_names::ID.to_string(), @@ -282,7 +343,8 @@ impl<'a> DriveChainedDocumentQuery<'a> { start_at_included: false, block_time_ms: None, resolved_time_ranges: Vec::new(), - } + sub_queries: vec![], + }) } /// Reorders the outer documents (returned in key order by the by-ids @@ -291,7 +353,7 @@ impl<'a> DriveChainedDocumentQuery<'a> { /// values — both directions. Shared by the server (where a mismatch /// is corrupted state: permanentDocument references cannot dangle) /// and the verifier (where it is an invalid proof). - pub fn assemble_outer_documents( + pub fn assemble_chained_outer_documents( &self, join_values: &[Identifier], outer_documents: Vec, @@ -343,7 +405,7 @@ impl<'a> DriveChainedDocumentQuery<'a> { /// `SizedQuery::limit` into its branch's per-instance /// `Query::limit`, which is exact here: the branch instance /// executes once. - pub fn proof_path_queries( + pub fn chained_proof_path_queries( &self, join_values: &[Identifier], platform_version: &PlatformVersion, @@ -352,7 +414,8 @@ impl<'a> DriveChainedDocumentQuery<'a> { // before deriving, so an oversized list fails here with a clear // message instead of deep in the `in`-clause lowering. An // honest list cannot exceed this: it is deduplicated from an - // inner page whose limit `validate` bounds to the same cap. + // inner page whose limit `validate_chained` bounds to the same + // cap. if join_values.len() > MAX_CHAINED_JOIN_VALUES { return Err(Error::Query(QuerySyntaxError::Unsupported(format!( "{} chained join values exceed the {} an outer `$id IN` clause admits", @@ -360,21 +423,21 @@ impl<'a> DriveChainedDocumentQuery<'a> { MAX_CHAINED_JOIN_VALUES, )))); } - let inner = self.inner.construct_path_query(None, platform_version)?; + let inner = self.construct_path_query(None, platform_version)?; if join_values.is_empty() { return Ok(vec![inner]); } let outer = self - .derive_outer_query(join_values) + .derive_chained_outer_query(join_values)? .construct_path_query(None, platform_version)?; Ok(vec![inner, outer]) } } #[cfg(feature = "server")] -impl DriveChainedDocumentQuery<'_> { +impl DriveDocumentQuery<'_> { /// Executes the chained query without proofs. - pub(crate) fn execute_no_proof_internal( + pub(crate) fn execute_chained_no_proof_internal( &self, drive: &crate::drive::Drive, transaction: grovedb::TransactionArg, @@ -383,16 +446,15 @@ impl DriveChainedDocumentQuery<'_> { ) -> Result { use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; - self.validate(platform_version)?; + self.validate_chained(platform_version)?; - let (inner_documents, _skipped) = - self.inner.execute_index_only_documents_no_proof_internal( - drive, - transaction, - drive_operations, - platform_version, - )?; - let join_values = self.join_values(&inner_documents)?; + let (inner_documents, _skipped) = self.execute_index_only_documents_no_proof_internal( + drive, + transaction, + drive_operations, + platform_version, + )?; + let join_values = self.chained_join_values(&inner_documents)?; if join_values.is_empty() { return Ok(ChainedDocumentsResult { inner_documents, @@ -400,7 +462,8 @@ impl DriveChainedDocumentQuery<'_> { }); } - let outer_query = self.derive_outer_query(&join_values); + let outer_query = self.derive_chained_outer_query(&join_values)?; + let outer_document_type = outer_query.document_type; let (serialized_outer, _outer_skipped) = outer_query .execute_raw_results_no_proof_internal( drive, @@ -411,15 +474,12 @@ impl DriveChainedDocumentQuery<'_> { let outer_documents = serialized_outer .into_iter() .map(|serialized| { - Document::from_bytes( - serialized.as_slice(), - self.outer_document_type, - platform_version, - ) - .map_err(|e| Error::Protocol(Box::new(e))) + Document::from_bytes(serialized.as_slice(), outer_document_type, platform_version) + .map_err(|e| Error::Protocol(Box::new(e))) }) .collect::, Error>>()?; - let outer_documents = self.assemble_outer_documents(&join_values, outer_documents)?; + let outer_documents = + self.assemble_chained_outer_documents(&join_values, outer_documents)?; Ok(ChainedDocumentsResult { inner_documents, @@ -431,7 +491,7 @@ impl DriveChainedDocumentQuery<'_> { /// proof. /// /// The inner page and the derived outer by-ids fetch are proven as - /// ONE grovedb proof: [`Self::proof_path_queries`] builds the + /// ONE grovedb proof: [`Self::chained_proof_path_queries`] builds the /// component path queries and `prove_query_many` merges them /// (grovedb merge slot 2 LIFTS the inner query's global limit into /// its merged branch's per-instance `Query::limit` — semantically @@ -453,13 +513,13 @@ impl DriveChainedDocumentQuery<'_> { /// deliberately NOT materialized here — the proof pass covers them, /// so reading their bodies a second time would double the state /// reads for data the proved response never carries inline. - pub(crate) fn execute_with_proof_internal( + pub(crate) fn execute_chained_with_proof_internal( &self, drive: &crate::drive::Drive, drive_operations: &mut Vec, platform_version: &PlatformVersion, ) -> Result<(Vec, Vec), Error> { - self.validate(platform_version)?; + self.validate_chained(platform_version)?; // Block commits are seconds apart while an attempt is // milliseconds, so a bracket collision is rare and two in a row @@ -473,16 +533,15 @@ impl DriveChainedDocumentQuery<'_> { // Materialize the INNER half only — the join values the // outer component derives from live in its projections. - let (inner_documents, _skipped) = - self.inner.execute_index_only_documents_no_proof_internal( - drive, - None, - drive_operations, - platform_version, - )?; - let join_values = self.join_values(&inner_documents)?; + let (inner_documents, _skipped) = self.execute_index_only_documents_no_proof_internal( + drive, + None, + drive_operations, + platform_version, + )?; + let join_values = self.chained_join_values(&inner_documents)?; - let path_queries = self.proof_path_queries(&join_values, platform_version)?; + let path_queries = self.chained_proof_path_queries(&join_values, platform_version)?; let path_query_refs: Vec<&grovedb::PathQuery> = path_queries.iter().collect(); let proof = drive .grove diff --git a/packages/rs-drive/src/query/composite_document_query/mod.rs b/packages/rs-drive/src/query/composite_document_query/mod.rs new file mode 100644 index 00000000000..d0f87868559 --- /dev/null +++ b/packages/rs-drive/src/query/composite_document_query/mod.rs @@ -0,0 +1,2040 @@ +//! Composite document queries: one page query plus sub-queries derived +//! from its proven results, answered as ONE merged grovedb proof. +//! +//! There is no separate composite query type: a composite query is a +//! [`DriveDocumentQuery`] — the page — whose +//! [`sub_queries`](DriveDocumentQuery::sub_queries) are non-empty. This +//! module holds the sub-query shapes ([`DriveSubQuery`] and friends) and +//! the composite behaviour of `DriveDocumentQuery`: shape validation, +//! derivation, the component path-query builders, proof merging, and the +//! server-side executors behind `Drive::query_composite_documents` / +//! `query_composite_documents_with_proof` (the verifier half lives in +//! `verify::composite_document`). +//! +//! A feed is a page of posts and then, for that page, the things a card +//! renders: the referenced (quoted) posts, the per-post engagement +//! counts, the authors' profiles, the viewer's own likes. Each of those +//! is a query whose INPUT is the page — its ids, its owners, a +//! property's values — and asking for them one round trip at a time +//! turns a single feed into a burst of dependent calls. A composite +//! query carries the page and its sub-queries in one request and proves +//! them together: the server materializes the page, derives every +//! sub-query's `IN` clause from it (or from an earlier sub-query's +//! documents), and `prove_query_many` merges all the component path +//! queries into one proof over one state root. +//! +//! Soundness never rests on the server's derivation. The verifier +//! bootstraps the page (a subset pass against the merged proof), derives +//! every sub-query itself with the SAME builders the server ran, merges +//! the same way, and verifies the whole composition in one authoritative +//! pass; then it recomputes the derived values from the proven page and +//! refuses any divergence from the bootstrap, any result outside a +//! derived value set, and (for by-id joins on `refersTo: +//! permanentDocument` properties, which cannot dangle) any missing +//! referenced document. A node that ignores the sub-queries serves a +//! page-only proof, which cannot satisfy the merged query whenever a +//! sub-query derived anything — the composition fails closed. +//! +//! Three sub-query shapes, one binding rule: +//! +//! - **Documents by id** (`bind.field == "$id"`): the classic join. The +//! source property must declare `refersTo: permanentDocument` targeting +//! the sub-query's type, so every derived id MUST resolve — the result +//! is the referenced documents in first-appearance order, set-equal to +//! the derived ids. +//! - **Documents by an indexed property** (`bind.field` is `$ownerId` or +//! an indexed property): a lookup, `WHERE AND +//! IN `, with an explicit limit unless the values +//! already bound it (a unique index, or an indexOnly terminal with +//! every prefix fixed, yields at most one row per value). Absence is +//! inherent in the range proof (a value with no document simply +//! yields none), so profiles keyed by owner or reposts keyed by post +//! work without absence proofs, and the target may live in another +//! contract. +//! - **Count** by an indexed property: the grouped point-lookup count +//! `COUNT(*) WHERE AND IN +//! GROUP BY ` on a `countable` index — one entry per value that +//! has a count tree (zero-count trees are not materialized). +//! +//! A sub-query without a binding is a **sibling**: an independent +//! documents query proven under the same root (counts must be bound — +//! the aggregate and range count shapes have their own proof +//! primitives and stay on the regular count surface). +//! +//! Derived values are identifiers only (v1): the page's `$id`, its +//! `$ownerId`, or an identifier-typed property. The page limit is +//! required and capped at [`MAX_BOUND_VALUES`] (an `IN` clause admits at +//! most that many values); the page takes no cursor and no offset — +//! paginate with a range clause, exactly as chained queries do. A by-ids +//! page is proven without its limit, which must therefore cover its ids +//! (a plain documents query would truncate instead). +//! +//! Direction: grovedb merges only queries that agree on their walk +//! direction, so every component walks in the page's. Counts and by-id +//! joins are aligned freely — their selected sets do not depend on it — +//! while a documents lookup the caller left unordered on its bound field +//! inherits it (which decides WHICH rows a limited lookup returns under a +//! descending page), an explicit ordering that disagrees is refused, and +//! so is an unordered sibling under a descending page: order it, in the +//! page's direction. + +use crate::error::drive::DriveError; +use crate::error::proof::ProofError; +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +use crate::query::drive_document_count_query::point_lookup_count_entries; +use crate::query::index_only_synthesis::synthesize_index_only_document; +use crate::query::{ + DriveDocumentCountQuery, DriveDocumentQuery, InternalClauses, OrderClause, SplitCountEntry, + WhereClause, WhereOperator, +}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, +}; +use dpp::data_contract::DataContract; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::identifier::Identifier; +use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use dpp::platform_value::Value; +use dpp::version::PlatformVersion; +use grovedb::{Element, PathQuery}; +use std::collections::{BTreeMap, BTreeSet}; + +/// The most sub-queries one composite request carries. Every sub-query +/// is another branch of one merged proof; ten covers a feed card's +/// whole enrichment (quotes, four counts, reposts, profiles, names, +/// the viewer's marks) with room to spare. +pub const MAX_SUB_QUERIES: usize = 10; + +/// The most values one binding can derive: a derived `IN` clause admits +/// at most this many (`WhereClause::in_values`), so the page limit and +/// every sub-query limit that feeds a later binding are capped here. +pub const MAX_BOUND_VALUES: usize = 100; + +/// Where a sub-query's derived values come from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingSource { + /// The page's proven documents. + Page, + /// An earlier documents sub-query's proven documents (its index in + /// [`DriveDocumentQuery::sub_queries`]). + SubQuery(usize), +} + +/// The derived clause of a sub-query: ` IN `, where the +/// values are read off the source's proven documents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubQueryBinding { + /// Whose documents supply the values. + pub source: BindingSource, + /// The source property read off each document: `$id`, `$ownerId`, + /// or an identifier-typed property (dotted paths reach nested + /// properties). Documents without the property contribute nothing. + pub source_property: String, + /// The sub-query field that receives the `IN` clause: `$id` for a + /// by-id join, otherwise `$ownerId` or an indexed property. + pub field: String, +} + +/// What a sub-query returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubQueryKind { + /// The matching documents. + Documents, + /// One count per derived value, from the countable index covering + /// the fixed clauses plus the bound field. + Count, +} + +/// One sub-query of a composite request. +#[derive(Debug, Clone, PartialEq)] +pub struct DriveSubQuery<'a> { + /// The contract the sub-query targets — the page's, or another one. + pub contract: &'a DataContract, + /// The document type queried. + pub document_type: DocumentTypeRef<'a>, + /// Documents or counts. + pub kind: SubQueryKind, + /// The fixed clauses (everything but the derived `IN`), typed. + /// Must be empty for a by-id join, which resolves every derived id. + pub where_clauses: Vec, + /// Ordering; documents only. Every component of the merged proof + /// walks in the page's direction, so a documents sub-query must agree + /// with it: a bound field the caller did not order by is appended in + /// the page's direction (a minimal request never conflicts), and an + /// explicit ordering that disagrees is refused, because changing it + /// for the proof would change the rows its limit selects. + pub order_by: Vec, + /// Required for a documents lookup on a non-unique index: it caps the + /// rows the lookup returns in total, in walk order, exactly as the + /// limit of an ordinary `IN` query does (at most `MAX_BOUND_VALUES`). + /// Forbidden for a value-bounded lookup, a by-id join (completeness is + /// set-based) and a count. + pub limit: Option, + /// The derived clause, or `None` for a sibling. + pub binding: Option, +} + +/// One sub-query's materialized result. +#[derive(Debug, Clone, PartialEq)] +pub enum SubQueryResult { + /// Documents: for a by-id join, in first-appearance order of their + /// ids among the source documents; otherwise in query order. + Documents(Vec), + /// Counts keyed by the bound value's index-key bytes (a 32-byte + /// identifier), one entry per value with a materialized count. + Counts(Vec), +} + +impl SubQueryResult { + /// The documents of a documents result, or an empty slice. + pub fn documents(&self) -> &[Document] { + match self { + Self::Documents(documents) => documents, + Self::Counts(_) => &[], + } + } + + /// The entries of a count result, or an empty slice. + pub fn counts(&self) -> &[SplitCountEntry] { + match self { + Self::Counts(entries) => entries, + Self::Documents(_) => &[], + } + } +} + +/// The materialized result of a composite query. +#[derive(Debug, Default)] +pub struct CompositeDocumentsResult { + /// The page, exactly as the page query alone would return it. + pub page_documents: Vec, + /// One result per sub-query, in request order. + pub sub_results: Vec, +} + +/// The values one binding derived, deduplicated to first appearance. +type DerivedValues = Vec; + +/// A `(path, key, element)` triple as grovedb's verifier reports it — +/// the element absent for a queried key that is not there. +pub(crate) type ProvedTrio = (Vec>, Vec, Option); + +/// A proved triple whose element is present. +pub(crate) type PresentTrio = (Vec>, Vec, Element); + +/// A component of the merged proof: the page or one sub-query. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Component { + Page, + Sub(usize), +} + +fn unsupported(message: String) -> Error { + Error::Query(QuerySyntaxError::Unsupported(message)) +} + +fn corrupted_proof(message: String) -> Error { + Error::Proof(ProofError::CorruptedProof(message)) +} + +/// A merge refusal is a property of the request's shape (the same +/// components refuse identically on every node and every verifier), so it +/// is reported as one rather than as an internal grovedb failure. +fn merge_error_to_shape_error(error: grovedb::Error) -> Error { + match error { + grovedb::Error::NotSupported(message) => unsupported(format!( + "the composite query's components cannot be merged into one proof: {}", + message + )), + other => Error::from(other), + } +} + +/// The bound identifier a document carries for `field`, or `None` when +/// the property is absent. +fn document_bound_value(document: &Document, field: &str) -> Result, Error> { + use dpp::document::property_names::{ID, OWNER_ID}; + if field == ID { + return Ok(Some(document.id())); + } + if field == OWNER_ID { + return Ok(Some(document.owner_id())); + } + let Some(value) = document + .properties() + .get_optional_at_path(field) + .ok() + .flatten() + else { + return Ok(None); + }; + value.to_identifier().map(Some).map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a bound composite property must decode as an identifier: validate() only \ + admits identifier-typed properties", + )) + }) +} + +/// Canonical value order for a derived `IN` clause: byte-ascending, so +/// the built query — and therefore the proof — is byte-identical between +/// the server and a verifier that extracted the ids in any order. +fn sorted_values(values: &[Identifier]) -> Vec { + let mut sorted = values.to_vec(); + sorted.sort(); + sorted +} + +impl<'a> DriveSubQuery<'a> { + fn bound_field(&self) -> Option<&str> { + self.binding.as_ref().map(|binding| binding.field.as_str()) + } + + fn is_by_id_join(&self) -> bool { + self.bound_field() == Some(dpp::document::property_names::ID) + } +} + +impl<'a> DriveDocumentQuery<'a> { + /// Validates the composite shape: this query as the page plus its + /// [`sub_queries`](Self::sub_queries). Called by the server before + /// executing and by the verifier before verifying, so an invalid + /// request fails identically on both sides. + /// + /// Construction contract: every sub-query's `document_type` MUST be a + /// document type of its own `contract`. This validates everything + /// derivable from the shapes themselves. + pub fn validate_composite(&self, platform_version: &PlatformVersion) -> Result<(), Error> { + if self.sub_queries.is_empty() { + return Err(unsupported( + "a composite query needs at least one sub-query; a page alone is a plain \ + documents query" + .to_string(), + )); + } + if self.sub_queries.len() > MAX_SUB_QUERIES { + return Err(unsupported(format!( + "a composite query carries at most {} sub-queries, got {}", + MAX_SUB_QUERIES, + self.sub_queries.len(), + ))); + } + let page_limit = match self.limit { + None => { + return Err(unsupported( + "composite queries require an explicit limit on the page: the page size \ + bounds every derived sub-query" + .to_string(), + )); + } + Some(0) => { + return Err(unsupported( + "a composite page limit must be at least 1".to_string(), + )); + } + Some(limit) if limit as usize > MAX_BOUND_VALUES => { + return Err(unsupported(format!( + "a composite page limit of {} exceeds {}: a derived `IN` clause admits at \ + most that many values", + limit, MAX_BOUND_VALUES, + ))); + } + Some(limit) => limit, + }; + if self.offset.is_some() { + return Err(unsupported( + "composite queries do not support a page offset; paginate with a range clause" + .to_string(), + )); + } + if self.start_at.is_some() { + return Err(unsupported( + "composite queries do not support a page cursor (startAt/startAfter); \ + paginate with a range clause on the page's ordering property" + .to_string(), + )); + } + // A by-ids page is proven without its limit (see + // `page_path_query`), so the limit must not be what bounds it. + if self.page_is_by_ids() { + let ids = self.page_ids()?.len(); + if (page_limit as usize) < ids { + return Err(unsupported(format!( + "a by-ids composite page addresses {} ids but its limit is {}: the ids \ + bound the page, so the limit must cover them", + ids, page_limit, + ))); + } + } + // The page must lower to a path query at all — an unindexed + // shape fails here, before any sub-query is inspected. + let direction = self.page_direction(platform_version)?; + + for (index, sub_query) in self.sub_queries.iter().enumerate() { + self.validate_sub_query(index, sub_query, direction, platform_version)?; + } + self.validate_component_paths(platform_version) + } + + fn validate_sub_query( + &self, + index: usize, + sub_query: &DriveSubQuery<'a>, + direction: bool, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let label = |message: &str| unsupported(format!("sub-query {}: {}", index, message)); + + let Some(binding) = &sub_query.binding else { + // A sibling: an independent documents query. + if sub_query.kind == SubQueryKind::Count { + return Err(label( + "a count sub-query must be bound (`COUNT ... WHERE IN GROUP BY `); unbound counts stay on the regular count \ + surface", + )); + } + match sub_query.limit { + None => { + return Err(label( + "a sibling documents sub-query requires an explicit limit", + )); + } + Some(0) => { + return Err(label("a sibling's limit must be at least 1")); + } + Some(limit) if limit as usize > MAX_BOUND_VALUES => { + return Err(label(&format!( + "limit {} exceeds {}", + limit, MAX_BOUND_VALUES + ))); + } + Some(_) => {} + } + // Must lower to a path query. + self.sub_query_document_query_with_direction( + sub_query, + &[], + direction, + platform_version, + )? + .construct_path_query(None, platform_version)?; + return Ok(()); + }; + + // The source must precede this sub-query and produce documents. + let (source_contract, source_type, source_is_index_only_query) = match binding.source { + BindingSource::Page => ( + self.contract, + self.document_type, + self.document_type.index_only(), + ), + BindingSource::SubQuery(source_index) => { + if source_index >= index { + return Err(label("a binding may only reference an earlier sub-query")); + } + let source = &self.sub_queries[source_index]; + if source.kind != SubQueryKind::Documents { + return Err(label("a binding must reference a documents sub-query")); + } + ( + source.contract, + source.document_type, + source.document_type.index_only(), + ) + } + }; + + // The source property: a system identifier or an identifier-typed + // property of the source type. + let source_property_type: Option<&DocumentPropertyType> = { + use dpp::document::property_names::{ID, OWNER_ID}; + if binding.source_property == ID || binding.source_property == OWNER_ID { + None + } else { + let Some(property) = source_type + .flattened_properties() + .get(binding.source_property.as_str()) + else { + return Err(label(&format!( + "source property \"{}\" does not name a property of \"{}\"", + binding.source_property, + source_type.name(), + ))); + }; + if !matches!( + property.property_type, + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(label(&format!( + "source property \"{}\" is not identifier-typed; composite bindings \ + derive identifiers only", + binding.source_property, + ))); + } + Some(&property.property_type) + } + }; + + // An indexOnly source proves only what its resolved index + // carries, so the property must sit on that index. + if source_is_index_only_query { + let carries = |index: &dpp::data_contract::document_type::Index| { + index.terminal.as_deref() == Some(binding.source_property.as_str()) + || index + .properties + .iter() + .any(|property| property.name == binding.source_property) + }; + let (carried, index_name) = match binding.source { + BindingSource::Page => { + let index = self.index_only_query_index(platform_version)?; + (carries(index), index.name.clone()) + } + BindingSource::SubQuery(source_index) => { + let source = &self.sub_queries[source_index]; + let shape = self.sub_query_document_query_with_direction( + source, + &[Identifier::default()], + direction, + platform_version, + )?; + let index = shape.index_only_query_index(platform_version)?; + (carries(index), index.name.clone()) + } + }; + if !carried { + return Err(label(&format!( + "the indexOnly source resolves to index \"{}\", which does not carry the \ + source property \"{}\"", + index_name, binding.source_property, + ))); + } + } + + if sub_query + .where_clauses + .iter() + .any(|clause| clause.field == binding.field) + { + return Err(label(&format!( + "the fixed clauses may not name the bound field \"{}\"; its `IN` clause is \ + derived", + binding.field, + ))); + } + + // The bound field must hold identifiers on the sub-query's own + // type: `$ownerId`, or an identifier-typed property (`$id` is the + // by-id join, checked below). Derived values are identifiers, so + // any other type could never match, and assembly reads the field + // back as an identifier. + if !sub_query.is_by_id_join() && binding.field != dpp::document::property_names::OWNER_ID { + let Some(property) = sub_query + .document_type + .flattened_properties() + .get(binding.field.as_str()) + else { + return Err(label(&format!( + "bound field \"{}\" does not name a property of \"{}\"", + binding.field, + sub_query.document_type.name(), + ))); + }; + if !matches!( + property.property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(label(&format!( + "bound field \"{}\" is not identifier-typed; composite bindings derive \ + identifiers only", + binding.field, + ))); + } + } + + match sub_query.kind { + SubQueryKind::Documents if sub_query.is_by_id_join() => { + if sub_query.document_type.index_only() { + return Err(label( + "a by-id join cannot target an indexOnly type: there is no \ + primary-key tree to fetch from", + )); + } + if sub_query.limit.is_some() { + return Err(label( + "a by-id join takes no limit: every derived id must resolve, so \ + completeness is set equality, not a page", + )); + } + if !sub_query.order_by.is_empty() { + return Err(label( + "a by-id join takes no ordering: results follow the derived ids' \ + first appearance", + )); + } + // Only a permanentDocument reference guarantees every + // derived id resolves, which is what lets a missing + // document be an invalid proof instead of an absence. + match source_property_type { + Some(DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id, + document_type_name, + .. + }, + )) => { + let referenced_contract = + contract_id.unwrap_or_else(|| source_contract.id()); + if referenced_contract != sub_query.contract.id() + || document_type_name != sub_query.document_type.name() + { + return Err(label(&format!( + "the source property's refersTo targets \"{}\", not this \ + sub-query's type \"{}\"", + document_type_name, + sub_query.document_type.name(), + ))); + } + } + _ => { + return Err(label(&format!( + "a by-id join needs a source property declaring `refersTo: \ + permanentDocument` (\"{}\" does not): only a permanent-document \ + reference guarantees every derived id resolves", + binding.source_property, + ))); + } + } + } + SubQueryKind::Documents => { + // Must lower to a path query with a representative value. + let shape = self.sub_query_document_query_with_direction( + sub_query, + &[Identifier::default()], + direction, + platform_version, + )?; + shape.construct_path_query(None, platform_version)?; + if sub_query.document_type.index_only() { + // The lookup's own field must be provable positionally: + // the resolved index has to carry it. + let index = shape.index_only_query_index(platform_version)?; + let carried = index.terminal.as_deref() == Some(binding.field.as_str()) + || index + .properties + .iter() + .any(|property| property.name == binding.field); + if !carried { + return Err(label(&format!( + "the indexOnly lookup resolves to index \"{}\", which does not \ + carry the bound field \"{}\"", + index.name, binding.field, + ))); + } + } + // A lookup whose rows are bounded by its values (at most + // one per derived value) carries no limit: the values + // are the bound, and a limit it does not need is exactly + // what would keep it from merging with another lookup on + // the same index. Anything else needs one, to bound the + // walk under each value. + let value_bounded = + self.lookup_is_value_bounded(sub_query, binding, &shape, platform_version)?; + match (value_bounded, sub_query.limit) { + (true, Some(_)) => { + return Err(label( + "a value-bounded lookup (a unique index, or an indexOnly terminal \ + with every prefix fixed, yields at most one row per derived \ + value) takes no limit", + )); + } + (false, Some(0)) => { + return Err(label("a lookup's limit must be at least 1")); + } + (false, None) => { + return Err(label( + "a documents lookup on a non-unique index requires an explicit \ + limit: it bounds the walk under each derived value", + )); + } + (false, Some(limit)) if limit as usize > MAX_BOUND_VALUES => { + return Err(label(&format!( + "limit {} exceeds {}", + limit, MAX_BOUND_VALUES + ))); + } + _ => {} + } + } + SubQueryKind::Count => { + if sub_query.limit.is_some() { + return Err(label("a count sub-query takes no limit")); + } + if !sub_query.order_by.is_empty() { + return Err(label("a count sub-query takes no ordering")); + } + if sub_query.is_by_id_join() { + return Err(label( + "a count sub-query counts by an indexed property, not by `$id`", + )); + } + // Must resolve a countable index with a representative value. + self.sub_query_count_query(sub_query, &[Identifier::default()], platform_version)? + .point_lookup_count_path_query(platform_version)?; + } + } + Ok(()) + } + + /// Whether a bound documents lookup yields at most one row per + /// derived value: on an indexOnly type, when the resolved index's + /// terminal is the bound field and every prefix property is fixed + /// by an equality (entries are unique per full index path); on a + /// stored type, when a `unique` index's properties are exactly the + /// fixed equality fields plus the bound field. + fn lookup_is_value_bounded( + &self, + sub_query: &DriveSubQuery<'a>, + binding: &SubQueryBinding, + shape: &DriveDocumentQuery<'a>, + platform_version: &PlatformVersion, + ) -> Result { + let fixed_equalities: BTreeSet<&str> = sub_query + .where_clauses + .iter() + .filter(|clause| clause.operator == WhereOperator::Equal) + .map(|clause| clause.field.as_str()) + .collect(); + if sub_query.document_type.index_only() { + let index = shape.index_only_query_index(platform_version)?; + let terminal_is_bound = index.terminal.as_deref() == Some(binding.field.as_str()); + let prefix_fixed = index + .properties + .iter() + .all(|property| fixed_equalities.contains(property.name.as_str())); + return Ok(terminal_is_bound && prefix_fixed); + } + let mut wanted: BTreeSet<&str> = fixed_equalities.clone(); + wanted.insert(binding.field.as_str()); + Ok(sub_query.document_type.indexes().values().any(|index| { + index.unique + && index.properties.len() == wanted.len() + && index + .properties + .iter() + .all(|property| wanted.contains(property.name.as_str())) + })) + } + + /// Whether the page is a primary-key fetch (`$id IN` / `$id ==`). + fn page_is_by_ids(&self) -> bool { + self.internal_clauses.primary_key_in_clause.is_some() + || self.internal_clauses.primary_key_equal_clause.is_some() + } + + /// The page's path query as the proof covers it. A by-ids page is + /// built WITHOUT its limit: its ids already bound it, and grovedb + /// cannot lift a limit off a query that lands at the merged root + /// (which a by-ids page shares with a join on the same type). Every + /// other page keeps its limit, lifted into its branch on merge. + pub fn page_path_query(&self, platform_version: &PlatformVersion) -> Result { + if self.page_is_by_ids() { + let mut unlimited = self.clone(); + unlimited.limit = None; + let mut path_query = unlimited.construct_path_query(None, platform_version)?; + // A `$id ==` page lowers with a limit of one whatever the + // query's own limit says; the single key already bounds it, + // so the proof query carries no limit either way. + path_query.query.limit = None; + return Ok(path_query); + } + self.construct_path_query(None, platform_version) + } + + /// The shape rules routing and merging need up front. Document + /// entries are routed back to components by the longest matching + /// base path and then by bound-value membership (counts by their + /// exact terminal positions), so documents components sharing a base + /// path must be tellable apart by their derived values: a sibling, + /// which has none, stays alone, and a page only shares the primary + /// tree with joins when it is itself a by-ids fetch. And no limited + /// component may land at the merged root, where grovedb has no + /// branch to lift its limit into. A bound sub-query that derives + /// nothing contributes no branch, so the merged root is not fixed by + /// the shapes: it is the common prefix of whichever components are + /// present, and a limited component lands on it exactly when every + /// other present component's path extends its own. The page and the + /// siblings are always present and any bound sub-query may be + /// absent, so the rule is checked over that worst case rather than + /// over the full set, and a request that validates never fails the + /// merge for lack of data. + fn validate_component_paths(&self, platform_version: &PlatformVersion) -> Result<(), Error> { + let representative = [Identifier::default()]; + let mut components: Vec<(Vec>, Component, bool)> = Vec::new(); + let page = self.page_path_query(platform_version)?; + let direction = page.query.query.left_to_right; + components.push((page.path, Component::Page, page.query.limit.is_some())); + for (index, sub_query) in self.sub_queries.iter().enumerate() { + let path_query = self.sub_query_proof_path_query( + sub_query, + &representative, + direction, + platform_version, + )?; + components.push(( + path_query.path, + Component::Sub(index), + path_query.query.limit.is_some(), + )); + } + + let is_bound = |component: &Component| matches!(component, Component::Sub(index) if self.sub_queries[*index].binding.is_some()); + for (path, component, limited) in &components { + if !*limited { + continue; + } + let lands_at_root = match component { + // Any bound sub-query below the page puts the page at the + // root once it is the only other component present; so + // do the siblings when every one of them is below it. + Component::Page => { + let (siblings, bound): (Vec<_>, Vec<_>) = components + .iter() + .skip(1) + .partition(|(_, other, _)| !is_bound(other)); + bound.iter().any(|(other, _, _)| other.starts_with(path)) + || (!siblings.is_empty() + && siblings.iter().all(|(other, _, _)| other.starts_with(path))) + } + // The page and every other sibling are always present: + // when all of them are below this component, the bound + // sub-queries deriving nothing leaves it at the root. + Component::Sub(_) => components + .iter() + .filter(|(_, other, _)| other != component && !is_bound(other)) + .all(|(other, _, _)| other.starts_with(path)), + }; + if lands_at_root { + return Err(unsupported(format!( + "{} carries a limit and lands at the merged root of the composite proof \ + (once the bound sub-queries that derive nothing drop out), where grovedb \ + has no branch to lift the limit into; give it a clause that narrows its \ + path, or split it into a separate request", + match component { + Component::Page => "the page".to_string(), + Component::Sub(index) => format!("sub-query {}", index), + } + ))); + } + } + + let mut groups: BTreeMap<&Vec>, Vec<(Component, bool)>> = BTreeMap::new(); + for (path, component, limited) in &components { + groups.entry(path).or_default().push((*component, *limited)); + } + for members in groups.values() { + let documents_members: Vec = members + .iter() + .map(|(component, _)| *component) + .filter(|component| match component { + Component::Page => true, + Component::Sub(index) => { + self.sub_queries[*index].kind == SubQueryKind::Documents + } + }) + .collect(); + let has_count_member = members.iter().any(|(component, _)| { + matches!(component, Component::Sub(index) if self.sub_queries[*index].kind == SubQueryKind::Count) + }); + // A count reads an index's value trees themselves; a documents + // component on the same index descends past them to the rows. + // One tree node cannot serve both selections in one proof, and + // grovedb's merge does not refuse the combination: the descent + // wins and the count silently drops out of the merged query, so + // this guard (and the concrete-value one in + // `proof_path_queries`, for nested bases) is what keeps a count + // from verifying as empty. Shapes sharing a base are refused + // here regardless of data, so acceptance stays predictable. + if has_count_member && !documents_members.is_empty() { + return Err(unsupported( + "a count sub-query shares its index path with a documents component: \ + the count reads the index's value trees themselves while the documents \ + query descends past them, and one proof cannot serve both; count on \ + another index, or split them into separate requests" + .to_string(), + )); + } + if documents_members.len() < 2 { + continue; + } + let has_sibling = documents_members.iter().any(|component| { + matches!(component, Component::Sub(index) if self.sub_queries[*index].binding.is_none()) + }); + let has_page = documents_members.contains(&Component::Page); + let all_subs_are_joins = documents_members.iter().all(|component| match component { + Component::Page => true, + Component::Sub(index) => self.sub_queries[*index].is_by_id_join(), + }); + if has_sibling || (has_page && !(self.page_is_by_ids() && all_subs_are_joins)) { + return Err(unsupported( + "two documents components of the composite query address the same index \ + path and cannot be told apart by their derived values (a sibling, or a \ + page that is not a by-ids fetch, shares a path with another component); \ + split them into separate requests" + .to_string(), + )); + } + // Components sharing a base path merge into one body, and + // budgets never blend: a limited one among them can never be + // merged (value-bounded lookups, which carry none, can). + if members.iter().any(|(_, limited)| *limited) { + return Err(unsupported( + "two documents components of the composite query address the same index \ + path and one of them carries a limit, which cannot be merged with the \ + other's selection; split them into separate requests" + .to_string(), + )); + } + } + Ok(()) + } + + /// Extracts a binding's values from its source documents in their + /// order, deduplicated to first appearance. ONE extraction both the + /// server and the verifier run — the single-builder rule that keeps + /// every derived sub-query identical on both sides. + pub fn derive_values( + &self, + binding: &SubQueryBinding, + source_documents: &[Document], + ) -> Result { + let mut seen: BTreeSet = BTreeSet::new(); + let mut values = Vec::new(); + for document in source_documents { + if let Some(value) = document_bound_value(document, &binding.source_property)? { + if seen.insert(value) { + values.push(value); + } + } + } + if values.len() > MAX_BOUND_VALUES { + // The page limit, every sub-query limit and every value-bounded + // lookup cap a source at MAX_BOUND_VALUES documents, so this is + // an invariant on both sides, not a shape or proof condition. + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a composite binding source yielded more documents than the shapes allow", + ))); + } + Ok(values) + } + + /// The concrete documents query of a sub-query for `values`: the + /// fixed clauses plus the derived `IN`, or a pure by-ids fetch for + /// a join. A sibling ignores `values`. + pub fn sub_query_document_query( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + platform_version: &PlatformVersion, + ) -> Result, Error> { + let direction = self.page_direction(platform_version)?; + self.sub_query_document_query_with_direction(sub_query, values, direction, platform_version) + } + + /// [`Self::sub_query_document_query`] with the page's direction + /// already in hand: what every internal caller uses, so the page path + /// query is lowered once per request rather than once per sub-query. + pub(crate) fn sub_query_document_query_with_direction( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + direction: bool, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let ids = sorted_values(values); + let in_value = || { + Value::Array( + ids.iter() + .map(|id| Value::Identifier(id.to_buffer())) + .collect(), + ) + }; + + if sub_query.is_by_id_join() { + if !sub_query.where_clauses.is_empty() { + return Err(unsupported( + "a by-id join takes no fixed clauses: every derived id must resolve" + .to_string(), + )); + } + return Ok(DriveDocumentQuery { + contract: sub_query.contract, + document_type: sub_query.document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: Some(WhereClause { + field: dpp::document::property_names::ID.to_string(), + operator: WhereOperator::In, + value: in_value(), + }), + primary_key_equal_clause: None, + in_clauses: Vec::new(), + range_clause: None, + equal_clauses: Default::default(), + }, + offset: None, + limit: None, + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: Vec::new(), + sub_queries: Vec::new(), + }); + } + + let mut clauses = sub_query.where_clauses.clone(); + let mut order_by: indexmap::IndexMap = sub_query + .order_by + .iter() + .map(|clause| (clause.field.clone(), clause.clone())) + .collect(); + if let Some(binding) = &sub_query.binding { + clauses.push(WhereClause { + field: binding.field.clone(), + operator: WhereOperator::In, + value: in_value(), + }); + // An `IN` on a secondary index orders by the bound field; + // supply the ordering when the caller did not, so the + // request stays minimal and both sides build the same query. + // It inherits the page's direction: the merged proof walks + // every component the page's way, and a documents sub-query + // may not be turned around behind the caller's back (see + // `sub_query_proof_path_query`), so this default is what + // keeps an unordered lookup mergeable under a descending page. + if !order_by.contains_key(&binding.field) { + order_by.insert( + binding.field.clone(), + OrderClause { + field: binding.field.clone(), + ascending: direction, + }, + ); + } + } + Ok(DriveDocumentQuery { + contract: sub_query.contract, + document_type: sub_query.document_type, + internal_clauses: InternalClauses::extract_from_clauses(clauses, platform_version)?, + offset: None, + limit: sub_query.limit, + order_by, + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: Vec::new(), + sub_queries: Vec::new(), + }) + } + + /// The concrete count query of a bound count sub-query for `values`. + /// Borrows the covering index through `sub_query`, so the count query + /// lives as long as that reference. + pub fn sub_query_count_query<'b>( + &'b self, + sub_query: &'b DriveSubQuery<'a>, + values: &[Identifier], + _platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(binding) = &sub_query.binding else { + return Err(unsupported("a count sub-query must be bound".to_string())); + }; + let mut where_clauses = sub_query.where_clauses.clone(); + where_clauses.push(WhereClause { + field: binding.field.clone(), + operator: WhereOperator::In, + value: Value::Array( + sorted_values(values) + .into_iter() + .map(|id| Value::Identifier(id.to_buffer())) + .collect(), + ), + }); + let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( + sub_query.document_type.indexes(), + &where_clauses, + &[], + ) + .ok_or_else(|| { + unsupported(format!( + "count sub-query on \"{}\" needs a `countable: true` index covering its fixed \ + clauses and the bound field \"{}\"", + sub_query.document_type.name(), + binding.field, + )) + })?; + Ok(DriveDocumentCountQuery { + document_type: sub_query.document_type, + contract_id: sub_query.contract.id().to_buffer(), + document_type_name: sub_query.document_type.name().to_string(), + index, + where_clauses, + }) + } + + /// The path query of one sub-query for `values`. + pub fn sub_query_path_query( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + platform_version: &PlatformVersion, + ) -> Result { + let direction = self.page_direction(platform_version)?; + self.sub_query_path_query_with_direction(sub_query, values, direction, platform_version) + } + + fn sub_query_path_query_with_direction( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + direction: bool, + platform_version: &PlatformVersion, + ) -> Result { + match sub_query.kind { + SubQueryKind::Documents => self + .sub_query_document_query_with_direction( + sub_query, + values, + direction, + platform_version, + )? + .construct_path_query(None, platform_version), + SubQueryKind::Count => self + .sub_query_count_query(sub_query, values, platform_version)? + .point_lookup_count_path_query(platform_version), + } + } + + /// The page's walk direction: what every component of the merged + /// proof walks in, and what an unordered documents sub-query inherits. + fn page_direction(&self, platform_version: &PlatformVersion) -> Result { + Ok(self + .page_path_query(platform_version)? + .query + .query + .left_to_right) + } + + /// Aligns set-based components for merging without changing a + /// documents query's ordering or the rows selected by its limit. + /// Validation, proof generation and bootstrap use the same check, + /// including when a binding will derive no values at execution time. + pub(crate) fn sub_query_proof_path_query( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + direction: bool, + platform_version: &PlatformVersion, + ) -> Result { + let mut path_query = self.sub_query_path_query_with_direction( + sub_query, + values, + direction, + platform_version, + )?; + if sub_query.kind == SubQueryKind::Documents + && !sub_query.is_by_id_join() + && path_query.query.query.left_to_right != direction + { + return Err(unsupported(if sub_query.binding.is_none() { + "a sibling sub-query's ordering must match the page's direction; order it \ + explicitly by its index property, in the page's direction" + .to_string() + } else { + "a documents sub-query's outer ordering must match the page's direction; \ + changing it for the merged proof would change its result" + .to_string() + })); + } + // Joins restore first-appearance order after decoding. Counts + // restore key order. Their selected sets do not depend on direction. + path_query.query.query.left_to_right = direction; + Ok(path_query) + } + + /// The component path queries the merged proof covers, in component + /// order: the page, then one entry per sub-query — `None` for a + /// bound sub-query whose binding derived nothing (it has no branch). + /// Every sub-query walks in the page's direction: documents must + /// already agree, while counts and by-id joins may be aligned without + /// changing their selected sets. ONE builder both the prover + /// (`prove_query_many`) and the verifier (`PathQuery::merge`) call, + /// so the merged query is byte-identical on both sides. + pub fn proof_path_queries( + &self, + derived: &[DerivedValues], + platform_version: &PlatformVersion, + ) -> Result<(PathQuery, Vec>), Error> { + if derived.len() != self.sub_queries.len() { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "one derived value list per sub-query", + ))); + } + let page = self.page_path_query(platform_version)?; + let direction = page.query.query.left_to_right; + let mut sub_path_queries = Vec::with_capacity(self.sub_queries.len()); + for (sub_query, values) in self.sub_queries.iter().zip(derived) { + if sub_query.binding.is_some() && values.is_empty() { + sub_path_queries.push(None); + continue; + } + let path_query = + self.sub_query_proof_path_query(sub_query, values, direction, platform_version)?; + sub_path_queries.push(Some(path_query)); + } + // GroveDB cannot return a count tree and descend through that + // same tree for another component in one merged selection. Check + // concrete values so disjoint selections on the same index remain + // usable, including documents whose base path is below the count's. + let mut count_terminal_paths = BTreeSet::new(); + for (sub_query, path_query) in self.sub_queries.iter().zip(&sub_path_queries) { + if sub_query.kind != SubQueryKind::Count { + continue; + } + if let Some(path_query) = path_query { + for (mut path, key) in path_query + .terminal_keys(MAX_BOUND_VALUES, &platform_version.drive.grove_version)? + { + path.push(key); + count_terminal_paths.insert(path); + } + } + } + for terminal_path in count_terminal_paths { + for component in std::iter::once(&page).chain(sub_path_queries.iter().flatten()) { + // A walk never leaves its own base path, so a component + // reaches the terminal only when one path prefixes the + // other (a base below the terminal passes through it). + if !terminal_path.starts_with(&component.path) + && !component.path.starts_with(&terminal_path) + { + continue; + } + if Self::path_query_descends_through(component, &terminal_path, platform_version)? { + return Err(unsupported( + "a count sub-query selects a tree another component descends through; \ + split them into separate requests" + .to_string(), + )); + } + } + } + // Document entries are routed to the component with the longest + // base path that prefixes them, which is only right when no + // documents component walks through another's base path to + // deeper rows (those rows would be routed to the deeper one). + // Exact base-path sharing is refused by the shapes; nesting + // depends on the concrete values, so it is checked here. + let documents: Vec<&PathQuery> = std::iter::once(&page) + .chain( + sub_path_queries + .iter() + .zip(&self.sub_queries) + .filter(|(_, sub_query)| sub_query.kind == SubQueryKind::Documents) + .filter_map(|(path_query, _)| path_query.as_ref()), + ) + .collect(); + for deeper in &documents { + for shallower in &documents { + if deeper.path.len() <= shallower.path.len() + || !deeper.path.starts_with(&shallower.path) + { + continue; + } + if Self::path_query_descends_through(shallower, &deeper.path, platform_version)? { + return Err(unsupported( + "a documents sub-query walks through another documents component's \ + subtree, so their rows could not be told apart; split them into \ + separate requests" + .to_string(), + )); + } + } + } + Ok((page, sub_path_queries)) + } + + /// Whether a component walks through this count terminal to a deeper + /// result. Check membership at every level: a default subquery alone + /// does not mean its parent key was selected by this component. + fn path_query_descends_through( + query: &PathQuery, + terminal_path: &[Vec], + platform_version: &PlatformVersion, + ) -> Result { + let mut prefix = Vec::with_capacity(terminal_path.len()); + for key in terminal_path { + let Some(selection) = + query.query_items_at_path(&prefix, &platform_version.drive.grove_version)? + else { + return Ok(false); + }; + if !selection.items.iter().any(|item| item.contains(key)) + || !selection.has_subquery_or_matching_in_path_on_key(key) + { + return Ok(false); + } + prefix.push(key.as_slice()); + } + Ok(true) + } + + /// Merges the component path queries into the one query the proof + /// covers. + pub fn merged_path_query( + page: &PathQuery, + sub_path_queries: &[Option], + platform_version: &PlatformVersion, + ) -> Result { + let mut components: Vec<&PathQuery> = vec![page]; + components.extend(sub_path_queries.iter().flatten()); + if components.len() == 1 { + return Ok(page.clone()); + } + PathQuery::merge(components, &platform_version.drive.grove_version) + .map_err(merge_error_to_shape_error) + } + + /// Decodes the proved entries of a documents component: stored + /// documents from item elements, indexOnly projections synthesized + /// from their proved positions. + pub(crate) fn decode_document_trios( + query: &DriveDocumentQuery<'a>, + trios: Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if query.document_type.index_only() { + let index = query.index_only_query_index(platform_version)?; + return trios + .into_iter() + .map(|(path, key, _)| { + synthesize_index_only_document( + query.contract.id(), + query.document_type, + index, + &path, + &key, + ) + }) + .collect(); + } + trios + .into_iter() + .map(|(_, _, element)| { + let serialized = element.into_item_bytes().map_err(Error::from)?; + Document::from_bytes(serialized.as_slice(), query.document_type, platform_version) + .map_err(|e| Error::Protocol(Box::new(e))) + }) + .collect() + } + + /// Decodes a documents sub-query and applies the same result assembly + /// as execution, particularly a join's first-appearance ordering, + /// before its documents can supply values to a later binding. + pub(crate) fn decode_sub_query_document_trios( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + direction: bool, + trios: Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let query = self.sub_query_document_query_with_direction( + sub_query, + values, + direction, + platform_version, + )?; + let documents = Self::decode_document_trios(&query, trios, platform_version)?; + self.assemble_documents(sub_query, values, &documents) + } + + /// Decodes the proved entries of a count component: one entry per + /// count tree, keyed by the `IN` value — which sits one segment + /// past the base path when the walk descended through trailing + /// equalities, and IS the key otherwise (the same layout + /// `verify_point_lookup_count_proof` reads). + fn decode_count_trios(base_path_len: usize, trios: Vec) -> Vec { + // A composite count is always bound, so it always carries an `IN`. + let mut entries = point_lookup_count_entries( + base_path_len, + true, + trios + .into_iter() + .map(|(path, key, element)| (path, key, Some(element))), + ); + // Proof merging may align the count walk with a descending page; + // count results retain the ordinary point-lookup's key order. + entries.sort_by(|a, b| a.key.cmp(&b.key)); + entries + } + + /// Assembles one documents sub-query's result from its decoded + /// documents, keeping only the ones its derived values admit and, for + /// a by-id join, enforcing exact set equality in first-appearance + /// order. Shared by the server (where a violation is corrupted state) + /// and the verifier (where it is an invalid proof). + fn assemble_documents( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + documents: &[Document], + ) -> Result, Error> { + let Some(binding) = &sub_query.binding else { + return Ok(documents.to_vec()); + }; + let admitted: BTreeSet = values.iter().copied().collect(); + if sub_query.is_by_id_join() { + let mut by_id: BTreeMap = BTreeMap::new(); + for document in documents { + let id = document.id(); + if !admitted.contains(&id) { + // Another join on the same type owns it. + continue; + } + if by_id.insert(id, document).is_some() { + return Err(corrupted_proof(format!( + "composite join results carry document {} twice", + id + ))); + } + } + let mut ordered = Vec::with_capacity(values.len()); + for value in values { + let document = by_id.remove(value).ok_or_else(|| { + corrupted_proof(format!( + "composite join results are missing referenced document {}: a \ + permanentDocument reference cannot dangle, so the proof does not \ + cover the derived query", + value + )) + })?; + ordered.push(document.clone()); + } + return Ok(ordered); + } + let mut mine = Vec::new(); + for document in documents { + match document_bound_value(document, &binding.field)? { + Some(value) if admitted.contains(&value) => mine.push(document.clone()), + _ => {} + } + } + Ok(mine) + } + + /// Assembles one count sub-query's result: the entries its derived + /// values admit. + fn assemble_counts( + values: &[Identifier], + entries: Vec, + ) -> Result, Error> { + let admitted: BTreeSet = values.iter().copied().collect(); + let mut mine = Vec::with_capacity(entries.len()); + for entry in entries { + let Ok(value) = Identifier::from_bytes(&entry.key) else { + return Err(corrupted_proof( + "a composite count entry is keyed by something other than an identifier" + .to_string(), + )); + }; + if admitted.contains(&value) { + mine.push(entry); + } + } + Ok(mine) + } + + /// Routes the proved trios of the merged query back to the page and + /// the sub-queries, decodes each group, and assembles every + /// component's result. Every trio must land in a component, and + /// every decoded item must be claimed by one — an entry the + /// derivation never asked for means the responding node steered the + /// composition. + pub(crate) fn assemble_from_trios( + &self, + derived: &[DerivedValues], + page_path_query: &PathQuery, + sub_path_queries: &[Option], + trios: Vec, + platform_version: &PlatformVersion, + ) -> Result { + // Group documents by base path. Counts instead route by their + // complete terminal positions: a shared base and bound value can + // still select different trailing equality values. A terminal may + // belong to several counts, including counts with nested base paths. + let direction = page_path_query.query.query.left_to_right; + let mut groups: Vec<(Vec>, Vec)> = Vec::new(); + let mut count_members_by_position: BTreeMap<_, Vec> = BTreeMap::new(); + let mut register = |path: &Vec>, component: Component| { + if let Some((_, members)) = groups.iter_mut().find(|(p, _)| p == path) { + members.push(component); + } else { + groups.push((path.clone(), vec![component])); + } + }; + register(&page_path_query.path, Component::Page); + for (index, path_query) in sub_path_queries.iter().enumerate() { + if let Some(path_query) = path_query { + if self.sub_queries[index].kind == SubQueryKind::Count { + for position in path_query + .terminal_keys(MAX_BOUND_VALUES, &platform_version.drive.grove_version)? + { + count_members_by_position + .entry(position) + .or_default() + .push(index); + } + } else { + register(&path_query.path, Component::Sub(index)); + } + } + } + + // Distribute counts before their positional information is lost + // during decoding, and documents by the longest matching base path. + let mut trios_by_group: Vec> = vec![Vec::new(); groups.len()]; + let mut count_trios_by_sub: Vec> = + vec![Vec::new(); self.sub_queries.len()]; + for (path, key, element) in trios { + let Some(element) = element else { + continue; + }; + if !matches!(element, Element::Item(..)) { + let position = (path, key); + let members = count_members_by_position.get(&position).ok_or_else(|| { + corrupted_proof( + "the composite proof carries a count at a position no component \ + selected" + .to_string(), + ) + })?; + // Every member takes a copy; the last takes the original. + let (last, others) = members.split_last().ok_or_else(|| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a registered count position has at least one member", + )) + })?; + for index in others { + count_trios_by_sub[*index].push(( + position.0.clone(), + position.1.clone(), + element.clone(), + )); + } + count_trios_by_sub[*last].push((position.0, position.1, element)); + continue; + } + let best = groups + .iter() + .enumerate() + .filter(|(_, (base, _))| path.starts_with(base)) + .max_by_key(|(_, (base, _))| base.len()) + .map(|(index, _)| index) + .ok_or_else(|| { + corrupted_proof( + "the composite proof proved an entry outside every component's \ + subtree" + .to_string(), + ) + })?; + trios_by_group[best].push((path, key, element)); + } + + // Decode each documents group once, then let every member claim + // its share. + let mut page_documents: Option> = None; + let mut sub_results: Vec> = vec![None; self.sub_queries.len()]; + for ((_, documents_members), document_trios) in groups.iter().zip(trios_by_group) { + // Every documents member of a group addresses the same + // type, so any member's query decodes the group. + let documents = match documents_members[0] { + Component::Page => { + Self::decode_document_trios(self, document_trios, platform_version)? + } + Component::Sub(index) => { + let query = self.sub_query_document_query_with_direction( + &self.sub_queries[index], + &derived[index], + direction, + platform_version, + )?; + Self::decode_document_trios(&query, document_trios, platform_version)? + } + }; + let mut claimed: BTreeSet = BTreeSet::new(); + for member in documents_members { + match member { + Component::Page => { + let page_ids: Option> = if documents_members.len() > 1 + { + Some(self.page_ids()?) + } else { + None + }; + let mut mine = Vec::new(); + for (position, document) in documents.iter().enumerate() { + let is_mine = page_ids + .as_ref() + .is_none_or(|ids| ids.contains(&document.id())); + if is_mine { + claimed.insert(position); + mine.push(document.clone()); + } + } + page_documents = Some(mine); + } + Component::Sub(index) => { + let sub_query = &self.sub_queries[*index]; + let mine = + self.assemble_documents(sub_query, &derived[*index], &documents)?; + let mine_ids: BTreeSet = + mine.iter().map(|document| document.id()).collect(); + for (position, document) in documents.iter().enumerate() { + if mine_ids.contains(&document.id()) { + claimed.insert(position); + } + } + sub_results[*index] = Some(SubQueryResult::Documents(mine)); + } + } + } + if claimed.len() != documents.len() { + return Err(corrupted_proof( + "the composite proof carries a document that no component's \ + derivation asked for" + .to_string(), + )); + } + } + + for (index, count_trios) in count_trios_by_sub.into_iter().enumerate() { + if self.sub_queries[index].kind != SubQueryKind::Count { + continue; + } + let Some(path_query) = &sub_path_queries[index] else { + continue; + }; + let entries = Self::decode_count_trios(path_query.path.len(), count_trios); + sub_results[index] = Some(SubQueryResult::Counts(Self::assemble_counts( + &derived[index], + entries, + )?)); + } + + Ok(CompositeDocumentsResult { + page_documents: page_documents.unwrap_or_default(), + sub_results: sub_results + .into_iter() + .zip(&self.sub_queries) + .map(|(result, sub_query)| { + result.unwrap_or_else(|| match sub_query.kind { + SubQueryKind::Documents => SubQueryResult::Documents(Vec::new()), + SubQueryKind::Count => SubQueryResult::Counts(Vec::new()), + }) + }) + .collect(), + }) + } + + /// The ids a by-ids page addresses (its `$id IN` / `$id ==` clause), + /// used to tell the page's documents from a join's when they share + /// the primary tree. + fn page_ids(&self) -> Result, Error> { + let mut ids = BTreeSet::new(); + if let Some(clause) = &self.internal_clauses.primary_key_equal_clause { + ids.insert(clause.value.to_identifier().map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a primary-key equality clause holds an identifier", + )) + })?); + } + if let Some(clause) = &self.internal_clauses.primary_key_in_clause { + for value in clause + .in_values() + .into_data() + .map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a primary-key in clause holds an array", + )) + })? + .iter() + { + ids.insert(value.to_identifier().map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a primary-key in clause holds identifiers", + )) + })?); + } + } + Ok(ids) + } + + /// Derives one sub-query's values from the (materialized or proven) + /// page and earlier sub-query documents: `sub_documents(i)` is the + /// documents of sub-query `i`, which every source has by the time a + /// later sub-query binds it (validation orders bindings; the + /// executors and the verifier's bootstrap materialize sources + /// first). ONE derivation every path runs — the no-proof executor, + /// the prover, the verifier's bootstrap and its authoritative + /// re-check — which is what keeps them identical. + pub(crate) fn derive_for<'d>( + &self, + sub_query: &DriveSubQuery<'a>, + page_documents: &[Document], + sub_documents: impl Fn(usize) -> Option<&'d [Document]>, + ) -> Result { + let Some(binding) = &sub_query.binding else { + return Ok(Vec::new()); + }; + match binding.source { + BindingSource::Page => self.derive_values(binding, page_documents), + BindingSource::SubQuery(source_index) => { + let documents = sub_documents(source_index).ok_or_else(|| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a binding's source sub-query was not materialized before it", + )) + })?; + self.derive_values(binding, documents) + } + } + } + + /// Derives every sub-query's values, in request order — see + /// [`Self::derive_for`]. + pub fn derive_all<'d>( + &self, + page_documents: &[Document], + sub_documents: impl Fn(usize) -> Option<&'d [Document]>, + ) -> Result, Error> { + self.sub_queries + .iter() + .map(|sub_query| self.derive_for(sub_query, page_documents, &sub_documents)) + .collect() + } + + /// Whether a sub-query's documents feed a later binding. + pub(crate) fn is_binding_source(&self, index: usize) -> bool { + self.sub_queries.iter().any(|sub_query| { + matches!( + sub_query.binding, + Some(SubQueryBinding { + source: BindingSource::SubQuery(source), + .. + }) if source == index + ) + }) + } +} + +#[cfg(feature = "server")] +impl<'a> DriveDocumentQuery<'a> { + /// Materializes a documents query without a proof: indexOnly + /// projections are synthesized, stored documents deserialized. + fn materialize_documents( + query: &DriveDocumentQuery<'a>, + drive: &crate::drive::Drive, + transaction: grovedb::TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if query.document_type.index_only() { + let (documents, _skipped) = query.execute_index_only_documents_no_proof_internal( + drive, + transaction, + drive_operations, + platform_version, + )?; + return Ok(documents); + } + let (serialized, _skipped) = query.execute_raw_results_no_proof_internal( + drive, + transaction, + drive_operations, + platform_version, + )?; + serialized + .into_iter() + .map(|bytes| { + Document::from_bytes(bytes.as_slice(), query.document_type, platform_version) + .map_err(|e| Error::Protocol(Box::new(e))) + }) + .collect() + } + + /// Materializes one sub-query's result without a proof. + // The drive handle, transaction and operation sink travel together + // through every materializer here; bundling them buys nothing. + #[allow(clippy::too_many_arguments)] + fn materialize_sub_result( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + direction: bool, + drive: &crate::drive::Drive, + transaction: grovedb::TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + use grovedb::query_result_type::{QueryResultElement, QueryResultType}; + + if sub_query.binding.is_some() && values.is_empty() { + return Ok(match sub_query.kind { + SubQueryKind::Documents => SubQueryResult::Documents(Vec::new()), + SubQueryKind::Count => SubQueryResult::Counts(Vec::new()), + }); + } + match sub_query.kind { + SubQueryKind::Documents => { + let query = self.sub_query_document_query_with_direction( + sub_query, + values, + direction, + platform_version, + )?; + let documents = Self::materialize_documents( + &query, + drive, + transaction, + drive_operations, + platform_version, + )?; + Ok(SubQueryResult::Documents( + self.assemble_documents(sub_query, values, &documents)?, + )) + } + SubQueryKind::Count => { + let path_query = self + .sub_query_count_query(sub_query, values, platform_version)? + .point_lookup_count_path_query(platform_version)?; + let base_path_len = path_query.path.len(); + let (results, _skipped) = match drive.grove_get_path_query( + &path_query, + transaction, + QueryResultType::QueryPathKeyElementTrioResultType, + drive_operations, + &platform_version.drive, + ) { + // No count tree yet under this index: every count is zero. + Err(Error::GroveDB(e)) + if matches!( + e.as_ref(), + grovedb::Error::PathKeyNotFound(_) + | grovedb::Error::PathNotFound(_) + | grovedb::Error::PathParentLayerNotFound(_) + ) => + { + return Ok(SubQueryResult::Counts(Vec::new())); + } + other => other?, + }; + let trios = results + .elements + .into_iter() + .filter_map(|element| match element { + QueryResultElement::PathKeyElementTrioResultItem(trio) => Some(trio), + _ => None, + }) + .collect(); + let entries = Self::decode_count_trios(base_path_len, trios); + Ok(SubQueryResult::Counts(Self::assemble_counts( + values, entries, + )?)) + } + } + } + + /// Executes the composite query without proofs. + pub(crate) fn execute_composite_no_proof_internal( + &self, + drive: &crate::drive::Drive, + transaction: grovedb::TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + self.validate_composite(platform_version)?; + + let direction = self.page_direction(platform_version)?; + let page_documents = Self::materialize_documents( + self, + drive, + transaction, + drive_operations, + platform_version, + )?; + let mut sub_results: Vec = Vec::with_capacity(self.sub_queries.len()); + let mut derived = Vec::with_capacity(self.sub_queries.len()); + for sub_query in &self.sub_queries { + let values = self.derive_for(sub_query, &page_documents, |source| { + sub_results.get(source).map(|result| result.documents()) + })?; + sub_results.push(self.materialize_sub_result( + sub_query, + &values, + direction, + drive, + transaction, + drive_operations, + platform_version, + )?); + derived.push(values); + } + // Count-tree conflicts depend on the actual derived values, not + // just the representative shapes checked by validate(). Reject + // them on the materialized entry point as on the proof entry point. + self.proof_path_queries(&derived, platform_version)?; + Ok(CompositeDocumentsResult { + page_documents, + sub_results, + }) + } + + /// Executes the composite query AND generates its single merged + /// proof. + /// + /// The page (and every sub-query that feeds a later binding) is + /// materialized so the sub-queries can be derived; then + /// [`Self::proof_path_queries`] builds the component path queries + /// and `prove_query_many` merges them — one proof, one root by + /// construction. Grovedb proves committed state only, so the + /// materialize/prove sequence is bracketed by root-hash reads and + /// retried if a block commit interleaved (otherwise the proof's page + /// branch could disagree with the sub-queries derived from a stale + /// materialization and every verifier would reject it). + /// + /// Returns the proof and the materialized page (the caller's + /// pagination cursor derives from it); the sub-query results are + /// covered by the proof and not materialized twice. + pub(crate) fn execute_composite_with_proof_internal( + &self, + drive: &crate::drive::Drive, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(Vec, Vec), Error> { + self.validate_composite(platform_version)?; + let direction = self.page_direction(platform_version)?; + + // Block commits are seconds apart while an attempt is + // milliseconds, so a bracket collision is rare and two in a row + // vanishingly so; three attempts is generosity, not need. + const MAX_ATTEMPTS: usize = 3; + for _ in 0..MAX_ATTEMPTS { + // An attempt that loses the race is discarded whole, its + // operations included: the caller is billed for one run. + let operations_before = drive_operations.len(); + let root_before = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap()?; + + let page_documents = + Self::materialize_documents(self, drive, None, drive_operations, platform_version)?; + // Sub-queries that feed later bindings are materialized in + // order; everything else is only derived. + let mut derived: Vec = Vec::with_capacity(self.sub_queries.len()); + let mut materialized: Vec>> = vec![None; self.sub_queries.len()]; + for (index, sub_query) in self.sub_queries.iter().enumerate() { + let values = self.derive_for(sub_query, &page_documents, |source| { + materialized + .get(source) + .and_then(|documents| documents.as_deref()) + })?; + if self.is_binding_source(index) { + let result = self.materialize_sub_result( + sub_query, + &values, + direction, + drive, + None, + drive_operations, + platform_version, + )?; + materialized[index] = Some(result.documents().to_vec()); + } + derived.push(values); + } + + let (page_path_query, sub_path_queries) = + self.proof_path_queries(&derived, platform_version)?; + let mut components: Vec<&PathQuery> = vec![&page_path_query]; + components.extend(sub_path_queries.iter().flatten()); + let proof = drive + .grove + .prove_query_many(components, None, &platform_version.drive.grove_version) + .unwrap() + .map_err(merge_error_to_shape_error)?; + + let root_after = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap()?; + if root_before != root_after { + drive_operations.truncate(operations_before); + continue; + } + return Ok((proof, page_documents)); + } + Err(Error::Drive(DriveError::NotSupported( + "composite proof generation raced a block commit on every attempt; transient — \ + retry the request", + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use grovedb::{Query, SizedQuery, SubqueryBranch}; + + /// The nested-documents guard asks whether one component's walk + /// passes through another's base path to deeper rows: it must follow + /// the query's own base path, its selected keys and its subqueries, + /// and stop at a key the query does not select. + #[test] + fn should_follow_a_walk_through_selected_keys_and_subqueries_only() { + let pv = PlatformVersion::latest(); + let key = |name: &str| name.as_bytes().to_vec(); + let mut body = Query::new(); + body.insert_key(key("x")); + body.default_subquery_branch = SubqueryBranch { + subquery_path: Some(vec![key("c")]), + subquery: Some(Box::new(Query::new_range_full())), + }; + let shallower = PathQuery::new(vec![key("a"), key("b")], SizedQuery::new(body, None, None)); + let descends = |path: &[&str]| { + DriveDocumentQuery::path_query_descends_through( + &shallower, + &path.iter().map(|segment| key(segment)).collect::>(), + pv, + ) + .expect("the walk resolves") + }; + assert!( + descends(&["a", "b", "x", "c"]), + "selected key, then its subquery path" + ); + assert!(!descends(&["a", "b", "x", "d"]), "not the subquery path"); + assert!(!descends(&["a", "b", "y", "c"]), "an unselected key"); + assert!(!descends(&["a", "z"]), "off the base path"); + assert!( + !descends(&["a", "b", "x", "c", "k"]), + "past the walk's leaves" + ); + } +} diff --git a/packages/rs-drive/src/query/drive_document_count_query/mod.rs b/packages/rs-drive/src/query/drive_document_count_query/mod.rs index 8ad2a644327..cb91feecf69 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/mod.rs @@ -148,6 +148,43 @@ pub struct DriveDocumentCountQuery<'a> { pub where_clauses: Vec, } +/// Turns the `(path, key, element)` triples a point-lookup count path +/// query yields (see `point_lookup_count_path_query`) into one entry per +/// count tree. For compound (`In`) shapes the `In` value sits at +/// `path[base_path_len]` when the walk descended past the base path (the +/// `In` + trailing `Equal`s shape) and IS the key otherwise (the +/// `In`-on-terminator shape); `Equal`-only shapes have no per-key +/// dimension. The element's own count is the per-branch document count +/// (every countable terminator value tree is a CountTree); an absent +/// element becomes `count: None`. ONE decoder for every reader of that +/// layout — the proof verifier, the no-proof executor and composite +/// queries — so the layout has one owner. +pub fn point_lookup_count_entries( + base_path_len: usize, + has_in_clause: bool, + elements: impl IntoIterator>, Vec, Option)>, +) -> Vec { + elements + .into_iter() + .map(|(path, grove_key, element)| { + let key = if has_in_clause { + if path.len() > base_path_len { + path[base_path_len].clone() + } else { + grove_key + } + } else { + Vec::new() + }; + SplitCountEntry { + in_key: None, + key, + count: element.map(|element| element.count_value_or_default()), + } + }) + .collect() +} + /// An entry in a split count result, containing the serialized /// key(s) and the count of documents matching them. /// diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index f154d2fd128..27c23346f06 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -3,6 +3,17 @@ use std::sync::Arc; #[cfg(any(feature = "server", feature = "verify"))] pub use { + // Chained-query building blocks: the result shape and the join-value + // cap. The join itself is a by-id join sub-query in + // [`DriveDocumentQuery::sub_queries`]. + chained_document_query::{ChainedDocumentsResult, MAX_CHAINED_JOIN_VALUES}, + // Composite-query building blocks: the sub-query shapes carried by + // [`DriveDocumentQuery::sub_queries`] and the assembled result. The + // verifier needs them all to rebuild and route the merged proof. + composite_document_query::{ + BindingSource, CompositeDocumentsResult, DriveSubQuery, SubQueryBinding, SubQueryKind, + SubQueryResult, MAX_BOUND_VALUES, MAX_SUB_QUERIES, + }, conditions::{ValueClause, WhereClause, WhereOperator}, // Average-query verifier-shareable types — same split as sum: // `AverageEntry` is the per-key `(count, sum)` pair the verifier @@ -288,10 +299,18 @@ pub mod drive_document_ranked_query; pub(crate) mod index_only_synthesis; /// Chained document queries — a provable semi-join: an inner indexOnly -/// query whose proven `refersTo` values become the outer query's -/// primary keys, proven against one state root. See the module docs. +/// [`DriveDocumentQuery`] whose proven `refersTo` values become the outer +/// query's primary keys (carried as a single by-id join in +/// [`DriveDocumentQuery::sub_queries`]), proven against one state root. +/// See the module docs. +#[cfg(any(feature = "server", feature = "verify"))] +pub mod chained_document_query; + +/// Composite document queries — a [`DriveDocumentQuery`] page plus +/// sub-queries derived from its proven results (joins, lookups, counts), +/// proven as one merged proof against one state root. See the module docs. #[cfg(any(feature = "server", feature = "verify"))] -pub mod drive_chained_document_query; +pub mod composite_document_query; /// Joint count-and-sum no-prove executor surface — backs the AVG /// no-prove path's unified single-walk dispatch. See its module @@ -1089,6 +1108,25 @@ pub struct DriveDocumentQuery<'a> { /// /// Empty for every raw query. pub resolved_time_ranges: Vec, + /// The composite sub-queries: queries whose `IN` clauses are derived + /// from this query's proven results (by-id joins, indexed lookups, + /// counts — see the [`composite_document_query`] module docs), listed + /// in binding order (a sub-query may only bind an earlier one) and + /// answered together with this query as ONE merged grovedb proof. + /// + /// Empty for an ordinary documents query, which is what every plain + /// entry point requires: a query carrying sub-queries is served by + /// `Drive::query_composite_documents` / + /// `query_composite_documents_with_proof` and verified by + /// `verify_composite_documents_proof`, and the plain + /// query/proof/verify surfaces refuse it rather than silently prove + /// the page alone. + /// + /// Never parsed from the wire: every `from_cbor` / `from_value` / + /// `from_typed_clauses` entry point leaves this empty; composite + /// requests are built programmatically (see + /// [`Self::with_sub_queries`]). + pub sub_queries: Vec>, } impl<'a> DriveDocumentQuery<'a> { @@ -1120,6 +1158,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } @@ -1137,6 +1176,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included: true, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } @@ -1158,7 +1198,71 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included: true, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], + } + } + + #[cfg(any(feature = "server", feature = "verify"))] + /// Extends this query into a composite one: `self` becomes the page + /// and `sub_queries` are derived from its proven results — see + /// [`Self::sub_queries`] and the [`composite_document_query`] module + /// docs. + pub fn with_sub_queries(mut self, sub_queries: Vec>) -> Self { + self.sub_queries = sub_queries; + self + } + + #[cfg(any(feature = "server", feature = "verify"))] + /// Appends a by-id join sub-query: `source_property`'s values, read + /// off this query's proven documents, become the `$id`s of + /// `document_type` documents fetched from the same contract. The + /// property must carry a `refersTo: permanentDocument` declaration + /// targeting `document_type`, so every derived id resolves. + /// + /// This is the one shape the chained surface + /// (`Drive::query_chained_documents`, + /// `verify_chained_documents_proof`) requires exactly one of, and one + /// of the composite sub-query shapes. A cross-contract by-id join + /// (composite only) is built by pushing a [`DriveSubQuery`] with the + /// target contract instead. + pub fn with_by_id_join( + mut self, + source_property: impl Into, + document_type: DocumentTypeRef<'a>, + ) -> Self { + self.sub_queries.push(DriveSubQuery { + contract: self.contract, + document_type, + kind: SubQueryKind::Documents, + where_clauses: vec![], + order_by: vec![], + limit: None, + binding: Some(SubQueryBinding { + source: BindingSource::Page, + source_property: source_property.into(), + field: document::property_names::ID.to_string(), + }), + }); + self + } + + #[cfg(any(feature = "server", feature = "verify"))] + /// Refuses a query carrying composite sub-queries on a plain + /// (page-only) surface, which would otherwise silently ignore them — + /// on the verify side that would mean reporting the composition + /// verified when only the page was. + pub(crate) fn ensure_no_sub_queries(&self, surface: &str) -> Result<(), Error> { + if self.sub_queries.is_empty() { + return Ok(()); } + Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "this query carries {} sub-queries, which {} would silently ignore; execute and \ + verify it on the composite surface (query_composite_documents / \ + verify_composite_documents_proof) or, for a single by-id join, the chained one \ + (query_chained_documents / verify_chained_documents_proof)", + self.sub_queries.len(), + surface, + )))) } #[cfg(any(feature = "server", feature = "verify"))] @@ -1385,6 +1489,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included, block_time_ms, resolved_time_ranges: vec![], + sub_queries: vec![], }) } @@ -1532,6 +1637,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included, block_time_ms, resolved_time_ranges: vec![], + sub_queries: vec![], }) } @@ -1697,6 +1803,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }) } @@ -2467,6 +2574,7 @@ impl<'a> DriveDocumentQuery<'a> { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(Vec, u64), Error> { + self.ensure_no_sub_queries("execute_with_proof")?; let mut drive_operations = vec![]; let items = self.execute_with_proof_internal( drive, @@ -2523,6 +2631,7 @@ impl<'a> DriveDocumentQuery<'a> { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(RootHash, Vec>, u64), Error> { + self.ensure_no_sub_queries("execute_with_proof_only_get_elements")?; let mut drive_operations = vec![]; let (root_hash, items) = self.execute_with_proof_only_get_elements_internal( drive, @@ -2581,6 +2690,7 @@ impl<'a> DriveDocumentQuery<'a> { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(Vec>, u16, u64), Error> { + self.ensure_no_sub_queries("execute_raw_results_no_proof")?; let mut drive_operations = vec![]; let (items, skipped) = self.execute_raw_results_no_proof_internal( drive, @@ -3210,6 +3320,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let path_query = query_asc @@ -3693,6 +3804,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // Create a document that we are starting at, which may be missing 'transactionIndex' @@ -3810,6 +3922,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } diff --git a/packages/rs-drive/src/verify/chained_document/mod.rs b/packages/rs-drive/src/verify/chained_document/mod.rs index 68ce536a97e..db3b82dc5db 100644 --- a/packages/rs-drive/src/verify/chained_document/mod.rs +++ b/packages/rs-drive/src/verify/chained_document/mod.rs @@ -1,5 +1,5 @@ //! Chained document query proof verification — the verifier half of the //! provable semi-join in -//! [`drive_chained_document_query`](crate::query::drive_chained_document_query). +//! [`chained_document_query`](crate::query::chained_document_query). mod verify_chained_documents_proof; diff --git a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs index 5541ef2cdee..a1f28a9c7e6 100644 --- a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs +++ b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs @@ -2,15 +2,15 @@ mod v0; use crate::error::drive::DriveError; use crate::error::Error; -use crate::query::drive_chained_document_query::{ - ChainedDocumentsResult, DriveChainedDocumentQuery, -}; +use crate::query::{ChainedDocumentsResult, DriveDocumentQuery}; use crate::verify::RootHash; use dpp::version::PlatformVersion; -impl DriveChainedDocumentQuery<'_> { - /// Verifies a chained query's single merged proof and returns - /// `(root_hash, result)`. +impl DriveDocumentQuery<'_> { + /// Verifies a chained query's single merged proof — this query as the + /// inner half plus the single by-id join its + /// [`sub_queries`](DriveDocumentQuery::sub_queries) carry — and + /// returns `(root_hash, result)`. /// /// The verifier trusts nothing about the join, and needs nothing /// beyond the proof itself: a BOOTSTRAP subset pass runs the inner @@ -46,7 +46,7 @@ impl DriveChainedDocumentQuery<'_> { { 0 => self.verify_chained_documents_proof_v0(proof, platform_version), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "DriveChainedDocumentQuery::verify_chained_documents_proof".to_string(), + method: "DriveDocumentQuery::verify_chained_documents_proof".to_string(), known_versions: vec![0], received: version, })), @@ -80,28 +80,26 @@ mod tests { .chained_document .verify_chained_documents_proof = 255; - let query = DriveChainedDocumentQuery { - inner: DriveDocumentQuery { - contract: &contract, - document_type, - internal_clauses: Default::default(), - offset: None, - limit: Some(1), - order_by: Default::default(), - start_at: None, - start_at_included: false, - block_time_ms: None, - resolved_time_ranges: vec![], - }, - join_property: "records".to_string(), - outer_document_type: document_type, - }; + let query = DriveDocumentQuery { + contract: &contract, + document_type, + internal_clauses: Default::default(), + offset: None, + limit: Some(1), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + } + .with_by_id_join("records", document_type); let result = query.verify_chained_documents_proof(&[], &platform_version); assert!(matches!( result, Err(Error::Drive(DriveError::UnknownVersionMismatch { method, .. })) - if method == "DriveChainedDocumentQuery::verify_chained_documents_proof" + if method == "DriveDocumentQuery::verify_chained_documents_proof" )); } } diff --git a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs index 694c67a9ca3..a99ecb7f67e 100644 --- a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs @@ -1,9 +1,7 @@ use crate::error::proof::ProofError; use crate::error::Error; -use crate::query::drive_chained_document_query::{ - ChainedDocumentsResult, DriveChainedDocumentQuery, -}; use crate::query::index_only_synthesis::synthesize_index_only_document; +use crate::query::{ChainedDocumentsResult, DriveDocumentQuery}; use crate::verify::RootHash; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -12,7 +10,7 @@ use dpp::document::Document; use dpp::version::PlatformVersion; use grovedb::{GroveDb, PathQuery}; -impl DriveChainedDocumentQuery<'_> { +impl DriveDocumentQuery<'_> { /// v0 of the chained proof verification — see the versioned wrapper /// for the trust model. #[inline(always)] @@ -21,8 +19,11 @@ impl DriveChainedDocumentQuery<'_> { proof: &[u8], platform_version: &PlatformVersion, ) -> Result<(RootHash, ChainedDocumentsResult), Error> { - self.validate(platform_version)?; + self.validate_chained(platform_version)?; let grove_version = &platform_version.drive.grove_version; + // The join edge: `validate_chained` just admitted exactly one + // by-id join, so this cannot fail past it. + let (_, outer_document_type, _) = self.chained_join()?; // BOOTSTRAP PASS: run the inner query alone against the merged // proof (subset verification — succinctness off, so the outer @@ -31,24 +32,24 @@ impl DriveChainedDocumentQuery<'_> { // for reconstructing the merged query; the full pass below is // the authority, so nothing rests on this pass's completeness // semantics. - let inner_path_query = self.inner.construct_path_query(None, platform_version)?; + let inner_path_query = self.construct_path_query(None, platform_version)?; let (_, bootstrap_trios) = GroveDb::verify_subset_query(proof, &inner_path_query, grove_version)?; - let index = self.inner.index_only_query_index(platform_version)?; + let index = self.index_only_query_index(platform_version)?; let bootstrap_documents = bootstrap_trios .into_iter() .filter(|(_, _, element)| element.is_some()) .map(|(path, key, _)| { synthesize_index_only_document( - self.inner.contract.id(), - self.inner.document_type, + self.contract.id(), + self.document_type, index, &path, &key, ) }) .collect::, Error>>()?; - let candidate_join_values = self.join_values(&bootstrap_documents)?; + let candidate_join_values = self.chained_join_values(&bootstrap_documents)?; // AUTHORITATIVE PASS: re-derive the outer component from the // candidates, re-merge at the same grove version (identical to @@ -60,7 +61,8 @@ impl DriveChainedDocumentQuery<'_> { // plain inner query) fails this pass whenever the candidates // are non-empty: the merged query demands outer coverage the // proof cannot supply. - let path_queries = self.proof_path_queries(&candidate_join_values, platform_version)?; + let path_queries = + self.chained_proof_path_queries(&candidate_join_values, platform_version)?; let path_query_refs: Vec<&PathQuery> = path_queries.iter().collect(); let merged_query = if path_query_refs.len() > 1 { PathQuery::merge(path_query_refs, grove_version)? @@ -74,8 +76,8 @@ impl DriveChainedDocumentQuery<'_> { // Split the proved trios between the halves by their doctype // path segment: `[DataContractDocuments, contract_id, 1, // , …]`. - let inner_type_name = self.inner.document_type.name().as_bytes(); - let outer_type_name = self.outer_document_type.name().as_bytes(); + let inner_type_name = self.document_type.name().as_bytes(); + let outer_type_name = outer_document_type.name().as_bytes(); let mut inner_documents: Vec = Vec::new(); let mut outer_documents: Vec = Vec::new(); for (path, key, element) in proved_path_key_values { @@ -85,8 +87,8 @@ impl DriveChainedDocumentQuery<'_> { match path.get(3).map(|segment| segment.as_slice()) { Some(segment) if segment == inner_type_name => { inner_documents.push(synthesize_index_only_document( - self.inner.contract.id(), - self.inner.document_type, + self.contract.id(), + self.document_type, index, &path, &key, @@ -103,7 +105,7 @@ impl DriveChainedDocumentQuery<'_> { outer_documents.push( Document::from_bytes( serialized.as_slice(), - self.outer_document_type, + outer_document_type, platform_version, ) .map_err(|e| Error::Protocol(Box::new(e)))?, @@ -123,8 +125,9 @@ impl DriveChainedDocumentQuery<'_> { // bootstrap candidates were only for reconstructing the query — // and the exact-set assembly refuses any divergence between // them and the proven outer documents, in either direction. - let join_values = self.join_values(&inner_documents)?; - let outer_documents = self.assemble_outer_documents(&join_values, outer_documents)?; + let join_values = self.chained_join_values(&inner_documents)?; + let outer_documents = + self.assemble_chained_outer_documents(&join_values, outer_documents)?; Ok(( root_hash, diff --git a/packages/rs-drive/src/verify/composite_document/mod.rs b/packages/rs-drive/src/verify/composite_document/mod.rs new file mode 100644 index 00000000000..4d95e5e1c7e --- /dev/null +++ b/packages/rs-drive/src/verify/composite_document/mod.rs @@ -0,0 +1,4 @@ +//! Composite document query proof verification — the verifier half of +//! [`composite_document_query`](crate::query::composite_document_query). + +mod verify_composite_documents_proof; diff --git a/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs new file mode 100644 index 00000000000..06be0215101 --- /dev/null +++ b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs @@ -0,0 +1,54 @@ +mod v0; + +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::{CompositeDocumentsResult, DriveDocumentQuery}; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; + +impl DriveDocumentQuery<'_> { + /// Verifies a composite query's single merged proof — this query as + /// the page plus its [`sub_queries`](DriveDocumentQuery::sub_queries) + /// — and returns `(root_hash, result)`. + /// + /// The verifier trusts nothing about the derivation, and needs + /// nothing beyond the proof itself: a BOOTSTRAP subset pass runs the + /// page query (and every sub-query that feeds a later binding) alone + /// against the merged proof to extract candidate values; every + /// sub-query is derived from those exactly as the prover derived it + /// from its materialization, the merged query is rebuilt, and the + /// AUTHORITATIVE full pass verifies the whole composition — grovedb + /// enforces every component's lifted per-instance limit and range + /// completeness. The proven results are then routed back to their + /// components: an entry no derivation asked for is an invalid proof, + /// so is a by-id join missing a referenced document (a + /// `permanentDocument` reference cannot dangle), and so is any + /// divergence between the values the proven page derives and the + /// candidates the query was built from. A proof covering only the + /// page (an old node serving the plain query) fails the full pass + /// whenever a sub-query derived anything. + /// + /// One proof means one root by construction; the caller combines the + /// returned root hash with the surrounding tenderdash signature — see + /// `rs-drive-proof-verifier` for the canonical composition. + pub fn verify_composite_documents_proof( + &self, + proof: &[u8], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, CompositeDocumentsResult), Error> { + match platform_version + .drive + .methods + .verify + .composite_document + .verify_composite_documents_proof + { + 0 => self.verify_composite_documents_proof_v0(proof, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "DriveDocumentQuery::verify_composite_documents_proof".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/v0/mod.rs b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/v0/mod.rs new file mode 100644 index 00000000000..3f49c557bfd --- /dev/null +++ b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/v0/mod.rs @@ -0,0 +1,137 @@ +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::query::composite_document_query::{PresentTrio, ProvedTrio}; +use crate::query::{CompositeDocumentsResult, DriveDocumentQuery}; +use crate::verify::RootHash; +use dpp::document::Document; +use dpp::version::PlatformVersion; +use grovedb::GroveDb; + +impl DriveDocumentQuery<'_> { + /// v0 of the composite proof verification — see the versioned + /// wrapper for the trust model. + #[inline(always)] + pub(super) fn verify_composite_documents_proof_v0( + &self, + proof: &[u8], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, CompositeDocumentsResult), Error> { + self.validate_composite(platform_version)?; + let grove_version = &platform_version.drive.grove_version; + + let present = |trios: Vec| { + trios + .into_iter() + .filter_map(|(path, key, element)| element.map(|element| (path, key, element))) + .collect::>() + }; + + // BOOTSTRAP PASS: the page alone against the merged proof (subset + // verification — succinctness off, so the sub-query branches' + // extra coverage is tolerated), decoded into candidate documents. + // Candidates only reconstruct the merged query; the full pass + // below is the authority. + let page_path_query = self.page_path_query(platform_version)?; + let direction = page_path_query.query.query.left_to_right; + let (_, page_trios) = GroveDb::verify_subset_query(proof, &page_path_query, grove_version)?; + let bootstrap_page = + Self::decode_document_trios(self, present(page_trios), platform_version)?; + + // Derive every sub-query in order. A sub-query that feeds a later + // binding is itself bootstrapped by a subset pass, so the later + // binding has candidates to derive from. + let mut derived = Vec::with_capacity(self.sub_queries.len()); + let mut bootstrap_sub_documents: Vec>> = + vec![None; self.sub_queries.len()]; + for (index, sub_query) in self.sub_queries.iter().enumerate() { + let values = self.derive_for(sub_query, &bootstrap_page, |source| { + bootstrap_sub_documents + .get(source) + .and_then(|documents| documents.as_deref()) + })?; + if self.is_binding_source(index) { + let documents = if sub_query.binding.is_some() && values.is_empty() { + Vec::new() + } else { + let path_query = self.sub_query_proof_path_query( + sub_query, + &values, + direction, + platform_version, + )?; + let (_, trios) = + GroveDb::verify_subset_query(proof, &path_query, grove_version)?; + self.decode_sub_query_document_trios( + sub_query, + &values, + direction, + present(trios), + platform_version, + )? + }; + bootstrap_sub_documents[index] = Some(documents); + } + derived.push(values); + } + + // AUTHORITATIVE PASS: rebuild every component from the candidates, + // re-merge at the same grove version (identical to the prover's + // merge by the single-builder rule), and verify the whole + // composition with succinctness on. + let (page_path_query, sub_path_queries) = + self.proof_path_queries(&derived, platform_version)?; + let merged_query = + Self::merged_path_query(&page_path_query, &sub_path_queries, platform_version)?; + let (root_hash, proved_trios) = GroveDb::verify_query(proof, &merged_query, grove_version)?; + + let result = self.assemble_from_trios( + &derived, + &page_path_query, + &sub_path_queries, + proved_trios, + platform_version, + )?; + + // The PROVEN results are authoritative. The bootstrap read the + // same proof through each component's own query, so what it + // decoded must be exactly what the routed authoritative pass + // assigned to that component — a difference means the routing + // misassigned an entry — and every derivation must come out + // identical from the proven results, or the proof was built over + // a different page than it proves. + if bootstrap_page != result.page_documents { + return Err(Error::Proof(ProofError::CorruptedProof( + "the composite proof's page differs between the page query alone and the \ + merged composition" + .to_string(), + ))); + } + for (index, bootstrapped) in bootstrap_sub_documents.iter().enumerate() { + let Some(bootstrapped) = bootstrapped else { + continue; + }; + if bootstrapped.as_slice() != result.sub_results[index].documents() { + return Err(Error::Proof(ProofError::CorruptedProof(format!( + "the composite proof's sub-query {} differs between its own query and the \ + merged composition", + index + )))); + } + } + let authoritative = self.derive_all(&result.page_documents, |index| { + result + .sub_results + .get(index) + .map(|result| result.documents()) + })?; + if authoritative != derived { + return Err(Error::Proof(ProofError::CorruptedProof( + "the composite proof's page derives different sub-query values than the \ + ones the proof covers" + .to_string(), + ))); + } + + Ok((root_hash, result)) + } +} diff --git a/packages/rs-drive/src/verify/document/verify_proof/mod.rs b/packages/rs-drive/src/verify/document/verify_proof/mod.rs index d7a383f4220..60f0dec108a 100644 --- a/packages/rs-drive/src/verify/document/verify_proof/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_proof/mod.rs @@ -36,6 +36,7 @@ impl DriveDocumentQuery<'_> { proof: &[u8], platform_version: &PlatformVersion, ) -> Result<(RootHash, Vec), Error> { + self.ensure_no_sub_queries("verify_proof")?; match platform_version.drive.methods.verify.document.verify_proof { 0 => self.verify_proof_v0(proof, platform_version), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { @@ -78,6 +79,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let result = query.verify_proof(&[], &platform_version); diff --git a/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs b/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs index b19f32d2693..f3084b30e13 100644 --- a/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_proof_keep_serialized/mod.rs @@ -30,6 +30,7 @@ impl DriveDocumentQuery<'_> { proof: &[u8], platform_version: &PlatformVersion, ) -> Result<(RootHash, Vec>), Error> { + self.ensure_no_sub_queries("verify_proof_keep_serialized")?; match platform_version .drive .methods @@ -83,6 +84,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let result = query.verify_proof_keep_serialized(&[], &platform_version); diff --git a/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs b/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs index 854902b78c1..840e804ec04 100644 --- a/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_start_at_document_in_proof/mod.rs @@ -37,6 +37,7 @@ impl DriveDocumentQuery<'_> { document_id: [u8; 32], platform_version: &PlatformVersion, ) -> Result<(RootHash, Option), Error> { + self.ensure_no_sub_queries("verify_start_at_document_in_proof")?; match platform_version .drive .methods @@ -95,6 +96,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let result = diff --git a/packages/rs-drive/src/verify/document_count/verify_point_lookup_count_proof/v0/mod.rs b/packages/rs-drive/src/verify/document_count/verify_point_lookup_count_proof/v0/mod.rs index 85cc16e20e0..ccdf44da203 100644 --- a/packages/rs-drive/src/verify/document_count/verify_point_lookup_count_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/document_count/verify_point_lookup_count_proof/v0/mod.rs @@ -1,4 +1,5 @@ use crate::error::Error; +use crate::query::drive_document_count_query::point_lookup_count_entries; use crate::query::{DriveDocumentCountQuery, SplitCountEntry, WhereOperator}; use crate::verify::RootHash; use dpp::version::PlatformVersion; @@ -99,54 +100,9 @@ impl DriveDocumentCountQuery<'_> { GroveDb::verify_query(proof, &path_query, &platform_version.drive.grove_version) .map_err(|e| Error::GroveDB(Box::new(e)))?; - let mut out: Vec = Vec::with_capacity(elements.len()); - for (path, grove_key, elem) in elements { - // For compound (In) shapes the In value is at: - // - `path[base_path_len]` when the descent walked past - // `base_path` (the In + trailing Equals shape — outer - // key + trailing `(name, value)` pairs land the - // resolved element past base_path); - // - `grove_key` when no descent happened beyond - // `base_path` (the In-on-terminator shape, where outer - // `Key(in_value)` resolves to the value tree directly - // with no subquery). - // - // For Equal-only shapes (`has_in_clause = false`) the - // entry has no per-key dimension; `key` stays empty. - let key = if has_in_clause { - if path.len() > base_path_len { - path[base_path_len].clone() - } else { - // In-on-terminator shape — `grove_key` is the - // serialized In value. - grove_key - } - } else { - Vec::new() - }; - // Propagate grovedb's `Option` directly: - // `Some(element)` → `Some(count_value_or_default())` - // `None` → `None` (not produced by today's - // path query — see fn docstring; - // forward-compat for an absence-proof - // variant). - // `count_value_or_default()` reads the terminator value - // tree's own count — the insertion side stores every - // countable terminator value tree as a CountTree with - // sibling continuations `NonCounted`-wrapped, so this - // count equals the per-branch doc count exactly. - // Zero-count CountTree elements aren't materialized in - // the merk tree (a CountTree is removed when its last - // doc is deleted), so `Some(0)` from this branch would - // mean a malformed proof — pass it through verbatim - // rather than swallow it. - let count = elem.map(|e| e.count_value_or_default()); - out.push(SplitCountEntry { - in_key: None, - key, - count, - }); - } + // The layout decoder lives with the path-query builder — see + // `point_lookup_count_entries` for the In-value placement. + let out = point_lookup_count_entries(base_path_len, has_in_clause, elements); Ok((root_hash, out)) } } diff --git a/packages/rs-drive/src/verify/mod.rs b/packages/rs-drive/src/verify/mod.rs index a6c0593912b..2b6f2e49b8c 100644 --- a/packages/rs-drive/src/verify/mod.rs +++ b/packages/rs-drive/src/verify/mod.rs @@ -3,6 +3,10 @@ /// Chained document query (provable semi-join) verification methods on /// proofs — two grovedb proofs verified as one composed statement. pub mod chained_document; +/// Composite document query (page plus derived sub-queries) +/// verification methods on proofs — one merged proof verified as one +/// composed statement. +pub mod composite_document; ///DataContract verification methods on proofs pub mod contract; /// Document verification methods on proofs diff --git a/packages/rs-drive/tests/query_tests.rs b/packages/rs-drive/tests/query_tests.rs index 6b673198745..de895e70f2e 100644 --- a/packages/rs-drive/tests/query_tests.rs +++ b/packages/rs-drive/tests/query_tests.rs @@ -9303,6 +9303,7 @@ mod withdrawal_in_clause_placement_equivalence { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; // The current shape: the In clause in in_clauses @@ -9325,6 +9326,7 @@ mod withdrawal_in_clause_placement_equivalence { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; for protocol_version in [13u32, 14u32] { diff --git a/packages/rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json b/packages/rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json new file mode 100644 index 00000000000..875985f6766 --- /dev/null +++ b/packages/rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json @@ -0,0 +1,189 @@ +{ + "$formatVersion": "0", + "id": "7RJ5bcEyBLDXFbmDSNzHeAKUC5z7z3Du5mKLY7FuyeeA", + "ownerId": "AtirhSVpAWF7dEt6dLAmesC4Sr1MsJ9bFC1nLAoNnq2S", + "version": 1, + "documentSchemas": { + "post": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byHashtag", + "properties": [ + { + "hashtag": "asc" + } + ] + }, + { + "name": "quotesOfPost", + "properties": [ + { + "quotedPostId": "asc" + } + ], + "countable": true + } + ], + "properties": { + "hashtag": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 0 + }, + "message": { + "type": "string", + "maxLength": 280, + "position": 1 + }, + "quotedPostId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "post" + }, + "position": 2 + } + }, + "required": [ + "hashtag", + "message" + ], + "additionalProperties": false + }, + "like": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byHashtagPost", + "properties": [ + { + "hashtag": "asc" + }, + { + "postId": "asc" + } + ], + "countable": "countable", + "terminal": "$ownerId" + }, + { + "name": "byPost", + "properties": [ + { + "postId": "asc" + } + ], + "countable": "countable", + "rangeCountable": true, + "rankedCountable": true + }, + { + "name": "byLiker", + "properties": [ + { + "$ownerId": "asc" + } + ], + "terminal": "postId" + } + ], + "properties": { + "hashtag": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 0 + }, + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "post", + "propertyAgreement": { + "hashtag": "hashtag" + } + }, + "position": 1 + } + }, + "required": [ + "hashtag", + "postId" + ], + "additionalProperties": false + }, + "repost": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byPost", + "properties": [ + { + "postId": "asc" + } + ], + "countable": true + }, + { + "name": "ownerAndPost", + "properties": [ + { + "$ownerId": "asc" + }, + { + "postId": "asc" + } + ], + "unique": true + }, + { + "name": "postAndOwner", + "properties": [ + { + "postId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "countable": true + } + ], + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "post" + }, + "position": 0 + } + }, + "required": [ + "postId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index c5ceef2e1ec..4cfcfd81df0 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -23,6 +23,10 @@ pub struct DriveDocumentQueryMethodVersions { /// Chained document queries (provable semi-join): the version slot /// shared by the no-proof and the two-proof execution paths. pub query_chained_documents: FeatureVersion, + /// Composite document queries (a page plus sub-queries derived from + /// its results, one merged proof): the version slot shared by the + /// no-proof and the proof execution paths. + pub query_composite_documents: FeatureVersion, pub query_contested_documents: FeatureVersion, pub query_contested_documents_vote_state: FeatureVersion, pub query_documents_with_flags: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs index 66aaa63c641..471d5d35457 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs @@ -10,6 +10,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = query: DriveDocumentQueryMethodVersions { query_documents: 0, query_chained_documents: 0, + query_composite_documents: 0, query_contested_documents: 0, query_contested_documents_vote_state: 0, query_documents_with_flags: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs index ce962c55a75..1f9438ee229 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs @@ -12,6 +12,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = query: DriveDocumentQueryMethodVersions { query_documents: 0, query_chained_documents: 0, + query_composite_documents: 0, query_contested_documents: 0, query_contested_documents_vote_state: 0, query_documents_with_flags: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs index f2635d5ff18..b2d3a909e28 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs @@ -22,6 +22,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = query: DriveDocumentQueryMethodVersions { query_documents: 0, query_chained_documents: 0, + query_composite_documents: 0, query_contested_documents: 0, query_contested_documents_vote_state: 0, query_documents_with_flags: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index 14dd88c5839..f55291f9d45 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -69,6 +69,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = query: DriveDocumentQueryMethodVersions { query_documents: 0, query_chained_documents: 0, + query_composite_documents: 0, query_contested_documents: 0, query_contested_documents_vote_state: 0, query_documents_with_flags: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs index bda81602f40..017bd182bcb 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs @@ -8,6 +8,7 @@ pub struct DriveVerifyMethodVersions { pub contract: DriveVerifyContractMethodVersions, pub document: DriveVerifyDocumentMethodVersions, pub chained_document: DriveVerifyChainedDocumentMethodVersions, + pub composite_document: DriveVerifyCompositeDocumentMethodVersions, pub document_count: DriveVerifyDocumentCountMethodVersions, pub document_sum: DriveVerifyDocumentSumMethodVersions, pub document_ranked: DriveVerifyDocumentRankedMethodVersions, @@ -55,6 +56,14 @@ pub struct DriveVerifyChainedDocumentMethodVersions { pub verify_chained_documents_proof: FeatureVersion, } +/// Versions for the composite document query (page plus derived +/// sub-queries) prove-path verifier (grovedb-level — the tenderdash +/// composition layer lives in rs-drive-proof-verifier). +#[derive(Clone, Debug, Default)] +pub struct DriveVerifyCompositeDocumentMethodVersions { + pub verify_composite_documents_proof: FeatureVersion, +} + /// Versions for the `GetDocumentsCount` prove-path verifiers /// (grovedb-level — the tenderdash composition layer lives in /// rs-drive-proof-verifier). All three methods are implemented on diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs index 02c7fd7f1cb..b2a0fc7e687 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs @@ -1,9 +1,9 @@ use crate::version::drive_versions::drive_verify_method_versions::{ DriveVerifyAddressFundsMethodVersions, DriveVerifyChainedDocumentMethodVersions, - DriveVerifyContractMethodVersions, DriveVerifyDocumentCountMethodVersions, - DriveVerifyDocumentMethodVersions, DriveVerifyDocumentRankedMethodVersions, - DriveVerifyDocumentSumMethodVersions, DriveVerifyGroupMethodVersions, - DriveVerifyIdentityMethodVersions, DriveVerifyMethodVersions, + DriveVerifyCompositeDocumentMethodVersions, DriveVerifyContractMethodVersions, + DriveVerifyDocumentCountMethodVersions, DriveVerifyDocumentMethodVersions, + DriveVerifyDocumentRankedMethodVersions, DriveVerifyDocumentSumMethodVersions, + DriveVerifyGroupMethodVersions, DriveVerifyIdentityMethodVersions, DriveVerifyMethodVersions, DriveVerifyShieldedMethodVersions, DriveVerifySingleDocumentMethodVersions, DriveVerifyStateTransitionMethodVersions, DriveVerifySystemMethodVersions, DriveVerifyTokenMethodVersions, DriveVerifyVoteMethodVersions, @@ -24,6 +24,9 @@ pub const DRIVE_VERIFY_METHOD_VERSIONS_V1: DriveVerifyMethodVersions = DriveVeri chained_document: DriveVerifyChainedDocumentMethodVersions { verify_chained_documents_proof: 0, }, + composite_document: DriveVerifyCompositeDocumentMethodVersions { + verify_composite_documents_proof: 0, + }, document_count: DriveVerifyDocumentCountMethodVersions { verify_aggregate_count_proof: 0, verify_carrier_aggregate_count_proof: 0, diff --git a/packages/rs-sdk/tests/fetch/document.rs b/packages/rs-sdk/tests/fetch/document.rs index fdfe8ee8417..f448b357d53 100644 --- a/packages/rs-sdk/tests/fetch/document.rs +++ b/packages/rs-sdk/tests/fetch/document.rs @@ -133,6 +133,7 @@ async fn document_list_drive_query() { start_at_included: true, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let docs = Document::fetch_many(&sdk, query) diff --git a/packages/wasm-drive-verify/src/document/verify_proof.rs b/packages/wasm-drive-verify/src/document/verify_proof.rs index 1cd189c9f3e..516210b2fcd 100644 --- a/packages/wasm-drive-verify/src/document/verify_proof.rs +++ b/packages/wasm-drive-verify/src/document/verify_proof.rs @@ -115,6 +115,7 @@ pub fn verify_document_proof( // verification fails closed. Use the SDK's FromProof path (which // resolves from the signed metadata time) for those proofs. resolved_time_ranges: vec![], + sub_queries: vec![], }; let (root_hash, documents) = query diff --git a/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs b/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs index 53e73ac22ab..39de614da0f 100644 --- a/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs +++ b/packages/wasm-drive-verify/src/document/verify_proof_keep_serialized.rs @@ -106,6 +106,7 @@ pub fn verify_document_proof_keep_serialized( // verification fails closed. Use the SDK's FromProof path (which // resolves from the signed metadata time) for those proofs. resolved_time_ranges: vec![], + sub_queries: vec![], }; let (root_hash, serialized_docs) = query diff --git a/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs b/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs index 12559c18701..c1267e8d3e4 100644 --- a/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs +++ b/packages/wasm-drive-verify/src/document/verify_start_at_document_in_proof.rs @@ -114,6 +114,7 @@ pub fn verify_start_at_document_in_proof( // verification fails closed. Use the SDK's FromProof path (which // resolves from the signed metadata time) for those proofs. resolved_time_ranges: vec![], + sub_queries: vec![], }; let (root_hash, document_option) = query