From a28fe7e8403ea147809d88abdf90cec420feadfc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 4 Sep 2026 20:19:32 +0200 Subject: [PATCH 1/9] feat(drive): composite document queries: a page plus derived sub-queries under one merged proof A feed is a page of posts and, for that page, everything a card renders: the quoted posts, the per-post counts, the authors' profiles, the viewer's own likes. Each is a query whose input is the page, so today a single feed is a burst of dependent round trips. 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 documents sub-query), and `prove_query_many` merges every component into one proof over one root. Three bound shapes and one unbound one: a by-id join (the source property must be a `refersTo: permanentDocument`, so every derived id resolves and the result is set-equal to the ids), a documents lookup by an indexed property (absence inherent in the range proof; may target another contract; limit required unless a unique index or an indexOnly terminal already bounds it to one row per value), a grouped point-lookup count on a countable index, and a sibling documents query. Derived values are identifiers; the page carries an explicit limit of at most 100 and no cursor. The verifier trusts nothing about the derivation: it bootstraps the page (and any sub-query feeding a later binding) with a subset pass, derives every sub-query with the same builders the server ran, merges the same way, verifies the composition in one authoritative pass, routes the proved entries back to their components (by path, by element kind, and by bound-value membership), and refuses an entry no derivation asked for, a dangling join, or a page that derives different values than the proof covers. A page-only proof from a node ignoring the sub-queries fails closed. Needs grovedb #850 (limited branches graft below a shared key). A count may not share an index path with a documents component: the count reads the value trees the lookup descends past. Co-Authored-By: Claude Fable 5.1 --- .../v0/tests/composite_query_e2e_tests.rs | 768 ++++++++ .../insert/insert_contract/v0/tests/mod.rs | 1 + .../rs-drive/src/drive/document/query/mod.rs | 2 + .../query/query_composite_documents/mod.rs | 74 + .../query/query_composite_documents/v0/mod.rs | 62 + .../drive_composite_document_query/mod.rs | 1673 +++++++++++++++++ packages/rs-drive/src/query/mod.rs | 6 + .../src/verify/composite_document/mod.rs | 4 + .../verify_composite_documents_proof/mod.rs | 55 + .../v0/mod.rs | 117 ++ packages/rs-drive/src/verify/mod.rs | 4 + .../yappr-feed/yappr-feed-contract.json | 177 ++ .../drive_document_method_versions/mod.rs | 4 + .../drive_document_method_versions/v1.rs | 1 + .../drive_document_method_versions/v2.rs | 1 + .../drive_document_method_versions/v3.rs | 1 + .../drive_document_method_versions/v4.rs | 1 + .../drive_verify_method_versions/mod.rs | 9 + .../drive_verify_method_versions/v1.rs | 11 +- 19 files changed, 2967 insertions(+), 4 deletions(-) create mode 100644 packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/composite_query_e2e_tests.rs create mode 100644 packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs create mode 100644 packages/rs-drive/src/drive/document/query/query_composite_documents/v0/mod.rs create mode 100644 packages/rs-drive/src/query/drive_composite_document_query/mod.rs create mode 100644 packages/rs-drive/src/verify/composite_document/mod.rs create mode 100644 packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs create mode 100644 packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/v0/mod.rs create mode 100644 packages/rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json 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..fe5a915293b --- /dev/null +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/composite_query_e2e_tests.rs @@ -0,0 +1,768 @@ +//! 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::drive_composite_document_query::{ + BindingSource, DriveCompositeDocumentQuery, DriveSubQuery, SubQueryBinding, SubQueryKind, + SubQueryResult, MAX_SUB_QUERIES, +}; +use crate::query::{DriveDocumentQuery, InternalClauses, OrderClause, WhereClause, WhereOperator}; +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![], + } +} + +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]>, +) -> DriveCompositeDocumentQuery<'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); + } + DriveCompositeDocumentQuery { + page: page_by_hashtag(feed, "dash", Some(10)), + 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() +} + +/// 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)); + query.page = page_by_hashtag(&feed, "nothing", Some(10)); + + 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 (page_only_proof, _cost) = query + .page + .clone() + .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" + ); +} + +/// 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 = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, "dash", Some(10)), + 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![], + }; + let query = DriveCompositeDocumentQuery { + page, + 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: DriveCompositeDocumentQuery, 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.page.limit = None; + expect_unsupported(no_limit, "page without a limit"); + + let mut oversized = base.clone(); + oversized.page.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 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", + ); +} 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/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_composite_documents/mod.rs b/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs new file mode 100644 index 00000000000..ad69de31400 --- /dev/null +++ b/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs @@ -0,0 +1,74 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::drive_composite_document_query::DriveCompositeDocumentQuery; +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 page plus its derived + /// 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: &DriveCompositeDocumentQuery, + 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 + /// [`DriveCompositeDocumentQuery::execute_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: &DriveCompositeDocumentQuery, + 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..e1e43a04027 --- /dev/null +++ b/packages/rs-drive/src/drive/document/query/query_composite_documents/v0/mod.rs @@ -0,0 +1,62 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::query::drive_composite_document_query::{ + CompositeDocumentsResult, DriveCompositeDocumentQuery, +}; +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: &DriveCompositeDocumentQuery, + epoch: Option<&Epoch>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut drive_operations: Vec = vec![]; + let result = query.execute_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: &DriveCompositeDocumentQuery, + platform_version: &PlatformVersion, + ) -> Result<(Vec, Vec), Error> { + let mut drive_operations: Vec = vec![]; + query.execute_with_proof_internal(self, &mut drive_operations, platform_version) + } +} diff --git a/packages/rs-drive/src/query/drive_composite_document_query/mod.rs b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs new file mode 100644 index 00000000000..f8abd1f8ab9 --- /dev/null +++ b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs @@ -0,0 +1,1673 @@ +//! Composite document queries: one page query plus sub-queries derived +//! from its proven results, answered as ONE merged grovedb proof. +//! +//! 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. + +use crate::error::drive::DriveError; +use crate::error::proof::ProofError; +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +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 + /// [`DriveCompositeDocumentQuery::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)] +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. + pub where_clauses: Vec, + /// Ordering; documents only. + pub order_by: Vec, + /// Required for a documents lookup on a non-unique index (it bounds + /// the walk under each value); 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, +} + +/// A composite document query. +/// +/// Construction contract: every sub-query's `document_type` MUST be a +/// document type of its own `contract`. [`Self::validate`] enforces +/// everything derivable from the shapes themselves. +#[derive(Debug, Clone)] +pub struct DriveCompositeDocumentQuery<'a> { + /// The page: an ordinary document query with an explicit limit. + pub page: DriveDocumentQuery<'a>, + /// The sub-queries, in binding order (a sub-query may only bind an + /// earlier one). + pub sub_queries: Vec>, +} + +/// 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); + +/// What the routing step decoded out of one path-query group's trios. +enum DecodedItems { + Documents(Vec), + Counts(Vec), +} + +/// 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)) +} + +/// 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> DriveCompositeDocumentQuery<'a> { + /// Validates the composite shape. Called by the server before + /// executing and by the verifier before verifying, so an invalid + /// request fails identically on both sides. + pub fn validate(&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(), + ))); + } + match self.page.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(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(_) => {} + } + if self.page.offset.is_some() { + return Err(unsupported( + "composite queries do not support a page offset; paginate with a range clause" + .to_string(), + )); + } + if self.page.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 (self.page.limit.unwrap_or(0) 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, + self.page.limit.unwrap_or(0), + ))); + } + } + // The page must lower to a path query at all — an unindexed + // shape fails here, before any sub-query is inspected. + self.page_path_query(platform_version)?; + + for (index, sub_query) in self.sub_queries.iter().enumerate() { + self.validate_sub_query(index, sub_query, platform_version)?; + } + self.validate_component_paths(platform_version) + } + + fn validate_sub_query( + &self, + index: usize, + sub_query: &DriveSubQuery<'a>, + 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(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(sub_query, &[], 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.page.contract, + self.page.document_type, + self.page.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.page.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( + source, + &[Identifier::default()], + 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, + ))); + } + + 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( + sub_query, + &[Identifier::default()], + 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, &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, 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>, + shape: &DriveDocumentQuery<'a>, + platform_version: &PlatformVersion, + ) -> Result { + let Some(binding) = &sub_query.binding else { + return Ok(false); + }; + 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.page.internal_clauses.primary_key_in_clause.is_some() + || self + .page + .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.page.clone(); + unlimited.limit = None; + return unlimited.construct_path_query(None, platform_version); + } + self.page.construct_path_query(None, platform_version) + } + + /// Every component lands its proved entries under its path query's + /// base path; entries are routed back to components by the longest + /// matching base path, and within one base path by what they are + /// (count trees go to count sub-queries, items to documents ones) + /// and by bound-value membership. That routing needs two things the + /// shapes must guarantee up front: no limited component may land at + /// the merged root (grovedb has no branch to lift its limit into), + /// and documents components sharing a base path must be tellable + /// apart by their derived values — so 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. + 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)?; + 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_path_query(sub_query, &representative, platform_version)?; + components.push(( + path_query.path, + Component::Sub(index), + path_query.query.limit.is_some(), + )); + } + + let merged_root: Vec> = + components + .iter() + .skip(1) + .fold(components[0].0.clone(), |common, (path, _, _)| { + common + .iter() + .zip(path) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.clone()) + .collect() + }); + for (path, component, limited) in &components { + if *limited && *path == merged_root { + return Err(unsupported(format!( + "{} carries a limit and lands at the merged root of the composite proof, \ + 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> = BTreeMap::new(); + for (path, component, _) in &components { + groups.entry(path).or_default().push(*component); + } + for members in groups.values() { + let documents_members: Vec = members + .iter() + .copied() + .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. + 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(), + )); + } + } + 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 { + return Err(unsupported(format!( + "{} derived values exceed the {} a derived `IN` clause admits", + values.len(), + MAX_BOUND_VALUES, + ))); + } + 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 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() { + 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(), + }); + } + + 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. + if !order_by.contains_key(&binding.field) { + order_by.insert( + binding.field.clone(), + OrderClause { + field: binding.field.clone(), + ascending: true, + }, + ); + } + } + 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(), + }) + } + + /// 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, + )) + })?; + let _ = platform_version; + 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 { + match sub_query.kind { + SubQueryKind::Documents => self + .sub_query_document_query(sub_query, values, 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 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: grovedb's merge + /// requires all inputs to agree, and a key-addressed sub-query means + /// the same set either way. 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 mut path_query = self.sub_query_path_query(sub_query, values, platform_version)?; + path_query.query.query.left_to_right = direction; + sub_path_queries.push(Some(path_query)); + } + Ok((page, sub_path_queries)) + } + + /// 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(Error::from) + } + + /// 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 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 { + trios + .into_iter() + .map(|(path, key, element)| { + let key = if path.len() > base_path_len { + path[base_path_len].clone() + } else { + key + }; + SplitCountEntry { + in_key: None, + key, + count: Some(element.count_value_or_default()), + } + }) + .collect() + } + + /// Assembles one sub-query's result from the decoded items routed to + /// its group, keeping only the items 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_sub_result( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + items: &DecodedItems, + ) -> Result { + let admitted: BTreeSet = values.iter().copied().collect(); + match (sub_query.kind, items) { + (SubQueryKind::Documents, DecodedItems::Documents(documents)) => { + let Some(binding) = &sub_query.binding else { + return Ok(SubQueryResult::Documents(documents.clone())); + }; + 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.clone()).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); + } + return Ok(SubQueryResult::Documents(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(SubQueryResult::Documents(mine)) + } + (SubQueryKind::Count, DecodedItems::Counts(entries)) => { + let mut mine = Vec::new(); + 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.clone()); + } + } + Ok(SubQueryResult::Counts(mine)) + } + _ => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a component group decoded into the wrong kind of items", + ))), + } + } + + /// 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 components by base path. + let mut groups: Vec<(Vec>, Vec)> = Vec::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 { + register(&path_query.path, Component::Sub(index)); + } + } + + // Distribute trios to the group with the longest matching base path. + let mut trios_by_group: Vec> = vec![Vec::new(); groups.len()]; + for (path, key, element) in trios { + let Some(element) = element else { + 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 group once — items are documents, everything else + // is a count tree — 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 ((base_path, members), group_trios) in groups.iter().zip(trios_by_group) { + let (document_trios, count_trios): (Vec<_>, Vec<_>) = group_trios + .into_iter() + .partition(|(_, _, element)| matches!(element, Element::Item(..))); + let documents_members: Vec = members + .iter() + .copied() + .filter(|component| match component { + Component::Page => true, + Component::Sub(index) => { + self.sub_queries[*index].kind == SubQueryKind::Documents + } + }) + .collect(); + let count_members: Vec = members + .iter() + .filter_map(|component| match component { + Component::Sub(index) + if self.sub_queries[*index].kind == SubQueryKind::Count => + { + Some(*index) + } + _ => None, + }) + .collect(); + if !document_trios.is_empty() && documents_members.is_empty() { + return Err(corrupted_proof( + "the composite proof proved documents under a path only counts were \ + asked of" + .to_string(), + )); + } + if !count_trios.is_empty() && count_members.is_empty() { + return Err(corrupted_proof( + "the composite proof proved count trees under a path only documents \ + were asked of" + .to_string(), + )); + } + + if !documents_members.is_empty() { + // 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.page, document_trios, platform_version)? + } + Component::Sub(index) => { + let query = self.sub_query_document_query( + &self.sub_queries[index], + &derived[index], + platform_version, + )?; + Self::decode_document_trios(&query, document_trios, platform_version)? + } + }; + let decoded = DecodedItems::Documents(documents.clone()); + 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 result = + self.assemble_sub_result(sub_query, &derived[*index], &decoded)?; + let mine_ids: BTreeSet = result + .documents() + .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(result); + } + } + } + if claimed.len() != documents.len() { + return Err(corrupted_proof( + "the composite proof carries a document that no component's \ + derivation asked for" + .to_string(), + )); + } + } + + if !count_members.is_empty() { + let entries = Self::decode_count_trios(base_path.len(), count_trios); + let decoded = DecodedItems::Counts(entries.clone()); + let mut claimed: BTreeSet = BTreeSet::new(); + for index in count_members { + let sub_query = &self.sub_queries[index]; + let result = self.assemble_sub_result(sub_query, &derived[index], &decoded)?; + let mine_keys: BTreeSet<&Vec> = + result.counts().iter().map(|entry| &entry.key).collect(); + for (position, entry) in entries.iter().enumerate() { + if mine_keys.contains(&entry.key) { + claimed.insert(position); + } + } + sub_results[index] = Some(result); + } + if claimed.len() != entries.len() { + return Err(corrupted_proof( + "the composite proof carries a count that no component's derivation \ + asked for" + .to_string(), + )); + } + } + } + + 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.page.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.page.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 every sub-query's values from the (materialized or + /// proven) page and earlier sub-query documents, in request order. + pub fn derive_all( + &self, + page_documents: &[Document], + sub_documents: &dyn Fn(usize) -> Option>, + ) -> Result, Error> { + let mut derived: Vec = Vec::with_capacity(self.sub_queries.len()); + for sub_query in &self.sub_queries { + let Some(binding) = &sub_query.binding else { + derived.push(Vec::new()); + continue; + }; + let values = 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)? + } + }; + derived.push(values); + } + Ok(derived) + } + + /// 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> DriveCompositeDocumentQuery<'a> { + /// Materializes the page without a proof. + fn materialize_page( + &self, + drive: &crate::drive::Drive, + transaction: grovedb::TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + Self::materialize_documents( + &self.page, + drive, + transaction, + drive_operations, + platform_version, + ) + } + + /// 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. + fn materialize_sub_result( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + 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(sub_query, values, platform_version)?; + let documents = Self::materialize_documents( + &query, + drive, + transaction, + drive_operations, + platform_version, + )?; + self.assemble_sub_result(sub_query, values, &DecodedItems::Documents(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); + self.assemble_sub_result(sub_query, values, &DecodedItems::Counts(entries)) + } + } + } + + /// Executes the composite query without proofs. + pub(crate) fn execute_no_proof_internal( + &self, + drive: &crate::drive::Drive, + transaction: grovedb::TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + self.validate(platform_version)?; + + let page_documents = + self.materialize_page(drive, transaction, drive_operations, platform_version)?; + let mut sub_results: Vec = Vec::with_capacity(self.sub_queries.len()); + for sub_query in &self.sub_queries { + let values = match &sub_query.binding { + None => Vec::new(), + Some(binding) => match binding.source { + BindingSource::Page => self.derive_values(binding, &page_documents)?, + BindingSource::SubQuery(source) => { + self.derive_values(binding, sub_results[source].documents())? + } + }, + }; + sub_results.push(self.materialize_sub_result( + sub_query, + &values, + drive, + transaction, + drive_operations, + 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_with_proof_internal( + &self, + drive: &crate::drive::Drive, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(Vec, Vec), Error> { + self.validate(platform_version)?; + + const MAX_ATTEMPTS: usize = 3; + for _ in 0..MAX_ATTEMPTS { + let root_before = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap()?; + + let page_documents = + self.materialize_page(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 = match &sub_query.binding { + None => Vec::new(), + Some(binding) => match binding.source { + BindingSource::Page => self.derive_values(binding, &page_documents)?, + BindingSource::SubQuery(source) => { + let documents = materialized[source].as_deref().ok_or_else(|| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a binding's source sub-query was not materialized", + )) + })?; + self.derive_values(binding, documents)? + } + }, + }; + if self.is_binding_source(index) { + let result = self.materialize_sub_result( + sub_query, + &values, + 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()?; + + let root_after = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap()?; + if root_before != root_after { + 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", + ))) + } +} diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index f154d2fd128..37fe4720293 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -293,6 +293,12 @@ pub(crate) mod index_only_synthesis; #[cfg(any(feature = "server", feature = "verify"))] pub mod drive_chained_document_query; +/// Composite document queries — a 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_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 /// docstring for the perf / atomicity contract. Server-only because 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..7a35555ce3c --- /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 +//! [`drive_composite_document_query`](crate::query::drive_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..c15a0431287 --- /dev/null +++ b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs @@ -0,0 +1,55 @@ +mod v0; + +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::drive_composite_document_query::{ + CompositeDocumentsResult, DriveCompositeDocumentQuery, +}; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; + +impl DriveCompositeDocumentQuery<'_> { + /// Verifies a composite query's single merged proof 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: "DriveCompositeDocumentQuery::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..c00927d32c3 --- /dev/null +++ b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/v0/mod.rs @@ -0,0 +1,117 @@ +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::query::drive_composite_document_query::{ + BindingSource, CompositeDocumentsResult, DriveCompositeDocumentQuery, PresentTrio, ProvedTrio, +}; +use crate::verify::RootHash; +use dpp::document::Document; +use dpp::version::PlatformVersion; +use grovedb::GroveDb; + +impl DriveCompositeDocumentQuery<'_> { + /// 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(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.page, 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 = match &sub_query.binding { + None => Vec::new(), + Some(binding) => match binding.source { + BindingSource::Page => self.derive_values(binding, &bootstrap_page)?, + BindingSource::SubQuery(source) => { + let documents = + bootstrap_sub_documents[source].as_deref().ok_or_else(|| { + Error::Proof(ProofError::CorruptedProof( + "a binding's source sub-query was not bootstrapped before \ + it" + .to_string(), + )) + })?; + self.derive_values(binding, documents)? + } + }, + }; + if self.is_binding_source(index) { + let documents = if sub_query.binding.is_some() && values.is_empty() { + Vec::new() + } else { + let mut path_query = + self.sub_query_path_query(sub_query, &values, platform_version)?; + path_query.query.query.left_to_right = direction; + let (_, trios) = + GroveDb::verify_subset_query(proof, &path_query, grove_version)?; + let query = + self.sub_query_document_query(sub_query, &values, platform_version)?; + Self::decode_document_trios(&query, 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: every derivation must come + // out identical from them, or the proof was built over a different + // page than it proves. + let authoritative = self.derive_all(&result.page_documents, &|index| { + Some(result.sub_results[index].documents().to_vec()) + })?; + 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/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/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..3d0d7fa617f --- /dev/null +++ b/packages/rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json @@ -0,0 +1,177 @@ +{ + "$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 + } + ], + "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, From d14631126825d39f4f85a390a2e7a120785d3b2d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 5 Sep 2026 10:51:25 +0200 Subject: [PATCH 2/9] fix(drive): preserve composite query selections and binding order --- .../v0/tests/composite_query_e2e_tests.rs | 429 ++++++++++++++++++ .../drive_composite_document_query/mod.rs | 387 ++++++++++------ .../v0/mod.rs | 18 +- .../yappr-feed/yappr-feed-contract.json | 12 + 4 files changed, 701 insertions(+), 145 deletions(-) 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 index fe5a915293b..777f7c48ff9 100644 --- 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 @@ -384,6 +384,331 @@ 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 = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, "dash", Some(10)), + 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 = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, hashtag, Some(10)), + 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("outer ordering")); + } + } + } +} + +#[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 = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, "dash", Some(10)), + 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 = DriveCompositeDocumentQuery { page, 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 full round trip: the server's materialized result and the /// verifier's composed result agree component for component. #[test] @@ -691,6 +1016,36 @@ fn should_reject_invalid_composite_shapes() { 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"); @@ -766,3 +1121,77 @@ fn should_reject_invalid_composite_shapes() { "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 = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, "dash", Some(10)), + 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); +} diff --git a/packages/rs-drive/src/query/drive_composite_document_query/mod.rs b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs index f8abd1f8ab9..c3e5ef2196f 100644 --- a/packages/rs-drive/src/query/drive_composite_document_query/mod.rs +++ b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs @@ -137,8 +137,10 @@ pub struct DriveSubQuery<'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. + /// Ordering; documents only. The outer query direction must agree + /// with the page's direction so merging preserves the requested rows. pub order_by: Vec, /// Required for a documents lookup on a non-unique index (it bounds /// the walk under each value); forbidden for a value-bounded lookup, @@ -683,11 +685,10 @@ impl<'a> DriveCompositeDocumentQuery<'a> { self.page.construct_path_query(None, platform_version) } - /// Every component lands its proved entries under its path query's - /// base path; entries are routed back to components by the longest - /// matching base path, and within one base path by what they are - /// (count trees go to count sub-queries, items to documents ones) - /// and by bound-value membership. That routing needs two things the + /// Document entries are routed back to components by the longest + /// matching base path and then by bound-value membership; counts use + /// their exact terminal positions. Document routing and merging need + /// two things the /// shapes must guarantee up front: no limited component may land at /// the merged root (grovedb has no branch to lift its limit into), /// and documents components sharing a base path must be tellable @@ -698,10 +699,15 @@ impl<'a> DriveCompositeDocumentQuery<'a> { 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_path_query(sub_query, &representative, platform_version)?; + let path_query = self.sub_query_proof_path_query( + sub_query, + &representative, + direction, + platform_version, + )?; components.push(( path_query.path, Component::Sub(index), @@ -836,6 +842,12 @@ impl<'a> DriveCompositeDocumentQuery<'a> { }; 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, @@ -962,12 +974,40 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } } + /// 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(sub_query, values, 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( + "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: grovedb's merge - /// requires all inputs to agree, and a key-addressed sub-query means - /// the same set either way. ONE builder both the prover + /// 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( @@ -988,13 +1028,67 @@ impl<'a> DriveCompositeDocumentQuery<'a> { sub_path_queries.push(None); continue; } - let mut path_query = self.sub_query_path_query(sub_query, values, platform_version)?; - path_query.query.query.left_to_right = direction; + 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()) { + 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(), + )); + } + } + } 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( @@ -1043,13 +1137,33 @@ impl<'a> DriveCompositeDocumentQuery<'a> { .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], + trios: Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let query = self.sub_query_document_query(sub_query, values, platform_version)?; + let documents = Self::decode_document_trios(&query, trios, platform_version)?; + match self.assemble_sub_result(sub_query, values, &DecodedItems::Documents(documents))? { + SubQueryResult::Documents(documents) => Ok(documents), + SubQueryResult::Counts(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a documents sub-query must assemble 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 { - trios + let mut entries: Vec<_> = trios .into_iter() .map(|(path, key, element)| { let key = if path.len() > base_path_len { @@ -1063,7 +1177,11 @@ impl<'a> DriveCompositeDocumentQuery<'a> { count: Some(element.count_value_or_default()), } }) - .collect() + .collect(); + // 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 sub-query's result from the decoded items routed to @@ -1157,8 +1275,12 @@ impl<'a> DriveCompositeDocumentQuery<'a> { trios: Vec, platform_version: &PlatformVersion, ) -> Result { - // Group components by base path. + // 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 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); @@ -1169,16 +1291,48 @@ impl<'a> DriveCompositeDocumentQuery<'a> { register(&page_path_query.path, Component::Page); for (index, path_query) in sub_path_queries.iter().enumerate() { if let Some(path_query) = path_query { - register(&path_query.path, Component::Sub(index)); + 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 trios to the group with the longest matching base path. + // 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(), + ) + })?; + for index in members { + count_trios_by_sub[*index].push(( + position.0.clone(), + position.1.clone(), + element.clone(), + )); + } + continue; + } let best = groups .iter() .enumerate() @@ -1195,140 +1349,89 @@ impl<'a> DriveCompositeDocumentQuery<'a> { trios_by_group[best].push((path, key, element)); } - // Decode each group once — items are documents, everything else - // is a count tree — then let every member claim its share. + // 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 ((base_path, members), group_trios) in groups.iter().zip(trios_by_group) { - let (document_trios, count_trios): (Vec<_>, Vec<_>) = group_trios - .into_iter() - .partition(|(_, _, element)| matches!(element, Element::Item(..))); - let documents_members: Vec = members - .iter() - .copied() - .filter(|component| match component { - Component::Page => true, - Component::Sub(index) => { - self.sub_queries[*index].kind == SubQueryKind::Documents - } - }) - .collect(); - let count_members: Vec = members - .iter() - .filter_map(|component| match component { - Component::Sub(index) - if self.sub_queries[*index].kind == SubQueryKind::Count => - { - Some(*index) - } - _ => None, - }) - .collect(); - if !document_trios.is_empty() && documents_members.is_empty() { - return Err(corrupted_proof( - "the composite proof proved documents under a path only counts were \ - asked of" - .to_string(), - )); - } - if !count_trios.is_empty() && count_members.is_empty() { - return Err(corrupted_proof( - "the composite proof proved count trees under a path only documents \ - were asked of" - .to_string(), - )); - } - - if !documents_members.is_empty() { - // Every documents member of a group addresses the same - // type, so any member's query decodes the group. - let documents = match documents_members[0] { + 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.page, document_trios, platform_version)? + } + Component::Sub(index) => { + let query = self.sub_query_document_query( + &self.sub_queries[index], + &derived[index], + platform_version, + )?; + Self::decode_document_trios(&query, document_trios, platform_version)? + } + }; + let decoded = DecodedItems::Documents(documents.clone()); + let mut claimed: BTreeSet = BTreeSet::new(); + for member in documents_members { + match member { Component::Page => { - Self::decode_document_trios(&self.page, document_trios, platform_version)? - } - Component::Sub(index) => { - let query = self.sub_query_document_query( - &self.sub_queries[index], - &derived[index], - platform_version, - )?; - Self::decode_document_trios(&query, document_trios, platform_version)? - } - }; - let decoded = DecodedItems::Documents(documents.clone()); - 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()); - } + 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 result = - self.assemble_sub_result(sub_query, &derived[*index], &decoded)?; - let mine_ids: BTreeSet = result - .documents() - .iter() - .map(|document| document.id()) - .collect(); - for (position, document) in documents.iter().enumerate() { - if mine_ids.contains(&document.id()) { - claimed.insert(position); - } + page_documents = Some(mine); + } + Component::Sub(index) => { + let sub_query = &self.sub_queries[*index]; + let result = + self.assemble_sub_result(sub_query, &derived[*index], &decoded)?; + let mine_ids: BTreeSet = result + .documents() + .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(result); } + sub_results[*index] = Some(result); } } - if claimed.len() != documents.len() { - return Err(corrupted_proof( - "the composite proof carries a document that no component's \ + } + if claimed.len() != documents.len() { + return Err(corrupted_proof( + "the composite proof carries a document that no component's \ derivation asked for" - .to_string(), - )); - } + .to_string(), + )); } + } - if !count_members.is_empty() { - let entries = Self::decode_count_trios(base_path.len(), count_trios); - let decoded = DecodedItems::Counts(entries.clone()); - let mut claimed: BTreeSet = BTreeSet::new(); - for index in count_members { - let sub_query = &self.sub_queries[index]; - let result = self.assemble_sub_result(sub_query, &derived[index], &decoded)?; - let mine_keys: BTreeSet<&Vec> = - result.counts().iter().map(|entry| &entry.key).collect(); - for (position, entry) in entries.iter().enumerate() { - if mine_keys.contains(&entry.key) { - claimed.insert(position); - } - } - sub_results[index] = Some(result); - } - if claimed.len() != entries.len() { - return Err(corrupted_proof( - "the composite proof carries a count 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(self.assemble_sub_result( + &self.sub_queries[index], + &derived[index], + &DecodedItems::Counts(entries), + )?); } Ok(CompositeDocumentsResult { @@ -1556,6 +1659,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { let page_documents = self.materialize_page(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 = match &sub_query.binding { None => Vec::new(), @@ -1574,7 +1678,12 @@ impl<'a> DriveCompositeDocumentQuery<'a> { 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, 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 index c00927d32c3..8cd8eebf304 100644 --- 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 @@ -66,14 +66,20 @@ impl DriveCompositeDocumentQuery<'_> { let documents = if sub_query.binding.is_some() && values.is_empty() { Vec::new() } else { - let mut path_query = - self.sub_query_path_query(sub_query, &values, platform_version)?; - path_query.query.query.left_to_right = direction; + 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)?; - let query = - self.sub_query_document_query(sub_query, &values, platform_version)?; - Self::decode_document_trios(&query, present(trios), platform_version)? + self.decode_sub_query_document_trios( + sub_query, + &values, + present(trios), + platform_version, + )? }; bootstrap_sub_documents[index] = Some(documents); } 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 index 3d0d7fa617f..875985f6766 100644 --- 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 @@ -152,6 +152,18 @@ } ], "unique": true + }, + { + "name": "postAndOwner", + "properties": [ + { + "postId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "countable": true } ], "properties": { From d734175e4b0d90d3a6abc6c8904aa070c40e5e2d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 5 Sep 2026 11:10:48 +0200 Subject: [PATCH 3/9] fix(drive): unordered composite lookups inherit the page's direction A documents sub-query the caller left unordered on its bound field was appended ascending, which the direction rule then refused under a descending page: every feed page (`$createdAt desc`) with default lookups failed with "outer ordering must match the page's direction". The appended clause now takes the page's direction, so a minimal request never conflicts with its page; an explicit ordering that disagrees is still refused, on every entry point. Also documents a lookup's limit for what it is: a cap on the rows the lookup returns in total, in walk order, like an ordinary `IN` query's, not a per-value bound. Test: a descending by-ids page with unordered cross-contract profiles, the viewer's likes and a count merges, proves and verifies with the lookups walking descending; a limited same-contract lookup does too, returning its capped rows from the top of the walk; an explicit ascending ordering under the descending page is refused by all three entry points. Co-Authored-By: Claude Fable 5.1 --- .../v0/tests/composite_query_e2e_tests.rs | 162 ++++++++++++++++++ .../drive_composite_document_query/mod.rs | 33 +++- 2 files changed, 189 insertions(+), 6 deletions(-) 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 index 777f7c48ff9..0297eb16089 100644 --- 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 @@ -1195,3 +1195,165 @@ fn should_check_count_and_document_descents_against_the_actual_bound_values() { ); 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: &DriveCompositeDocumentQuery, 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 = DriveCompositeDocumentQuery { + page: descending_page(), + 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 = DriveCompositeDocumentQuery { + page: descending_page(), + 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" + ); + + // 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")); + } +} diff --git a/packages/rs-drive/src/query/drive_composite_document_query/mod.rs b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs index c3e5ef2196f..4a59d660317 100644 --- a/packages/rs-drive/src/query/drive_composite_document_query/mod.rs +++ b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs @@ -139,12 +139,18 @@ pub struct DriveSubQuery<'a> { /// 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. The outer query direction must agree - /// with the page's direction so merging preserves the requested rows. + /// 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 bounds - /// the walk under each value); forbidden for a value-bounded lookup, - /// a by-id join (completeness is set-based) and a count. + /// 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, @@ -887,12 +893,17 @@ impl<'a> DriveCompositeDocumentQuery<'a> { // 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: true, + ascending: self.page_direction(platform_version)?, }, ); } @@ -974,6 +985,16 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } } + /// 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, From 7710e4d730b0e739dd85d306b7945bd5eb1807d2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 5 Sep 2026 11:19:35 +0200 Subject: [PATCH 4/9] test(drive): assert the sibling-bound count's derived post The descending-page test bound a like count to a limited sibling to show the sibling's limit applies before values derive, but only compared the materialized and verified results with each other. It now asserts the count covers exactly the sibling's single post, with the like count that post carries. Co-Authored-By: Claude Fable 5.1 --- .../v0/tests/composite_query_e2e_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 index 0297eb16089..f151c61b654 100644 --- 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 @@ -707,6 +707,16 @@ fn should_preserve_descending_documents_and_key_ordered_counts() { .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 From 93fa8185a3a395c7e2370594c3c4f0098467fec2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 5 Sep 2026 11:23:25 +0200 Subject: [PATCH 5/9] test(drive): cover a descending page with a cross-contract and a limited lookup 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. With grovedb pinned at #851 that split carries the inputs' direction and the descending composition merges, proves and verifies like its ascending twin. Co-Authored-By: Claude Fable 5.1 --- .../v0/tests/composite_query_e2e_tests.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 index f151c61b654..3c9d7f9bbfa 100644 --- 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 @@ -1345,6 +1345,42 @@ fn should_inherit_the_page_direction_for_unordered_lookups() { "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 = DriveCompositeDocumentQuery { + page: descending_page(), + 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(); From 0995a36b6260cc323871c3b538cf1b333324517d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 7 Sep 2026 20:43:41 +0700 Subject: [PATCH 6/9] fix(drive): close the composite review findings Shape rules the merge needs, decided from the shapes alone so a request that validates never fails the merge for lack of data: - The "no limited component at the merged root" rule is checked over the worst case. A bound sub-query that derives nothing contributes no branch, so the merged root is the common prefix of whichever components are present: a limited page is refused when a bound sub-query's path extends its own (that sub-query alone leaves the page at the root) or when every sibling does; a limited sub-query when the page and every sibling extend its path. - Two documents components on one index path may not carry a limit: they merge into one body and budgets never blend. - A `$id ==` page lowers with a limit of one whatever its limit says; the proof query now drops it like a `$id IN` page's, so such a page can share the primary tree with a join. - Limits of zero are refused up front instead of failing inside grovedb; a bound field must be `$ownerId` or identifier-typed on the sub-query's own type; a merge refusal at prove or verify time is reported as a shape error, not an internal one. Routing hardening: - Document entries are routed to the longest matching base path, which is only right when no documents component walks through another's base path to deeper rows; that is now checked on the concrete walks wherever the count-tree check runs (both entry points and the verifier). - The verifier compares what the bootstrap decoded through each component's own query with what the routed authoritative pass assigned to it; a difference is a misassigned entry. Cleanups: one `derive_for` the executors and the verifier all call; the page's direction lowered once per request and threaded through; `assemble_documents` / `assemble_counts` over slices in place of the `DecodedItems` enum; the point-lookup count entry decoder shared with `verify_point_lookup_count_proof`; a losing proof attempt discards its operations; the count-tree check skips unrelated components; module docs cover direction inheritance, siblings under a descending page and the by-ids page limit. Tests: zero limits, a non-identifier bound field, a limited page a sub-query could leave at the merged root, a `$id ==` page with a join, two limited lookups on one index path, and the descent predicate. Co-Authored-By: Claude Fable 5.1 --- .../v0/tests/composite_query_e2e_tests.rs | 217 +++++- .../drive_composite_document_query/mod.rs | 696 ++++++++++++------ .../query/drive_document_count_query/mod.rs | 37 + .../v0/mod.rs | 61 +- .../verify_point_lookup_count_proof/v0/mod.rs | 52 +- 5 files changed, 768 insertions(+), 295 deletions(-) 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 index 3c9d7f9bbfa..1b085e023f6 100644 --- 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 @@ -545,7 +545,10 @@ fn should_reject_conflicting_document_directions_even_when_the_page_is_empty() { query.verify_composite_documents_proof(&[], pv).map(|_| ()), ] { assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); - assert!(result.unwrap_err().to_string().contains("outer ordering")); + assert!(result + .unwrap_err() + .to_string() + .contains("must match the page's direction")); } } } @@ -1403,3 +1406,215 @@ fn should_inherit_the_page_direction_for_unordered_lookups() { 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.page.limit = Some(0); + let refused = zero_page + .validate(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(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(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(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![], + }; + 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 = DriveCompositeDocumentQuery { + page, + 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(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 = DriveCompositeDocumentQuery { + page, + 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(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/query/drive_composite_document_query/mod.rs b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs index 4a59d660317..c52d4e5f9cf 100644 --- a/packages/rs-drive/src/query/drive_composite_document_query/mod.rs +++ b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs @@ -55,12 +55,24 @@ //! `$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. +//! 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, @@ -218,12 +230,6 @@ pub(crate) type ProvedTrio = (Vec>, Vec, Option); /// A proved triple whose element is present. pub(crate) type PresentTrio = (Vec>, Vec, Element); -/// What the routing step decoded out of one path-query group's trios. -enum DecodedItems { - Documents(Vec), - Counts(Vec), -} - /// A component of the merged proof: the page or one sub-query. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Component { @@ -239,6 +245,19 @@ 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> { @@ -303,7 +322,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { self.sub_queries.len(), ))); } - match self.page.limit { + let page_limit = match self.page.limit { None => { return Err(unsupported( "composite queries require an explicit limit on the page: the page size \ @@ -311,6 +330,11 @@ impl<'a> DriveCompositeDocumentQuery<'a> { .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 \ @@ -318,8 +342,8 @@ impl<'a> DriveCompositeDocumentQuery<'a> { limit, MAX_BOUND_VALUES, ))); } - Some(_) => {} - } + Some(limit) => limit, + }; if self.page.offset.is_some() { return Err(unsupported( "composite queries do not support a page offset; paginate with a range clause" @@ -337,21 +361,20 @@ impl<'a> DriveCompositeDocumentQuery<'a> { // `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 (self.page.limit.unwrap_or(0) as usize) < ids { + 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, - self.page.limit.unwrap_or(0), + ids, page_limit, ))); } } // The page must lower to a path query at all — an unindexed // shape fails here, before any sub-query is inspected. - self.page_path_query(platform_version)?; + let direction = self.page_direction(platform_version)?; for (index, sub_query) in self.sub_queries.iter().enumerate() { - self.validate_sub_query(index, sub_query, platform_version)?; + self.validate_sub_query(index, sub_query, direction, platform_version)?; } self.validate_component_paths(platform_version) } @@ -360,6 +383,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { &self, index: usize, sub_query: &DriveSubQuery<'a>, + direction: bool, platform_version: &PlatformVersion, ) -> Result<(), Error> { let label = |message: &str| unsupported(format!("sub-query {}: {}", index, message)); @@ -379,6 +403,9 @@ impl<'a> DriveCompositeDocumentQuery<'a> { "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 {}", @@ -388,8 +415,13 @@ impl<'a> DriveCompositeDocumentQuery<'a> { Some(_) => {} } // Must lower to a path query. - self.sub_query_document_query(sub_query, &[], platform_version)? - .construct_path_query(None, platform_version)?; + self.sub_query_document_query_with_direction( + sub_query, + &[], + direction, + platform_version, + )? + .construct_path_query(None, platform_version)?; return Ok(()); }; @@ -465,9 +497,10 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } BindingSource::SubQuery(source_index) => { let source = &self.sub_queries[source_index]; - let shape = self.sub_query_document_query( + let shape = self.sub_query_document_query_with_direction( source, &[Identifier::default()], + direction, platform_version, )?; let index = shape.index_only_query_index(platform_version)?; @@ -495,6 +528,35 @@ impl<'a> DriveCompositeDocumentQuery<'a> { ))); } + // 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() { @@ -551,9 +613,10 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } SubQueryKind::Documents => { // Must lower to a path query with a representative value. - let shape = self.sub_query_document_query( + let shape = self.sub_query_document_query_with_direction( sub_query, &[Identifier::default()], + direction, platform_version, )?; shape.construct_path_query(None, platform_version)?; @@ -581,7 +644,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { // the same index. Anything else needs one, to bound the // walk under each value. let value_bounded = - self.lookup_is_value_bounded(sub_query, &shape, platform_version)?; + self.lookup_is_value_bounded(sub_query, binding, &shape, platform_version)?; match (value_bounded, sub_query.limit) { (true, Some(_)) => { return Err(label( @@ -590,6 +653,9 @@ impl<'a> DriveCompositeDocumentQuery<'a> { 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 \ @@ -634,12 +700,10 @@ impl<'a> DriveCompositeDocumentQuery<'a> { fn lookup_is_value_bounded( &self, sub_query: &DriveSubQuery<'a>, + binding: &SubQueryBinding, shape: &DriveDocumentQuery<'a>, platform_version: &PlatformVersion, ) -> Result { - let Some(binding) = &sub_query.binding else { - return Ok(false); - }; let fixed_equalities: BTreeSet<&str> = sub_query .where_clauses .iter() @@ -686,21 +750,33 @@ impl<'a> DriveCompositeDocumentQuery<'a> { if self.page_is_by_ids() { let mut unlimited = self.page.clone(); unlimited.limit = None; - return unlimited.construct_path_query(None, platform_version); + 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.page.construct_path_query(None, platform_version) } - /// Document entries are routed back to components by the longest - /// matching base path and then by bound-value membership; counts use - /// their exact terminal positions. Document routing and merging need - /// two things the - /// shapes must guarantee up front: no limited component may land at - /// the merged root (grovedb has no branch to lift its limit into), - /// and documents components sharing a base path must be tellable - /// apart by their derived values — so 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. + /// 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(); @@ -721,24 +797,38 @@ impl<'a> DriveCompositeDocumentQuery<'a> { )); } - let merged_root: Vec> = - components - .iter() - .skip(1) - .fold(components[0].0.clone(), |common, (path, _, _)| { - common - .iter() - .zip(path) - .take_while(|(a, b)| a == b) - .map(|(a, _)| a.clone()) - .collect() - }); + 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 && *path == merged_root { + 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, \ - 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", + "{} 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), @@ -747,14 +837,14 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } } - let mut groups: BTreeMap<&Vec>, Vec> = BTreeMap::new(); - for (path, component, _) in &components { - groups.entry(path).or_default().push(*component); + 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() - .copied() + .map(|(component, _)| *component) .filter(|component| match component { Component::Page => true, Component::Sub(index) => { @@ -762,12 +852,18 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } }) .collect(); - let has_count_member = members.iter().any(|component| { + 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. + // 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: \ @@ -797,6 +893,17 @@ impl<'a> DriveCompositeDocumentQuery<'a> { .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(()) } @@ -820,10 +927,11 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } } if values.len() > MAX_BOUND_VALUES { - return Err(unsupported(format!( - "{} derived values exceed the {} a derived `IN` clause admits", - 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) @@ -837,6 +945,20 @@ impl<'a> DriveCompositeDocumentQuery<'a> { 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 = || { @@ -903,7 +1025,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { binding.field.clone(), OrderClause { field: binding.field.clone(), - ascending: self.page_direction(platform_version)?, + ascending: direction, }, ); } @@ -929,7 +1051,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { &'b self, sub_query: &'b DriveSubQuery<'a>, values: &[Identifier], - platform_version: &PlatformVersion, + _platform_version: &PlatformVersion, ) -> Result, Error> { let Some(binding) = &sub_query.binding else { return Err(unsupported("a count sub-query must be bound".to_string())); @@ -958,7 +1080,6 @@ impl<'a> DriveCompositeDocumentQuery<'a> { binding.field, )) })?; - let _ = platform_version; Ok(DriveDocumentCountQuery { document_type: sub_query.document_type, contract_id: sub_query.contract.id().to_buffer(), @@ -974,10 +1095,26 @@ impl<'a> DriveCompositeDocumentQuery<'a> { 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(sub_query, values, platform_version)? + .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)? @@ -1006,16 +1143,25 @@ impl<'a> DriveCompositeDocumentQuery<'a> { direction: bool, platform_version: &PlatformVersion, ) -> Result { - let mut path_query = self.sub_query_path_query(sub_query, values, platform_version)?; + 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( + 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(), - )); + .to_string() + })); } // Joins restore first-appearance order after decoding. Counts // restore key order. Their selected sets do not depend on direction. @@ -1073,6 +1219,14 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } 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; \ @@ -1082,6 +1236,38 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } } } + // 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)) } @@ -1122,7 +1308,8 @@ impl<'a> DriveCompositeDocumentQuery<'a> { if components.len() == 1 { return Ok(page.clone()); } - PathQuery::merge(components, &platform_version.drive.grove_version).map_err(Error::from) + PathQuery::merge(components, &platform_version.drive.grove_version) + .map_err(merge_error_to_shape_error) } /// Decodes the proved entries of a documents component: stored @@ -1165,17 +1352,18 @@ impl<'a> DriveCompositeDocumentQuery<'a> { &self, sub_query: &DriveSubQuery<'a>, values: &[Identifier], + direction: bool, trios: Vec, platform_version: &PlatformVersion, ) -> Result, Error> { - let query = self.sub_query_document_query(sub_query, values, platform_version)?; + 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)?; - match self.assemble_sub_result(sub_query, values, &DecodedItems::Documents(documents))? { - SubQueryResult::Documents(documents) => Ok(documents), - SubQueryResult::Counts(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( - "a documents sub-query must assemble documents", - ))), - } + self.assemble_documents(sub_query, values, &documents) } /// Decodes the proved entries of a count component: one entry per @@ -1184,102 +1372,94 @@ impl<'a> DriveCompositeDocumentQuery<'a> { /// 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 { - let mut entries: Vec<_> = trios - .into_iter() - .map(|(path, key, element)| { - let key = if path.len() > base_path_len { - path[base_path_len].clone() - } else { - key - }; - SplitCountEntry { - in_key: None, - key, - count: Some(element.count_value_or_default()), - } - }) - .collect(); + // 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 sub-query's result from the decoded items routed to - /// its group, keeping only the items 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_sub_result( + /// 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], - items: &DecodedItems, - ) -> Result { + documents: &[Document], + ) -> Result, Error> { + let Some(binding) = &sub_query.binding else { + return Ok(documents.to_vec()); + }; let admitted: BTreeSet = values.iter().copied().collect(); - match (sub_query.kind, items) { - (SubQueryKind::Documents, DecodedItems::Documents(documents)) => { - let Some(binding) = &sub_query.binding else { - return Ok(SubQueryResult::Documents(documents.clone())); - }; - 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.clone()).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); - } - return Ok(SubQueryResult::Documents(ordered)); + 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; } - 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()), - _ => {} - } + if by_id.insert(id, document).is_some() { + return Err(corrupted_proof(format!( + "composite join results carry document {} twice", + id + ))); } - Ok(SubQueryResult::Documents(mine)) } - (SubQueryKind::Count, DecodedItems::Counts(entries)) => { - let mut mine = Vec::new(); - 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.clone()); - } - } - Ok(SubQueryResult::Counts(mine)) + 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); } - _ => Err(Error::Drive(DriveError::CorruptedCodeExecution( - "a component group decoded into the wrong kind of items", - ))), } + Ok(mine) } /// Routes the proved trios of the merged query back to the page and @@ -1300,6 +1480,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { // 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| { @@ -1345,13 +1526,20 @@ impl<'a> DriveCompositeDocumentQuery<'a> { .to_string(), ) })?; - for index in members { + // 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 @@ -1382,15 +1570,15 @@ impl<'a> DriveCompositeDocumentQuery<'a> { Self::decode_document_trios(&self.page, document_trios, platform_version)? } Component::Sub(index) => { - let query = self.sub_query_document_query( + 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 decoded = DecodedItems::Documents(documents.clone()); let mut claimed: BTreeSet = BTreeSet::new(); for member in documents_members { match member { @@ -1415,19 +1603,16 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } Component::Sub(index) => { let sub_query = &self.sub_queries[*index]; - let result = - self.assemble_sub_result(sub_query, &derived[*index], &decoded)?; - let mine_ids: BTreeSet = result - .documents() - .iter() - .map(|document| document.id()) - .collect(); + 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(result); + sub_results[*index] = Some(SubQueryResult::Documents(mine)); } } } @@ -1448,11 +1633,10 @@ impl<'a> DriveCompositeDocumentQuery<'a> { continue; }; let entries = Self::decode_count_trios(path_query.path.len(), count_trios); - sub_results[index] = Some(self.assemble_sub_result( - &self.sub_queries[index], + sub_results[index] = Some(SubQueryResult::Counts(Self::assemble_counts( &derived[index], - &DecodedItems::Counts(entries), - )?); + entries, + )?)); } Ok(CompositeDocumentsResult { @@ -1503,33 +1687,47 @@ impl<'a> DriveCompositeDocumentQuery<'a> { Ok(ids) } - /// Derives every sub-query's values from the (materialized or - /// proven) page and earlier sub-query documents, in request order. - pub fn derive_all( + /// 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: &dyn Fn(usize) -> Option>, - ) -> Result, Error> { - let mut derived: Vec = Vec::with_capacity(self.sub_queries.len()); - for sub_query in &self.sub_queries { - let Some(binding) = &sub_query.binding else { - derived.push(Vec::new()); - continue; - }; - let values = 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)? - } - }; - derived.push(values); + 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) + } } - Ok(derived) + } + + /// 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. @@ -1599,10 +1797,14 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } /// 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, @@ -1618,7 +1820,12 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } match sub_query.kind { SubQueryKind::Documents => { - let query = self.sub_query_document_query(sub_query, values, platform_version)?; + let query = self.sub_query_document_query_with_direction( + sub_query, + values, + direction, + platform_version, + )?; let documents = Self::materialize_documents( &query, drive, @@ -1626,7 +1833,9 @@ impl<'a> DriveCompositeDocumentQuery<'a> { drive_operations, platform_version, )?; - self.assemble_sub_result(sub_query, values, &DecodedItems::Documents(documents)) + Ok(SubQueryResult::Documents( + self.assemble_documents(sub_query, values, &documents)?, + )) } SubQueryKind::Count => { let path_query = self @@ -1662,7 +1871,9 @@ impl<'a> DriveCompositeDocumentQuery<'a> { }) .collect(); let entries = Self::decode_count_trios(base_path_len, trios); - self.assemble_sub_result(sub_query, values, &DecodedItems::Counts(entries)) + Ok(SubQueryResult::Counts(Self::assemble_counts( + values, entries, + )?)) } } } @@ -1677,23 +1888,19 @@ impl<'a> DriveCompositeDocumentQuery<'a> { ) -> Result { self.validate(platform_version)?; + let direction = self.page_direction(platform_version)?; let page_documents = self.materialize_page(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 = match &sub_query.binding { - None => Vec::new(), - Some(binding) => match binding.source { - BindingSource::Page => self.derive_values(binding, &page_documents)?, - BindingSource::SubQuery(source) => { - self.derive_values(binding, sub_results[source].documents())? - } - }, - }; + 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, @@ -1734,9 +1941,16 @@ impl<'a> DriveCompositeDocumentQuery<'a> { platform_version: &PlatformVersion, ) -> Result<(Vec, Vec), Error> { self.validate(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) @@ -1749,24 +1963,16 @@ impl<'a> DriveCompositeDocumentQuery<'a> { 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 = match &sub_query.binding { - None => Vec::new(), - Some(binding) => match binding.source { - BindingSource::Page => self.derive_values(binding, &page_documents)?, - BindingSource::SubQuery(source) => { - let documents = materialized[source].as_deref().ok_or_else(|| { - Error::Drive(DriveError::CorruptedCodeExecution( - "a binding's source sub-query was not materialized", - )) - })?; - self.derive_values(binding, documents)? - } - }, - }; + 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, @@ -1784,13 +1990,15 @@ impl<'a> DriveCompositeDocumentQuery<'a> { let proof = drive .grove .prove_query_many(components, None, &platform_version.drive.grove_version) - .unwrap()?; + .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)); @@ -1801,3 +2009,45 @@ impl<'a> DriveCompositeDocumentQuery<'a> { ))) } } + +#[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]| { + DriveCompositeDocumentQuery::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/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 index 8cd8eebf304..0750f56e61f 100644 --- 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 @@ -1,7 +1,7 @@ use crate::error::proof::ProofError; use crate::error::Error; use crate::query::drive_composite_document_query::{ - BindingSource, CompositeDocumentsResult, DriveCompositeDocumentQuery, PresentTrio, ProvedTrio, + CompositeDocumentsResult, DriveCompositeDocumentQuery, PresentTrio, ProvedTrio, }; use crate::verify::RootHash; use dpp::document::Document; @@ -45,23 +45,11 @@ impl DriveCompositeDocumentQuery<'_> { let mut bootstrap_sub_documents: Vec>> = vec![None; self.sub_queries.len()]; for (index, sub_query) in self.sub_queries.iter().enumerate() { - let values = match &sub_query.binding { - None => Vec::new(), - Some(binding) => match binding.source { - BindingSource::Page => self.derive_values(binding, &bootstrap_page)?, - BindingSource::SubQuery(source) => { - let documents = - bootstrap_sub_documents[source].as_deref().ok_or_else(|| { - Error::Proof(ProofError::CorruptedProof( - "a binding's source sub-query was not bootstrapped before \ - it" - .to_string(), - )) - })?; - self.derive_values(binding, documents)? - } - }, - }; + 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() @@ -77,6 +65,7 @@ impl DriveCompositeDocumentQuery<'_> { self.decode_sub_query_document_trios( sub_query, &values, + direction, present(trios), platform_version, )? @@ -104,11 +93,37 @@ impl DriveCompositeDocumentQuery<'_> { platform_version, )?; - // The PROVEN results are authoritative: every derivation must come - // out identical from them, or the proof was built over a different - // page than it proves. - let authoritative = self.derive_all(&result.page_documents, &|index| { - Some(result.sub_results[index].documents().to_vec()) + // 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( 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)) } } From ddb9e2b1f27b3122a2c8c39924012a1b8f5d2286 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 7 Sep 2026 23:43:05 +0700 Subject: [PATCH 7/9] refactor(drive): fold DriveCompositeDocumentQuery into DriveDocumentQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composite query is no longer a wrapper struct: DriveDocumentQuery gains `sub_queries` (empty for an ordinary query, never parsed from the wire; `with_sub_queries` builds the composite form) and all composite behaviour moves onto it — validate_composite, derivation, the component path-query builders, proof merging, the executors behind query_composite_documents(_with_proof) and verify_composite_documents_proof. The module renames to query::composite_document_query and the sub-query types re-export from crate::query. The plain page-only surfaces — query_documents(_with_flags), the execute_* proof/no-proof wrappers, verify_proof(_keep_serialized), verify_start_at_document_in_proof and a chained query's inner page — refuse a query carrying sub-queries instead of silently proving or verifying the page alone; a new e2e test pins the refusals. Co-Authored-By: Claude Fable 5 --- .../src/documents/document_query.rs | 3 + .../v0/mod.rs | 1 + .../data_triggers/triggers/dpns/v0/mod.rs | 2 + .../data_triggers/triggers/dpns/v1/mod.rs | 2 + .../triggers/withdrawals/v0/mod.rs | 1 + .../triggers/withdrawals/v1/mod.rs | 1 + .../batch/state/v0/fetch_documents.rs | 4 + .../batch/tests/document/dpns.rs | 6 + .../data_contract_update/mod.rs | 2 + .../src/query/document_query/v0/mod.rs | 8 + .../document_query/v1/dispatch/chained.rs | 1 + .../src/query/document_query/v1/tests.rs | 1 + .../tests/vectors_documents.rs | 1 + .../v0/tests/chained_query_e2e_tests.rs | 1 + .../v0/tests/composite_query_e2e_tests.rs | 354 +++++++++--------- .../v0/tests/index_only_e2e_tests.rs | 3 + .../validate_uniqueness_of_data/v0/mod.rs | 1 + .../validate_uniqueness_of_data/v1/mod.rs | 1 + .../query/query_composite_documents/mod.rs | 15 +- .../query/query_composite_documents/v0/mod.rs | 12 +- .../document/query/query_documents/mod.rs | 1 + .../query/query_documents_with_flags/mod.rs | 1 + .../v0/mod.rs | 3 + .../v0/mod.rs | 1 + .../v1/mod.rs | 1 + .../mod.rs | 113 +++--- .../query/drive_chained_document_query/mod.rs | 6 + packages/rs-drive/src/query/mod.rs | 74 +++- .../verify_chained_documents_proof/mod.rs | 1 + .../src/verify/composite_document/mod.rs | 2 +- .../verify_composite_documents_proof/mod.rs | 13 +- .../v0/mod.rs | 11 +- .../src/verify/document/verify_proof/mod.rs | 2 + .../verify_proof_keep_serialized/mod.rs | 2 + .../verify_start_at_document_in_proof/mod.rs | 2 + packages/rs-drive/tests/query_tests.rs | 2 + packages/rs-sdk/tests/fetch/document.rs | 1 + .../src/document/verify_proof.rs | 1 + .../document/verify_proof_keep_serialized.rs | 1 + .../verify_start_at_document_in_proof.rs | 1 + 40 files changed, 388 insertions(+), 271 deletions(-) rename packages/rs-drive/src/query/{drive_composite_document_query => composite_document_query}/mod.rs (97%) diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 97c880e1c58..3b8173a62c0 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -1155,6 +1155,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..5584d79e832 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 @@ -437,6 +437,7 @@ mod tests { start_at_included: true, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let chained = DriveChainedDocumentQuery { inner, 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..69017c2e4fb 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 @@ -6174,6 +6174,7 @@ mod chained_trust_boundary { start_at_included: true, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }, join_property: "postId".to_string(), outer_document_type: contract 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..304cf43101f 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 @@ -119,6 +119,7 @@ fn my_likes_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/composite_query_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/composite_query_e2e_tests.rs index 1b085e023f6..efa88446a61 100644 --- 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 @@ -13,11 +13,10 @@ //! the primary tree. use crate::error::Error; -use crate::query::drive_composite_document_query::{ - BindingSource, DriveCompositeDocumentQuery, DriveSubQuery, SubQueryBinding, SubQueryKind, - SubQueryResult, MAX_SUB_QUERIES, +use crate::query::{ + BindingSource, DriveDocumentQuery, DriveSubQuery, InternalClauses, OrderClause, + SubQueryBinding, SubQueryKind, SubQueryResult, WhereClause, WhereOperator, MAX_SUB_QUERIES, }; -use crate::query::{DriveDocumentQuery, InternalClauses, OrderClause, WhereClause, WhereOperator}; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use crate::util::storage_flags::StorageFlags; @@ -229,6 +228,7 @@ fn page_by_hashtag<'a>( start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } @@ -276,7 +276,7 @@ fn feed_query<'a>( feed: &'a DataContract, dashpay: &'a DataContract, viewer: Option<[u8; 32]>, -) -> DriveCompositeDocumentQuery<'a> { +) -> DriveDocumentQuery<'a> { let mut sub_queries = vec![ bound( feed, @@ -344,10 +344,7 @@ fn feed_query<'a>( }]; sub_queries.push(marks); } - DriveCompositeDocumentQuery { - page: page_by_hashtag(feed, "dash", Some(10)), - sub_queries, - } + page_by_hashtag(feed, "dash", Some(10)).with_sub_queries(sub_queries) } fn ids(documents: &[Document]) -> Vec<[u8; 32]> { @@ -455,10 +452,7 @@ fn should_route_counts_by_complete_positions_including_overlapping_queries() { value: Value::Identifier(POST_B), }); sub_queries.push(owners_of_b); - let query = DriveCompositeDocumentQuery { - page: page_by_hashtag(&feed, "dash", Some(10)), - sub_queries, - }; + 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") @@ -531,10 +525,8 @@ fn should_reject_conflicting_document_directions_even_when_the_page_is_empty() { }; for sub_query in [lookup, profiles, sibling] { for hashtag in ["dash", "empty"] { - let query = DriveCompositeDocumentQuery { - page: page_by_hashtag(&feed, hashtag, Some(10)), - sub_queries: vec![sub_query.clone()], - }; + 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) @@ -575,10 +567,8 @@ fn should_reject_count_tree_descents_but_allow_disjoint_count_selections() { operator: WhereOperator::Equal, value: Value::Identifier(OWNER_1), }); - let mut query = DriveCompositeDocumentQuery { - page: page_by_hashtag(&feed, "dash", Some(10)), - sub_queries: vec![total, per_owner], - }; + 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) @@ -692,7 +682,7 @@ fn should_preserve_descending_documents_and_key_ordered_counts() { "postId", None, )); - let query = DriveCompositeDocumentQuery { page, sub_queries }; + let query = page.with_sub_queries(sub_queries); let materialized = drive .query_composite_documents(&query, None, None, pv) .expect("materializes") @@ -826,7 +816,8 @@ fn should_prove_an_empty_page_alone() { seed_feed(&drive, &feed, &dashpay); let pv = platform_version(); let mut query = feed_query(&feed, &dashpay, Some(OWNER_1)); - query.page = page_by_hashtag(&feed, "nothing", Some(10)); + 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) @@ -858,9 +849,9 @@ fn should_refuse_a_page_only_proof() { let pv = platform_version(); let query = feed_query(&feed, &dashpay, None); - let (page_only_proof, _cost) = query - .page - .clone() + 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!( @@ -871,6 +862,39 @@ fn should_refuse_a_page_only_proof() { ); } +/// 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("composite sub-queries"), + "{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("composite sub-queries"), + "{refused}" + ); + let refused = query + .verify_proof(&[], pv) + .expect_err("the plain verifier must refuse sub-queries"); + assert!( + refused.to_string().contains("composite sub-queries"), + "{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. @@ -887,18 +911,15 @@ fn should_refuse_a_dangling_reference() { 1, ); let pv = platform_version(); - let query = DriveCompositeDocumentQuery { - page: page_by_hashtag(&feed, "dash", Some(10)), - sub_queries: vec![bound( - &feed, - "post", - SubQueryKind::Documents, - BindingSource::Page, - "quotedPostId", - "$id", - None, - )], - }; + 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!( @@ -944,19 +965,17 @@ fn should_tell_a_by_ids_page_from_a_join_on_the_same_type() { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; - let query = DriveCompositeDocumentQuery { - page, - sub_queries: vec![bound( - &feed, - "post", - SubQueryKind::Documents, - BindingSource::Page, - "quotedPostId", - "$id", - None, - )], - }; + 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) @@ -979,7 +998,7 @@ 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: DriveCompositeDocumentQuery, what: &str| { + let expect_unsupported = |query: DriveDocumentQuery, what: &str| { let result = drive.query_composite_documents(&query, None, None, pv); assert!( matches!(result, Err(Error::Query(_))), @@ -988,11 +1007,11 @@ fn should_reject_invalid_composite_shapes() { }; let mut no_limit = base.clone(); - no_limit.page.limit = None; + no_limit.limit = None; expect_unsupported(no_limit, "page without a limit"); let mut oversized = base.clone(); - oversized.page.limit = Some(101); + oversized.limit = Some(101); expect_unsupported(oversized, "page limit above the bound-value cap"); let mut none = base.clone(); @@ -1141,37 +1160,34 @@ fn should_check_count_and_document_descents_against_the_actual_bound_values() { seed_feed(&drive, &feed, &dashpay); insert_repost(&drive, &feed, OWNER_3, POST_D, 23); let pv = platform_version(); - let mut query = DriveCompositeDocumentQuery { - page: page_by_hashtag(&feed, "dash", Some(10)), - 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(), - }), - }, - ], - }; + 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) @@ -1253,7 +1269,7 @@ fn should_inherit_the_page_direction_for_unordered_lookups() { None, ) }; - let round_trip = |query: &DriveCompositeDocumentQuery, what: &str| { + 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}")) @@ -1289,22 +1305,19 @@ fn should_inherit_the_page_direction_for_unordered_lookups() { operator: WhereOperator::Equal, value: Value::Identifier(OWNER_1), }]; - let feed_shape = DriveCompositeDocumentQuery { - page: descending_page(), - sub_queries: vec![ - bound( - &dashpay, - "profile", - SubQueryKind::Documents, - BindingSource::Page, - "$ownerId", - "$ownerId", - None, - ), - viewer_likes, - like_counts(), - ], - }; + 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. @@ -1326,21 +1339,18 @@ fn should_inherit_the_page_direction_for_unordered_lookups() { // 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 = DriveCompositeDocumentQuery { - page: descending_page(), - sub_queries: vec![ - bound( - &feed, - "repost", - SubQueryKind::Documents, - BindingSource::Page, - "$id", - "postId", - Some(1), - ), - like_counts(), - ], - }; + 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"), @@ -1353,30 +1363,27 @@ fn should_inherit_the_page_direction_for_unordered_lookups() { // 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 = DriveCompositeDocumentQuery { - page: descending_page(), - 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 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()), @@ -1414,16 +1421,16 @@ fn should_reject_zero_limits() { let base = feed_query(&feed, &dashpay, None); let mut zero_page = base.clone(); - zero_page.page.limit = Some(0); + zero_page.limit = Some(0); let refused = zero_page - .validate(pv) + .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(pv) + .validate_composite(pv) .expect_err("a zero lookup limit is refused"); assert!(refused.to_string().contains("at least 1"), "{refused}"); @@ -1438,7 +1445,7 @@ fn should_reject_zero_limits() { binding: None, }); let refused = zero_sibling - .validate(pv) + .validate_composite(pv) .expect_err("a zero sibling limit is refused"); assert!(refused.to_string().contains("at least 1"), "{refused}"); drop(drive); @@ -1460,7 +1467,7 @@ fn should_reject_a_bound_field_that_is_not_identifier_typed() { Some(5), )); let refused = query - .validate(pv) + .validate_composite(pv) .expect_err("a string bound field is refused"); assert!( refused.to_string().contains("not identifier-typed"), @@ -1502,6 +1509,7 @@ fn should_reject_a_limited_page_a_sub_query_below_it_could_leave_at_the_merged_r start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let mut below_the_page = bound( &feed, @@ -1524,25 +1532,22 @@ fn should_reject_a_limited_page_a_sub_query_below_it_could_leave_at_the_merged_r value: Value::Identifier(POST_A), }, ]; - let query = DriveCompositeDocumentQuery { - page, - 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 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(pv) + .validate_composite(pv) .expect_err("a limited page above a sub-query is refused"); assert!( refused.to_string().contains("lands at the merged root"), @@ -1568,18 +1573,15 @@ fn should_prove_a_single_id_page_with_a_join_on_the_same_type() { pv, ) .expect("by-id page"); - let query = DriveCompositeDocumentQuery { - page, - sub_queries: vec![bound( - &feed, - "post", - SubQueryKind::Documents, - BindingSource::Page, - "quotedPostId", - "$id", - None, - )], - }; + 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") @@ -1613,7 +1615,7 @@ fn should_reject_two_limited_lookups_on_one_index_path() { Some(20), )); let refused = query - .validate(pv) + .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/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/query_composite_documents/mod.rs b/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs index ad69de31400..2fd22b091b9 100644 --- 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 @@ -3,7 +3,7 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; -use crate::query::drive_composite_document_query::DriveCompositeDocumentQuery; +use crate::query::DriveDocumentQuery; use dpp::block::epoch::Epoch; use dpp::document::Document; use dpp::version::PlatformVersion; @@ -12,12 +12,13 @@ use grovedb::TransactionArg; pub use v0::QueryCompositeDocumentsOutcomeV0; impl Drive { - /// Executes a composite document query (a page plus its derived - /// sub-queries) without proofs and returns the materialized results - /// plus the processing cost (when an epoch is given). + /// 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: &DriveCompositeDocumentQuery, + query: &DriveDocumentQuery, epoch: Option<&Epoch>, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -44,7 +45,7 @@ impl Drive { /// proves committed state only, so the materialize/prove sequence is /// bracketed by root-hash reads and retried when a block commit /// interleaves — see - /// [`DriveCompositeDocumentQuery::execute_with_proof_internal`]. + /// [`DriveDocumentQuery::execute_composite_with_proof_internal`]. /// Shares the `query_composite_documents` version slot with the /// no-proof path (one surface, one version). /// @@ -53,7 +54,7 @@ impl Drive { /// results are covered by the proof and not materialized twice. pub fn query_composite_documents_with_proof( &self, - query: &DriveCompositeDocumentQuery, + query: &DriveDocumentQuery, platform_version: &PlatformVersion, ) -> Result<(Vec, Vec), Error> { match platform_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 index e1e43a04027..99bf2786502 100644 --- 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 @@ -1,9 +1,7 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; -use crate::query::drive_composite_document_query::{ - CompositeDocumentsResult, DriveCompositeDocumentQuery, -}; +use crate::query::{CompositeDocumentsResult, 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_composite_documents_v0( &self, - query: &DriveCompositeDocumentQuery, + 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_composite_no_proof_internal( self, transaction, &mut drive_operations, @@ -53,10 +51,10 @@ impl Drive { #[inline(always)] pub(super) fn query_composite_documents_with_proof_v0( &self, - query: &DriveCompositeDocumentQuery, + 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_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_composite_document_query/mod.rs b/packages/rs-drive/src/query/composite_document_query/mod.rs similarity index 97% rename from packages/rs-drive/src/query/drive_composite_document_query/mod.rs rename to packages/rs-drive/src/query/composite_document_query/mod.rs index c52d4e5f9cf..d0f87868559 100644 --- a/packages/rs-drive/src/query/drive_composite_document_query/mod.rs +++ b/packages/rs-drive/src/query/composite_document_query/mod.rs @@ -1,6 +1,16 @@ //! 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 @@ -110,7 +120,7 @@ pub enum BindingSource { /// The page's proven documents. Page, /// An earlier documents sub-query's proven documents (its index in - /// [`DriveCompositeDocumentQuery::sub_queries`]). + /// [`DriveDocumentQuery::sub_queries`]). SubQuery(usize), } @@ -140,7 +150,7 @@ pub enum SubQueryKind { } /// One sub-query of a composite request. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct DriveSubQuery<'a> { /// The contract the sub-query targets — the page's, or another one. pub contract: &'a DataContract, @@ -168,20 +178,6 @@ pub struct DriveSubQuery<'a> { pub binding: Option, } -/// A composite document query. -/// -/// Construction contract: every sub-query's `document_type` MUST be a -/// document type of its own `contract`. [`Self::validate`] enforces -/// everything derivable from the shapes themselves. -#[derive(Debug, Clone)] -pub struct DriveCompositeDocumentQuery<'a> { - /// The page: an ordinary document query with an explicit limit. - pub page: DriveDocumentQuery<'a>, - /// The sub-queries, in binding order (a sub-query may only bind an - /// earlier one). - pub sub_queries: Vec>, -} - /// One sub-query's materialized result. #[derive(Debug, Clone, PartialEq)] pub enum SubQueryResult { @@ -303,11 +299,16 @@ impl<'a> DriveSubQuery<'a> { } } -impl<'a> DriveCompositeDocumentQuery<'a> { - /// Validates the composite shape. Called by the server before +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. - pub fn validate(&self, platform_version: &PlatformVersion) -> Result<(), Error> { + /// + /// 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 \ @@ -322,7 +323,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { self.sub_queries.len(), ))); } - let page_limit = match self.page.limit { + let page_limit = match self.limit { None => { return Err(unsupported( "composite queries require an explicit limit on the page: the page size \ @@ -344,13 +345,13 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } Some(limit) => limit, }; - if self.page.offset.is_some() { + 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.page.start_at.is_some() { + 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" @@ -428,9 +429,9 @@ impl<'a> DriveCompositeDocumentQuery<'a> { // 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.page.contract, - self.page.document_type, - self.page.document_type.index_only(), + self.contract, + self.document_type, + self.document_type.index_only(), ), BindingSource::SubQuery(source_index) => { if source_index >= index { @@ -492,7 +493,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { }; let (carried, index_name) = match binding.source { BindingSource::Page => { - let index = self.page.index_only_query_index(platform_version)?; + let index = self.index_only_query_index(platform_version)?; (carries(index), index.name.clone()) } BindingSource::SubQuery(source_index) => { @@ -733,12 +734,8 @@ impl<'a> DriveCompositeDocumentQuery<'a> { /// Whether the page is a primary-key fetch (`$id IN` / `$id ==`). fn page_is_by_ids(&self) -> bool { - self.page.internal_clauses.primary_key_in_clause.is_some() - || self - .page - .internal_clauses - .primary_key_equal_clause - .is_some() + 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 @@ -748,7 +745,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { /// 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.page.clone(); + 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 @@ -757,7 +754,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { path_query.query.limit = None; return Ok(path_query); } - self.page.construct_path_query(None, platform_version) + self.construct_path_query(None, platform_version) } /// The shape rules routing and merging need up front. Document @@ -997,6 +994,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { start_at_included: false, block_time_ms: None, resolved_time_ranges: Vec::new(), + sub_queries: Vec::new(), }); } @@ -1041,6 +1039,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { start_at_included: false, block_time_ms: None, resolved_time_ranges: Vec::new(), + sub_queries: Vec::new(), }) } @@ -1567,7 +1566,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { // type, so any member's query decodes the group. let documents = match documents_members[0] { Component::Page => { - Self::decode_document_trios(&self.page, document_trios, platform_version)? + Self::decode_document_trios(self, document_trios, platform_version)? } Component::Sub(index) => { let query = self.sub_query_document_query_with_direction( @@ -1659,14 +1658,14 @@ impl<'a> DriveCompositeDocumentQuery<'a> { /// the primary tree. fn page_ids(&self) -> Result, Error> { let mut ids = BTreeSet::new(); - if let Some(clause) = &self.page.internal_clauses.primary_key_equal_clause { + 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.page.internal_clauses.primary_key_in_clause { + if let Some(clause) = &self.internal_clauses.primary_key_in_clause { for value in clause .in_values() .into_data() @@ -1745,24 +1744,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } #[cfg(feature = "server")] -impl<'a> DriveCompositeDocumentQuery<'a> { - /// Materializes the page without a proof. - fn materialize_page( - &self, - drive: &crate::drive::Drive, - transaction: grovedb::TransactionArg, - drive_operations: &mut Vec, - platform_version: &PlatformVersion, - ) -> Result, Error> { - Self::materialize_documents( - &self.page, - drive, - transaction, - drive_operations, - platform_version, - ) - } - +impl<'a> DriveDocumentQuery<'a> { /// Materializes a documents query without a proof: indexOnly /// projections are synthesized, stored documents deserialized. fn materialize_documents( @@ -1879,18 +1861,23 @@ impl<'a> DriveCompositeDocumentQuery<'a> { } /// Executes the composite query without proofs. - pub(crate) fn execute_no_proof_internal( + 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(platform_version)?; + self.validate_composite(platform_version)?; let direction = self.page_direction(platform_version)?; - let page_documents = - self.materialize_page(drive, transaction, drive_operations, 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 { @@ -1934,13 +1921,13 @@ impl<'a> DriveCompositeDocumentQuery<'a> { /// 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_with_proof_internal( + 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(platform_version)?; + self.validate_composite(platform_version)?; let direction = self.page_direction(platform_version)?; // Block commits are seconds apart while an attempt is @@ -1957,7 +1944,7 @@ impl<'a> DriveCompositeDocumentQuery<'a> { .unwrap()?; let page_documents = - self.materialize_page(drive, None, drive_operations, platform_version)?; + 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()); @@ -2031,7 +2018,7 @@ mod tests { }; let shallower = PathQuery::new(vec![key("a"), key("b")], SizedQuery::new(body, None, None)); let descends = |path: &[&str]| { - DriveCompositeDocumentQuery::path_query_descends_through( + DriveDocumentQuery::path_query_descends_through( &shallower, &path.iter().map(|segment| key(segment)).collect::>(), pv, diff --git a/packages/rs-drive/src/query/drive_chained_document_query/mod.rs b/packages/rs-drive/src/query/drive_chained_document_query/mod.rs index 305601b9f14..7238782dd78 100644 --- a/packages/rs-drive/src/query/drive_chained_document_query/mod.rs +++ b/packages/rs-drive/src/query/drive_chained_document_query/mod.rs @@ -95,6 +95,11 @@ impl<'a> DriveChainedDocumentQuery<'a> { pub fn validate(&self, platform_version: &PlatformVersion) -> Result<(), Error> { let unsupported = |message: String| Error::Query(QuerySyntaxError::Unsupported(message)); + // A chained inner query is the whole request's page; composite + // sub-queries have their own surface and would be silently + // ignored here. + self.inner + .ensure_no_sub_queries("a chained query's inner query")?; if !self.inner.document_type.index_only() { return Err(unsupported( "chained document queries require an indexOnly inner document type: only \ @@ -282,6 +287,7 @@ impl<'a> DriveChainedDocumentQuery<'a> { start_at_included: false, block_time_ms: None, resolved_time_ranges: Vec::new(), + sub_queries: vec![], } } diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 37fe4720293..fcc795f9793 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -3,6 +3,13 @@ use std::sync::Arc; #[cfg(any(feature = "server", feature = "verify"))] pub use { + // 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 @@ -293,11 +300,11 @@ pub(crate) mod index_only_synthesis; #[cfg(any(feature = "server", feature = "verify"))] pub mod drive_chained_document_query; -/// Composite document queries — a 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. +/// 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_composite_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 @@ -1095,6 +1102,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> { @@ -1126,6 +1152,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } @@ -1143,6 +1170,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included: true, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], } } @@ -1164,7 +1192,36 @@ 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"))] + /// 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 {} composite sub-queries, which {} would silently ignore; \ + use query_composite_documents / query_composite_documents_with_proof to execute \ + it and verify_composite_documents_proof to verify its proof", + self.sub_queries.len(), + surface, + )))) } #[cfg(any(feature = "server", feature = "verify"))] @@ -1391,6 +1448,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included, block_time_ms, resolved_time_ranges: vec![], + sub_queries: vec![], }) } @@ -1538,6 +1596,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included, block_time_ms, resolved_time_ranges: vec![], + sub_queries: vec![], }) } @@ -1703,6 +1762,7 @@ impl<'a> DriveDocumentQuery<'a> { start_at_included, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }) } @@ -2473,6 +2533,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, @@ -2529,6 +2590,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, @@ -2587,6 +2649,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, @@ -3216,6 +3279,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }; let path_query = query_asc @@ -3699,6 +3763,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' @@ -3816,6 +3881,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/verify_chained_documents_proof/mod.rs b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs index 5541ef2cdee..d3608164a8e 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 @@ -92,6 +92,7 @@ mod tests { start_at_included: false, block_time_ms: None, resolved_time_ranges: vec![], + sub_queries: vec![], }, join_property: "records".to_string(), outer_document_type: document_type, diff --git a/packages/rs-drive/src/verify/composite_document/mod.rs b/packages/rs-drive/src/verify/composite_document/mod.rs index 7a35555ce3c..4d95e5e1c7e 100644 --- a/packages/rs-drive/src/verify/composite_document/mod.rs +++ b/packages/rs-drive/src/verify/composite_document/mod.rs @@ -1,4 +1,4 @@ //! Composite document query proof verification — the verifier half of -//! [`drive_composite_document_query`](crate::query::drive_composite_document_query). +//! [`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 index c15a0431287..06be0215101 100644 --- 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 @@ -2,15 +2,14 @@ mod v0; use crate::error::drive::DriveError; use crate::error::Error; -use crate::query::drive_composite_document_query::{ - CompositeDocumentsResult, DriveCompositeDocumentQuery, -}; +use crate::query::{CompositeDocumentsResult, DriveDocumentQuery}; use crate::verify::RootHash; use dpp::version::PlatformVersion; -impl DriveCompositeDocumentQuery<'_> { - /// Verifies a composite query's single merged proof and returns - /// `(root_hash, result)`. +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 @@ -46,7 +45,7 @@ impl DriveCompositeDocumentQuery<'_> { { 0 => self.verify_composite_documents_proof_v0(proof, platform_version), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "DriveCompositeDocumentQuery::verify_composite_documents_proof".to_string(), + 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 index 0750f56e61f..3f49c557bfd 100644 --- 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 @@ -1,14 +1,13 @@ use crate::error::proof::ProofError; use crate::error::Error; -use crate::query::drive_composite_document_query::{ - CompositeDocumentsResult, DriveCompositeDocumentQuery, PresentTrio, ProvedTrio, -}; +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 DriveCompositeDocumentQuery<'_> { +impl DriveDocumentQuery<'_> { /// v0 of the composite proof verification — see the versioned /// wrapper for the trust model. #[inline(always)] @@ -17,7 +16,7 @@ impl DriveCompositeDocumentQuery<'_> { proof: &[u8], platform_version: &PlatformVersion, ) -> Result<(RootHash, CompositeDocumentsResult), Error> { - self.validate(platform_version)?; + self.validate_composite(platform_version)?; let grove_version = &platform_version.drive.grove_version; let present = |trios: Vec| { @@ -36,7 +35,7 @@ impl DriveCompositeDocumentQuery<'_> { 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.page, present(page_trios), platform_version)?; + 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 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/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-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 From d6ab675124657c7e04867e34eaf07ab2ae3c0bbd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 8 Sep 2026 00:09:32 +0700 Subject: [PATCH 8/9] refactor(drive): fold DriveChainedDocumentQuery into DriveDocumentQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chained query was always a page plus one by-id join — exactly the shape `DriveDocumentQuery::sub_queries` expresses — so the wrapper struct goes the way of DriveCompositeDocumentQuery: a chained query is now a DriveDocumentQuery (the inner half) whose sub_queries carry exactly one by-id join bound to it, built with the new `with_by_id_join(source_property, outer_document_type)`. The chained behaviour moves onto DriveDocumentQuery: `chained_join` admits only that shape, `validate_chained` (was `validate`) runs the same guardrails plus an explicit same-contract check, `chained_join_values` / `derive_chained_outer_query` / `assemble_chained_outer_documents` / `chained_proof_path_queries` and the `execute_chained_*_internal` executors are the renamed equivalents (the bare names collide with the ordinary and composite methods). The module renames to query::chained_document_query; `ChainedDocumentsResult` and `MAX_CHAINED_JOIN_VALUES` re-export from crate::query. The entry points, the verifier, the drive-abci chained dispatch, dash-platform-queries' rich-query conversion and the proof-verifier's FromProof bound all take DriveDocumentQuery now; version slots unchanged. Co-Authored-By: Claude Fable 5 --- .../src/documents/chained_document_query.rs | 32 ++- .../document_query/v1/dispatch/chained.rs | 29 +- .../src/query/document_query/v1/tests.rs | 51 ++-- .../src/proof/chained_document.rs | 15 +- .../v0/tests/chained_query_e2e_tests.rs | 26 +- .../v0/tests/composite_query_e2e_tests.rs | 6 +- .../query/query_chained_documents/mod.rs | 17 +- .../query/query_chained_documents/v0/mod.rs | 12 +- .../mod.rs | 269 +++++++++++------- packages/rs-drive/src/query/mod.rs | 53 +++- .../src/verify/chained_document/mod.rs | 2 +- .../verify_chained_documents_proof/mod.rs | 47 ++- .../verify_chained_documents_proof/v0/mod.rs | 39 +-- 13 files changed, 344 insertions(+), 254 deletions(-) rename packages/rs-drive/src/query/{drive_chained_document_query => chained_document_query}/mod.rs (69%) 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..2242c008f69 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(), } @@ -276,13 +271,20 @@ 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)); } #[test] @@ -294,9 +296,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/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 5584d79e832..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 { @@ -439,13 +429,12 @@ mod tests { 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 69017c2e4fb..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,33 +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![], - sub_queries: 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/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 304cf43101f..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}; @@ -128,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 @@ -307,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), @@ -319,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!( @@ -380,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 index efa88446a61..51f48b29424 100644 --- 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 @@ -875,7 +875,7 @@ fn should_refuse_composite_queries_on_plain_surfaces() { .query_documents(query.clone(), None, false, None, None) .expect_err("plain query_documents must refuse sub-queries"); assert!( - refused.to_string().contains("composite sub-queries"), + refused.to_string().contains("would silently ignore"), "{refused}" ); let refused = query @@ -883,14 +883,14 @@ fn should_refuse_composite_queries_on_plain_surfaces() { .execute_with_proof(&drive, None, None, pv) .expect_err("the plain proof surface must refuse sub-queries"); assert!( - refused.to_string().contains("composite sub-queries"), + 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("composite sub-queries"), + refused.to_string().contains("would silently ignore"), "{refused}" ); } 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/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 7238782dd78..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,26 +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)); - // A chained inner query is the whole request's page; composite - // sub-queries have their own surface and would be silently - // ignored here. - self.inner - .ensure_no_sub_queries("a chained query's inner query")?; - 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 \ @@ -115,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: \ @@ -132,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" @@ -146,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 { @@ -168,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" @@ -176,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, ))); } @@ -190,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, ))); } @@ -219,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(|_| { @@ -255,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(), @@ -288,7 +344,7 @@ impl<'a> DriveChainedDocumentQuery<'a> { block_time_ms: None, resolved_time_ranges: Vec::new(), sub_queries: vec![], - } + }) } /// Reorders the outer documents (returned in key order by the by-ids @@ -297,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, @@ -349,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, @@ -358,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", @@ -366,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, @@ -389,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, @@ -406,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, @@ -417,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, @@ -437,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 @@ -459,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 @@ -479,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/mod.rs b/packages/rs-drive/src/query/mod.rs index fcc795f9793..27c23346f06 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -3,6 +3,10 @@ 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. @@ -295,10 +299,12 @@ 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 drive_chained_document_query; +pub mod chained_document_query; /// Composite document queries — a [`DriveDocumentQuery`] page plus /// sub-queries derived from its proven results (joins, lookups, counts), @@ -1206,6 +1212,40 @@ impl<'a> DriveDocumentQuery<'a> { 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 — @@ -1216,9 +1256,10 @@ impl<'a> DriveDocumentQuery<'a> { return Ok(()); } Err(Error::Query(QuerySyntaxError::Unsupported(format!( - "this query carries {} composite sub-queries, which {} would silently ignore; \ - use query_composite_documents / query_composite_documents_with_proof to execute \ - it and verify_composite_documents_proof to verify its proof", + "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, )))) 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 d3608164a8e..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,29 +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![], - sub_queries: 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, From bd47707bea125bb7d223fcff4bf9ae8a05252d97 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 8 Sep 2026 01:23:12 +0700 Subject: [PATCH 9/9] fix(sdk): reject plain conversion of composed drive queries --- .../src/documents/chained_document_query.rs | 64 ++++++++++++++++++- .../src/documents/document_query.rs | 16 ++++- 2 files changed, 78 insertions(+), 2 deletions(-) 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 2242c008f69..0e1e8d54d65 100644 --- a/packages/dash-platform-queries/src/documents/chained_document_query.rs +++ b/packages/dash-platform-queries/src/documents/chained_document_query.rs @@ -197,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 = @@ -287,6 +289,66 @@ mod tests { 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] fn conversion_surfaces_shape_errors() { let query = ChainedDocumentQuery::new( diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 3b8173a62c0..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) }