diff --git a/grovedb/src/operations/get/aggregate_per_key/carrier.rs b/grovedb/src/operations/get/aggregate_per_key/carrier.rs new file mode 100644 index 000000000..ed5fc135c --- /dev/null +++ b/grovedb/src/operations/get/aggregate_per_key/carrier.rs @@ -0,0 +1,172 @@ +//! The one carrier walk shared by every per-key aggregate axis. +//! +//! See [`super`] for the leaf-vs-carrier vocabulary and the per-axis entry +//! points that delegate here. +//! +//! ## The leaf shape's empty stand-in key +//! +//! Each entry point handles the leaf shape itself, before reaching this +//! driver, by delegating to its single-value sibling +//! (`query_aggregate_{count,sum,count_and_sum}`) and wrapping the result +//! as a one-entry vector keyed by `Vec::new()`. +//! +//! That empty key is deliberate, not incidental. It is the convention all +//! three per-key *verifiers* already collapse a leaf proof to, so keeping +//! it is what lets a caller swap a trusted read for `prove_query` + +//! `verify_aggregate_*_query_per_key` (or back) and compare results +//! element-for-element without branching on shape. A leaf query has no +//! outer key to report, so *some* stand-in is unavoidable; matching the +//! already-shipped proof-side convention is worth more than a prettier +//! one. It is also unambiguous: outer keys are never empty in a valid +//! carrier, since validation requires every `subquery_path` element to be +//! a non-empty key. + +use grovedb_costs::{cost_return_on_error, CostResult, CostsExt, OperationCost}; +use grovedb_merk::{error::Error as GrovedbMerkError, Merk}; +use grovedb_path::SubtreePath; +use grovedb_storage::rocksdb_storage::PrefixedRocksDbTransactionContext; +use grovedb_version::version::GroveVersion; + +use crate::{ + query_result_type::QueryResultType, Error, GroveDb, PathQuery, QueryItem, SizedQuery, + Transaction, TransactionArg, +}; + +impl GroveDb { + /// Shared carrier walk behind the three `query_aggregate_*_per_key` + /// entry points. + /// + /// Everything the carrier shape needs is aggregate-agnostic: the + /// "shallow" outer-key enumeration (deliberately *not* descending + /// into the subquery), the `SizedQuery::limit` propagation, the + /// non-tree-match rejection, the `path / outer_key / + /// subquery_path...` leaf-path assembly, and the per-match merk + /// open. The only axis-specific step is which merk-level aggregate + /// primitive terminates each walk, supplied by the caller as + /// `merk_walk` — one of [`Merk::count_aggregate_on_range`], + /// [`Merk::sum_aggregate_on_range`], or + /// [`Merk::count_and_sum_aggregate_on_range`]. All three share the + /// same signature shape and the same O(log n) Contained / Disjoint + /// short-circuit, so the driver never needs to know which axis it is + /// running; `T` is the axis's per-key payload (`u64`, `i64`, or + /// `(u64, i64)`). + /// + /// This helper performs **no** shape validation of its own. Callers + /// must have already run the matching + /// `validate_aggregate_*_on_range` (which is where `inner_range` + /// comes from, and where `SizedQuery::offset` is rejected) and must + /// have handled the leaf shape before calling — this drives the + /// carrier shape only. + /// + /// `non_tree_match_error` is the axis-specific message used when an + /// outer-key match resolves to a non-tree element; + /// `Error::InvalidQuery` carries a `&'static str`, so the message + /// cannot be formatted here. + /// + /// `tx` is supplied by the caller rather than opened here so that + /// `'db` stays a single *named* lifetime. `Merk`'s storage context + /// carries its lifetime inside a type parameter, and a higher-ranked + /// `for<'a> Fn(&Merk>, ..)` + /// bound cannot be satisfied by a function item — so naming the + /// lifetime is what lets callers pass the three merk methods + /// directly instead of wrapping each in a closure. + pub(super) fn query_aggregate_carrier_per_key<'db, T, WalkFn>( + &'db self, + path_query: &PathQuery, + inner_range: &QueryItem, + non_tree_match_error: &'static str, + merk_walk: WalkFn, + tx: &'db Transaction, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult, T)>, Error> + where + WalkFn: Fn( + &Merk>, + &QueryItem, + &GroveVersion, + ) -> CostResult, + { + let mut cost = OperationCost::default(); + + // Enumerate matched outer keys at the carrier subtree, then per + // match navigate `subquery_path` and run the merk-level + // aggregate walk on the leaf. + let q = &path_query.query.query; + let outer_items = q.items.clone(); + let subquery_path = q + .default_subquery_branch + .subquery_path + .clone() + .unwrap_or_default(); + let left_to_right = q.left_to_right; + + // Build a "shallow" path query that enumerates the carrier's + // outer items at `path_query.path` without descending into the + // subquery — we want just the matched outer keys, not the + // (unproven) results of the leaf aggregate. + // + // Propagate `SizedQuery::limit` (validated as carrier-only by + // the caller): it caps the number of outer-key matches the walk + // returns. Each matched outer key still produces a complete + // leaf aggregate below. `offset` is rejected at validation, so + // we don't propagate it here. + let mut shallow_query = grovedb_query::Query::new_with_direction(left_to_right); + shallow_query.items = outer_items; + let shallow_pq = PathQuery::new( + path_query.path.clone(), + SizedQuery::new(shallow_query, path_query.query.limit, None), + ); + + let (matched, _skipped) = cost_return_on_error!( + &mut cost, + self.query_raw( + &shallow_pq, + true, // allow_cache + false, // decrease_limit_on_range_with_no_sub_elements + true, // error_if_intermediate_path_tree_not_present + QueryResultType::QueryKeyElementPairResultType, + transaction, + grove_version, + ) + ); + + let key_elements = matched.to_key_elements(); + let mut results: Vec<(Vec, T)> = Vec::with_capacity(key_elements.len()); + + for (key, element) in key_elements { + // Refuse non-tree matches: every aggregate axis requires + // descending into the matched element to find the leaf + // aggregate subtree. + if !element.is_any_tree() { + return Err(Error::InvalidQuery(non_tree_match_error)).wrap_with_cost(cost); + } + + // Build the path to the leaf aggregate subtree: + // `path_query.path / outer_key / subquery_path...`. + let mut leaf_path_owned: Vec> = path_query.path.clone(); + leaf_path_owned.push(key.clone()); + leaf_path_owned.extend(subquery_path.iter().cloned()); + let leaf_path: Vec<&[u8]> = leaf_path_owned.iter().map(|p| p.as_slice()).collect(); + + let leaf_subtree = cost_return_on_error!( + &mut cost, + self.open_transactional_merk_at_path( + SubtreePath::from(leaf_path.as_slice()), + tx, + None, + grove_version, + ) + ); + + let value = cost_return_on_error!( + &mut cost, + merk_walk(&leaf_subtree, inner_range, grove_version).map_err(Error::MerkError) + ); + + results.push((key, value)); + } + + Ok(results).wrap_with_cost(cost) + } +} diff --git a/grovedb/src/operations/get/aggregate_per_key/count.rs b/grovedb/src/operations/get/aggregate_per_key/count.rs new file mode 100644 index 000000000..dd48cc096 --- /dev/null +++ b/grovedb/src/operations/get/aggregate_per_key/count.rs @@ -0,0 +1,117 @@ +//! `query_aggregate_count_per_key` — trusted per-key reads on the count +//! axis. +//! +//! See [`super`] for the leaf-vs-carrier vocabulary and +//! [`super::carrier`] for the shared walk this delegates to. + +use grovedb_costs::{ + cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, +}; +use grovedb_merk::Merk; +use grovedb_version::{check_grovedb_v0_with_cost, version::GroveVersion}; + +use crate::{util::TxRef, Error, GroveDb, PathQuery, TransactionArg}; + +impl GroveDb { + /// Executes an `AggregateCountOnRange` query in either the **leaf** or + /// **carrier** shape without generating a proof, returning one + /// `(outer_key, count)` pair per matched outer key. + /// + /// This is the no-proof counterpart of + /// [`GroveDb::verify_aggregate_count_query_per_key`]: it performs the + /// same merk-level boundary walks the per-key verifier reconstructs + /// from a proof but skips proof generation, encoding, decoding, and + /// chain verification entirely. + /// + /// For a **leaf** query the returned vector contains exactly one + /// entry whose key is an empty byte string and whose count is the + /// same `u64` [`Self::query_aggregate_count`] would have returned. + /// This matches the per-key verifier's leaf behavior, so callers + /// that always handle `Vec<(Vec, u64)>` don't need to branch on + /// the shape. See [`Self::query_aggregate_sum_per_key`] for why the + /// empty stand-in key is deliberate rather than incidental. + /// + /// For a **carrier** query the outer items must be `Key(_)` / + /// `Range*(_)` and the `default_subquery_branch.subquery` must + /// validate as a leaf `AggregateCountOnRange`. The optional + /// `subquery_path` is followed exactly (single-key step per element) + /// before the count walk. The returned vector has one entry per + /// matched outer key in query-direction order (ascending lex when + /// `left_to_right = true`, descending otherwise). Outer-key + /// candidates that don't exist contribute no entry; outer-key + /// candidates whose leaf subtree is empty contribute `(key, 0)`. + /// + /// `path_query` must satisfy + /// [`PathQuery::validate_aggregate_count_on_range`] in either + /// shape. Pagination rules differ by shape: for **leaf** queries + /// both `SizedQuery::limit` and `SizedQuery::offset` are rejected + /// (a leaf returns a single `u64` and pagination would silently + /// change the answer); for **carrier** queries `SizedQuery::limit` + /// is accepted and caps the number of outer-key matches the walk + /// returns (each matched outer key still produces a complete + /// leaf-ACOR `u64`, the inner range is not capped), while + /// `SizedQuery::offset` is still rejected. Each leaf subtree the + /// walk terminates in must be a `ProvableCountTree` or + /// `ProvableCountSumTree` — the merk-level walk rejects any other + /// tree type. + /// + /// The returned counts are **not** independently verifiable — + /// callers are trusting their own merk read path. For verifiable + /// counts, use [`Self::prove_query`] + + /// [`GroveDb::verify_aggregate_count_query_per_key`]. + pub fn query_aggregate_count_per_key( + &self, + path_query: &PathQuery, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult, u64)>, Error> { + check_grovedb_v0_with_cost!( + "query_aggregate_count_per_key", + grove_version + .grovedb_versions + .operations + .query + .query_aggregate_count_on_range + ); + + let mut cost = OperationCost::default(); + + // Up-front shape validation: accept both leaf and carrier shapes. + // We classify by what the top-level query owns: a direct + // `AggregateCountOnRange` item means leaf; otherwise the + // dispatcher already confirmed a valid carrier subquery exists. + let inner_range = cost_return_on_error_no_add!( + cost, + path_query.validate_aggregate_count_on_range().cloned() + ); + + if path_query.query.query.aggregate_count_on_range().is_some() { + // Leaf shape: delegate to the existing single-`u64` entry + // point and wrap as a one-entry vector with an empty key. + let count = cost_return_on_error!( + &mut cost, + self.query_aggregate_count(path_query, transaction, grove_version) + ); + return Ok(vec![(Vec::new(), count)]).wrap_with_cost(cost); + } + + // Carrier shape: delegate to the shared carrier driver, which + // terminates each per-key walk in the count primitive. + let tx = TxRef::new(&self.db, transaction); + let results = cost_return_on_error!( + &mut cost, + self.query_aggregate_carrier_per_key( + path_query, + &inner_range, + "carrier aggregate-count matched a non-tree element; outer items must resolve \ + to tree elements", + Merk::count_aggregate_on_range, + tx.as_ref(), + transaction, + grove_version, + ) + ); + + Ok(results).wrap_with_cost(cost) + } +} diff --git a/grovedb/src/operations/get/aggregate_per_key/count_and_sum.rs b/grovedb/src/operations/get/aggregate_per_key/count_and_sum.rs new file mode 100644 index 000000000..85eabaced --- /dev/null +++ b/grovedb/src/operations/get/aggregate_per_key/count_and_sum.rs @@ -0,0 +1,141 @@ +//! `query_aggregate_count_and_sum_per_key` — trusted per-key reads on the +//! combined count+sum axis. +//! +//! See [`super`] for the leaf-vs-carrier vocabulary and +//! [`super::carrier`] for the shared walk this delegates to. Only +//! `ProvableCountProvableSumTree` can terminate a walk on this axis — it +//! is the only tree type whose node hash binds both aggregates. + +use grovedb_costs::{ + cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, +}; +use grovedb_merk::Merk; +use grovedb_version::{check_grovedb_v0_with_cost, version::GroveVersion}; + +use crate::{util::TxRef, Error, GroveDb, PathQuery, TransactionArg}; + +impl GroveDb { + /// Executes an `AggregateCountAndSumOnRange` query in either the + /// **leaf** or **carrier** shape without generating a proof, + /// returning one `(outer_key, count, sum)` triple per matched outer + /// key. + /// + /// Combined-axis mirror of [`Self::query_aggregate_count_per_key`] + /// and [`Self::query_aggregate_sum_per_key`], and the no-proof + /// counterpart of + /// [`GroveDb::verify_aggregate_count_and_sum_query_per_key`]: it + /// performs the same merk-level boundary walks the per-key verifier + /// reconstructs from a proof but skips proof generation, encoding, + /// decoding, and chain verification entirely. + /// + /// Each matched outer key costs **one** classification walk over its + /// leaf merk (the same shape the combined prover walks) with both + /// axes accumulated in parallel — strictly cheaper than calling + /// [`Self::query_aggregate_count_per_key`] and + /// [`Self::query_aggregate_sum_per_key`] separately. + /// + /// For a **leaf** query the returned vector contains exactly one + /// entry whose key is an empty byte string and whose `(count, sum)` + /// is the same pair [`Self::query_aggregate_count_and_sum`] would + /// have returned — see [`Self::query_aggregate_sum_per_key`] for why + /// the empty stand-in key is deliberate. + /// + /// For a **carrier** query the outer items must be `Key(_)` / + /// `Range*(_)` and the `default_subquery_branch.subquery` must + /// validate as a leaf `AggregateCountAndSumOnRange`. The optional + /// `subquery_path` is followed exactly (single-key step per element) + /// before the combined walk. The returned vector has one entry per + /// matched outer key in query-direction order (ascending lex when + /// `left_to_right = true`, descending otherwise). Outer-key + /// candidates that don't exist contribute no entry; outer-key + /// candidates whose leaf subtree is empty contribute `(key, 0, 0)`. + /// + /// `path_query` must satisfy + /// [`PathQuery::validate_aggregate_count_and_sum_on_range`] in + /// either shape. Pagination rules differ by shape: for **leaf** + /// queries both `SizedQuery::limit` and `SizedQuery::offset` are + /// rejected (a leaf returns a single `(u64, i64)` and pagination + /// would silently change both answers); for **carrier** queries + /// `SizedQuery::limit` is accepted and caps the number of outer-key + /// matches the walk returns (each matched outer key still produces a + /// complete leaf pair, the inner range is not capped), while + /// `SizedQuery::offset` is still rejected. Each leaf subtree the + /// walk terminates in must be a `ProvableCountProvableSumTree` — + /// PCPS is the only tree type whose node hash binds *both* + /// aggregates, so the merk-level walk rejects every single-axis host. + /// + /// The returned pairs are **not** independently verifiable — callers + /// are trusting their own merk read path. For verifiable pairs, use + /// [`Self::prove_query`] + + /// [`GroveDb::verify_aggregate_count_and_sum_query_per_key`]. + pub fn query_aggregate_count_and_sum_per_key( + &self, + path_query: &PathQuery, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult, u64, i64)>, Error> { + check_grovedb_v0_with_cost!( + "query_aggregate_count_and_sum_per_key", + grove_version + .grovedb_versions + .operations + .query + .query_aggregate_count_and_sum_on_range + ); + + let mut cost = OperationCost::default(); + + // Up-front shape validation: accept both leaf and carrier + // shapes. We classify by what the top-level query owns: a direct + // `AggregateCountAndSumOnRange` item means leaf; otherwise the + // validator has confirmed a valid carrier subquery exists. + let inner_range = cost_return_on_error_no_add!( + cost, + path_query + .validate_aggregate_count_and_sum_on_range() + .cloned() + ); + + if path_query + .query + .query + .aggregate_count_and_sum_on_range() + .is_some() + { + // Leaf shape: delegate to the existing single-pair entry + // point and wrap as a one-entry vector with an empty key. + let (count, sum) = cost_return_on_error!( + &mut cost, + self.query_aggregate_count_and_sum(path_query, transaction, grove_version) + ); + return Ok(vec![(Vec::new(), count, sum)]).wrap_with_cost(cost); + } + + // Carrier shape: delegate to the shared carrier driver, which + // terminates each per-key walk in the combined primitive. The + // driver is generic over the per-key payload, so the `(u64, + // i64)` pairs come back tupled and are flattened to triples + // here to match the per-key verifier's surface. + let tx = TxRef::new(&self.db, transaction); + let pairs = cost_return_on_error!( + &mut cost, + self.query_aggregate_carrier_per_key( + path_query, + &inner_range, + "carrier aggregate-count-and-sum matched a non-tree element; outer items must \ + resolve to tree elements", + Merk::count_and_sum_aggregate_on_range, + tx.as_ref(), + transaction, + grove_version, + ) + ); + + let results = pairs + .into_iter() + .map(|(key, (count, sum))| (key, count, sum)) + .collect(); + + Ok(results).wrap_with_cost(cost) + } +} diff --git a/grovedb/src/operations/get/aggregate_per_key/mod.rs b/grovedb/src/operations/get/aggregate_per_key/mod.rs new file mode 100644 index 000000000..c6eb02eab --- /dev/null +++ b/grovedb/src/operations/get/aggregate_per_key/mod.rs @@ -0,0 +1,35 @@ +//! Trusted per-key aggregate reads — one entry point per aggregate axis. +//! +//! A **carrier** aggregate query is an outer fan-out: the top-level query +//! items are `Key`/`Range*` and the `default_subquery_branch.subquery` +//! resolves (after walking the optional `subquery_path`) to a leaf +//! aggregate. The read returns one entry per matched outer key. A +//! **leaf** query owns the aggregate item directly and collapses to a +//! single entry — see [`carrier`] for why that entry carries an empty +//! stand-in key. +//! +//! One module per axis, mirroring how the proof side splits +//! `operations::proof::aggregate_{count,sum,count_and_sum}`: +//! +//! - [`count`] — `query_aggregate_count_per_key` → `(key, u64)` +//! - [`sum`] — `query_aggregate_sum_per_key` → `(key, i64)` +//! - [`count_and_sum`] — `query_aggregate_count_and_sum_per_key` → +//! `(key, u64, i64)` +//! +//! Every part of the carrier walk is aggregate-agnostic — only the +//! merk-level primitive that terminates each per-key descent differs by +//! axis — so all three entry points delegate to the single driver in +//! [`carrier`] and supply that primitive as an argument. Each entry point +//! module therefore holds only its own version gate, shape validation, +//! leaf-shape delegation, and axis-specific error text. +//! +//! Results from this module are **not** independently verifiable. The +//! verifiable counterparts are +//! `verify_aggregate_{count,sum,count_and_sum}_query_per_key` on the +//! proof side; the two surfaces return the same shapes on purpose, so a +//! caller can swap one for the other and compare element-for-element. + +mod carrier; +mod count; +mod count_and_sum; +mod sum; diff --git a/grovedb/src/operations/get/aggregate_per_key/sum.rs b/grovedb/src/operations/get/aggregate_per_key/sum.rs new file mode 100644 index 000000000..0b77d306b --- /dev/null +++ b/grovedb/src/operations/get/aggregate_per_key/sum.rs @@ -0,0 +1,127 @@ +//! `query_aggregate_sum_per_key` — trusted per-key reads on the signed-sum +//! axis. +//! +//! See [`super`] for the leaf-vs-carrier vocabulary and +//! [`super::carrier`] for the shared walk this delegates to. + +use grovedb_costs::{ + cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, +}; +use grovedb_merk::Merk; +use grovedb_version::{check_grovedb_v0_with_cost, version::GroveVersion}; + +use crate::{util::TxRef, Error, GroveDb, PathQuery, TransactionArg}; + +impl GroveDb { + /// Executes an `AggregateSumOnRange` query in either the **leaf** or + /// **carrier** shape without generating a proof, returning one + /// `(outer_key, sum)` pair per matched outer key. + /// + /// Sum-axis mirror of [`Self::query_aggregate_count_per_key`], and + /// the no-proof counterpart of + /// [`GroveDb::verify_aggregate_sum_query_per_key`]: it performs the + /// same merk-level boundary walks the per-key verifier reconstructs + /// from a proof but skips proof generation, encoding, decoding, and + /// chain verification entirely. + /// + /// For a **leaf** query the returned vector contains exactly one + /// entry whose key is an empty byte string and whose sum is the same + /// `i64` [`Self::query_aggregate_sum`] would have returned. + /// + /// The empty stand-in key in the leaf shape is deliberate: it is the + /// convention the three per-key *verifiers* already collapse a leaf + /// proof to, so keeping it here is what lets a caller swap + /// `query_aggregate_*_per_key` for `prove_query` + + /// `verify_aggregate_*_query_per_key` (or back) and compare results + /// element-for-element without branching on the shape. A leaf query + /// has no outer key to report, so *some* stand-in is unavoidable; + /// matching the already-shipped proof-side convention is worth more + /// than a prettier one. Callers that need to distinguish "leaf" from + /// "carrier whose outer key happens to be empty" should classify the + /// query — outer keys are never empty in a valid carrier, since + /// [`PathQuery::validate_aggregate_sum_on_range`] requires every + /// `subquery_path` element to be a non-empty key. + /// + /// For a **carrier** query the outer items must be `Key(_)` / + /// `Range*(_)` and the `default_subquery_branch.subquery` must + /// validate as a leaf `AggregateSumOnRange`. The optional + /// `subquery_path` is followed exactly (single-key step per element) + /// before the sum walk. The returned vector has one entry per + /// matched outer key in query-direction order (ascending lex when + /// `left_to_right = true`, descending otherwise). Outer-key + /// candidates that don't exist contribute no entry; outer-key + /// candidates whose leaf subtree is empty contribute `(key, 0)`. + /// + /// `path_query` must satisfy + /// [`PathQuery::validate_aggregate_sum_on_range`] in either shape. + /// Pagination rules differ by shape: for **leaf** queries both + /// `SizedQuery::limit` and `SizedQuery::offset` are rejected (a leaf + /// returns a single `i64` and pagination would silently change the + /// answer); for **carrier** queries `SizedQuery::limit` is accepted + /// and caps the number of outer-key matches the walk returns (each + /// matched outer key still produces a complete leaf-ASOR `i64`, the + /// inner range is not capped), while `SizedQuery::offset` is still + /// rejected. Each leaf subtree the walk terminates in must be a + /// `ProvableSumTree` or `ProvableCountProvableSumTree` — the + /// merk-level walk rejects any other tree type. + /// + /// The returned sums are **not** independently verifiable — callers + /// are trusting their own merk read path. For verifiable sums, use + /// [`Self::prove_query`] + + /// [`GroveDb::verify_aggregate_sum_query_per_key`]. + pub fn query_aggregate_sum_per_key( + &self, + path_query: &PathQuery, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult, i64)>, Error> { + check_grovedb_v0_with_cost!( + "query_aggregate_sum_per_key", + grove_version + .grovedb_versions + .operations + .query + .query_aggregate_sum_on_range + ); + + let mut cost = OperationCost::default(); + + // Up-front shape validation: accept both leaf and carrier + // shapes. We classify by what the top-level query owns: a direct + // `AggregateSumOnRange` item means leaf; otherwise the validator + // has confirmed a valid carrier subquery exists. + let inner_range = cost_return_on_error_no_add!( + cost, + path_query.validate_aggregate_sum_on_range().cloned() + ); + + if path_query.query.query.aggregate_sum_on_range().is_some() { + // Leaf shape: delegate to the existing single-`i64` entry + // point and wrap as a one-entry vector with an empty key. + let sum = cost_return_on_error!( + &mut cost, + self.query_aggregate_sum(path_query, transaction, grove_version) + ); + return Ok(vec![(Vec::new(), sum)]).wrap_with_cost(cost); + } + + // Carrier shape: delegate to the shared carrier driver, which + // terminates each per-key walk in the sum primitive. + let tx = TxRef::new(&self.db, transaction); + let results = cost_return_on_error!( + &mut cost, + self.query_aggregate_carrier_per_key( + path_query, + &inner_range, + "carrier aggregate-sum matched a non-tree element; outer items must resolve to \ + tree elements", + Merk::sum_aggregate_on_range, + tx.as_ref(), + transaction, + grove_version, + ) + ); + + Ok(results).wrap_with_cost(cost) + } +} diff --git a/grovedb/src/operations/get/mod.rs b/grovedb/src/operations/get/mod.rs index dbfc9331c..e48fad237 100644 --- a/grovedb/src/operations/get/mod.rs +++ b/grovedb/src/operations/get/mod.rs @@ -3,6 +3,7 @@ #[cfg(feature = "estimated_costs")] mod average_case; +mod aggregate_per_key; mod query; use grovedb_storage::Storage; pub use query::QueryItemOrSumReturnType; diff --git a/grovedb/src/operations/get/query.rs b/grovedb/src/operations/get/query.rs index 5a4a303b5..4721197fb 100644 --- a/grovedb/src/operations/get/query.rs +++ b/grovedb/src/operations/get/query.rs @@ -20,7 +20,7 @@ use crate::{ query_result_type::{QueryResultElement, QueryResultElements, QueryResultType}, reference_path::ReferencePathType, util::TxRef, - Element, Error, GroveDb, PathQuery, SizedQuery, TransactionArg, + Element, Error, GroveDb, PathQuery, TransactionArg, }; use grovedb_costs::cost_return_on_error_default; #[cfg(feature = "minimal")] @@ -1018,175 +1018,6 @@ where { Ok(count_and_sum).wrap_with_cost(cost) } - /// Executes an `AggregateCountOnRange` query in either the **leaf** or - /// **carrier** shape without generating a proof, returning one - /// `(outer_key, count)` pair per matched outer key. - /// - /// This is the no-proof counterpart of - /// [`GroveDb::verify_aggregate_count_query_per_key`]: it performs the - /// same merk-level boundary walks the per-key verifier reconstructs - /// from a proof but skips proof generation, encoding, decoding, and - /// chain verification entirely. - /// - /// For a **leaf** query the returned vector contains exactly one - /// entry whose key is an empty byte string and whose count is the - /// same `u64` [`Self::query_aggregate_count`] would have returned. - /// This matches the per-key verifier's leaf behavior, so callers - /// that always handle `Vec<(Vec, u64)>` don't need to branch on - /// the shape. - /// - /// For a **carrier** query the outer items must be `Key(_)` / - /// `Range*(_)` and the `default_subquery_branch.subquery` must - /// validate as a leaf `AggregateCountOnRange`. The optional - /// `subquery_path` is followed exactly (single-key step per element) - /// before the count walk. The returned vector has one entry per - /// matched outer key in query-direction order (ascending lex when - /// `left_to_right = true`, descending otherwise). Outer-key - /// candidates that don't exist contribute no entry; outer-key - /// candidates whose leaf subtree is empty contribute `(key, 0)`. - /// - /// `path_query` must satisfy - /// [`PathQuery::validate_aggregate_count_on_range`] in either - /// shape. Pagination rules differ by shape: for **leaf** queries - /// both `SizedQuery::limit` and `SizedQuery::offset` are rejected - /// (a leaf returns a single `u64` and pagination would silently - /// change the answer); for **carrier** queries `SizedQuery::limit` - /// is accepted and caps the number of outer-key matches the walk - /// returns (each matched outer key still produces a complete - /// leaf-ACOR `u64`, the inner range is not capped), while - /// `SizedQuery::offset` is still rejected. Each leaf subtree the - /// walk terminates in must be a `ProvableCountTree` or - /// `ProvableCountSumTree` — the merk-level walk rejects any other - /// tree type. - /// - /// The returned counts are **not** independently verifiable — - /// callers are trusting their own merk read path. For verifiable - /// counts, use [`Self::prove_query`] + - /// [`GroveDb::verify_aggregate_count_query_per_key`]. - pub fn query_aggregate_count_per_key( - &self, - path_query: &PathQuery, - transaction: TransactionArg, - grove_version: &GroveVersion, - ) -> CostResult, u64)>, Error> { - check_grovedb_v0_with_cost!( - "query_aggregate_count_per_key", - grove_version - .grovedb_versions - .operations - .query - .query_aggregate_count_on_range - ); - - let mut cost = OperationCost::default(); - - // Up-front shape validation: accept both leaf and carrier shapes. - // We classify by what the top-level query owns: a direct - // `AggregateCountOnRange` item means leaf; otherwise the - // dispatcher already confirmed a valid carrier subquery exists. - let inner_range = cost_return_on_error_no_add!( - cost, - path_query.validate_aggregate_count_on_range().cloned() - ); - - if path_query.query.query.aggregate_count_on_range().is_some() { - // Leaf shape: delegate to the existing single-`u64` entry - // point and wrap as a one-entry vector with an empty key. - let count = cost_return_on_error!( - &mut cost, - self.query_aggregate_count(path_query, transaction, grove_version) - ); - return Ok(vec![(Vec::new(), count)]).wrap_with_cost(cost); - } - - // Carrier shape: enumerate matched outer keys at the carrier - // subtree, then per match navigate `subquery_path` and run the - // merk-level count walk on the leaf. - let q = &path_query.query.query; - let outer_items = q.items.clone(); - let subquery_path = q - .default_subquery_branch - .subquery_path - .clone() - .unwrap_or_default(); - let left_to_right = q.left_to_right; - - // Build a "shallow" path query that enumerates the carrier's - // outer items at `path_query.path` without descending into the - // subquery — we want just the matched outer keys, not the - // (unproven) results of the leaf aggregate-count. - // - // Propagate `SizedQuery::limit` (validated as carrier-only - // above): it caps the number of outer-key matches the walk - // returns. Each matched outer key still produces a complete - // leaf-ACOR `u64` below. `offset` is rejected at validation, so - // we don't propagate it here. - let mut shallow_query = grovedb_query::Query::new_with_direction(left_to_right); - shallow_query.items = outer_items; - let shallow_pq = PathQuery::new( - path_query.path.clone(), - SizedQuery::new(shallow_query, path_query.query.limit, None), - ); - - let (matched, _skipped) = cost_return_on_error!( - &mut cost, - self.query_raw( - &shallow_pq, - true, // allow_cache - false, // decrease_limit_on_range_with_no_sub_elements - true, // error_if_intermediate_path_tree_not_present - QueryResultType::QueryKeyElementPairResultType, - transaction, - grove_version, - ) - ); - - let key_elements = matched.to_key_elements(); - let mut results: Vec<(Vec, u64)> = Vec::with_capacity(key_elements.len()); - let tx = TxRef::new(&self.db, transaction); - - for (key, element) in key_elements { - // Refuse non-tree matches: aggregate-count requires - // descending into the matched element to find the leaf - // count subtree. - if !element.is_any_tree() { - return Err(Error::InvalidQuery( - "carrier aggregate-count matched a non-tree element; outer items must \ - resolve to tree elements", - )) - .wrap_with_cost(cost); - } - - // Build the path to the leaf count subtree: - // `path_query.path / outer_key / subquery_path...`. - let mut leaf_path_owned: Vec> = path_query.path.clone(); - leaf_path_owned.push(key.clone()); - leaf_path_owned.extend(subquery_path.iter().cloned()); - let leaf_path: Vec<&[u8]> = leaf_path_owned.iter().map(|p| p.as_slice()).collect(); - - let leaf_subtree = cost_return_on_error!( - &mut cost, - self.open_transactional_merk_at_path( - SubtreePath::from(leaf_path.as_slice()), - tx.as_ref(), - None, - grove_version, - ) - ); - - let count = cost_return_on_error!( - &mut cost, - leaf_subtree - .count_aggregate_on_range(&inner_range, grove_version) - .map_err(Error::MerkError) - ); - - results.push((key, count)); - } - - Ok(results).wrap_with_cost(cost) - } - /// Retrieves SumItem values that match a regular [`PathQuery`], returning /// a `Vec` of the raw sum values and the number of skipped elements. /// diff --git a/grovedb/src/tests/aggregate_count_and_sum_carrier_query_tests.rs b/grovedb/src/tests/aggregate_count_and_sum_carrier_query_tests.rs index a7af616dd..7ca1c297b 100644 --- a/grovedb/src/tests/aggregate_count_and_sum_carrier_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_and_sum_carrier_query_tests.rs @@ -585,4 +585,487 @@ mod tests { } } } + + // ---------- No-proof per-key entry point ---------- + // + // `query_aggregate_count_and_sum_per_key` is the trusted-read + // counterpart of `verify_aggregate_count_and_sum_query_per_key`: + // same surface shape (`Vec<(Vec, u64, i64)>`), accepts both leaf + // and carrier path queries, but skips proof generation and + // verification entirely. The strongest assertion available is + // differential equality with the proved path, which is already + // consensus-tested above. + + #[test] + fn no_proof_per_key_combined_leaf_matches_single_pair() { + // Leaf-shape path query → one-entry vec with an empty stand-in + // key and the same (count, sum) pair + // `query_aggregate_count_and_sum` returns. + let v = GroveVersion::latest(); + let (db, _) = setup_brand_value_pcps_carrier_tree(v, &[b"brand_000"], 10); + let path_query = PathQuery::new_aggregate_count_and_sum_on_range( + vec![ + TEST_LEAF.to_vec(), + b"byBrand".to_vec(), + b"brand_000".to_vec(), + b"value".to_vec(), + ], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + + let (count, sum) = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, v) + .unwrap() + .expect("single-pair entry should succeed"); + let per_key = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect("per-key entry should succeed"); + + // value_00005 .. value_00009 → count 5, sum 6+7+8+9+10 = 40 + assert_eq!((count, sum), (5, 40)); + assert_eq!(per_key.len(), 1); + assert_eq!(per_key[0].0, Vec::::new()); + assert_eq!(per_key[0].1, count); + assert_eq!(per_key[0].2, sum); + } + + #[test] + fn no_proof_per_key_combined_leaf_matches_proof_path() { + // Differential: the leaf shape must agree with the proved leaf + // shape, including the empty stand-in key. + let v = GroveVersion::latest(); + let (db, _) = setup_brand_value_pcps_carrier_tree(v, &[b"brand_000"], 10); + let path_query = PathQuery::new_aggregate_count_and_sum_on_range( + vec![ + TEST_LEAF.to_vec(), + b"byBrand".to_vec(), + b"brand_000".to_vec(), + b"value".to_vec(), + ], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + let no_proof = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof leaf per-key should succeed"); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = + GroveDb::verify_aggregate_count_and_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + } + + #[test] + fn no_proof_per_key_combined_carrier_returns_per_outer_pair() { + // Carrier shape → one (brand, count, sum) triple per matched + // outer key in query-direction order. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_pcps_carrier_tree(v, &[b"brand_000", b"brand_001"], 10); + let path_query = carrier_combined_path_query( + &[b"brand_000", b"brand_001"], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + let results = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof carrier query should succeed"); + assert_eq!(results.len(), 2); + assert_eq!(results[0], (b"brand_000".to_vec(), 5, 40)); + assert_eq!(results[1], (b"brand_001".to_vec(), 5, 40)); + } + + #[test] + fn no_proof_per_key_combined_matches_proof_path_per_key() { + // Differential over a non-trivial carrier: the trusted read must + // agree element-for-element with the proved per-key result. + let v = GroveVersion::latest(); + let (db, _root) = + setup_brand_value_pcps_carrier_tree(v, &[b"brand_000", b"brand_001", b"brand_002"], 10); + let path_query = carrier_combined_path_query( + &[b"brand_000", b"brand_001", b"brand_002"], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + let no_proof = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof should succeed"); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = + GroveDb::verify_aggregate_count_and_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + } + + #[test] + fn no_proof_per_key_combined_right_to_left_matches_proof_path() { + // Direction propagation: a descending carrier must produce the + // same ordering the proved path produces. + let v = GroveVersion::latest(); + let (db, _root) = + setup_brand_value_pcps_carrier_tree(v, &[b"brand_000", b"brand_001", b"brand_002"], 10); + let mut carrier = Query::new_with_direction(false); + for k in [b"brand_000", b"brand_001", b"brand_002"] { + carrier.insert_key(k.to_vec()); + } + carrier.set_subquery_path(vec![b"value".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_and_sum_on_range( + QueryItem::RangeFrom(b"value_00000".to_vec()..), + )); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, None), + ); + let no_proof = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof should succeed"); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = + GroveDb::verify_aggregate_count_and_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + assert_eq!(no_proof[0].0, b"brand_002".to_vec(), "descending order"); + } + + #[test] + fn no_proof_per_key_combined_skips_absent_outer_keys() { + // Absent outer keys contribute no entry — same as the proved + // path's behavior. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_pcps_carrier_tree(v, &[b"brand_000"], 10); + let path_query = carrier_combined_path_query( + &[b"brand_000", b"brand_999_missing"], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + let no_proof = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof carrier query should succeed"); + assert_eq!(no_proof.len(), 1, "absent key contributes no entry"); + assert_eq!(no_proof[0], (b"brand_000".to_vec(), 5, 40)); + + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = + GroveDb::verify_aggregate_count_and_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + } + + #[test] + fn no_proof_per_key_combined_empty_carrier_result_set() { + // No outer key matches at all → empty result vector, not an + // error, and the proved path agrees. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_pcps_carrier_tree(v, &[b"brand_000"], 10); + let mut carrier = Query::new(); + carrier.insert_range_after(b"brand_zzz".to_vec()..); + carrier.set_subquery_path(vec![b"value".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_and_sum_on_range( + QueryItem::RangeFrom(b"value_00000".to_vec()..), + )); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, None), + ); + let no_proof = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect("empty carrier result set must not be an error"); + assert!(no_proof.is_empty()); + + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = + GroveDb::verify_aggregate_count_and_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved, "empty result sets must agree too"); + } + + #[test] + fn no_proof_per_key_combined_empty_leaf_returns_zero_pair() { + // Outer key exists and `subquery_path` resolves cleanly, but the + // leaf PCPS tree is empty. Match the proved path: emit + // `(key, 0, 0)` rather than skipping or erroring. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"byBrand", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert byBrand"); + db.insert( + [TEST_LEAF, b"byBrand"].as_ref(), + b"brand_000", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert brand"); + db.insert( + [TEST_LEAF, b"byBrand", b"brand_000"].as_ref(), + b"value", + Element::empty_provable_count_provable_sum_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert empty PCPS value subtree"); + + let path_query = carrier_combined_path_query( + &[b"brand_000"], + QueryItem::Range(b"value_00000".to_vec()..b"value_99999".to_vec()), + ); + let results = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof carrier with empty leaf should succeed"); + assert_eq!(results.len(), 1); + assert_eq!(results[0], (b"brand_000".to_vec(), 0, 0)); + } + + #[test] + fn no_proof_per_key_combined_limit_caps_outer_matches() { + // `SizedQuery::limit` caps the number of outer-key matches. Each + // surviving match still carries a complete leaf pair — the inner + // range is not capped. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_pcps_carrier_tree( + v, + &[b"brand_000", b"brand_001", b"brand_002", b"brand_003"], + 10, + ); + let mut carrier = Query::new(); + for k in [b"brand_000", b"brand_001", b"brand_002", b"brand_003"] { + carrier.insert_key(k.to_vec()); + } + carrier.set_subquery_path(vec![b"value".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_and_sum_on_range( + QueryItem::RangeFrom(b"value_00000".to_vec()..), + )); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, Some(2), None), + ); + + let no_proof = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect("carrier with limit should succeed"); + assert_eq!(no_proof.len(), 2, "expected exactly `limit` outer matches"); + assert_eq!(no_proof[0].0, b"brand_000".to_vec()); + assert_eq!(no_proof[1].0, b"brand_001".to_vec()); + let expected_sum = triangular(10); + for (_, count, sum) in &no_proof { + assert_eq!(*count, 10, "inner range must not be capped"); + assert_eq!(*sum, expected_sum, "inner range must not be capped"); + } + + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = + GroveDb::verify_aggregate_count_and_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + } + + #[test] + fn no_proof_per_key_combined_rejects_non_tree_outer_match() { + // An outer-key match that resolves to a non-tree element can't + // be descended into, so the carrier walk rejects it rather than + // silently dropping the key. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_pcps_carrier_tree(v, &[b"brand_000"], 10); + db.insert( + [TEST_LEAF, b"byBrand"].as_ref(), + b"brand_001", + Element::new_item(b"not a tree".to_vec()), + None, + None, + v, + ) + .unwrap() + .expect("insert item at outer layer"); + + let path_query = carrier_combined_path_query( + &[b"brand_000", b"brand_001"], + QueryItem::RangeFrom(b"value_00000".to_vec()..), + ); + let err = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect_err("non-tree outer match must be rejected"); + match err { + crate::Error::InvalidQuery(msg) => { + assert!( + msg.contains("non-tree element"), + "unexpected message: {msg}" + ); + } + other => panic!("expected InvalidQuery, got {other:?}"), + } + } + + #[test] + fn no_proof_per_key_combined_rejects_single_axis_leaf_host() { + // Dual-axis invariant on the trusted-read path: only a PCPS host + // can ground a combined aggregate, so a carrier terminating in a + // plain ProvableSumTree must be rejected by the merk-level walk. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"byBrand", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert byBrand"); + db.insert( + [TEST_LEAF, b"byBrand"].as_ref(), + b"brand_000", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert brand"); + db.insert( + [TEST_LEAF, b"byBrand", b"brand_000"].as_ref(), + b"value", + Element::empty_provable_sum_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert single-axis ProvableSumTree"); + + let path_query = carrier_combined_path_query( + &[b"brand_000"], + QueryItem::RangeFrom(b"value_00000".to_vec()..), + ); + let err = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect_err("single-axis leaf host must be rejected"); + assert!( + format!("{err}").contains("ProvableCountProvableSumTree"), + "unexpected error: {err:?}" + ); + } + + #[test] + fn no_proof_per_key_combined_rejects_non_aggregate_query() { + // Same validation gate as the proved per-key entry: non-ACSOR + // path queries are rejected up front with `InvalidQuery`. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + let path_query = PathQuery::new_single_query_item( + vec![TEST_LEAF.to_vec()], + QueryItem::Key(b"k".to_vec()), + ); + let err = db + .grove_db + .query_aggregate_count_and_sum_per_key(&path_query, None, v) + .unwrap() + .expect_err("non-combined-aggregate path query must be rejected"); + assert!(matches!(err, crate::Error::InvalidQuery(_))); + } + + #[test] + fn no_proof_per_key_combined_error_surface_matches_validator() { + // For malformed queries the trusted read must surface exactly + // the validator's error — it does no shape reasoning of its own. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_pcps_carrier_tree(v, &[b"brand_000"], 10); + + let mut carrier = Query::new(); + carrier.insert_key(b"brand_000".to_vec()); + carrier.set_subquery_path(vec![b"value".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_and_sum_on_range( + QueryItem::RangeFrom(b"value_00000".to_vec()..), + )); + let carrier_with_offset = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, Some(1)), + ); + + let mut leaf_with_limit = PathQuery::new_aggregate_count_and_sum_on_range( + vec![ + TEST_LEAF.to_vec(), + b"byBrand".to_vec(), + b"brand_000".to_vec(), + b"value".to_vec(), + ], + QueryItem::RangeFrom(b"value_00000".to_vec()..), + ); + leaf_with_limit.query.limit = Some(1); + + let not_aggregate = PathQuery::new_single_query_item( + vec![TEST_LEAF.to_vec()], + QueryItem::Key(b"k".to_vec()), + ); + + for bad in [&carrier_with_offset, &leaf_with_limit, ¬_aggregate] { + let validator_err = bad + .validate_aggregate_count_and_sum_on_range() + .expect_err("validator must reject"); + let read_err = db + .grove_db + .query_aggregate_count_and_sum_per_key(bad, None, v) + .unwrap() + .expect_err("trusted read must reject"); + assert_eq!( + read_err.to_string(), + validator_err.to_string(), + "trusted read must surface the validator's error verbatim" + ); + } + } } diff --git a/grovedb/src/tests/aggregate_sum_carrier_query_tests.rs b/grovedb/src/tests/aggregate_sum_carrier_query_tests.rs index 2ef40c9c6..6aed25320 100644 --- a/grovedb/src/tests/aggregate_sum_carrier_query_tests.rs +++ b/grovedb/src/tests/aggregate_sum_carrier_query_tests.rs @@ -524,4 +524,432 @@ mod tests { other => panic!("expected InvalidQuery, got {:?}", other), } } + + // ---------- No-proof per-key entry point ---------- + // + // `query_aggregate_sum_per_key` is the trusted-read counterpart of + // `verify_aggregate_sum_query_per_key`: same surface shape + // (`Vec<(Vec, i64)>`), accepts both leaf and carrier path + // queries, but skips proof generation and verification entirely. + // The strongest assertion available is differential equality with + // the proved path, which is already consensus-tested above. + + #[test] + fn no_proof_per_key_sum_leaf_matches_single_sum() { + // Leaf-shape path query → one-entry vec with an empty stand-in + // key and the same sum `query_aggregate_sum` returns. This is + // the leaf-symmetry contract the per-key verifier also honors. + let v = GroveVersion::latest(); + let (db, _) = setup_brand_value_carrier_tree(v, &[b"brand_000"], 10); + let path_query = PathQuery::new_aggregate_sum_on_range( + vec![ + TEST_LEAF.to_vec(), + b"byBrand".to_vec(), + b"brand_000".to_vec(), + b"value".to_vec(), + ], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + + let single = db + .grove_db + .query_aggregate_sum(&path_query, None, v) + .unwrap() + .expect("single-i64 entry should succeed"); + let per_key = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect("per-key entry should succeed"); + + // 6 + 7 + 8 + 9 + 10 = 40 + assert_eq!(single, 40); + assert_eq!(per_key.len(), 1); + assert_eq!(per_key[0].0, Vec::::new()); + assert_eq!(per_key[0].1, single); + } + + #[test] + fn no_proof_per_key_sum_leaf_matches_proof_path() { + // Differential: the leaf shape must agree with the proved leaf + // shape, including the empty stand-in key. + let v = GroveVersion::latest(); + let (db, _) = setup_brand_value_carrier_tree(v, &[b"brand_000"], 10); + let path_query = PathQuery::new_aggregate_sum_on_range( + vec![ + TEST_LEAF.to_vec(), + b"byBrand".to_vec(), + b"brand_000".to_vec(), + b"value".to_vec(), + ], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + let no_proof = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof leaf per-key should succeed"); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = GroveDb::verify_aggregate_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + } + + #[test] + fn no_proof_per_key_sum_carrier_returns_per_outer_sum() { + // Carrier shape → one (brand, sum) entry per matched outer key + // in query-direction order. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_carrier_tree(v, &[b"brand_000", b"brand_001"], 10); + let path_query = carrier_sum_path_query( + &[b"brand_000", b"brand_001"], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + let results = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof carrier query should succeed"); + assert_eq!(results.len(), 2); + assert_eq!(results[0].0, b"brand_000".to_vec()); + assert_eq!(results[1].0, b"brand_001".to_vec()); + assert_eq!(results[0].1, 40); + assert_eq!(results[1].1, 40); + } + + #[test] + fn no_proof_per_key_sum_matches_proof_path_per_key() { + // Differential over a non-trivial carrier: the trusted read must + // agree element-for-element with the proved per-key result. + let v = GroveVersion::latest(); + let (db, _root) = + setup_brand_value_carrier_tree(v, &[b"brand_000", b"brand_001", b"brand_002"], 10); + let path_query = carrier_sum_path_query( + &[b"brand_000", b"brand_001", b"brand_002"], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + let no_proof = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof should succeed"); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = GroveDb::verify_aggregate_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + } + + #[test] + fn no_proof_per_key_sum_right_to_left_matches_proof_path() { + // Direction propagation: a descending carrier must produce the + // same ordering the proved path produces. + let v = GroveVersion::latest(); + let (db, _root) = + setup_brand_value_carrier_tree(v, &[b"brand_000", b"brand_001", b"brand_002"], 10); + let mut carrier = Query::new_with_direction(false); + for k in [b"brand_000", b"brand_001", b"brand_002"] { + carrier.insert_key(k.to_vec()); + } + carrier.set_subquery_path(vec![b"value".to_vec()]); + carrier.set_subquery(Query::new_aggregate_sum_on_range(QueryItem::RangeFrom( + b"value_00000".to_vec().., + ))); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, None), + ); + let no_proof = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof should succeed"); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = GroveDb::verify_aggregate_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + assert_eq!(no_proof[0].0, b"brand_002".to_vec(), "descending order"); + } + + #[test] + fn no_proof_per_key_sum_skips_absent_outer_keys() { + // Absent outer keys contribute no entry — same as the proved + // path's behavior. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_carrier_tree(v, &[b"brand_000"], 10); + let path_query = carrier_sum_path_query( + &[b"brand_000", b"brand_999_missing"], + QueryItem::RangeAfter(b"value_00004".to_vec()..), + ); + let no_proof = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof carrier query should succeed"); + assert_eq!(no_proof.len(), 1, "absent key contributes no entry"); + assert_eq!(no_proof[0].0, b"brand_000".to_vec()); + assert_eq!(no_proof[0].1, 40); + + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = GroveDb::verify_aggregate_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + } + + #[test] + fn no_proof_per_key_sum_empty_carrier_result_set() { + // No outer key matches at all → empty result vector, not an + // error, and the proved path agrees. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_carrier_tree(v, &[b"brand_000"], 10); + let mut carrier = Query::new(); + // Everything strictly after the only present brand → no matches. + carrier.insert_range_after(b"brand_zzz".to_vec()..); + carrier.set_subquery_path(vec![b"value".to_vec()]); + carrier.set_subquery(Query::new_aggregate_sum_on_range(QueryItem::RangeFrom( + b"value_00000".to_vec().., + ))); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, None), + ); + let no_proof = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect("empty carrier result set must not be an error"); + assert!(no_proof.is_empty()); + + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = GroveDb::verify_aggregate_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved, "empty result sets must agree too"); + } + + #[test] + fn no_proof_per_key_sum_empty_leaf_returns_zero() { + // Outer key exists and `subquery_path` resolves cleanly, but the + // leaf sum tree is empty. Match the proved path: emit `(key, 0)` + // rather than skipping or erroring. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"byBrand", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert byBrand"); + db.insert( + [TEST_LEAF, b"byBrand"].as_ref(), + b"brand_000", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert brand"); + db.insert( + [TEST_LEAF, b"byBrand", b"brand_000"].as_ref(), + b"value", + Element::empty_provable_sum_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert empty value subtree"); + + let path_query = carrier_sum_path_query( + &[b"brand_000"], + QueryItem::Range(b"value_00000".to_vec()..b"value_99999".to_vec()), + ); + let results = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof carrier with empty leaf should succeed"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, b"brand_000".to_vec()); + assert_eq!(results[0].1, 0); + } + + #[test] + fn no_proof_per_key_sum_limit_caps_outer_matches() { + // `SizedQuery::limit` caps the number of outer-key matches. Each + // surviving match still carries a complete leaf-ASOR i64 — the + // inner range is not capped. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_carrier_tree( + v, + &[b"brand_000", b"brand_001", b"brand_002", b"brand_003"], + 10, + ); + let mut carrier = Query::new(); + for k in [b"brand_000", b"brand_001", b"brand_002", b"brand_003"] { + carrier.insert_key(k.to_vec()); + } + carrier.set_subquery_path(vec![b"value".to_vec()]); + carrier.set_subquery(Query::new_aggregate_sum_on_range(QueryItem::RangeFrom( + b"value_00000".to_vec().., + ))); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, Some(2), None), + ); + + let no_proof = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect("carrier with limit should succeed"); + assert_eq!(no_proof.len(), 2, "expected exactly `limit` outer matches"); + assert_eq!(no_proof[0].0, b"brand_000".to_vec()); + assert_eq!(no_proof[1].0, b"brand_001".to_vec()); + let expected_sum = triangular(10); + for (_, sum) in &no_proof { + assert_eq!(*sum, expected_sum, "inner range must not be capped"); + } + + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = GroveDb::verify_aggregate_sum_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + } + + #[test] + fn no_proof_per_key_sum_rejects_non_tree_outer_match() { + // An outer-key match that resolves to a non-tree element can't + // be descended into, so the carrier walk rejects it rather than + // silently dropping the key. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_carrier_tree(v, &[b"brand_000"], 10); + db.insert( + [TEST_LEAF, b"byBrand"].as_ref(), + b"brand_001", + Element::new_item(b"not a tree".to_vec()), + None, + None, + v, + ) + .unwrap() + .expect("insert item at outer layer"); + + let path_query = carrier_sum_path_query( + &[b"brand_000", b"brand_001"], + QueryItem::RangeFrom(b"value_00000".to_vec()..), + ); + let err = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect_err("non-tree outer match must be rejected"); + match err { + crate::Error::InvalidQuery(msg) => { + assert!( + msg.contains("non-tree element"), + "unexpected message: {msg}" + ); + } + other => panic!("expected InvalidQuery, got {other:?}"), + } + } + + #[test] + fn no_proof_per_key_sum_rejects_non_aggregate_sum_query() { + // Same validation gate as the proved per-key entry: non-ASOR + // path queries are rejected up front with `InvalidQuery`. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + let path_query = PathQuery::new_single_query_item( + vec![TEST_LEAF.to_vec()], + QueryItem::Key(b"k".to_vec()), + ); + let err = db + .grove_db + .query_aggregate_sum_per_key(&path_query, None, v) + .unwrap() + .expect_err("non-aggregate-sum path query must be rejected"); + assert!(matches!(err, crate::Error::InvalidQuery(_))); + } + + #[test] + fn no_proof_per_key_sum_error_surface_matches_validator() { + // For malformed queries the trusted read must surface exactly + // the validator's error — it does no shape reasoning of its own. + // Covers a carrier with `offset` (carrier-illegal), a leaf with + // `limit` (leaf-illegal), and an outright non-aggregate query. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_value_carrier_tree(v, &[b"brand_000"], 10); + + let mut carrier = Query::new(); + carrier.insert_key(b"brand_000".to_vec()); + carrier.set_subquery_path(vec![b"value".to_vec()]); + carrier.set_subquery(Query::new_aggregate_sum_on_range(QueryItem::RangeFrom( + b"value_00000".to_vec().., + ))); + let carrier_with_offset = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, Some(1)), + ); + + let mut leaf_with_limit = PathQuery::new_aggregate_sum_on_range( + vec![ + TEST_LEAF.to_vec(), + b"byBrand".to_vec(), + b"brand_000".to_vec(), + b"value".to_vec(), + ], + QueryItem::RangeFrom(b"value_00000".to_vec()..), + ); + leaf_with_limit.query.limit = Some(1); + + let not_aggregate = PathQuery::new_single_query_item( + vec![TEST_LEAF.to_vec()], + QueryItem::Key(b"k".to_vec()), + ); + + for bad in [&carrier_with_offset, &leaf_with_limit, ¬_aggregate] { + let validator_err = bad + .validate_aggregate_sum_on_range() + .expect_err("validator must reject"); + let read_err = db + .grove_db + .query_aggregate_sum_per_key(bad, None, v) + .unwrap() + .expect_err("trusted read must reject"); + assert_eq!( + read_err.to_string(), + validator_err.to_string(), + "trusted read must surface the validator's error verbatim" + ); + } + } }