diff --git a/grovedb-version/src/version/grovedb_versions.rs b/grovedb-version/src/version/grovedb_versions.rs index eaf2eb31a..877aeb355 100644 --- a/grovedb-version/src/version/grovedb_versions.rs +++ b/grovedb-version/src/version/grovedb_versions.rs @@ -35,6 +35,24 @@ pub struct GroveDBPathQueryMethodVersions { pub merge: FeatureVersion, pub query_items_at_path: FeatureVersion, pub should_add_parent_tree_at_path: FeatureVersion, + /// Whether `PathQuery` read modes (axis-ordered and sum-budget + /// reads, `Query::read_mode`) are served. + /// + /// - `0` (GROVE_V1..=V3): any `PathQuery` carrying a read mode is + /// rejected with `NotSupported` at every read / prove / verify + /// entry point. The vocabulary itself still encodes and decodes — + /// the gate is about *serving*, so a v4-built query constructed + /// ahead of activation fails closed instead of being misread as + /// plain key selection (an axis read has empty items; running it + /// as key selection would return an empty result masquerading as + /// real absence, and a proof would attest to the wrong read). + /// - `1` (GROVE_V4+): `run_path_query` (and, as they land, the + /// unified prove/verify dispatch) serve read-mode queries. + /// + /// Prover and verifier read the same slot, so there is no version + /// at which the two sides can disagree about whether a read-mode + /// shape exists. + pub unified_read_mode: FeatureVersion, } #[derive(Clone, Debug, Default)] @@ -192,6 +210,11 @@ pub struct GroveDBOperationsQueryVersions { pub query_keys_optional: FeatureVersion, pub query_raw_keys_optional: FeatureVersion, pub follow_element: FeatureVersion, + /// The unified read dispatch (`GroveDb::run_path_query`). This is + /// the method's own algorithm slot; whether read-mode *shapes* are + /// served is the separate + /// `GroveDBPathQueryMethodVersions::unified_read_mode` gate. + pub run_path_query: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/grovedb-version/src/version/v1.rs b/grovedb-version/src/version/v1.rs index be2b1d472..6a1cf6bb3 100644 --- a/grovedb-version/src/version/v1.rs +++ b/grovedb-version/src/version/v1.rs @@ -148,6 +148,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { query_keys_optional: 0, query_raw_keys_optional: 0, follow_element: 0, + run_path_query: 0, }, proof: GroveDBOperationsProofVersions { prove_query: 0, @@ -204,6 +205,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { merge: 0, query_items_at_path: 0, should_add_parent_tree_at_path: 0, + unified_read_mode: 0, }, replication: GroveDBReplicationVersions { get_subtrees_metadata: 0, diff --git a/grovedb-version/src/version/v2.rs b/grovedb-version/src/version/v2.rs index 14f8d0936..63072919a 100644 --- a/grovedb-version/src/version/v2.rs +++ b/grovedb-version/src/version/v2.rs @@ -148,6 +148,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { query_keys_optional: 0, query_raw_keys_optional: 0, follow_element: 0, + run_path_query: 0, }, proof: GroveDBOperationsProofVersions { prove_query: 0, @@ -204,6 +205,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { merge: 0, query_items_at_path: 0, should_add_parent_tree_at_path: 0, + unified_read_mode: 0, }, replication: GroveDBReplicationVersions { get_subtrees_metadata: 0, diff --git a/grovedb-version/src/version/v3.rs b/grovedb-version/src/version/v3.rs index c65b8518f..96f0aa18d 100644 --- a/grovedb-version/src/version/v3.rs +++ b/grovedb-version/src/version/v3.rs @@ -152,6 +152,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { query_keys_optional: 0, query_raw_keys_optional: 0, follow_element: 0, + run_path_query: 0, }, proof: GroveDBOperationsProofVersions { prove_query: 0, @@ -208,6 +209,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { merge: 0, query_items_at_path: 0, should_add_parent_tree_at_path: 0, + unified_read_mode: 0, }, replication: GroveDBReplicationVersions { get_subtrees_metadata: 0, diff --git a/grovedb-version/src/version/v4.rs b/grovedb-version/src/version/v4.rs index 9be0893f6..fc22418d0 100644 --- a/grovedb-version/src/version/v4.rs +++ b/grovedb-version/src/version/v4.rs @@ -32,6 +32,14 @@ //! flips a rejected/accepted outcome and because deriving the state root //! costs the prover extra storage reads and hash calls. //! +//! - `path_query_methods.unified_read_mode: 1` — `PathQuery` read modes +//! (axis-ordered and sum-budget reads carried in `Query::read_mode`) are +//! served by the unified dispatch (`run_path_query`, and the unified +//! prove/verify as they land). V1..V3 reject any read-mode-bearing query +//! with `NotSupported` at every entry point — those versions also reject +//! the version-2 `Query` wire encoding outright, so the slot's `0` value +//! is the in-process mirror of that fail-closed decode. +//! //! Note that `GroveVersion::latest()` resolves to this version, so anything //! defaulting to "latest" — tests, benchmarks, tools — exercises every gate //! listed above rather than V3 behaviour. @@ -202,6 +210,7 @@ pub const GROVE_V4: GroveVersion = GroveVersion { query_keys_optional: 0, query_raw_keys_optional: 0, follow_element: 0, + run_path_query: 0, }, proof: GroveDBOperationsProofVersions { prove_query: 0, @@ -258,6 +267,7 @@ pub const GROVE_V4: GroveVersion = GroveVersion { merge: 0, query_items_at_path: 0, should_add_parent_tree_at_path: 0, + unified_read_mode: 1, }, replication: GroveDBReplicationVersions { get_subtrees_metadata: 0, diff --git a/grovedb/src/lib.rs b/grovedb/src/lib.rs index 88216551f..c0a7b2704 100644 --- a/grovedb/src/lib.rs +++ b/grovedb/src/lib.rs @@ -241,6 +241,11 @@ use grovedb_storage::{Storage, StorageContext}; use grovedb_version::version::GroveVersion; #[cfg(feature = "minimal")] use grovedb_visualize::DebugByteVectors; +/// The unified read dispatch's result types. `operations::get` is +/// crate-private, so without this re-export `run_path_query` would be +/// callable from outside the crate but its return type unnameable. +#[cfg(feature = "minimal")] +pub use operations::get::{AxisAggregateValue, PathQueryRun}; #[cfg(any(feature = "minimal", feature = "verify"))] pub use query::{ aggregate_sum_path_query::AggregateSumPathQuery, AggregateKind, GroveBranchQueryResult, diff --git a/grovedb/src/operations/get/mod.rs b/grovedb/src/operations/get/mod.rs index dbfc9331c..5cb33b8af 100644 --- a/grovedb/src/operations/get/mod.rs +++ b/grovedb/src/operations/get/mod.rs @@ -4,8 +4,10 @@ mod average_case; mod query; +mod run_path_query; use grovedb_storage::Storage; pub use query::QueryItemOrSumReturnType; +pub use run_path_query::{AxisAggregateValue, PathQueryRun}; #[cfg(feature = "estimated_costs")] mod worst_case; diff --git a/grovedb/src/operations/get/run_path_query.rs b/grovedb/src/operations/get/run_path_query.rs new file mode 100644 index 000000000..fd898d24c --- /dev/null +++ b/grovedb/src/operations/get/run_path_query.rs @@ -0,0 +1,531 @@ +//! The unified read entry point: one function that executes every +//! [`PathQuery`] shape. +//! +//! [`GroveDb::run_path_query`] classifies the query once +//! ([`PathQuery::classify`]) and routes it to the engine that already +//! serves that shape — the key-selection reader, the aggregate-on-range +//! readers, the indexed-axis primitives, or the budgeted sum reader. +//! It returns a [`PathQueryRun`] whose variant mirrors the shape, so a +//! caller holding an arbitrary `PathQuery` gets a typed answer without +//! knowing in advance which of the specialized entry points serves it. +//! +//! Read-mode shapes (axis and sum-budget reads) are gated on +//! `path_query_methods.unified_read_mode` — `0` before GROVE_V4 means +//! the whole vocabulary is rejected with `NotSupported`, mirroring the +//! fail-closed version-2 decode on older nodes. Key-selection and +//! aggregate shapes are served at every version, exactly as their +//! dedicated entry points serve them. +//! +//! Everything here is a **trusted read**: no result carries a +//! cryptographic guarantee. The proved counterparts are `prove_query` +//! (key selection, aggregates) and the indexed-axis proof family; the +//! unified proof dispatch arrives separately. + +use grovedb_costs::{cost_return_on_error, CostResult, CostsExt}; +use grovedb_merk::proofs::query::{AxisTraversal, IndexAxis}; +use grovedb_path::SubtreePath; +use grovedb_version::{ + check_grovedb_v0_with_cost, error::GroveVersionError, version::GroveVersion, +}; + +use crate::{ + element::aggregate_sum_query::AggregateSumQueryResult, + operations::proof::indexed_axis::AxisEntries, + query::{AggregateKind, PathQueryShape}, + query_result_type::{QueryResultElements, QueryResultType}, + AggregateSumPathQuery, Error, GroveDb, PathQuery, TransactionArg, +}; + +/// A single aggregate scalar read off an indexed tree's per-axis +/// secondary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AxisAggregateValue { + /// Count of matching entries (count axis). + Count(u64), + /// Signed sum of matching entries (sum axis). + Sum(i64), +} + +/// The typed answer to [`GroveDb::run_path_query`] — one variant per +/// [`PathQueryShape`] family. +#[derive(Debug)] +pub enum PathQueryRun { + /// Key-selection shapes (including count-offset pagination): the + /// regular result set plus the number of elements skipped by the + /// query's offset. + Elements { + /// The selected elements, in the requested result type. + elements: QueryResultElements, + /// Elements skipped by `SizedQuery::offset`. + skipped: u16, + }, + /// Leaf `AggregateCountOnRange`: one count. + AggregateCount(u64), + /// Leaf `AggregateSumOnRange`: one signed sum. + AggregateSum(i64), + /// Leaf `AggregateCountAndSumOnRange`: both, from one walk. + AggregateCountAndSum { + /// Count of matched children. + count: u64, + /// Signed sum of matched children. + sum: i64, + }, + /// Carrier `AggregateCountOnRange`: one count per matched outer key. + AggregateCountPerKey(Vec<(Vec, u64)>), + /// Single-path axis read (`TopK` / `Bounded` traversals): the + /// entries in walk order. + AxisEntries(AxisEntries), + /// Branched axis read: per branch key, in query order, the entries + /// — or `None` when the branch key is absent at the branching + /// level (mirroring the branched proof's authenticated-absence + /// slots, minus the authentication). + BranchedAxisEntries(Vec<(Vec, Option)>), + /// `RankOfKey` traversal: the item's 0-based rank in the walk. + AxisRank(u64), + /// `RangeAggregate` traversal: one scalar over the value range. + AxisAggregate(AxisAggregateValue), + /// Sum-budget read: the budgeted walk's matches and stop state. + SumBudget(AggregateSumQueryResult), +} + +impl GroveDb { + /// Execute any [`PathQuery`] shape as a trusted read and return the + /// shape's typed answer. See the module docs for routing and + /// gating; see [`PathQuery::classify`] for the shape grammar. + #[allow(clippy::too_many_arguments)] + pub fn run_path_query( + &self, + path_query: &PathQuery, + allow_cache: bool, + decrease_limit_on_range_with_no_sub_elements: bool, + error_if_intermediate_path_tree_not_present: bool, + result_type: QueryResultType, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { + check_grovedb_v0_with_cost!( + "run_path_query", + grove_version + .grovedb_versions + .operations + .query + .run_path_query + ); + let mut cost = Default::default(); + + let shape = match path_query.classify() { + Ok(shape) => shape, + Err(e) => return Err(e).wrap_with_cost(cost), + }; + + // Read-mode shapes are gated on `unified_read_mode`; the + // key-selection and aggregate shapes below are served at every + // version, exactly as their dedicated entry points serve them. + if matches!( + shape, + PathQueryShape::AxisRead { .. } + | PathQueryShape::BranchedAxisRead { .. } + | PathQueryShape::SumBudget { .. } + ) { + match grove_version + .grovedb_versions + .path_query_methods + .unified_read_mode + { + 0 => { + return Err(Error::NotSupported( + "read-mode (axis / sum-budget) path queries are not served at this \ + grove version" + .to_string(), + )) + .wrap_with_cost(cost); + } + 1 => {} + received => { + return Err(Error::VersionError( + GroveVersionError::UnknownVersionMismatch { + method: "run_path_query (unified_read_mode)".to_string(), + known_versions: vec![0, 1], + received, + }, + )) + .wrap_with_cost(cost); + } + } + } + + match shape { + PathQueryShape::KeySelection | PathQueryShape::CountOffsetPaginated { .. } => { + let (elements, skipped) = cost_return_on_error!( + &mut cost, + self.query_raw( + path_query, + allow_cache, + decrease_limit_on_range_with_no_sub_elements, + error_if_intermediate_path_tree_not_present, + result_type, + transaction, + grove_version, + ) + ); + Ok(PathQueryRun::Elements { elements, skipped }).wrap_with_cost(cost) + } + PathQueryShape::AggregateLeaf { kind, .. } => match kind { + AggregateKind::Count => { + let count = cost_return_on_error!( + &mut cost, + self.query_aggregate_count(path_query, transaction, grove_version) + ); + Ok(PathQueryRun::AggregateCount(count)).wrap_with_cost(cost) + } + AggregateKind::Sum => { + let sum = cost_return_on_error!( + &mut cost, + self.query_aggregate_sum(path_query, transaction, grove_version) + ); + Ok(PathQueryRun::AggregateSum(sum)).wrap_with_cost(cost) + } + AggregateKind::CountAndSum => { + let (count, sum) = cost_return_on_error!( + &mut cost, + self.query_aggregate_count_and_sum(path_query, transaction, grove_version) + ); + Ok(PathQueryRun::AggregateCountAndSum { count, sum }).wrap_with_cost(cost) + } + }, + PathQueryShape::AggregateCarrier { kind, .. } => match kind { + AggregateKind::Count => { + let per_key = cost_return_on_error!( + &mut cost, + self.query_aggregate_count_per_key(path_query, transaction, grove_version) + ); + Ok(PathQueryRun::AggregateCountPerKey(per_key)).wrap_with_cost(cost) + } + AggregateKind::Sum | AggregateKind::CountAndSum => Err(Error::NotSupported( + "carrier aggregate-sum reads have no trusted per-key read primitive; use \ + prove_query with verify_aggregate_sum_query_per_key / \ + verify_aggregate_count_and_sum_query_per_key" + .to_string(), + )) + .wrap_with_cost(cost), + }, + PathQueryShape::AxisRead { axis } => { + let path_refs: Vec<&[u8]> = path_query + .path + .iter() + .map(|segment| segment.as_slice()) + .collect(); + self.run_axis_read(path_refs.as_slice(), axis, transaction, grove_version) + .add_cost(cost) + } + PathQueryShape::BranchedAxisRead { + branch_items, + suffix, + axis, + } => { + // Hoisted: the branching prefix is the same for every + // branch key, so it is built once rather than per branch. + let prefix_refs: Vec<&[u8]> = path_query + .path + .iter() + .map(|segment| segment.as_slice()) + .collect(); + let mut branches = Vec::with_capacity(branch_items.len()); + for item in branch_items { + let grovedb_merk::proofs::query::query_item::QueryItem::Key(branch_key) = item + else { + // classify guarantees Key items only. + return Err(Error::CorruptedCodeExecution( + "branched axis read classified with a non-Key branch item", + )) + .wrap_with_cost(cost); + }; + // Mirror the branched proof's absence slots: a branch + // key missing at the branching level yields None + // rather than an error, so partially-populated + // branch sets read the same way they prove. + let present = cost_return_on_error!( + &mut cost, + self.get_raw_optional( + SubtreePath::from(prefix_refs.as_slice()), + branch_key, + transaction, + grove_version, + ) + ); + if present.is_none() { + branches.push((branch_key.clone(), None)); + continue; + } + let mut full_path: Vec<&[u8]> = + Vec::with_capacity(path_query.path.len() + 1 + suffix.len()); + full_path.extend(path_query.path.iter().map(|segment| segment.as_slice())); + full_path.push(branch_key.as_slice()); + full_path.extend(suffix.iter().map(|segment| segment.as_slice())); + let run = cost_return_on_error!( + &mut cost, + self.run_axis_read(full_path.as_slice(), axis, transaction, grove_version) + ); + let PathQueryRun::AxisEntries(entries) = run else { + return Err(Error::CorruptedCodeExecution( + "branched axis read requires an entry-listing traversal", + )) + .wrap_with_cost(cost); + }; + branches.push((branch_key.clone(), Some(entries))); + } + Ok(PathQueryRun::BranchedAxisEntries(branches)).wrap_with_cost(cost) + } + PathQueryShape::SumBudget { budget, items } => { + use grovedb_merk::proofs::query::AggregateSumQuery; + let aggregate_sum_path_query = AggregateSumPathQuery { + path: path_query.path.clone(), + aggregate_sum_query: AggregateSumQuery { + items: items.to_vec(), + left_to_right: path_query.query.query.left_to_right, + sum_limit: budget.sum_limit, + limit_of_items_to_check: budget.max_items_checked, + }, + }; + let result = cost_return_on_error!( + &mut cost, + self.query_aggregate_sums( + &aggregate_sum_path_query, + allow_cache, + error_if_intermediate_path_tree_not_present, + transaction, + grove_version, + ) + ); + Ok(PathQueryRun::SumBudget(result)).wrap_with_cost(cost) + } + } + } + + /// Single-path axis read: route one validated + /// [`AxisQuery`](grovedb_merk::proofs::query::AxisQuery) to the + /// indexed-tree primitive that serves its `(axis, traversal)` pair. + fn run_axis_read( + &self, + path: &[&[u8]], + axis_query: &grovedb_merk::proofs::query::AxisQuery, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = Default::default(); + let axis = axis_query.axis; + let descending = axis_query.descending; + + match &axis_query.traversal { + AxisTraversal::RankedPage { k, offset } => { + let entries = cost_return_on_error!( + &mut cost, + self.axis_top_k_paginated_entries( + path, + axis, + *k, + *offset, + descending, + transaction, + grove_version + ) + ); + Ok(PathQueryRun::AxisEntries(entries)).wrap_with_cost(cost) + } + AxisTraversal::Bounded { lo, hi, limit } => { + let entries = cost_return_on_error!( + &mut cost, + self.axis_bounded_entries( + path, + axis, + *lo, + *hi, + *limit, + descending, + transaction, + grove_version + ) + ); + Ok(PathQueryRun::AxisEntries(entries)).wrap_with_cost(cost) + } + AxisTraversal::RankOfKey { key } => { + let rank = cost_return_on_error!( + &mut cost, + self.compute_indexed_axis_rank_of_key( + path, + axis, + key, + descending, + transaction, + grove_version + ) + ); + Ok(PathQueryRun::AxisRank(rank)).wrap_with_cost(cost) + } + AxisTraversal::RangeAggregate { lo, hi } => match axis { + IndexAxis::Count => { + let (lo_count, hi_count) = clamp_count_bounds(*lo, *hi); + let value = cost_return_on_error!( + &mut cost, + self.indexed_count_range_aggregate( + path, + lo_count, + hi_count, + transaction, + grove_version + ) + ); + Ok(PathQueryRun::AxisAggregate(AxisAggregateValue::Count( + value, + ))) + .wrap_with_cost(cost) + } + IndexAxis::Sum => { + let (lo_sum, hi_sum) = clamp_sum_bounds(*lo, *hi); + let value = cost_return_on_error!( + &mut cost, + self.indexed_sum_range_aggregate( + path, + lo_sum, + hi_sum, + transaction, + grove_version + ) + ); + Ok(PathQueryRun::AxisAggregate(AxisAggregateValue::Sum(value))) + .wrap_with_cost(cost) + } + // classify rejects range aggregates on the Avg axis. + IndexAxis::Avg => Err(Error::CorruptedCodeExecution( + "range aggregate on the Avg axis survived classification", + )) + .wrap_with_cost(cost), + }, + } + } + + /// TopK dispatch across the three axes, normalizing into + /// [`AxisEntries`]. + #[allow(clippy::too_many_arguments)] + fn axis_top_k_paginated_entries( + &self, + path: &[&[u8]], + axis: IndexAxis, + k: u16, + offset: u64, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = Default::default(); + let entries = match axis { + IndexAxis::Count => AxisEntries::Count(cost_return_on_error!( + &mut cost, + self.indexed_count_top_k_paginated( + path, + k, + offset, + descending, + transaction, + grove_version + ) + )), + IndexAxis::Sum => AxisEntries::Sum(cost_return_on_error!( + &mut cost, + self.indexed_sum_top_k_paginated( + path, + k, + offset, + descending, + transaction, + grove_version + ) + )), + IndexAxis::Avg => AxisEntries::Avg(cost_return_on_error!( + &mut cost, + self.indexed_avg_top_k_paginated( + path, + k, + offset, + descending, + transaction, + grove_version + ) + )), + }; + Ok(entries).wrap_with_cost(cost) + } + + /// Bounded dispatch across the three axes, clamping the `i128` + /// bounds into each axis's own domain (classification already + /// rejected wholly-out-of-domain ranges). + #[allow(clippy::too_many_arguments)] + fn axis_bounded_entries( + &self, + path: &[&[u8]], + axis: IndexAxis, + lo: i128, + hi: i128, + limit: u16, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = Default::default(); + let entries = match axis { + IndexAxis::Count => { + let (lo_count, hi_count) = clamp_count_bounds(lo, hi); + AxisEntries::Count(cost_return_on_error!( + &mut cost, + self.indexed_count_range( + path, + lo_count, + hi_count, + descending, + limit, + transaction, + grove_version + ) + )) + } + IndexAxis::Sum => { + let (lo_sum, hi_sum) = clamp_sum_bounds(lo, hi); + AxisEntries::Sum(cost_return_on_error!( + &mut cost, + self.indexed_sum_range( + path, + lo_sum, + hi_sum, + descending, + limit, + transaction, + grove_version + ) + )) + } + IndexAxis::Avg => AxisEntries::Avg(cost_return_on_error!( + &mut cost, + self.indexed_avg_range(path, lo, hi, descending, limit, transaction, grove_version) + )), + }; + Ok(entries).wrap_with_cost(cost) + } +} + +/// Clamp inclusive `i128` bounds into the count axis's `u64` domain. +/// Callers have already rejected wholly-out-of-domain ranges, so the +/// clamped pair still satisfies `lo <= hi`. +fn clamp_count_bounds(lo: i128, hi: i128) -> (u64, u64) { + ( + lo.clamp(0, u64::MAX as i128) as u64, + hi.clamp(0, u64::MAX as i128) as u64, + ) +} + +/// Clamp inclusive `i128` bounds into the sum axis's `i64` domain. +fn clamp_sum_bounds(lo: i128, hi: i128) -> (i64, i64) { + ( + lo.clamp(i64::MIN as i128, i64::MAX as i128) as i64, + hi.clamp(i64::MIN as i128, i64::MAX as i128) as i64, + ) +} diff --git a/grovedb/src/operations/proof/indexed_axis/generate.rs b/grovedb/src/operations/proof/indexed_axis/generate.rs index 98b07ff91..d71dd4adb 100644 --- a/grovedb/src/operations/proof/indexed_axis/generate.rs +++ b/grovedb/src/operations/proof/indexed_axis/generate.rs @@ -456,25 +456,18 @@ impl GroveDb { Ok(bytes).wrap_with_cost(cost) } - /// Prove that `item_key` sits at a specific rank in the directional - /// walk of an indexed axis: rank `R` (0-based) means exactly `R` - /// entries come strictly before it in the walk. Ties (equal axis - /// values) are broken by `original_key` in walk direction — the - /// same total order every other axis proof uses — so the rank is - /// well-defined even inside a tie group. - /// - /// Returns `(proof_bytes, rank)`. The proof is an ordinary - /// offset-paginated envelope with `offset = rank, k = 1`: the count - /// commitments attest that exactly `rank` entries precede the - /// single yielded entry, and the yielded entry's key binds the - /// claim to `item_key`. Verify with - /// [`Self::verify_indexed_axis_rank_of_key`], which additionally - /// checks the yielded entry is `item_key` and the attested skip is - /// exactly `rank`. + /// Compute the rank of `item_key` in the directional walk over the + /// per-axis secondary of the indexed tree at `path` — the count of + /// entries strictly before it, read in O(log n) off the secondary's + /// count aggregates. Shared by the rank proof (which then attests + /// the rank via a paginated envelope at `offset = rank, k = 1`) and + /// the trusted read path. The returned rank has no cryptographic + /// guarantee on its own. /// - /// Errors if `item_key` is not present in the indexed tree's - /// primary, or if the axis is not indexed at this path. - pub fn prove_indexed_axis_rank_of_key<'b, B, P>( + /// Errors with `InvalidPath` at the root or on a non-indexed + /// target, and `PathKeyNotFound` when `item_key` is not in the + /// indexed primary. + pub(crate) fn compute_indexed_axis_rank_of_key<'b, B, P>( &self, path: P, axis: IndexAxis, @@ -482,7 +475,7 @@ impl GroveDb { descending: bool, transaction: TransactionArg, grove_version: &GroveVersion, - ) -> CostResult<(Vec, u64), Error> + ) -> CostResult where B: AsRef<[u8]> + 'b, P: Into>, @@ -498,7 +491,7 @@ impl GroveDb { let path_keys: Vec> = path.to_vec(); if path_keys.is_empty() { return Err(Error::InvalidPath( - "cannot prove indexed-axis rank at root path".to_string(), + "cannot compute an indexed-axis rank at the root path".to_string(), )) .wrap_with_cost(cost); } @@ -512,8 +505,8 @@ impl GroveDb { ); if !primary_merk.tree_type.is_indexed_primary() { return Err(Error::InvalidPath( - "prove_indexed_axis_rank_of_key requires the path's last segment to be an \ - indexed-tree element" + "indexed-axis rank requires the path's last segment to be an indexed-tree \ + element" .to_string(), )) .wrap_with_cost(cost); @@ -522,7 +515,7 @@ impl GroveDb { &mut cost, Element::get(&primary_merk, item_key, true, grove_version).map_err(|e| { Error::PathKeyNotFound(format!( - "indexed-axis rank proof: item key {} not found in the indexed primary: {e}", + "indexed-axis rank: item key {} not found in the indexed primary: {e}", hex::encode(item_key) )) }) @@ -542,7 +535,7 @@ impl GroveDb { tx_ref, &batch, grove_version, - "indexed-axis rank proof", + "indexed-axis rank", ) ); let secondary_merk = cost_return_on_error!( @@ -570,11 +563,65 @@ impl GroveDb { secondary_merk .count_aggregate_on_range(&before_range, grove_version) .map_err(|e| Error::CorruptedData(format!( - "indexed-axis rank proof: counting entries before the item: {e}" + "indexed-axis rank: counting entries before the item: {e}" ))) ); - drop(secondary_merk); - drop(primary_merk); + Ok(rank).wrap_with_cost(cost) + } + + /// Prove that `item_key` sits at a specific rank in the directional + /// walk of an indexed axis: rank `R` (0-based) means exactly `R` + /// entries come strictly before it in the walk. Ties (equal axis + /// values) are broken by `original_key` in walk direction — the + /// same total order every other axis proof uses — so the rank is + /// well-defined even inside a tie group. + /// + /// Returns `(proof_bytes, rank)`. The proof is an ordinary + /// offset-paginated envelope with `offset = rank, k = 1`: the count + /// commitments attest that exactly `rank` entries precede the + /// single yielded entry, and the yielded entry's key binds the + /// claim to `item_key`. Verify with + /// [`Self::verify_indexed_axis_rank_of_key`], which additionally + /// checks the yielded entry is `item_key` and the attested skip is + /// exactly `rank`. + /// + /// Errors if `item_key` is not present in the indexed tree's + /// primary, or if the axis is not indexed at this path. + pub fn prove_indexed_axis_rank_of_key<'b, B, P>( + &self, + path: P, + axis: IndexAxis, + item_key: &[u8], + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult<(Vec, u64), Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + let mut cost = OperationCost::default(); + let path: SubtreePath = path.into(); + + // Steps 1-2 (derive the item's secondary sort key, count the + // entries strictly before it in the walk direction) are shared + // with the trusted read path — see + // `compute_indexed_axis_rank_of_key`. + let rank = cost_return_on_error!( + &mut cost, + self.compute_indexed_axis_rank_of_key( + path.clone(), + axis, + item_key, + descending, + transaction, + grove_version, + ) + ); + + let batch = StorageBatch::new(); + let tx = TxRef::new(&self.db, transaction); + let tx_ref = tx.as_ref(); // 3. The rank proof IS the paginated proof at (offset = rank, // k = 1): its counted commitments attest the skipped prefix diff --git a/grovedb/src/query/shape.rs b/grovedb/src/query/shape.rs index ac54710be..711ada6d2 100644 --- a/grovedb/src/query/shape.rs +++ b/grovedb/src/query/shape.rs @@ -31,7 +31,7 @@ //! verification time. Classification is purely syntactic. use grovedb_merk::proofs::query::{ - query_item::QueryItem, AxisQuery, ReadMode, SumBudgetRead as SumBudgetReadSpec, + query_item::QueryItem, AxisQuery, AxisTraversal, ReadMode, SumBudgetRead as SumBudgetReadSpec, }; use crate::{Error, PathQuery}; @@ -373,6 +373,24 @@ impl PathQuery { )); } axis.validate().map_err(read_mode_validation_error)?; + // A branched read answers with one entry list per + // branch, so its terminal must be an + // entry-listing traversal. Rank-of-key and + // range-aggregate produce a single scalar about + // one tree and have no per-branch list to fill; + // rejecting them here keeps the reader and the + // verifier from having to treat "impossible" + // shapes as internal errors. + if matches!( + axis.traversal, + AxisTraversal::RankOfKey { .. } | AxisTraversal::RangeAggregate { .. } + ) { + return Err(Error::InvalidQuery( + "a branched axis read serves entry-listing traversals \ + (RankedPage / Bounded) only; rank-of-key and range-aggregate \ + are single-path reads", + )); + } Ok(PathQueryShape::BranchedAxisRead { branch_items: &query.items, suffix, diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index cc51649c3..99271cf18 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -86,6 +86,7 @@ mod reference_path_tests; mod reference_with_sum_item_tests; mod replication_session_tests; mod replication_utils_tests; +mod run_path_query_tests; mod succinctness_gap_test; mod test_compaction_sizes; mod test_provable_count_fresh; diff --git a/grovedb/src/tests/run_path_query_tests.rs b/grovedb/src/tests/run_path_query_tests.rs new file mode 100644 index 000000000..57025b437 --- /dev/null +++ b/grovedb/src/tests/run_path_query_tests.rs @@ -0,0 +1,1194 @@ +//! Differential tests for [`GroveDb::run_path_query`], the unified read +//! dispatch: for every shape, the unified answer must equal the answer +//! of the dedicated entry point it routes to, over the same state. + +#[cfg(test)] +mod tests { + use grovedb_merk::proofs::{ + query::{query_item::QueryItem, AggregateSumQuery, AxisQuery, IndexAxis}, + Query, + }; + use grovedb_version::version::{GroveVersion, GROVE_VERSIONS}; + + use crate::{ + operations::{ + get::{AxisAggregateValue, PathQueryRun}, + proof::indexed_axis::AxisEntries, + }, + query_result_type::QueryResultType, + tests::{make_test_grovedb, make_test_sum_tree_grovedb, TEST_LEAF}, + AggregateSumPathQuery, Element, Error, GroveDb, PathQuery, SizedQuery, + }; + + // ----------------------------------------------------------------- + // Fixtures + // ----------------------------------------------------------------- + + /// Build a PSIT at `[TEST_LEAF, b"psit"]` with `(key, sum)` entries. + fn build_psit(db: &GroveDb, grove_version: &GroveVersion, entries: &[(&[u8], i64)]) { + db.insert( + [TEST_LEAF].as_ref(), + b"psit", + Element::empty_provable_sum_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PSIT"); + for (k, s) in entries { + db.insert_into_provable_sum_indexed_tree( + [TEST_LEAF, b"psit"].as_ref(), + k, + Element::new_sum_item(*s), + None, + grove_version, + ) + .unwrap() + .expect("insert PSIT entry"); + } + } + + /// Build PSITs under two sibling branch keys: + /// `[TEST_LEAF, branch, b"scores"]` for each `(branch, entries)`. + fn build_branched_psits( + db: &GroveDb, + grove_version: &GroveVersion, + branches: &[(&[u8], &[(&[u8], i64)])], + ) { + for (branch, entries) in branches { + db.insert( + [TEST_LEAF].as_ref(), + branch, + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create branch tree"); + db.insert( + [TEST_LEAF, branch].as_ref(), + b"scores", + Element::empty_provable_sum_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create branch PSIT"); + for (k, s) in *entries { + db.insert_into_provable_sum_indexed_tree( + [TEST_LEAF, branch, b"scores".as_slice()].as_ref(), + k, + Element::new_sum_item(*s), + None, + grove_version, + ) + .unwrap() + .expect("insert branch PSIT entry"); + } + } + } + + fn psit_path() -> Vec> { + vec![TEST_LEAF.to_vec(), b"psit".to_vec()] + } + + const PSIT_ENTRIES: &[(&[u8], i64)] = &[ + (b"alice", 40), + (b"bob", -10), + (b"carol", 25), + (b"dave", 40), + (b"erin", 5), + ]; + + // ----------------------------------------------------------------- + // Key selection + // ----------------------------------------------------------------- + + #[test] + fn key_selection_matches_query_raw() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + for (key, value) in [(b"a", b"1"), (b"b", b"2"), (b"c", b"3")] { + db.insert( + [TEST_LEAF].as_ref(), + key, + Element::new_item(value.to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + } + let path_query = PathQuery::new_unsized( + vec![TEST_LEAF.to_vec()], + Query::new_single_query_item(QueryItem::RangeFull(..)), + ); + + let (direct, direct_skipped) = db + .query_raw( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("query_raw"); + let run = db + .run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("run_path_query"); + match run { + PathQueryRun::Elements { elements, skipped } => { + assert_eq!(elements.len(), direct.len()); + assert_eq!( + elements.to_key_elements(), + direct.to_key_elements(), + "unified read must equal query_raw" + ); + assert_eq!(skipped, direct_skipped); + } + other => panic!("expected Elements, got {other:?}"), + } + } + + // ----------------------------------------------------------------- + // Axis reads + // ----------------------------------------------------------------- + + #[test] + fn axis_top_k_matches_direct_primitive() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_psit(&db, grove_version, PSIT_ENTRIES); + + for (k, offset, descending) in [ + (3u16, 0u64, true), + (2, 1, true), + (5, 0, false), + (2, 3, false), + ] { + let direct = db + .indexed_sum_top_k_paginated( + [TEST_LEAF, b"psit"].as_ref(), + k, + offset, + descending, + None, + grove_version, + ) + .unwrap() + .expect("direct top-k"); + let run = db + .run_path_query( + &PathQuery::new_axis_top_k(psit_path(), IndexAxis::Sum, k, offset, descending), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified top-k"); + assert_eq!( + run_entries(run), + AxisEntries::Sum(direct), + "top-k k={k} offset={offset} descending={descending}" + ); + } + } + + #[test] + fn axis_bounded_matches_direct_primitive_with_clamping() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_psit(&db, grove_version, PSIT_ENTRIES); + + // Bounds deliberately exceed the i64 domain on both sides: the + // unified read must clamp to the axis domain, matching a direct + // call at the domain edges. + let direct = db + .indexed_sum_range( + [TEST_LEAF, b"psit"].as_ref(), + 0, + i64::MAX, + true, + 10, + None, + grove_version, + ) + .unwrap() + .expect("direct bounded"); + let run = db + .run_path_query( + &PathQuery::new_axis_bounded(psit_path(), IndexAxis::Sum, 0, i128::MAX, 10, true), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified bounded"); + assert_eq!(run_entries(run), AxisEntries::Sum(direct)); + } + + #[test] + fn axis_rank_matches_proved_rank() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_psit(&db, grove_version, PSIT_ENTRIES); + + for descending in [true, false] { + let (_, proved_rank) = db + .prove_indexed_axis_rank_of_key( + [TEST_LEAF, b"psit"].as_ref(), + IndexAxis::Sum, + b"carol", + descending, + None, + grove_version, + ) + .unwrap() + .expect("proved rank"); + let run = db + .run_path_query( + &PathQuery::new_axis_rank_of_key( + psit_path(), + IndexAxis::Sum, + b"carol".to_vec(), + descending, + ), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified rank"); + match run { + PathQueryRun::AxisRank(rank) => { + assert_eq!(rank, proved_rank, "descending={descending}") + } + other => panic!("expected AxisRank, got {other:?}"), + } + } + } + + #[test] + fn axis_range_aggregate_matches_direct_primitive() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_psit(&db, grove_version, PSIT_ENTRIES); + + let direct = db + .indexed_sum_range_aggregate([TEST_LEAF, b"psit"].as_ref(), 0, 40, None, grove_version) + .unwrap() + .expect("direct range aggregate"); + let run = db + .run_path_query( + &PathQuery::new_axis_range_aggregate(psit_path(), IndexAxis::Sum, 0, 40), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified range aggregate"); + match run { + PathQueryRun::AxisAggregate(AxisAggregateValue::Sum(sum)) => assert_eq!(sum, direct), + other => panic!("expected AxisAggregate(Sum), got {other:?}"), + } + } + + // ----------------------------------------------------------------- + // Branched axis reads + // ----------------------------------------------------------------- + + #[test] + fn branched_axis_read_mirrors_per_branch_reads_and_absence() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_branched_psits( + &db, + grove_version, + &[ + (b"alice", &[(b"m1", 10), (b"m2", 30)]), + (b"carol", &[(b"m1", 7)]), + ], + ); + + let axis_query = AxisQuery::top_k(IndexAxis::Sum, 2, 0, true); + let path_query = PathQuery::new_branched_axis( + vec![TEST_LEAF.to_vec()], + vec![b"alice".to_vec(), b"bob".to_vec(), b"carol".to_vec()], + vec![b"scores".to_vec()], + axis_query, + ); + let run = db + .run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified branched read"); + let PathQueryRun::BranchedAxisEntries(branches) = run else { + panic!("expected BranchedAxisEntries"); + }; + // The slots are documented as "per branch key, in query order", + // so pin the order itself — matching each key by value would let + // a reordering regression through. + assert_eq!( + branches + .iter() + .map(|(key, _)| key.clone()) + .collect::>(), + vec![b"alice".to_vec(), b"bob".to_vec(), b"carol".to_vec()], + "branch slots must follow query order" + ); + + // Present branches equal the single-path primitive. + for (branch_key, entries) in &branches { + match branch_key.as_slice() { + b"bob" => assert!(entries.is_none(), "absent branch must be None"), + present => { + let direct = db + .indexed_sum_top_k_paginated( + [TEST_LEAF, present, b"scores".as_slice()].as_ref(), + 2, + 0, + true, + None, + grove_version, + ) + .unwrap() + .expect("direct branch read"); + assert_eq!( + entries.as_ref().expect("present branch"), + &AxisEntries::Sum(direct), + "branch {}", + String::from_utf8_lossy(present) + ); + } + } + } + } + + // ----------------------------------------------------------------- + // Sum budget + // ----------------------------------------------------------------- + + #[test] + fn sum_budget_matches_query_aggregate_sums() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + for (key, sum) in [(b"a", 7i64), (b"b", 5), (b"c", 3), (b"d", 11)] { + db.insert( + [TEST_LEAF].as_ref(), + key, + Element::new_sum_item(sum), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert sum item"); + } + + for (sum_limit, max_items) in [(10u64, None), (100, Some(2u16)), (1, None)] { + let direct = db + .query_aggregate_sums( + &AggregateSumPathQuery { + path: vec![TEST_LEAF.to_vec()], + aggregate_sum_query: AggregateSumQuery { + items: vec![QueryItem::RangeFull(..)], + left_to_right: true, + sum_limit, + limit_of_items_to_check: max_items, + }, + }, + true, + true, + None, + grove_version, + ) + .unwrap() + .expect("direct sum budget"); + let run = db + .run_path_query( + &PathQuery::new_sum_budget( + vec![TEST_LEAF.to_vec()], + vec![QueryItem::RangeFull(..)], + true, + sum_limit, + max_items, + ), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified sum budget"); + match run { + PathQueryRun::SumBudget(result) => { + assert_eq!( + result, direct, + "sum_limit={sum_limit} max_items={max_items:?}" + ); + } + other => panic!("expected SumBudget, got {other:?}"), + } + } + } + + // ----------------------------------------------------------------- + // Aggregates route through the existing readers + // ----------------------------------------------------------------- + + #[test] + fn aggregate_leaf_count_matches_query_aggregate_count() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + [TEST_LEAF].as_ref(), + b"pct", + Element::empty_provable_count_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create provable count tree"); + for key in [b"a", b"b", b"c"] { + db.insert( + [TEST_LEAF, b"pct"].as_ref(), + key, + Element::new_item(b"v".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert counted item"); + } + let path_query = PathQuery::new_aggregate_count_on_range( + vec![TEST_LEAF.to_vec(), b"pct".to_vec()], + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ); + + let direct = db + .query_aggregate_count(&path_query, None, grove_version) + .unwrap() + .expect("direct aggregate count"); + let run = db + .run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified aggregate count"); + match run { + PathQueryRun::AggregateCount(count) => assert_eq!(count, direct), + other => panic!("expected AggregateCount, got {other:?}"), + } + } + + // ----------------------------------------------------------------- + // Version gating + // ----------------------------------------------------------------- + + #[test] + fn read_mode_shapes_are_gated_to_grove_v4() { + let v3 = &GROVE_VERSIONS[2]; + assert_eq!(v3.protocol_version, 3, "GROVE_VERSIONS[2] must be V3"); + let v4 = GroveVersion::latest(); + assert_eq!(v4.protocol_version, 4, "latest must be V4"); + + let db = make_test_grovedb(v4); + build_psit(&db, v4, PSIT_ENTRIES); + let path_query = PathQuery::new_axis_top_k(psit_path(), IndexAxis::Sum, 2, 0, true); + + // V3: the shape classifies but serving is refused. + match db + .run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + v3, + ) + .unwrap() + { + Err(Error::NotSupported(_)) => {} + other => panic!("V3 must reject read-mode shapes, got {other:?}"), + } + + // V4: served. + db.run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + v4, + ) + .unwrap() + .expect("V4 must serve read-mode shapes"); + } + + // ----------------------------------------------------------------- + // The other two axes + // + // The dispatch fans out per axis inside `axis_top_k_paginated_entries` + // / `axis_bounded_entries` / the range-aggregate arm, so exercising + // only the sum axis leaves two thirds of each fan-out — and the whole + // count-bounds clamp — unexecuted. + // ----------------------------------------------------------------- + + /// Build a PCIT at `[TEST_LEAF, b"pcit"]` whose entries carry the + /// given counts. Counts are DERIVED: each child is a provable count + /// tree populated with `c` items. + fn build_pcit(db: &GroveDb, grove_version: &GroveVersion, entries: &[(&[u8], u64)]) { + db.insert( + [TEST_LEAF].as_ref(), + b"pcit", + Element::empty_provable_count_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PCIT"); + for (key, count) in entries { + db.insert_into_count_indexed_tree( + [TEST_LEAF, b"pcit"].as_ref(), + key, + Element::empty_provable_count_tree(), + None, + grove_version, + ) + .unwrap() + .expect("insert PCIT child"); + for i in 0..*count { + db.insert( + [TEST_LEAF, b"pcit", key].as_ref(), + &i.to_be_bytes(), + Element::new_item(b"v".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("populate PCIT child"); + } + } + } + + fn pcit_path() -> Vec> { + vec![TEST_LEAF.to_vec(), b"pcit".to_vec()] + } + + const PCIT_ENTRIES: &[(&[u8], u64)] = &[(b"alpha", 3), (b"beta", 1), (b"gamma", 5)]; + + #[test] + fn count_axis_reads_match_the_direct_primitives() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_pcit(&db, grove_version, PCIT_ENTRIES); + let path = [TEST_LEAF, b"pcit"]; + + // Paginated page on the count axis. + let direct = db + .indexed_count_top_k_paginated(path.as_ref(), 2, 1, true, None, grove_version) + .unwrap() + .expect("direct count top-k"); + let run = db + .run_path_query( + &PathQuery::new_axis_top_k(pcit_path(), IndexAxis::Count, 2, 1, true), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified count top-k"); + assert_eq!(run_entries(run), AxisEntries::Count(direct)); + + // Bounded on the count axis, with bounds deliberately below and + // above the u64 domain so the count clamp is exercised (the sum + // clamp is covered by the sum-axis test). + let direct = db + .indexed_count_range(path.as_ref(), 0, u64::MAX, false, 10, None, grove_version) + .unwrap() + .expect("direct count range"); + let run = db + .run_path_query( + &PathQuery::new_axis_bounded( + pcit_path(), + IndexAxis::Count, + i128::MIN, + i128::MAX, + 10, + false, + ), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified count bounded"); + assert_eq!(run_entries(run), AxisEntries::Count(direct)); + + // Range aggregate on the count axis. + let direct = db + .indexed_count_range_aggregate(path.as_ref(), 0, 10, None, grove_version) + .unwrap() + .expect("direct count range aggregate"); + let run = db + .run_path_query( + &PathQuery::new_axis_range_aggregate(pcit_path(), IndexAxis::Count, 0, 10), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified count range aggregate"); + match run { + PathQueryRun::AxisAggregate(AxisAggregateValue::Count(value)) => { + assert_eq!(value, direct) + } + other => panic!("expected AxisAggregate(Count), got {other:?}"), + } + } + + #[test] + fn avg_axis_reads_match_the_direct_primitives() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + // A PCPSIT carrying all three axes, so the avg secondary exists. + let axes: Vec<(u8, Option>)> = vec![(0, None), (1, None), (2, None)]; + db.insert( + [TEST_LEAF].as_ref(), + b"pcpsit", + Element::empty_provable_count_provable_sum_indexed_tree(axes).expect("axes canonical"), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PCPSIT"); + for (key, sum) in [(b"a", 10i64), (b"b", 40), (b"c", -5)] { + db.insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, b"pcpsit"].as_ref(), + key, + Element::new_item_with_sum_item(b"v".to_vec(), sum), + None, + grove_version, + ) + .unwrap() + .expect("insert PCPSIT entry"); + } + let path = [TEST_LEAF, b"pcpsit"]; + let pcpsit_path = vec![TEST_LEAF.to_vec(), b"pcpsit".to_vec()]; + + let direct = db + .indexed_avg_top_k_paginated(path.as_ref(), 2, 0, true, None, grove_version) + .unwrap() + .expect("direct avg top-k"); + let run = db + .run_path_query( + &PathQuery::new_axis_top_k(pcpsit_path.clone(), IndexAxis::Avg, 2, 0, true), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified avg top-k"); + assert_eq!(run_entries(run), AxisEntries::Avg(direct)); + + // Bounded on the avg axis takes the i128 bounds unclamped — the + // avg domain is the whole i128 range. + let direct = db + .indexed_avg_range( + path.as_ref(), + i128::MIN, + i128::MAX, + false, + 10, + None, + grove_version, + ) + .unwrap() + .expect("direct avg range"); + let run = db + .run_path_query( + &PathQuery::new_axis_bounded( + pcpsit_path, + IndexAxis::Avg, + i128::MIN, + i128::MAX, + 10, + false, + ), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified avg bounded"); + assert_eq!(run_entries(run), AxisEntries::Avg(direct)); + } + + // ----------------------------------------------------------------- + // The remaining aggregate arms + // ----------------------------------------------------------------- + + #[test] + fn aggregate_leaf_sum_and_count_and_sum_match_their_readers() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + // Sum leaf against a ProvableSumTree. + db.insert( + [TEST_LEAF].as_ref(), + b"pst", + Element::empty_provable_sum_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create provable sum tree"); + for (key, sum) in [(b"a", 5i64), (b"b", -2), (b"c", 11)] { + db.insert( + [TEST_LEAF, b"pst"].as_ref(), + key, + Element::new_sum_item(sum), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert sum item"); + } + let sum_pq = PathQuery::new_aggregate_sum_on_range( + vec![TEST_LEAF.to_vec(), b"pst".to_vec()], + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ); + let direct = db + .query_aggregate_sum(&sum_pq, None, grove_version) + .unwrap() + .expect("direct aggregate sum"); + match db + .run_path_query( + &sum_pq, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified aggregate sum") + { + PathQueryRun::AggregateSum(sum) => assert_eq!(sum, direct), + other => panic!("expected AggregateSum, got {other:?}"), + } + + // Combined leaf against a ProvableCountProvableSumTree. + db.insert( + [TEST_LEAF].as_ref(), + b"pcps", + Element::empty_provable_count_provable_sum_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PCPS tree"); + for (key, sum) in [(b"a", 7i64), (b"b", 3)] { + db.insert( + [TEST_LEAF, b"pcps"].as_ref(), + key, + Element::new_sum_item(sum), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert PCPS sum item"); + } + let combined_pq = PathQuery::new_aggregate_count_and_sum_on_range( + vec![TEST_LEAF.to_vec(), b"pcps".to_vec()], + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ); + let (direct_count, direct_sum) = db + .query_aggregate_count_and_sum(&combined_pq, None, grove_version) + .unwrap() + .expect("direct combined aggregate"); + match db + .run_path_query( + &combined_pq, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified combined aggregate") + { + PathQueryRun::AggregateCountAndSum { count, sum } => { + assert_eq!((count, sum), (direct_count, direct_sum)) + } + other => panic!("expected AggregateCountAndSum, got {other:?}"), + } + } + + #[test] + fn aggregate_carrier_count_matches_per_key_reader_and_others_are_refused() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + // Two outer keys, each holding a provable count tree. + for outer in [b"one", b"two"] { + db.insert( + [TEST_LEAF].as_ref(), + outer, + Element::empty_provable_count_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create carrier outer"); + for key in [b"a", b"b"] { + db.insert( + [TEST_LEAF, outer].as_ref(), + key, + Element::new_item(b"v".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert counted item"); + } + } + + // Carrier: outer keys at TEST_LEAF, leaf aggregate underneath. + let mut carrier = Query::new(); + carrier.insert_key(b"one".to_vec()); + carrier.insert_key(b"two".to_vec()); + carrier.set_subquery(Query::new_aggregate_count_on_range(QueryItem::Range( + b"a".to_vec()..b"z".to_vec(), + ))); + let carrier_pq = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], carrier); + + let direct = db + .query_aggregate_count_per_key(&carrier_pq, None, grove_version) + .unwrap() + .expect("direct per-key counts"); + match db + .run_path_query( + &carrier_pq, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified per-key counts") + { + PathQueryRun::AggregateCountPerKey(per_key) => assert_eq!(per_key, direct), + other => panic!("expected AggregateCountPerKey, got {other:?}"), + } + + // The sum and combined carriers have no trusted per-key reader + // yet; the dispatch must refuse them by name rather than + // silently answering something else. + for subquery in [ + Query::new_aggregate_sum_on_range(QueryItem::Range(b"a".to_vec()..b"z".to_vec())), + Query::new_aggregate_count_and_sum_on_range(QueryItem::Range( + b"a".to_vec()..b"z".to_vec(), + )), + ] { + let mut carrier = Query::new(); + carrier.insert_key(b"one".to_vec()); + carrier.set_subquery(subquery); + let pq = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], carrier); + match db + .run_path_query( + &pq, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + { + Err(Error::NotSupported(message)) => { + assert!(message.contains("per-key"), "got: {message}") + } + other => panic!("sum/combined carriers must be refused, got {other:?}"), + } + } + } + + // ----------------------------------------------------------------- + // Count-offset pagination and the version gate + // ----------------------------------------------------------------- + + #[test] + fn count_offset_paginated_matches_query_raw() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + [TEST_LEAF].as_ref(), + b"pct", + Element::empty_provable_count_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create provable count tree"); + for key in [b"a", b"b", b"c", b"d"] { + db.insert( + [TEST_LEAF, b"pct"].as_ref(), + key, + Element::new_item(b"v".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert counted item"); + } + // A non-zero offset over a single range item classifies as the + // count-offset paginated shape, a distinct dispatch arm from + // plain key selection. + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"pct".to_vec()], + SizedQuery::new( + Query::new_single_query_item(QueryItem::RangeFull(..)), + Some(2), + Some(1), + ), + ); + let (direct, direct_skipped) = db + .query_raw( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("query_raw"); + match db + .run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified paginated read") + { + PathQueryRun::Elements { elements, skipped } => { + assert_eq!(elements.to_key_elements(), direct.to_key_elements()); + assert_eq!(skipped, direct_skipped); + } + other => panic!("expected Elements, got {other:?}"), + } + } + + #[test] + fn unknown_unified_read_mode_version_is_rejected() { + // The slot is versioned, so an unrecognized value must surface + // as a VersionError rather than being treated as "off" (0) or + // "on" (1). + let mut doctored = GroveVersion::latest().clone(); + doctored + .grovedb_versions + .path_query_methods + .unified_read_mode = 9; + let db = make_test_grovedb(&doctored); + let path_query = PathQuery::new_axis_top_k(psit_path(), IndexAxis::Sum, 1, 0, true); + match db + .run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + &doctored, + ) + .unwrap() + { + Err(Error::VersionError(_)) => {} + other => panic!("unknown slot value must be rejected, got {other:?}"), + } + } + + #[test] + fn unknown_run_path_query_version_is_rejected() { + // The method's own slot, distinct from the read-mode gate above. + let mut doctored = GroveVersion::latest().clone(); + doctored.grovedb_versions.operations.query.run_path_query = 9; + let db = make_test_grovedb(&doctored); + match db + .run_path_query( + &PathQuery::new_single_key(vec![TEST_LEAF.to_vec()], b"k".to_vec()), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + &doctored, + ) + .unwrap() + { + Err(Error::VersionError(_)) => {} + other => panic!("unknown run_path_query version must be rejected, got {other:?}"), + } + } + + #[test] + fn classification_errors_surface_from_the_dispatch() { + // A malformed shape must fail at classification and propagate + // out of `run_path_query` unchanged, rather than being routed + // anywhere. An axis read carrying query items is the simplest + // violation of the read-mode grammar. + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + let mut malformed = Query::new(); + malformed.read_mode = Some(Box::new(grovedb_merk::proofs::query::ReadMode::Axis( + AxisQuery::top_k(IndexAxis::Sum, 1, 0, true), + ))); + malformed.insert_key(b"unexpected".to_vec()); + let path_query = PathQuery::new_unsized(psit_path(), malformed); + + let from_classify = path_query + .classify() + .expect_err("an axis read with items is malformed"); + match db + .run_path_query( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + { + Err(e) => assert_eq!( + format!("{e}"), + format!("{from_classify}"), + "the dispatch must surface classify's error verbatim" + ), + Ok(run) => panic!("malformed query must be rejected, got {run:?}"), + } + } + + #[test] + fn branched_non_entry_listing_traversal_is_rejected_at_classification() { + // A branched read whose terminal is rank-of-key or range-aggregate + // has no per-branch entry list to return. It must be refused as a + // malformed query, not reach the dispatch and surface as an + // internal CorruptedCodeExecution. + for axis_query in [ + AxisQuery::rank_of_key(IndexAxis::Sum, b"alice".to_vec(), true), + AxisQuery::range_aggregate(IndexAxis::Sum, 0, 10), + ] { + let pq = PathQuery::new_branched_axis( + vec![TEST_LEAF.to_vec()], + vec![b"alice".to_vec()], + vec![b"scores".to_vec()], + axis_query, + ); + match pq.classify() { + Err(Error::InvalidQuery(m)) => { + assert!(m.contains("entry-listing"), "got: {m}") + } + other => panic!("must be rejected at classification, got {other:?}"), + } + } + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + fn run_entries(run: PathQueryRun) -> AxisEntries { + match run { + PathQueryRun::AxisEntries(entries) => entries, + other => panic!("expected AxisEntries, got {other:?}"), + } + } +}