From 3fed9ed35979f12f780258435a6a6e7583f78c48 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 4 Sep 2026 20:30:27 +0200 Subject: [PATCH] feat(dapi): composite document queries on the getDocuments V1 wire `GetDocumentsRequestV1.sub_queries` (field 14): each entry is a typed sub-query (contract, document type, fixed where/order clauses, limit, DOCUMENTS or COUNT) with an optional binding ` IN ` whose values the node derives from the page's, or an earlier sub-query's, proven documents. Presence selects composite mode: the request's own clauses describe the page; `chained` and `sub_queries` are mutually exclusive. `ResultData.composite` (variant 7) carries the page and one result per sub-query (documents, or count entries keyed by the bound value) for the no-proof path; the proof path is the single merged proof in the standard envelope, verified client-side by re-deriving the whole composition from the proven page. drive-abci intercepts composite requests before select routing (`dispatch/composite.rs`): the page limit is required (1..=max), every SQL-shaped knob, cursor and time-range clause is refused, each distinct sub-query contract is fetched once, and drive's shape validation surfaces as client-attributable query errors. Old nodes ignore the field and serve a page-only proof, which the verifier refuses. Existing V1 request literals gain the new field; the serde default keeps mock vectors captured before it wire-compatible. Co-Authored-By: Claude Fable 5.1 --- packages/dapi-grpc/build.rs | 1 + .../protos/platform/v0/platform.proto | 103 +++ .../src/documents/document_query.rs | 1 + .../document_query/v1/dispatch/chained.rs | 1 + .../document_query/v1/dispatch/composite.rs | 814 ++++++++++++++++++ .../query/document_query/v1/dispatch/count.rs | 2 +- .../query/document_query/v1/dispatch/mod.rs | 1 + .../src/query/document_query/v1/mod.rs | 32 + .../src/query/document_query/v1/tests.rs | 13 + .../src/proof/document_ranked.rs | 1 + 10 files changed, 968 insertions(+), 1 deletion(-) create mode 100644 packages/rs-drive-abci/src/query/document_query/v1/dispatch/composite.rs diff --git a/packages/dapi-grpc/build.rs b/packages/dapi-grpc/build.rs index c6d1ea066ed..1bd333e20ef 100644 --- a/packages/dapi-grpc/build.rs +++ b/packages/dapi-grpc/build.rs @@ -341,6 +341,7 @@ fn configure_platform(mut platform: MappingConfig) -> MappingConfig { // absent (same pattern DocumentQuery's own serde defaults follow // for pre-SQL-surface fixtures). .field_attribute("GetDocumentsRequestV1.chained", SERDE_DEFAULT) + .field_attribute("GetDocumentsRequestV1.sub_queries", SERDE_DEFAULT) // Same compat rule for the typed IN_TIME_RANGE operand: mock // vectors captured while the operand still rode `value` carry no // `time_range` key. diff --git a/packages/dapi-grpc/protos/platform/v0/platform.proto b/packages/dapi-grpc/protos/platform/v0/platform.proto index dbcfafa3e4e..616c169cf90 100644 --- a/packages/dapi-grpc/protos/platform/v0/platform.proto +++ b/packages/dapi-grpc/protos/platform/v0/platform.proto @@ -1253,6 +1253,86 @@ message GetDocumentsRequest { string outer_document_type = 2; } ChainedJoin chained = 13; + + // Composite mode — a page plus sub-queries DERIVED from its + // results, answered as ONE merged proof over one state root. + // + // Presence of any `sub_queries` selects composite mode: this + // request's own `data_contract_id` / `document_type` / + // `where_clauses` / `order_by` / `limit` describe the PAGE, and + // every sub-query's `IN` clause is derived by the node from the + // page's (or an earlier sub-query's) proven documents. The + // verifier re-derives every sub-query from the proven page with + // the same builders, re-merges, and verifies the whole + // composition — so the composition cannot be steered by the + // responding node, and a node that predates this field (proto3 + // unknown field) serves a page-only proof that FAILS CLOSED + // client-side. + // + // Mode gates (rejected otherwise): `limit` is REQUIRED on the + // page (at most 100 — it bounds every derived clause); `selects` + // must be empty or a single DOCUMENTS projection; `group_by`, + // `having`, time-range clauses, cursors and `offset` are + // rejected (paginate with a range clause on the page's ordering + // property); `chained` and `sub_queries` are mutually exclusive. + // See `SubQuery` for the per-sub-query rules. + message SubQuery { + // The contract this sub-query targets. Empty = the page's own + // contract; otherwise any contract (profiles keyed by owner, + // names keyed by identity). + bytes data_contract_id = 1; + string document_type = 2; + // The FIXED clauses — everything but the derived `IN`, which + // must not be named here. + repeated WhereClause where_clauses = 3; + // Ordering (documents only). Every component of the merged proof + // walks in the page's direction: a bound field missing from here + // is appended in that direction by the node and the verifier + // alike, and an ordering that disagrees with the page's direction + // is refused (turning a limited lookup around would change the + // rows it returns). + repeated OrderClause order_by = 4; + // Documents lookups on a non-unique index REQUIRE a limit: it + // caps the rows the lookup returns in total, in walk order, like + // an ordinary IN query's limit (at most 100). Lookups already + // bounded by their values (a unique index, or an indexOnly + // terminal with every prefix fixed), by-id joins (completeness is + // set equality) and counts take none. + optional uint32 limit = 5; + enum Kind { + // The matching documents. + DOCUMENTS = 0; + // One count per derived value from the `countable` index + // covering the fixed clauses plus the bound field. Must be + // bound, and must not share its index path with a documents + // component (the count reads the value trees the documents + // query descends past). + COUNT = 1; + } + Kind kind = 6; + // The derived clause ` IN `. Absent = a SIBLING: + // an independent documents query proven under the same root. + message Binding { + // Whose proven documents supply the values: `0` = the page, + // `n` = `sub_queries[n - 1]` (which must precede this one and + // be a DOCUMENTS sub-query). + uint32 source = 1; + // The source property read off each document: `$id`, + // `$ownerId`, or an identifier-typed property (dotted paths + // reach nested properties). Documents without it contribute + // nothing. + string source_property = 2; + // The sub-query field receiving the `IN` clause. `$id` makes + // this a by-id JOIN: the source property must then declare + // `refersTo: permanentDocument` targeting this document type, + // so every derived id resolves and a missing document is an + // invalid proof. Otherwise `$ownerId` or an indexed property + // (a LOOKUP, where absence is a proven fact). + string field = 3; + } + Binding bind = 7; + } + repeated SubQuery sub_queries = 14; } oneof version { @@ -1605,6 +1685,10 @@ message GetDocumentsResponse { // among the inner projections, deduplicated). Routed when // the request's `chained` message is present. ChainedDocuments chained = 6; + // Composite-mode result: the page plus one result per + // sub-query, in request order. Routed when the request + // carries `sub_queries`. + CompositeDocuments composite = 7; } } @@ -1615,6 +1699,25 @@ message GetDocumentsResponse { repeated bytes outer_documents = 2; } + // A composite query's page and per-sub-query results, documents + // serialized with their own document type. + message CompositeDocuments { + // The page, exactly as the page query alone would return it. + repeated bytes page_documents = 1; + message SubQueryResult { + oneof result { + // DOCUMENTS: a by-id join in first-appearance order of the + // derived ids; a lookup or sibling in query order. + Documents documents = 1; + // COUNT: one entry per derived value that has a count tree + // (a value with no entry counts zero), keyed by the + // value's index-key bytes. + CountEntries counts = 2; + } + } + repeated SubQueryResult sub_results = 2; + } + oneof result { ResultData data = 1; Proof proof = 2; diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 97c880e1c58..a8e88a37690 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -872,6 +872,7 @@ fn encode_v1( // a second copy of that rule in the SDK. offset, chained: None, + sub_queries: Vec::new(), })), }) } 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..0290d1af9ed 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 @@ -348,6 +348,7 @@ mod tests { group_by: Vec::new(), having: Vec::new(), offset: None, + sub_queries: Vec::new(), chained: Some(ChainedJoin { join_property: "postId".to_string(), outer_document_type: "post".to_string(), diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/composite.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/composite.rs new file mode 100644 index 00000000000..cb1f3a389f9 --- /dev/null +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/composite.rs @@ -0,0 +1,814 @@ +//! Composite-mode dispatch: a page plus sub-queries derived from its +//! results, answered as ONE merged proof. The request's own type, +//! clauses and limit describe the PAGE; each `sub_queries` entry is a +//! join, a lookup, a count, or a sibling (see the proto), whose `IN` +//! clause the node derives from the proven page (or an earlier +//! sub-query). On the proof path everything rides one merged grovedb +//! proof (drive brackets its materialize/prove sequence with root-hash +//! reads, since grovedb proves committed state only), and the verifier +//! re-derives the whole composition from the proven page. + +use super::count::into_v1_entry; +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::document_query::v1::conversions; +use crate::query::response_metadata::CheckpointUsed; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::{ + select, sub_query, Select as ProtoSelect, Start as RequestV1Start, SubQuery as ProtoSubQuery, +}; +use dapi_grpc::platform::v0::get_documents_request::{ + HavingClause as ProtoHavingClause, OrderClause as ProtoOrderClause, + WhereClause as ProtoWhereClause, +}; +use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::{ + composite_documents, result_data, CompositeDocuments, CountEntries, Documents, ResultData, +}; +use dapi_grpc::platform::v0::get_documents_response::{ + get_documents_response_v1, GetDocumentsResponseV1, +}; +use dpp::check_validation_result_with_data; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::validation::ValidationResult; +use dpp::version::PlatformVersion; +use drive::drive::contract::DataContractFetchInfo; +use drive::error::query::QuerySyntaxError; +use drive::query::drive_composite_document_query::{ + BindingSource, DriveCompositeDocumentQuery, DriveSubQuery, SubQueryBinding, SubQueryKind, + SubQueryResult, +}; +use drive::query::DriveDocumentQuery; +use drive::util::grove_operations::GroveDBToUse; +use std::sync::Arc; + +/// A sub-query's wire fields decoded into drive's typed forms, before +/// the contract it targets is bound. +struct DecodedSubQuery { + contract_index: usize, + document_type: String, + kind: SubQueryKind, + where_clauses: Vec, + order_by: Vec, + limit: Option, + binding: Option, +} + +impl Platform { + /// Serve a composite-mode v1 request. Runs before select routing: + /// the composite surface owns its own (deliberately narrow) shape. + #[allow(clippy::too_many_arguments)] + pub(in crate::query::document_query::v1) fn dispatch_composite_v1( + &self, + data_contract_id: Vec, + document_type: String, + proto_sub_queries: Vec, + proto_where_clauses: Vec, + proto_order_by: Vec, + limit: Option, + start: Option, + prove: bool, + proto_selects: Vec, + group_by: Vec, + having: Vec, + offset: Option, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let unsupported = |message: &str| { + QueryValidationResult::new_with_error(QueryError::Query(QuerySyntaxError::Unsupported( + message.to_string(), + ))) + }; + + // The composite surface is documents-shaped by construction: + // an empty `selects` or a single DOCUMENTS projection; every + // SQL-shaped knob and every cursor is rejected — pagination + // is a range clause on the page's ordering property. + let selects_are_documents = match proto_selects.as_slice() { + [] => true, + [single] => { + single.function == select::Function::Documents as i32 && single.field.is_empty() + } + _ => false, + }; + if !selects_are_documents { + return Ok(unsupported( + "a composite request supports the DOCUMENTS projection only", + )); + } + if !group_by.is_empty() || !having.is_empty() { + return Ok(unsupported( + "a composite request supports no group_by or having clauses", + )); + } + if start.is_some() { + return Ok(unsupported( + "a composite request supports no cursor; paginate with a range clause on \ + the page's ordering property", + )); + } + if offset.is_some() { + return Ok(unsupported("a composite request supports no offset")); + } + if proto_where_clauses + .iter() + .chain( + proto_sub_queries + .iter() + .flat_map(|sub| sub.where_clauses.iter()), + ) + .any(conversions::is_time_range_clause) + { + return Ok(unsupported( + "a composite request supports no time-range (IN_TIME_RANGE) clauses", + )); + } + + // The page limit is REQUIRED — it bounds every derived clause. + let max_query_limit = self.config.drive.max_query_limit as u32; + let page_limit = match limit { + Some(n) if n >= 1 && n <= max_query_limit => n as u16, + other => { + return Ok(QueryValidationResult::new_with_error(QueryError::Query( + QuerySyntaxError::InvalidLimit(format!( + "composite requests require an explicit page limit in [1, {}], got {:?}", + max_query_limit, other + )), + ))); + } + }; + + let where_clauses = match conversions::where_clauses_from_proto(proto_where_clauses) { + Ok(c) => c, + Err(e) => return Ok(QueryValidationResult::new_with_error(e)), + }; + let order_by_clauses = match conversions::order_clauses_from_proto(proto_order_by) { + Ok(c) => c, + Err(e) => return Ok(QueryValidationResult::new_with_error(e)), + }; + + // Every contract the composition touches, fetched once: the + // page's first, then each distinct sub-query contract. + let (page_contract_id, page_contract) = check_validation_result_with_data!( + self.fetch_contract_for_document_query_v1(data_contract_id, platform_version)? + ); + let mut contracts: Vec> = vec![page_contract]; + let mut contract_ids: Vec> = vec![page_contract_id.to_vec()]; + + let mut decoded: Vec = Vec::with_capacity(proto_sub_queries.len()); + for (index, proto) in proto_sub_queries.into_iter().enumerate() { + let label = |message: String| { + QueryValidationResult::new_with_error(QueryError::InvalidArgument(format!( + "sub-query {}: {}", + index, message + ))) + }; + let contract_index = if proto.data_contract_id.is_empty() { + 0 + } else if let Some(position) = contract_ids + .iter() + .position(|id| *id == proto.data_contract_id) + { + position + } else { + let (id, fetched) = check_validation_result_with_data!(self + .fetch_contract_for_document_query_v1( + proto.data_contract_id.clone(), + platform_version + )?); + contracts.push(fetched); + contract_ids.push(id.to_vec()); + contracts.len() - 1 + }; + let kind = match sub_query::Kind::try_from(proto.kind) { + Ok(sub_query::Kind::Documents) => SubQueryKind::Documents, + Ok(sub_query::Kind::Count) => SubQueryKind::Count, + Err(_) => return Ok(label(format!("unknown kind {}", proto.kind))), + }; + let limit = match proto.limit { + None => None, + Some(n) if n >= 1 && n <= max_query_limit => Some(n as u16), + Some(n) => { + return Ok(QueryValidationResult::new_with_error(QueryError::Query( + QuerySyntaxError::InvalidLimit(format!( + "sub-query {}: limit must be in [1, {}], got {}", + index, max_query_limit, n + )), + ))); + } + }; + let where_clauses = match conversions::where_clauses_from_proto(proto.where_clauses) { + Ok(c) => c, + Err(e) => return Ok(QueryValidationResult::new_with_error(e)), + }; + let order_by = match conversions::order_clauses_from_proto(proto.order_by) { + Ok(c) => c, + Err(e) => return Ok(QueryValidationResult::new_with_error(e)), + }; + let binding = proto.bind.map(|bind| SubQueryBinding { + source: match bind.source { + 0 => BindingSource::Page, + n => BindingSource::SubQuery(n as usize - 1), + }, + source_property: bind.source_property, + field: bind.field, + }); + decoded.push(DecodedSubQuery { + contract_index, + document_type: proto.document_type, + kind, + where_clauses, + order_by, + limit, + binding, + }); + } + + // Bind the typed shapes to the fetched contracts. + let page_contract_ref = &contracts[0].contract; + let page_type = check_validation_result_with_data!(page_contract_ref + .document_type_for_name(document_type.as_str()) + .map_err(|_| QueryError::InvalidArgument(format!( + "document type {} not found for the queried contract", + document_type + )))); + let page = check_validation_result_with_data!(DriveDocumentQuery::from_typed_clauses( + where_clauses, + order_by_clauses, + Some(page_limit), + None, + true, + None, + page_contract_ref, + page_type, + &self.config.drive, + platform_version, + )); + let mut sub_queries: Vec = Vec::with_capacity(decoded.len()); + for (index, sub) in decoded.into_iter().enumerate() { + let contract_ref = &contracts[sub.contract_index].contract; + let document_type = check_validation_result_with_data!(contract_ref + .document_type_for_name(sub.document_type.as_str()) + .map_err(|_| QueryError::InvalidArgument(format!( + "sub-query {}: document type {} not found for its contract", + index, sub.document_type + )))); + sub_queries.push(DriveSubQuery { + contract: contract_ref, + document_type, + kind: sub.kind, + where_clauses: sub.where_clauses, + order_by: sub.order_by, + limit: sub.limit, + binding: sub.binding, + }); + } + let composite = DriveCompositeDocumentQuery { page, sub_queries }; + // Fail the shape checks as query errors (client-attributable), + // before any execution. + match composite.validate(platform_version) { + Ok(()) => {} + Err(drive::error::Error::Query(query_error)) => { + return Ok(QueryValidationResult::new_with_error(QueryError::Query( + query_error, + ))); + } + Err(e) => return Err(e.into()), + } + + let response = if prove { + let (merged_proof, _page_documents) = match self + .drive + .query_composite_documents_with_proof(&composite, platform_version) + { + Ok(result) => result, + Err(drive::error::Error::Query(query_error)) => { + return Ok(QueryValidationResult::new_with_error(QueryError::Query( + query_error, + ))); + } + Err(e) => return Err(e.into()), + }; + let (grovedb_used, proof) = + self.response_proof_v0(platform_state, merged_proof, GroveDBToUse::Current)?; + GetDocumentsResponseV1 { + result: Some(get_documents_response_v1::Result::Proof(proof)), + metadata: Some(self.response_metadata_v0(platform_state, grovedb_used)), + } + } else { + let outcome = + match self + .drive + .query_composite_documents(&composite, None, None, platform_version) + { + Ok(outcome) => outcome, + Err(drive::error::Error::Query(query_error)) => { + return Ok(QueryValidationResult::new_with_error(QueryError::Query( + query_error, + ))); + } + Err(e) => return Err(e.into()), + }; + let serialize_all = |documents: &[dpp::document::Document], + sub: Option<&DriveSubQuery>| + -> Result>, Error> { + let (document_type, contract) = match sub { + None => (composite.page.document_type, composite.page.contract), + Some(sub) => (sub.document_type, sub.contract), + }; + documents + .iter() + .map(|document| { + document + .serialize(document_type, contract, platform_version) + .map_err(Error::Protocol) + }) + .collect() + }; + let page_documents = serialize_all(&outcome.result.page_documents, None)?; + let mut sub_results = Vec::with_capacity(composite.sub_queries.len()); + for (sub, result) in composite.sub_queries.iter().zip(outcome.result.sub_results) { + let result = match result { + SubQueryResult::Documents(documents) => { + composite_documents::sub_query_result::Result::Documents(Documents { + documents: serialize_all(&documents, Some(sub))?, + }) + } + SubQueryResult::Counts(entries) => { + composite_documents::sub_query_result::Result::Counts(CountEntries { + entries: entries.into_iter().map(into_v1_entry).collect(), + }) + } + }; + sub_results.push(composite_documents::SubQueryResult { + result: Some(result), + }); + } + GetDocumentsResponseV1 { + result: Some(get_documents_response_v1::Result::Data(ResultData { + variant: Some(result_data::Variant::Composite(CompositeDocuments { + page_documents, + sub_results, + })), + })), + metadata: Some(self.response_metadata_v0(platform_state, CheckpointUsed::Current)), + } + }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::query::tests::{setup_platform, store_data_contract, store_document}; + use dapi_grpc::platform::v0::get_documents_request::document_field_value; + use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::{ + sub_query::Binding as ProtoBinding, ChainedJoin, + }; + use dapi_grpc::platform::v0::get_documents_request::DocumentFieldValue as ProtoDocumentFieldValue; + use dapi_grpc::platform::v0::get_documents_request::GetDocumentsRequestV1; + use dapi_grpc::platform::v0::get_documents_request::WhereOperator as ProtoWhereOperator; + use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::Result as ResponseResult; + use dpp::dashcore::Network; + use dpp::data_contract::document_type::random_document::CreateRandomDocument; + use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; + use dpp::identifier::Identifier; + use dpp::platform_value::Value; + use dpp::prelude::DataContract; + use dpp::tests::json_document::json_document_to_contract; + use drive::query::{InternalClauses, WhereClause, WhereOperator}; + + const FEED_CONTRACT_PATH: &str = + "../rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json"; + const DASHPAY_CONTRACT_PATH: &str = + "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json"; + const POST_A: [u8; 32] = [0xA1; 32]; + const POST_B: [u8; 32] = [0xB2; 32]; + const POST_D: [u8; 32] = [0xD4; 32]; + const OWNER_1: [u8; 32] = [0x11; 32]; + const OWNER_2: [u8; 32] = [0x22; 32]; + + /// Two `dash` posts (A by owner 1 quoting D, B by owner 2), the + /// quoted `btc` post D, two likes on A and one on B, and a profile + /// for owner 1 only. + fn setup_feed_state() -> ( + crate::test::helpers::setup::TempPlatform, + std::sync::Arc, + &'static PlatformVersion, + DataContract, + DataContract, + ) { + let (platform, state, version) = setup_platform(None, Network::Testnet, None); + let feed = json_document_to_contract(FEED_CONTRACT_PATH, false, version) + .expect("expected to parse the feed contract"); + let dashpay = json_document_to_contract(DASHPAY_CONTRACT_PATH, false, version) + .expect("expected to parse the dashpay contract"); + store_data_contract(&platform.platform, &feed, version); + store_data_contract(&platform.platform, &dashpay, version); + + let post_type = feed.document_type_for_name("post").expect("post doctype"); + let like_type = feed.document_type_for_name("like").expect("like doctype"); + let profile_type = dashpay + .document_type_for_name("profile") + .expect("profile doctype"); + + for (id, owner, hashtag, quoted, seed) in [ + (POST_D, OWNER_2, "btc", None, 4u64), + (POST_A, OWNER_1, "dash", Some(POST_D), 1), + (POST_B, OWNER_2, "dash", None, 2), + ] { + let mut post = post_type + .random_document(Some(seed), version) + .expect("post"); + let mut props = std::collections::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)); + } + post.set_properties(props); + post.set_id(Identifier::from(id)); + post.set_owner_id(Identifier::from(owner)); + store_document(&platform.platform, &feed, post_type, &post, version); + } + for (owner, post, seed) in [ + (OWNER_1, POST_A, 10u64), + (OWNER_2, POST_A, 11), + (OWNER_1, POST_B, 12), + ] { + let mut like = like_type + .random_document(Some(seed), version) + .expect("like"); + let mut props = std::collections::BTreeMap::new(); + props.insert("hashtag".to_string(), Value::Text("dash".to_string())); + props.insert("postId".to_string(), Value::Identifier(post)); + like.set_properties(props); + like.set_owner_id(Identifier::from(owner)); + store_document(&platform.platform, &feed, like_type, &like, version); + } + let mut profile = profile_type + .random_document(Some(30), version) + .expect("profile"); + let mut props = std::collections::BTreeMap::new(); + props.insert("displayName".to_string(), Value::Text("one".to_string())); + profile.set_properties(props); + profile.set_owner_id(Identifier::from(OWNER_1)); + store_document( + &platform.platform, + &dashpay, + profile_type, + &profile, + version, + ); + + (platform, state, version, feed, dashpay) + } + + fn sub( + contract_id: Vec, + document_type: &str, + kind: sub_query::Kind, + limit: Option, + bind: Option<(u32, &str, &str)>, + ) -> ProtoSubQuery { + ProtoSubQuery { + data_contract_id: contract_id, + document_type: document_type.to_string(), + where_clauses: Vec::new(), + order_by: Vec::new(), + limit, + kind: kind as i32, + bind: bind.map(|(source, source_property, field)| ProtoBinding { + source, + source_property: source_property.to_string(), + field: field.to_string(), + }), + } + } + + /// Page: `dash` posts; sub-queries: like counts, the quoted posts + /// (by-id join), the authors' profiles (cross-contract lookup). + fn composite_request( + prove: bool, + feed_id: Vec, + dashpay_id: Vec, + ) -> GetDocumentsRequestV1 { + GetDocumentsRequestV1 { + data_contract_id: feed_id, + document_type: "post".to_string(), + where_clauses: vec![ + dapi_grpc::platform::v0::get_documents_request::WhereClause { + field: "hashtag".to_string(), + operator: ProtoWhereOperator::Equal as i32, + value: Some(ProtoDocumentFieldValue { + variant: Some(document_field_value::Variant::Text("dash".to_string())), + }), + time_range: None, + }, + ], + order_by: Vec::new(), + limit: Some(10), + start: None, + prove, + selects: Vec::new(), + group_by: Vec::new(), + having: Vec::new(), + offset: None, + chained: None, + sub_queries: vec![ + sub( + Vec::new(), + "like", + sub_query::Kind::Count, + None, + Some((0, "$id", "postId")), + ), + sub( + Vec::new(), + "post", + sub_query::Kind::Documents, + None, + Some((0, "quotedPostId", "$id")), + ), + sub( + dashpay_id, + "profile", + sub_query::Kind::Documents, + None, + Some((0, "$ownerId", "$ownerId")), + ), + ], + } + } + + /// The same composition built directly against drive, as the SDK + /// builds it to verify a proof. + fn client_query<'a>( + feed: &'a DataContract, + dashpay: &'a DataContract, + ) -> DriveCompositeDocumentQuery<'a> { + let page = DriveDocumentQuery { + contract: feed, + document_type: feed.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("dash".to_string()), + }], + PlatformVersion::latest(), + ) + .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![], + }; + let bound = |contract: &'a DataContract, + type_name: &str, + kind, + source_property: &str, + field: &str| { + DriveSubQuery { + contract, + document_type: contract.document_type_for_name(type_name).expect("doctype"), + kind, + where_clauses: vec![], + order_by: vec![], + limit: None, + binding: Some(SubQueryBinding { + source: BindingSource::Page, + source_property: source_property.to_string(), + field: field.to_string(), + }), + } + }; + DriveCompositeDocumentQuery { + page, + sub_queries: vec![ + bound(feed, "like", SubQueryKind::Count, "$id", "postId"), + bound(feed, "post", SubQueryKind::Documents, "quotedPostId", "$id"), + bound( + dashpay, + "profile", + SubQueryKind::Documents, + "$ownerId", + "$ownerId", + ), + ], + } + } + + #[test] + fn should_return_the_page_and_every_sub_result_without_proof() { + let (platform, state, version, feed, dashpay) = setup_feed_state(); + + let result = platform + .platform + .query_documents_v1( + composite_request(false, feed.id().to_vec(), dashpay.id().to_vec()), + &state, + version, + ) + .expect("query executes"); + assert!(result.errors.is_empty(), "errors: {:?}", result.errors); + let response = result.data.expect("response data"); + let Some(ResponseResult::Data(data)) = response.result else { + panic!("expected a data result"); + }; + let Some(result_data::Variant::Composite(composite)) = data.variant else { + panic!("expected the composite variant"); + }; + + let post_type = feed.document_type_for_name("post").expect("post"); + let page: Vec<[u8; 32]> = composite + .page_documents + .iter() + .map(|bytes| { + Document::from_bytes(bytes, post_type, version) + .expect("post deserializes") + .id() + .to_buffer() + }) + .collect(); + assert_eq!(page, vec![POST_A, POST_B]); + assert_eq!(composite.sub_results.len(), 3); + + let Some(composite_documents::sub_query_result::Result::Counts(counts)) = + &composite.sub_results[0].result + else { + panic!("expected count entries"); + }; + let like_counts: std::collections::BTreeMap, u64> = counts + .entries + .iter() + .map(|entry| (entry.key.clone(), entry.count)) + .collect(); + assert_eq!( + like_counts, + std::collections::BTreeMap::from([(POST_A.to_vec(), 2), (POST_B.to_vec(), 1)]) + ); + + let Some(composite_documents::sub_query_result::Result::Documents(quoted)) = + &composite.sub_results[1].result + else { + panic!("expected quoted documents"); + }; + assert_eq!(quoted.documents.len(), 1, "A quotes D"); + assert_eq!( + Document::from_bytes("ed.documents[0], post_type, version) + .expect("post deserializes") + .id() + .to_buffer(), + POST_D + ); + + let Some(composite_documents::sub_query_result::Result::Documents(profiles)) = + &composite.sub_results[2].result + else { + panic!("expected profile documents"); + }; + let profile_type = dashpay.document_type_for_name("profile").expect("profile"); + assert_eq!( + profiles.documents.len(), + 1, + "owner 2 has no profile: a proven absence" + ); + assert_eq!( + Document::from_bytes(&profiles.documents[0], profile_type, version) + .expect("profile deserializes") + .owner_id() + .to_buffer(), + OWNER_1 + ); + } + + #[test] + fn should_prove_end_to_end_through_the_v1_wire() { + let (platform, state, version, feed, dashpay) = setup_feed_state(); + + let result = platform + .platform + .query_documents_v1( + composite_request(true, feed.id().to_vec(), dashpay.id().to_vec()), + &state, + version, + ) + .expect("query executes"); + assert!(result.errors.is_empty(), "errors: {:?}", result.errors); + let response = result.data.expect("response data"); + let Some(ResponseResult::Proof(proof)) = response.result else { + panic!("expected a proof result"); + }; + + let query = client_query(&feed, &dashpay); + let (_root_hash, verified) = query + .verify_composite_documents_proof(proof.grovedb_proof.as_slice(), version) + .expect("the composite proof verifies from the proof alone"); + assert_eq!( + verified + .page_documents + .iter() + .map(|post| post.id().to_buffer()) + .collect::>(), + vec![POST_A, POST_B] + ); + assert_eq!(verified.sub_results[0].counts().len(), 2); + assert_eq!(verified.sub_results[1].documents().len(), 1); + assert_eq!(verified.sub_results[2].documents().len(), 1); + } + + #[test] + fn should_require_an_explicit_page_limit() { + let (platform, state, version, feed, dashpay) = setup_feed_state(); + + let mut no_limit = composite_request(false, feed.id().to_vec(), dashpay.id().to_vec()); + no_limit.limit = None; + let result = platform + .platform + .query_documents_v1(no_limit, &state, version) + .expect("query executes"); + assert!( + matches!( + result.errors.as_slice(), + [QueryError::Query(QuerySyntaxError::InvalidLimit(_))] + ), + "expected InvalidLimit, got {:?}", + result.errors + ); + } + + #[test] + fn should_reject_chained_and_composite_together() { + let (platform, state, version, feed, dashpay) = setup_feed_state(); + + let mut both = composite_request(false, feed.id().to_vec(), dashpay.id().to_vec()); + both.chained = Some(ChainedJoin { + join_property: "quotedPostId".to_string(), + outer_document_type: "post".to_string(), + }); + let result = platform + .platform + .query_documents_v1(both, &state, version) + .expect("query executes"); + assert!( + matches!( + result.errors.as_slice(), + [QueryError::Query(QuerySyntaxError::Unsupported(_))] + ), + "expected Unsupported, got {:?}", + result.errors + ); + } + + #[test] + fn should_reject_an_unknown_sub_query_contract() { + let (platform, state, version, feed, dashpay) = setup_feed_state(); + + let mut unknown = composite_request(false, feed.id().to_vec(), dashpay.id().to_vec()); + unknown.sub_queries[2].data_contract_id = vec![0x99; 32]; + let result = platform + .platform + .query_documents_v1(unknown, &state, version) + .expect("query executes"); + assert!( + matches!( + result.errors.as_slice(), + [QueryError::Query(QuerySyntaxError::DataContractNotFound(_))] + ), + "expected DataContractNotFound, got {:?}", + result.errors + ); + } + + #[test] + fn should_surface_shape_rejections_as_query_errors() { + let (platform, state, version, feed, dashpay) = setup_feed_state(); + + // A count with a limit is a shape the drive validator refuses; + // it must come back as a client-attributable query error. + let mut counted_with_limit = + composite_request(false, feed.id().to_vec(), dashpay.id().to_vec()); + counted_with_limit.sub_queries[0].limit = Some(5); + let result = platform + .platform + .query_documents_v1(counted_with_limit, &state, version) + .expect("query executes"); + assert!( + matches!( + result.errors.as_slice(), + [QueryError::Query(QuerySyntaxError::Unsupported(_))] + ), + "expected Unsupported, got {:?}", + result.errors + ); + } +} diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs index 5f4f3c921dd..302daa9c1c8 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/count.rs @@ -188,7 +188,7 @@ impl Platform { } } -fn into_v1_entry(e: SplitCountEntry) -> CountEntry { +pub(super) fn into_v1_entry(e: SplitCountEntry) -> CountEntry { CountEntry { in_key: e.in_key, key: e.key, diff --git a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/mod.rs b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/mod.rs index a829b46cc36..dbc9c027bb6 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/dispatch/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/dispatch/mod.rs @@ -7,6 +7,7 @@ mod average; mod chained; +mod composite; mod count; mod documents; mod having; diff --git a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs index e8d6ee3b5bc..ecd1f066e36 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/mod.rs @@ -230,8 +230,40 @@ impl Platform { having, offset, chained, + sub_queries, } = request_v1; + // Composite mode owns its own shape and routes before the + // SELECT machinery: the request's clauses describe the PAGE, + // and the sub-queries are derived from it. + if !sub_queries.is_empty() { + if chained.is_some() { + return Ok(QueryValidationResult::new_with_error(QueryError::Query( + QuerySyntaxError::Unsupported( + "a request carries either `chained` or `sub_queries`, not both: a \ + chained join is one sub-query shape" + .to_string(), + ), + ))); + } + return self.dispatch_composite_v1( + data_contract_id, + document_type, + sub_queries, + proto_where_clauses, + proto_order_by, + limit, + start, + prove, + proto_selects, + group_by, + having, + offset, + platform_state, + platform_version, + ); + } + // Chained mode owns its own (deliberately narrow) shape and // routes before the SELECT machinery: the request's clauses // describe the INNER indexOnly query of a provable semi-join. 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..a20462473a7 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 @@ -147,6 +147,7 @@ fn empty_v1_request() -> GetDocumentsRequestV1 { having: Vec::new(), offset: None, chained: None, + sub_queries: Vec::new(), } } @@ -957,6 +958,7 @@ fn e2e_documents_select_matches_v0() { having: Vec::new(), offset: None, chained: None, + sub_queries: Vec::new(), }; let v1_result = platform .query_documents_v1(request_v1, &state, version) @@ -994,6 +996,7 @@ fn e2e_having_rejection_surfaces_in_response() { )], offset: None, chained: None, + sub_queries: Vec::new(), }; let result = platform .query_documents_v1(request, &state, version) @@ -1030,6 +1033,7 @@ fn reject_start_with_select_count() { having: Vec::new(), offset: None, chained: None, + sub_queries: Vec::new(), }; let result = platform .query_documents_v1(request, &state, version) @@ -1190,6 +1194,7 @@ mod ported_v0_count_tests { having: Vec::new(), offset: None, chained: None, + sub_queries: Vec::new(), } } @@ -2079,6 +2084,7 @@ mod ranked_tests { having: Vec::new(), offset, chained: None, + sub_queries: Vec::new(), } } @@ -2975,6 +2981,7 @@ mod ranked_tests { having: Vec::new(), offset: None, chained: None, + sub_queries: Vec::new(), }; match ranked_error(&platform, &state, request.clone(), version) { @@ -3110,6 +3117,7 @@ mod multi_in_wire_tests { having: Vec::new(), offset: None, chained: None, + sub_queries: Vec::new(), } } @@ -3266,6 +3274,7 @@ mod having_range_tests { having: vec![clause], offset: None, chained: None, + sub_queries: Vec::new(), } } @@ -3696,6 +3705,7 @@ mod having_range_tests { )], offset: None, chained: None, + sub_queries: Vec::new(), }; match ranked_error(&platform, &state, request, version) { @@ -3850,6 +3860,7 @@ mod having_range_tests { )], offset: None, chained: None, + sub_queries: Vec::new(), }; match ranked_error(&platform, &state, request, version) { @@ -4607,6 +4618,7 @@ mod time_range_proof_verification { having: Vec::new(), offset: None, chained: None, + sub_queries: Vec::new(), } } @@ -5790,6 +5802,7 @@ mod time_range_proof_verification { having: Vec::new(), offset: None, chained: None, + sub_queries: Vec::new(), } } diff --git a/packages/rs-drive-proof-verifier/src/proof/document_ranked.rs b/packages/rs-drive-proof-verifier/src/proof/document_ranked.rs index b5ea0dcc5e2..386c2930414 100644 --- a/packages/rs-drive-proof-verifier/src/proof/document_ranked.rs +++ b/packages/rs-drive-proof-verifier/src/proof/document_ranked.rs @@ -227,6 +227,7 @@ pub(crate) fn result_variant_name( Some(result_data::Variant::Averages(_)) => "a ResultData.averages payload", Some(result_data::Variant::Ranked(_)) => "a ResultData.ranked payload", Some(result_data::Variant::Chained(_)) => "a ResultData.chained payload", + Some(result_data::Variant::Composite(_)) => "a ResultData.composite payload", }, } }