diff --git a/grovedb-element/Cargo.toml b/grovedb-element/Cargo.toml index 0e6b6abd7..c75a3316d 100644 --- a/grovedb-element/Cargo.toml +++ b/grovedb-element/Cargo.toml @@ -20,12 +20,15 @@ thiserror = { workspace = true } grovedb-version = { version = "5.0.1", path = "../grovedb-version" } grovedb-visualize = { version = "5.0.1", path = "../visualize", optional = true } grovedb-path = { version = "5.0.1", path = "../path" } +grovedb-query = { version = "5.0.1", path = "../grovedb-query" } [features] default = ["verify", "constructor"] verify = [] constructor = [] -serde = ["dep:serde"] +# Forwarded to grovedb-query so the re-exported `IndexAxis` (defined +# there) carries its serde impls whenever this crate's serde is on. +serde = ["dep:serde", "grovedb-query/serde"] visualize = ["dep:grovedb-visualize"] [dev-dependencies] diff --git a/grovedb-element/src/indexed/mod.rs b/grovedb-element/src/indexed/mod.rs index 731e254d3..28f31461a 100644 --- a/grovedb-element/src/indexed/mod.rs +++ b/grovedb-element/src/indexed/mod.rs @@ -23,23 +23,19 @@ use crate::error::ElementError; /// /// The TLV-encoded `axes` field on a `ProvableCountProvableSumIndexedTree` /// is a canonical list of `(tag, secondary_root_key)` pairs sorted by -/// tag, with 1..=3 entries and no duplicate tags. The numeric values -/// below are the on-disk tag bytes: +/// tag, with 1..=3 entries and no duplicate tags. /// -/// - `0` = `Count`: secondary keyed by `(count_be ‖ original_key)`. -/// - `1` = `Sum`: secondary keyed by `(sum_sortable_be ‖ original_key)`. -/// - `2` = `Avg`: secondary keyed by `(avg_sortable_be ‖ original_key)`. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] -#[repr(u8)] -pub enum IndexAxis { - /// Count axis. Secondary entries are ordered by aggregate count. - Count = 0, - /// Sum axis. Secondary entries are ordered by aggregate sum (signed). - Sum = 1, - /// Average axis. Secondary entries are ordered by the fixed-point - /// average `floor(sum * SCALE / count)`. See - /// [`sort_keys::AVG_FIXED_POINT_SCALE`] for the scale rationale. - Avg = 2, +/// The enum itself is defined in `grovedb-query` (re-exported here so +/// existing paths keep working), because the query vocabulary names +/// axes too and the tag byte must have exactly one definition. The +/// numeric values are the on-disk tag bytes: `0` = Count, `1` = Sum, +/// `2` = Avg — see the definition for the secondary key layouts. +pub use grovedb_query::axis_query::{IndexAxis, UnknownAxisTag}; + +impl From for ElementError { + fn from(err: UnknownAxisTag) -> Self { + ElementError::CorruptedData(err.to_string()) + } } /// One entry in a `ProvableCountProvableSumIndexedTree`'s `axes` TLV list: @@ -54,48 +50,18 @@ pub type IndexedTreeAxisEntry = (u8, Option>); /// encoding. pub type IndexedTreeAxes = Vec; -impl IndexAxis { - /// On-disk tag byte for this axis. - #[inline] - pub const fn tag(self) -> u8 { - self as u8 - } - - /// Inverse of [`tag`]: parse an on-disk tag byte into the axis. Returns - /// `Err(ElementError::CorruptedData)` for any byte outside the - /// `0..=2` range. - #[inline] - pub fn try_from_tag(b: u8) -> Result { - match b { - 0 => Ok(IndexAxis::Count), - 1 => Ok(IndexAxis::Sum), - 2 => Ok(IndexAxis::Avg), - _ => Err(ElementError::CorruptedData(format!("unknown axis tag {b}"))), - } - } -} - #[cfg(test)] mod tests { use super::*; #[test] - fn axis_tag_round_trip() { - for a in [IndexAxis::Count, IndexAxis::Sum, IndexAxis::Avg] { - assert_eq!(IndexAxis::try_from_tag(a.tag()).unwrap(), a); + fn unknown_axis_tag_converts_to_element_error() { + // The tag round-trip tests live with the enum in `grovedb-query`; + // what this crate owns is the error-boundary conversion. + let err: ElementError = IndexAxis::try_from_tag(9).unwrap_err().into(); + match err { + ElementError::CorruptedData(msg) => assert_eq!(msg, "unknown axis tag 9"), + other => panic!("expected CorruptedData, got {other:?}"), } } - - #[test] - fn axis_tag_rejects_unknown_byte() { - assert!(IndexAxis::try_from_tag(3).is_err()); - assert!(IndexAxis::try_from_tag(255).is_err()); - } - - #[test] - fn axis_ordering_is_canonical() { - // The canonical order required by the on-disk TLV: Count < Sum < Avg. - assert!(IndexAxis::Count < IndexAxis::Sum); - assert!(IndexAxis::Sum < IndexAxis::Avg); - } } diff --git a/grovedb-element/tests/serde_probe.rs b/grovedb-element/tests/serde_probe.rs new file mode 100644 index 000000000..934ca9ffe --- /dev/null +++ b/grovedb-element/tests/serde_probe.rs @@ -0,0 +1,12 @@ +//! Regression probe for the `serde` feature forward: enabling only +//! `grovedb-element/serde` must give the re-exported `IndexAxis` +//! (defined in `grovedb-query`) its serde implementations, which +//! requires this crate's `serde` feature to forward to +//! `grovedb-query/serde`. +#![cfg(feature = "serde")] + +#[test] +fn index_axis_has_serde_impls() { + fn assert_serde serde::Deserialize<'de>>() {} + assert_serde::(); +} diff --git a/grovedb-query/src/axis_query.rs b/grovedb-query/src/axis_query.rs new file mode 100644 index 000000000..24401970a --- /dev/null +++ b/grovedb-query/src/axis_query.rs @@ -0,0 +1,703 @@ +//! Vocabulary for **axis-ordered** reads of an indexed tree. +//! +//! An ordinary [`Query`](crate::Query) selects keys: its items name keys +//! or key ranges in the Merk a path points at. That vocabulary cannot +//! describe the other thing an indexed tree can answer — "the best `k` +//! groups by aggregate" — because that ordering lives in the tree's +//! per-axis **secondary**, which is keyed by `sort_key ‖ original_key` +//! and is not a path-addressable subtree. [`AxisQuery`] is the missing +//! vocabulary: which axis to read, how to walk it, and in which +//! direction. It travels inside a `Query` as a +//! [`ReadMode`](crate::ReadMode), never as a `QueryItem` — an axis read +//! has no key-range meaning, so it does not participate in the item +//! algebra (merge, intersect, ordering). +//! +//! Wire stability: every tag in this module (the [`IndexAxis`] tag +//! byte, the [`AxisTraversal`] variant tags) is frozen on first +//! release. The `bincode` implementations are written by hand so the +//! encoding cannot drift if variants are reordered or added. + +use std::fmt; + +use bincode::{ + de::{BorrowDecoder, Decoder}, + enc::Encoder, + error::{DecodeError, EncodeError}, + BorrowDecode, Decode, Encode, +}; + +use crate::error::Error; + +/// Axis tag for an indexed tree's per-axis secondary. +/// +/// The numeric values are on-disk / on-wire tag bytes shared by the +/// indexed-tree element encoding, every axis proof envelope, and the +/// [`AxisQuery`] encoding — they must never change: +/// +/// - `0` = `Count`: secondary keyed by `(count_be ‖ original_key)`. +/// - `1` = `Sum`: secondary keyed by `(sum_sortable_be ‖ original_key)`. +/// - `2` = `Avg`: secondary keyed by `(avg_sortable_be ‖ original_key)`. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[repr(u8)] +pub enum IndexAxis { + /// Count axis. Secondary entries are ordered by aggregate count. + Count = 0, + /// Sum axis. Secondary entries are ordered by aggregate sum (signed). + Sum = 1, + /// Average axis. Secondary entries are ordered by the fixed-point + /// average `floor(sum * SCALE / count)`. + Avg = 2, +} + +/// A tag byte that does not name any [`IndexAxis`]. Carries the +/// offending byte so error surfaces can report it; converts into the +/// element crate's error type at that boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnknownAxisTag(pub u8); + +impl fmt::Display for UnknownAxisTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "unknown axis tag {}", self.0) + } +} + +impl std::error::Error for UnknownAxisTag {} + +impl IndexAxis { + /// On-disk / on-wire tag byte for this axis. + #[inline] + pub const fn tag(self) -> u8 { + self as u8 + } + + /// Inverse of [`Self::tag`]: parse a tag byte into the axis. Returns + /// [`UnknownAxisTag`] for any byte outside the `0..=2` range. + #[inline] + pub const fn try_from_tag(b: u8) -> Result { + match b { + 0 => Ok(IndexAxis::Count), + 1 => Ok(IndexAxis::Sum), + 2 => Ok(IndexAxis::Avg), + other => Err(UnknownAxisTag(other)), + } + } +} + +/// Maximum length of the key carried by [`AxisTraversal::RankOfKey`]. +/// Matches the element layer's key-length limit; enforced at decode +/// time so a hostile payload cannot smuggle an oversized allocation +/// through the traversal. +pub const MAX_RANK_OF_KEY_LEN: usize = 255; + +/// How an [`AxisQuery`] walks the secondary. Wire tags are explicit and +/// frozen: `RankedPage = 0`, `Bounded = 1`, `RankOfKey = 2`, +/// `RangeAggregate = 3`. +/// +/// # Cost +/// +/// Each variant documents best / average / worst prover work, which is +/// also the shape of the proof and so of the verifier's work. Throughout, +/// `n` is the number of entries on the queried axis; an empty secondary +/// short-circuits every traversal to `O(1)`. +/// +/// The costs are worth reading before choosing a shape: none of them +/// scale with how *deep* into the ordering the answer sits, because +/// every axis secondary binds an aggregate count into its node hashes. +/// Skipping and counting are read off subtree commitments rather than +/// walked entry by entry. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum AxisTraversal { + /// The `k` entries starting at rank `offset` **in the walk + /// direction** (`offset = 0` for the first page). + /// + /// The direction is [`AxisQuery::descending`], not part of this + /// variant, so one shape serves both readings: + /// + /// - `descending: true` → the `k` **largest** by aggregate (top-k), + /// - `descending: false` → the `k` **smallest** (bottom-k). + /// + /// Named for what it is — a page at a rank — rather than "top-k", + /// which would read as a contradiction in the ascending case. + /// + /// Skipping is attested from the secondary's counted subtree + /// commitments rather than walked, so a large `offset` costs the + /// same as a small one. + /// + /// **Cost** — best `O(log n)` (single-entry page), average and + /// worst `O(log n + k)`: descend to the page start, then emit `k` + /// entries. **No term in `offset`**: each skipped subtree collapses + /// to one counted commitment, so the skip is `O(log n)` rather than + /// `O(offset)` — page 10 000 costs what page 1 costs. + RankedPage { + /// Number of entries to return. + k: u16, + /// Rank the returned page starts at. + offset: u64, + }, + /// Every entry whose aggregate falls in the **inclusive** range + /// `[lo, hi]`, up to `limit` entries, in the walk direction. + /// + /// Bounds are carried as `i128` for every axis — the convention the + /// aggregate-range entry points use — and are validated against the + /// axis's own domain. + /// + /// **Cost** — with `m` the entries actually inside `[lo, hi]`: best + /// `O(log n)` (the range matches nothing), average + /// `O(log n + min(limit, m))`, worst `O(log n + limit)`. Unlike + /// [`Self::RankedPage`] this walks the matched entries, so `limit` + /// is the real bound on work — an unbounded-looking range is only + /// as expensive as the `limit` you set. + Bounded { + /// Inclusive lower bound on the aggregate. + lo: i128, + /// Inclusive upper bound on the aggregate. + hi: i128, + /// Maximum entries to return. + limit: u16, + }, + /// The rank of `key` in the directional walk — "where does this + /// entry place?". Served as an `offset = rank, k = 1` page whose + /// verifier additionally checks the yielded key equals `key`. + /// + /// The rank is *derived*, never searched for: the entry's position + /// is a pure function of its aggregate and its key (the secondary + /// is keyed `sort_key ‖ original_key`), so one point read of the + /// primary reconstructs its secondary key, and the entries before + /// it are counted off the subtree commitments. + /// + /// **Cost** — `O(log n)` in every case: one primary point read, + /// one counted-range count, one single-entry page proof. **No term + /// in the rank itself** — ranking 5-millionth costs what ranking + /// 5th costs. Errors with `PathKeyNotFound` when `key` is absent + /// from the primary: this answers where an entry *does* place, not + /// where a hypothetical one would. + RankOfKey { + /// The original (primary) key whose rank is requested. + key: Vec, + }, + /// A single aggregate over every entry whose aggregate value lies in + /// the inclusive `[lo, hi]` range. Count and Sum axes only — the + /// Avg axis has no meaningful sum-of-averages. + /// + /// **Cost** — `O(log n)` in every case. The walk classifies each + /// subtree as fully Contained, Disjoint, or Partial and folds a + /// Contained subtree's stored aggregate in one step, descending + /// only along the two range boundaries. **No term in the number of + /// matched entries** — summing a million in-range entries costs + /// what summing one costs, which is what makes this preferable to + /// [`Self::Bounded`] whenever only the total is wanted. + RangeAggregate { + /// Inclusive lower bound on the aggregate. + lo: i128, + /// Inclusive upper bound on the aggregate. + hi: i128, + }, +} + +impl Encode for AxisTraversal { + fn encode(&self, encoder: &mut E) -> Result<(), EncodeError> { + match self { + AxisTraversal::RankedPage { k, offset } => { + 0u8.encode(encoder)?; + k.encode(encoder)?; + offset.encode(encoder) + } + AxisTraversal::Bounded { lo, hi, limit } => { + 1u8.encode(encoder)?; + lo.encode(encoder)?; + hi.encode(encoder)?; + limit.encode(encoder) + } + AxisTraversal::RankOfKey { key } => { + 2u8.encode(encoder)?; + key.encode(encoder) + } + AxisTraversal::RangeAggregate { lo, hi } => { + 3u8.encode(encoder)?; + lo.encode(encoder)?; + hi.encode(encoder) + } + } + } +} + +impl Decode for AxisTraversal { + fn decode>(decoder: &mut D) -> Result { + match u8::decode(decoder)? { + 0 => Ok(AxisTraversal::RankedPage { + k: u16::decode(decoder)?, + offset: u64::decode(decoder)?, + }), + 1 => Ok(AxisTraversal::Bounded { + lo: i128::decode(decoder)?, + hi: i128::decode(decoder)?, + limit: u16::decode(decoder)?, + }), + 2 => { + let key = Vec::::decode(decoder)?; + if key.len() > MAX_RANK_OF_KEY_LEN { + return Err(DecodeError::Other( + "rank-of-key key exceeds the maximum key length", + )); + } + Ok(AxisTraversal::RankOfKey { key }) + } + 3 => Ok(AxisTraversal::RangeAggregate { + lo: i128::decode(decoder)?, + hi: i128::decode(decoder)?, + }), + _ => Err(DecodeError::Other("unknown axis traversal tag")), + } + } +} + +impl<'de, Context> BorrowDecode<'de, Context> for AxisTraversal { + fn borrow_decode>( + decoder: &mut D, + ) -> Result { + Self::decode(decoder) + } +} + +/// What to read from one indexed tree's per-axis secondary: which axis, +/// how to walk it, and in which direction. The axis-ordered counterpart +/// of a key-selecting [`Query`](crate::Query). +/// +/// `bincode` is implemented by hand: the axis travels as its canonical +/// tag byte (the same byte the indexed-tree element and every proof +/// envelope use), so the encoding stays stable if the enum ever gains +/// variants. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AxisQuery { + /// Which per-axis secondary to read. The indexed tree must carry + /// this axis; it is authenticated in the proof, so a query naming + /// an axis the element does not configure fails rather than + /// silently reading another one. + pub axis: IndexAxis, + /// How to walk it. + pub traversal: AxisTraversal, + /// `true` walks from the largest aggregate down ("best first"); + /// `false` from the smallest up. Ties break by the entry's original + /// key **in the direction of the walk** — a property of the + /// directional scan over `sort_key ‖ original_key`, not a separate + /// rule. + pub descending: bool, +} + +impl Encode for AxisQuery { + fn encode(&self, encoder: &mut E) -> Result<(), EncodeError> { + self.axis.tag().encode(encoder)?; + self.traversal.encode(encoder)?; + self.descending.encode(encoder) + } +} + +impl Decode for AxisQuery { + fn decode>(decoder: &mut D) -> Result { + let tag = u8::decode(decoder)?; + let axis = IndexAxis::try_from_tag(tag) + .map_err(|_| DecodeError::Other("unknown index axis tag"))?; + Ok(Self { + axis, + traversal: AxisTraversal::decode(decoder)?, + descending: bool::decode(decoder)?, + }) + } +} + +impl<'de, Context> BorrowDecode<'de, Context> for AxisQuery { + fn borrow_decode>( + decoder: &mut D, + ) -> Result { + Self::decode(decoder) + } +} + +impl AxisQuery { + /// A page of `k` entries on `axis`, starting at rank `offset`. + /// + /// `descending` chooses which end the ranking starts from: `true` + /// gives the `k` largest by aggregate (top-k), `false` the `k` + /// smallest — for which [`Self::bottom_k`] is the clearer spelling. + /// See [`AxisTraversal::RankedPage`] for the shape and its cost. + pub const fn top_k(axis: IndexAxis, k: u16, offset: u64, descending: bool) -> Self { + Self { + axis, + traversal: AxisTraversal::RankedPage { k, offset }, + descending, + } + } + + /// The `k` **smallest** entries on `axis` by aggregate, starting at + /// rank `offset` — the ascending reading of + /// [`AxisTraversal::RankedPage`]. + /// + /// Identical to [`Self::top_k`] with `descending: false`, but says + /// which end it starts from in the name rather than in a boolean + /// argument, where `top_k(.., false)` reads as a contradiction. + /// Same cost: `O(log n + k)`, with no term in `offset`. + pub const fn bottom_k(axis: IndexAxis, k: u16, offset: u64) -> Self { + Self { + axis, + traversal: AxisTraversal::RankedPage { k, offset }, + descending: false, + } + } + + /// Every entry whose aggregate is in `[lo, hi]`, up to `limit`. + pub const fn bounded( + axis: IndexAxis, + lo: i128, + hi: i128, + limit: u16, + descending: bool, + ) -> Self { + Self { + axis, + traversal: AxisTraversal::Bounded { lo, hi, limit }, + descending, + } + } + + /// The rank of `key` in the directional walk over `axis`. + pub const fn rank_of_key(axis: IndexAxis, key: Vec, descending: bool) -> Self { + Self { + axis, + traversal: AxisTraversal::RankOfKey { key }, + descending, + } + } + + /// A single aggregate over entries whose value is in `[lo, hi]`. + /// Direction does not affect the answer; constructors set + /// `descending = false`. + pub const fn range_aggregate(axis: IndexAxis, lo: i128, hi: i128) -> Self { + Self { + axis, + traversal: AxisTraversal::RangeAggregate { lo, hi }, + descending: false, + } + } + + /// Reject a query that cannot describe any answer, so a caller + /// error surfaces as a query error rather than an empty result that + /// looks like real absence. + /// + /// Checked in one place so the read, prove, and verify paths cannot + /// disagree on what is well-formed. + pub fn validate(&self) -> Result<(), Error> { + match &self.traversal { + AxisTraversal::RankedPage { k, .. } => { + if *k == 0 { + return Err(Error::InvalidOperation( + "axis query: `k` must be at least 1; a zero-length page selects nothing", + )); + } + } + AxisTraversal::Bounded { lo, hi, limit } => { + if *limit == 0 { + return Err(Error::InvalidOperation( + "axis query: `limit` must be at least 1; a zero-length page selects \ + nothing", + )); + } + self.validate_bounds(*lo, *hi)?; + } + AxisTraversal::RankOfKey { key } => { + if key.is_empty() { + return Err(Error::InvalidOperation( + "axis query: rank-of-key requires a non-empty key", + )); + } + if key.len() > MAX_RANK_OF_KEY_LEN { + return Err(Error::InvalidOperation( + "axis query: rank-of-key key exceeds the maximum key length", + )); + } + } + AxisTraversal::RangeAggregate { lo, hi } => { + if self.axis == IndexAxis::Avg { + return Err(Error::InvalidOperation( + "axis query: the Avg axis has no range aggregate — a sum of averages \ + is not meaningful", + )); + } + self.validate_bounds(*lo, *hi)?; + } + } + Ok(()) + } + + /// Shared bound rules for [`AxisTraversal::Bounded`] and + /// [`AxisTraversal::RangeAggregate`]. + fn validate_bounds(&self, lo: i128, hi: i128) -> Result<(), Error> { + if lo > hi { + return Err(Error::InvalidOperation( + "axis query: the bounds are inverted (`lo > hi`), so they can match nothing", + )); + } + if self.bounds_out_of_domain(lo, hi) { + return Err(Error::InvalidOperation( + "axis query: the bounds fall entirely outside the axis's value domain, so \ + they can match nothing", + )); + } + Ok(()) + } + + /// Whether `[lo, hi]` lies wholly outside what this axis can hold. + /// A partial overlap is fine — execution clamps it to the domain. + fn bounds_out_of_domain(&self, lo: i128, hi: i128) -> bool { + match self.axis { + IndexAxis::Count => hi < 0 || lo > u64::MAX as i128, + IndexAxis::Sum => hi < i64::MIN as i128 || lo > i64::MAX as i128, + // The avg axis is ordered by the fixed-point average, whose + // domain is the whole i128 range; nothing is out of domain. + IndexAxis::Avg => false, + } + } + + /// The number of entries this query can return, when that is a + /// fixed property of the traversal (`None` for + /// [`AxisTraversal::RangeAggregate`], which returns one scalar, not + /// entries). + pub const fn entry_cap(&self) -> Option { + match &self.traversal { + AxisTraversal::RankedPage { k, .. } => Some(*k), + AxisTraversal::Bounded { limit, .. } => Some(*limit), + AxisTraversal::RankOfKey { .. } => Some(1), + AxisTraversal::RangeAggregate { .. } => None, + } + } +} + +impl fmt::Display for AxisTraversal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AxisTraversal::RankedPage { k, offset } => { + write!(f, "RankedPage {{ k: {k}, offset: {offset} }}") + } + AxisTraversal::Bounded { lo, hi, limit } => { + write!(f, "Bounded {{ lo: {lo}, hi: {hi}, limit: {limit} }}") + } + AxisTraversal::RankOfKey { key } => { + write!(f, "RankOfKey {{ key: {} }}", crate::hex_to_ascii(key)) + } + AxisTraversal::RangeAggregate { lo, hi } => { + write!(f, "RangeAggregate {{ lo: {lo}, hi: {hi} }}") + } + } + } +} + +impl fmt::Display for AxisQuery { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "AxisQuery {{ axis: {:?}, {}, {} }}", + self.axis, + self.traversal, + if self.descending { + "descending" + } else { + "ascending" + } + ) + } +} + +#[cfg(test)] +mod tests { + use bincode::config; + + use super::*; + + #[test] + fn axis_tag_round_trip() { + for a in [IndexAxis::Count, IndexAxis::Sum, IndexAxis::Avg] { + assert_eq!(IndexAxis::try_from_tag(a.tag()).unwrap(), a); + } + } + + #[test] + fn axis_tag_rejects_unknown_byte() { + assert_eq!(IndexAxis::try_from_tag(3), Err(UnknownAxisTag(3))); + assert_eq!(format!("{}", UnknownAxisTag(255)), "unknown axis tag 255"); + } + + #[test] + fn axis_ordering_is_canonical() { + // The canonical order required by the on-disk TLV: Count < Sum < Avg. + assert!(IndexAxis::Count < IndexAxis::Sum); + assert!(IndexAxis::Sum < IndexAxis::Avg); + } + + fn all_traversals() -> Vec { + vec![ + AxisTraversal::RankedPage { k: 5, offset: 100 }, + AxisTraversal::Bounded { + lo: -7, + hi: 12, + limit: 3, + }, + AxisTraversal::RankOfKey { + key: b"alice".to_vec(), + }, + AxisTraversal::RangeAggregate { lo: 0, hi: 50 }, + ] + } + + #[test] + fn axis_query_round_trips_every_traversal_and_axis() { + for axis in [IndexAxis::Count, IndexAxis::Sum, IndexAxis::Avg] { + for traversal in all_traversals() { + for descending in [false, true] { + let q = AxisQuery { + axis, + traversal: traversal.clone(), + descending, + }; + let bytes = bincode::encode_to_vec(&q, config::standard()).unwrap(); + let (decoded, consumed): (AxisQuery, usize) = + bincode::decode_from_slice(&bytes, config::standard()).unwrap(); + assert_eq!(consumed, bytes.len()); + assert_eq!(decoded, q); + } + } + } + } + + #[test] + fn traversal_wire_tags_are_frozen() { + // First byte of each traversal encoding is its frozen tag. + let tags: Vec = all_traversals() + .into_iter() + .map(|t| bincode::encode_to_vec(&t, config::standard()).unwrap()[0]) + .collect(); + assert_eq!(tags, vec![0, 1, 2, 3]); + // First byte of an AxisQuery encoding is the axis tag byte. + let q = AxisQuery::top_k(IndexAxis::Avg, 1, 0, true); + let bytes = bincode::encode_to_vec(&q, config::standard()).unwrap(); + assert_eq!(bytes[0], 2); + } + + #[test] + fn decode_rejects_unknown_tags_and_oversized_rank_key() { + // Unknown traversal tag. + let err = bincode::decode_from_slice::(&[9u8], config::standard()); + assert!(err.is_err()); + // Unknown axis tag at the head of an AxisQuery. + let err = + bincode::decode_from_slice::(&[7u8, 0, 1, 0, 0], config::standard()); + assert!(err.is_err()); + // Oversized rank-of-key key. + let mut q = AxisQuery::rank_of_key(IndexAxis::Count, vec![0u8; 256], false); + let bytes = bincode::encode_to_vec(&q, config::standard()).unwrap(); + assert!(bincode::decode_from_slice::(&bytes, config::standard()).is_err()); + // At the cap it round-trips. + q = AxisQuery::rank_of_key(IndexAxis::Count, vec![0u8; 255], false); + let bytes = bincode::encode_to_vec(&q, config::standard()).unwrap(); + assert!(bincode::decode_from_slice::(&bytes, config::standard()).is_ok()); + } + + #[test] + fn validate_rejects_unanswerable_queries() { + // k = 0. + assert!(AxisQuery::top_k(IndexAxis::Count, 0, 0, true) + .validate() + .is_err()); + // limit = 0. + assert!(AxisQuery::bounded(IndexAxis::Sum, 0, 10, 0, true) + .validate() + .is_err()); + // Inverted bounds. + assert!(AxisQuery::bounded(IndexAxis::Sum, 10, 0, 1, true) + .validate() + .is_err()); + // Wholly out of domain: negative counts. + assert!(AxisQuery::bounded(IndexAxis::Count, -10, -1, 1, true) + .validate() + .is_err()); + // Wholly out of domain: beyond i64 for sums. + assert!( + AxisQuery::range_aggregate(IndexAxis::Sum, i64::MAX as i128 + 1, i128::MAX) + .validate() + .is_err() + ); + // Range aggregate on Avg. + assert!(AxisQuery::range_aggregate(IndexAxis::Avg, 0, 10) + .validate() + .is_err()); + // Empty rank key. + assert!(AxisQuery::rank_of_key(IndexAxis::Count, vec![], true) + .validate() + .is_err()); + // Partial domain overlap is fine. + assert!(AxisQuery::bounded(IndexAxis::Count, -5, 5, 1, false) + .validate() + .is_ok()); + // Avg accepts the full i128 range for Bounded. + assert!( + AxisQuery::bounded(IndexAxis::Avg, i128::MIN, i128::MAX, 1, false) + .validate() + .is_ok() + ); + } + + #[test] + fn bottom_k_is_the_ascending_ranked_page() { + // `bottom_k` is exactly `top_k` with the walk reversed — same + // traversal, same wire bytes, only the direction differs. + let bottom = AxisQuery::bottom_k(IndexAxis::Sum, 5, 10); + assert_eq!(bottom, AxisQuery::top_k(IndexAxis::Sum, 5, 10, false)); + assert!(!bottom.descending); + assert_eq!( + bottom.traversal, + AxisTraversal::RankedPage { k: 5, offset: 10 } + ); + bottom.validate().expect("a bottom-k page is well formed"); + + // ...and is the mirror of the descending page, not a different + // shape: the two differ in exactly one byte on the wire. + let top = AxisQuery::top_k(IndexAxis::Sum, 5, 10, true); + let bottom_bytes = bincode::encode_to_vec(&bottom, config::standard()).unwrap(); + let top_bytes = bincode::encode_to_vec(&top, config::standard()).unwrap(); + assert_eq!(bottom_bytes.len(), top_bytes.len()); + assert_eq!( + bottom_bytes + .iter() + .zip(&top_bytes) + .filter(|(a, b)| a != b) + .count(), + 1, + "only the descending flag distinguishes the two directions" + ); + } + + #[test] + fn entry_caps() { + assert_eq!( + AxisQuery::top_k(IndexAxis::Count, 7, 0, true).entry_cap(), + Some(7) + ); + assert_eq!( + AxisQuery::bottom_k(IndexAxis::Count, 7, 0).entry_cap(), + Some(7) + ); + assert_eq!( + AxisQuery::bounded(IndexAxis::Sum, 0, 1, 9, true).entry_cap(), + Some(9) + ); + assert_eq!( + AxisQuery::rank_of_key(IndexAxis::Sum, b"k".to_vec(), true).entry_cap(), + Some(1) + ); + assert_eq!( + AxisQuery::range_aggregate(IndexAxis::Sum, 0, 1).entry_cap(), + None + ); + } +} diff --git a/grovedb-query/src/lib.rs b/grovedb-query/src/lib.rs index 10ae76071..eeda76c33 100644 --- a/grovedb-query/src/lib.rs +++ b/grovedb-query/src/lib.rs @@ -27,6 +27,14 @@ mod aggregate_sum; /// Aggregate sum query for sum-up-to style queries. pub mod aggregate_sum_query; +/// Axis-ordered read vocabulary for indexed trees: `IndexAxis`, +/// `AxisQuery`, `AxisTraversal`. +pub mod axis_query; + +/// Read modes: how a `Query` node reads the tree its path names +/// (`ReadMode`, `SumBudgetRead`). +pub mod read_mode; + mod common_path; mod insert; @@ -48,10 +56,12 @@ mod query; mod subquery_branch; pub use aggregate_sum_query::AggregateSumQuery; +pub use axis_query::{AxisQuery, AxisTraversal, IndexAxis, UnknownAxisTag}; pub use proof_items::ProofItems; pub use proof_status::ProofStatus; pub use query::Query; pub use query_item::{intersect::QueryItemIntersectionResult, QueryItem}; +pub use read_mode::{ReadMode, SumBudgetRead}; pub use subquery_branch::SubqueryBranch; /// Type alias for a path. diff --git a/grovedb-query/src/merge.rs b/grovedb-query/src/merge.rs index bf22f5ab1..86c9d1003 100644 --- a/grovedb-query/src/merge.rs +++ b/grovedb-query/src/merge.rs @@ -507,6 +507,11 @@ impl Query { conditional_subquery_branches, left_to_right: _, add_parent_tree_on_subquery, + // Read modes do not merge: PathQuery::merge rejects + // read-mode queries before reaching this, and these + // merge functions become fallible (rejecting read-mode + // conflicts explicitly) in a follow-up. + read_mode: _, } = query; // Preserve add_parent_tree_on_subquery if any query requests it if add_parent_tree_on_subquery { @@ -555,6 +560,9 @@ impl Query { conditional_subquery_branches, left_to_right: _, add_parent_tree_on_subquery, + // See merge_multiple: read modes do not merge; gated + // upstream and made explicitly fallible in a follow-up. + read_mode: _, } = other; // Preserve add_parent_tree_on_subquery if either query requests it if add_parent_tree_on_subquery { diff --git a/grovedb-query/src/query.rs b/grovedb-query/src/query.rs index 2ab979994..fbbb9cc6d 100644 --- a/grovedb-query/src/query.rs +++ b/grovedb-query/src/query.rs @@ -7,7 +7,7 @@ use bincode::{ }; use indexmap::IndexMap; -use crate::{error::Error, query_item::QueryItem, Key, Path, SubqueryBranch}; +use crate::{error::Error, query_item::QueryItem, Key, Path, ReadMode, SubqueryBranch}; /// `Query` represents one or more keys or ranges of keys, which can be used to /// resolve a proof which will include all the requested values. @@ -41,11 +41,45 @@ pub struct Query { /// Parent tree elements will therefore not appear in the verified /// result set in those modes. pub add_parent_tree_on_subquery: bool, + /// How this node reads the tree its (sub)path names. `None` is + /// plain key selection — all pre-existing behavior, byte-identical + /// on the wire (the encoding version byte stays `1`). `Some(_)` + /// switches the node to an axis-ordered or sum-budget read and + /// bumps the node's encoding version byte to `2`, which decoders + /// that predate read modes reject — fail-closed by construction. + /// + /// Placement rules (which items/branches may accompany a read mode, + /// where in a `PathQuery` it may appear) are enforced by + /// `PathQuery::classify` in the `grovedb` crate. + /// + /// **Boxed deliberately.** A read mode is absent from virtually + /// every query, but `AxisQuery`'s `i128` bounds make it 64 bytes + /// inline — which would fatten every `Query` (and through + /// `PathQuery`, the `Error::InvalidProof` variant and so every + /// `CostResult` in the crate) whether or not a read mode is + /// present. The indirection costs one allocation on the rare + /// read-mode path and keeps `Query` cheap to clone, which the + /// engine does constantly. It is invisible on the wire and in + /// serde: `Box` encodes exactly as `T`. + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub read_mode: Option>, } impl Encode for Query { fn encode(&self, encoder: &mut E) -> Result<(), EncodeError> { - 1u8.encode(encoder)?; + // Version byte. Queries without a read mode — everything that + // was expressible before read modes existed — keep encoding as + // version 1, byte-for-byte. Only a node that actually carries a + // read mode bumps to 2, so old decoders fail closed on exactly + // the queries they cannot execute and on nothing else. + if self.read_mode.is_some() { + 2u8.encode(encoder)?; + } else { + 1u8.encode(encoder)?; + } // Encode the items vector self.items.encode(encoder)?; @@ -76,6 +110,12 @@ impl Encode for Query { self.add_parent_tree_on_subquery.encode(encoder)?; + // Version 2 appends the read mode. No presence flag: the + // version byte already says it's there. + if let Some(read_mode) = &self.read_mode { + read_mode.encode(encoder)?; + } + Ok(()) } } @@ -104,7 +144,7 @@ impl Query { )); } let version = u8::decode(decoder)?; - if version != 1 { + if version != 1 && version != 2 { return Err(DecodeError::Other("unsupported Query encoding version")); } let items_len = u64::decode(decoder)? as usize; @@ -139,12 +179,21 @@ impl Query { let left_to_right = bool::decode(decoder)?; let add_parent_tree_on_subquery = bool::decode(decoder)?; + // Version 2 carries a read mode; version 1 never does. No + // presence flag — the version byte is the flag. + let read_mode = if version == 2 { + Some(Box::new(ReadMode::decode(decoder)?)) + } else { + None + }; + Ok(Query { items, default_subquery_branch, conditional_subquery_branches, left_to_right, add_parent_tree_on_subquery, + read_mode, }) } @@ -158,7 +207,7 @@ impl Query { )); } let version = u8::borrow_decode(decoder)?; - if version != 1 { + if version != 1 && version != 2 { return Err(DecodeError::Other("unsupported Query encoding version")); } let items_len = u64::borrow_decode(decoder)? as usize; @@ -193,12 +242,20 @@ impl Query { let left_to_right = bool::borrow_decode(decoder)?; let add_parent_tree_on_subquery = bool::borrow_decode(decoder)?; + // Version 2 carries a read mode; version 1 never does. + let read_mode = if version == 2 { + Some(Box::new(ReadMode::borrow_decode(decoder)?)) + } else { + None + }; + Ok(Query { items, default_subquery_branch, conditional_subquery_branches, left_to_right, add_parent_tree_on_subquery, + read_mode, }) } } @@ -245,6 +302,9 @@ impl fmt::Display for Query { " add_parent_tree_on_subquery: {},", self.add_parent_tree_on_subquery )?; + if let Some(read_mode) = &self.read_mode { + writeln!(f, " read_mode: {read_mode},")?; + } write!(f, "}}") } } @@ -667,6 +727,7 @@ impl> From> for Query { conditional_subquery_branches: None, left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, } } } @@ -778,7 +839,7 @@ mod tests { fn query_decode_rejects_invalid_version() { // Craft a payload with an invalid version byte let mut payload = Vec::new(); - payload.push(2u8); // invalid version (only version 1 is supported) + payload.push(3u8); // invalid version (only versions 1 and 2 are supported) // Add some dummy data after payload.extend_from_slice(&[0; 20]); diff --git a/grovedb-query/src/read_mode.rs b/grovedb-query/src/read_mode.rs new file mode 100644 index 000000000..64caf0a2d --- /dev/null +++ b/grovedb-query/src/read_mode.rs @@ -0,0 +1,299 @@ +//! How a [`Query`](crate::Query) node reads the tree its (sub)path +//! names. +//! +//! `read_mode: None` on a `Query` is ordinary key selection — all of +//! today's behavior, byte-identical on the wire. A `Some(ReadMode)` +//! changes what the node means: +//! +//! - [`ReadMode::Axis`] reads the per-axis secondary of the indexed +//! tree the node's path names, in aggregate order, instead of the +//! tree's own keyspace. +//! - [`ReadMode::SumBudget`] walks the node's items in key order but +//! stops on a running-sum budget instead of a result-count limit — +//! the read `AggregateSumPathQuery` serves today, expressed in the +//! unified vocabulary. +//! +//! Structural rules (which items/branches a carrying `Query` may have, +//! where in a `PathQuery` a read mode may appear) are owned by +//! `PathQuery::classify` in the `grovedb` crate; this module owns the +//! vocabulary, its encoding, and the per-mode well-formedness rules +//! that don't depend on position. +//! +//! Wire stability: mode tags (`Axis = 0`, `SumBudget = 1`) are frozen. + +use std::fmt; + +use bincode::{ + de::{BorrowDecoder, Decoder}, + enc::Encoder, + error::{DecodeError, EncodeError}, + BorrowDecode, Decode, Encode, +}; + +use crate::{axis_query::AxisQuery, error::Error, query::Query}; + +/// A key-ordered read that stops once the running sum of matched +/// sum-item values reaches a budget. The unified-vocabulary form of +/// `AggregateSumQuery`'s stop condition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SumBudgetRead { + /// Stop once the running sum of matched sum-item values reaches + /// this. Distinct from a result-count limit: how many entries that + /// takes depends on the data. + pub sum_limit: u64, + /// Cap on elements scanned (matched or skipped), on top of the + /// grove-version global scan cap. `None` = only the global cap. + pub max_items_checked: Option, +} + +impl SumBudgetRead { + /// Reject a budget that cannot describe any answer. + pub fn validate(&self) -> Result<(), Error> { + if self.sum_limit == 0 { + return Err(Error::InvalidOperation( + "sum-budget read: `sum_limit` must be at least 1; a zero budget stops before \ + selecting anything", + )); + } + if self.max_items_checked == Some(0) { + return Err(Error::InvalidOperation( + "sum-budget read: `max_items_checked` must be at least 1 when set; a zero \ + scan cap selects nothing", + )); + } + Ok(()) + } +} + +impl Encode for SumBudgetRead { + fn encode(&self, encoder: &mut E) -> Result<(), EncodeError> { + self.sum_limit.encode(encoder)?; + self.max_items_checked.encode(encoder) + } +} + +impl Decode for SumBudgetRead { + fn decode>(decoder: &mut D) -> Result { + Ok(Self { + sum_limit: u64::decode(decoder)?, + max_items_checked: Option::::decode(decoder)?, + }) + } +} + +impl<'de, Context> BorrowDecode<'de, Context> for SumBudgetRead { + fn borrow_decode>( + decoder: &mut D, + ) -> Result { + Self::decode(decoder) + } +} + +impl fmt::Display for SumBudgetRead { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "SumBudget {{ sum_limit: {}, max_items_checked: {:?} }}", + self.sum_limit, self.max_items_checked + ) + } +} + +/// How a [`Query`] node reads the tree its (sub)path +/// names. Absent (`None` on the `Query`) means plain key selection. +/// +/// Wire tags are frozen: `Axis = 0`, `SumBudget = 1`. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ReadMode { + /// Axis-ordered read of the indexed tree this node's path names. + /// The carrying `Query` must have empty `items` and no subquery + /// branches — the axis query is the whole read. + Axis(AxisQuery), + /// Key-ordered read of this node's items that stops on a running + /// sum budget. The carrying `Query` must have non-empty `items` + /// and no subquery branches. + SumBudget(SumBudgetRead), +} + +impl ReadMode { + /// Position-independent well-formedness of the mode itself. + pub fn validate(&self) -> Result<(), Error> { + match self { + ReadMode::Axis(axis_query) => axis_query.validate(), + ReadMode::SumBudget(budget) => budget.validate(), + } + } +} + +impl Encode for ReadMode { + fn encode(&self, encoder: &mut E) -> Result<(), EncodeError> { + match self { + ReadMode::Axis(axis_query) => { + 0u8.encode(encoder)?; + axis_query.encode(encoder) + } + ReadMode::SumBudget(budget) => { + 1u8.encode(encoder)?; + budget.encode(encoder) + } + } + } +} + +impl Decode for ReadMode { + fn decode>(decoder: &mut D) -> Result { + match u8::decode(decoder)? { + 0 => Ok(ReadMode::Axis(AxisQuery::decode(decoder)?)), + 1 => Ok(ReadMode::SumBudget(SumBudgetRead::decode(decoder)?)), + _ => Err(DecodeError::Other("unknown read mode tag")), + } + } +} + +impl<'de, Context> BorrowDecode<'de, Context> for ReadMode { + fn borrow_decode>( + decoder: &mut D, + ) -> Result { + Self::decode(decoder) + } +} + +impl fmt::Display for ReadMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadMode::Axis(axis_query) => write!(f, "Axis({axis_query})"), + ReadMode::SumBudget(budget) => write!(f, "{budget}"), + } + } +} + +impl Query { + /// Whether this query — or any query nested in its subquery + /// branches — carries a [`ReadMode`]. Entry points that don't serve + /// read modes use this to fail closed instead of silently running a + /// read-mode query as plain key selection. + pub fn has_read_mode_anywhere(&self) -> bool { + if self.read_mode.is_some() { + return true; + } + if let Some(sub) = self.default_subquery_branch.subquery.as_deref() + && sub.has_read_mode_anywhere() + { + return true; + } + if let Some(branches) = &self.conditional_subquery_branches { + for branch in branches.values() { + if let Some(sub) = branch.subquery.as_deref() + && sub.has_read_mode_anywhere() + { + return true; + } + } + } + false + } +} + +#[cfg(test)] +mod tests { + use bincode::config; + + use super::*; + use crate::axis_query::IndexAxis; + + #[test] + fn read_mode_round_trips() { + let modes = [ + ReadMode::Axis(AxisQuery::top_k(IndexAxis::Sum, 10, 20, true)), + ReadMode::SumBudget(SumBudgetRead { + sum_limit: 1000, + max_items_checked: Some(50), + }), + ReadMode::SumBudget(SumBudgetRead { + sum_limit: 1, + max_items_checked: None, + }), + ]; + for mode in modes { + let bytes = bincode::encode_to_vec(&mode, config::standard()).unwrap(); + let (decoded, consumed): (ReadMode, usize) = + bincode::decode_from_slice(&bytes, config::standard()).unwrap(); + assert_eq!(consumed, bytes.len()); + assert_eq!(decoded, mode); + } + } + + #[test] + fn read_mode_wire_tags_are_frozen() { + let axis = ReadMode::Axis(AxisQuery::top_k(IndexAxis::Count, 1, 0, false)); + assert_eq!( + bincode::encode_to_vec(&axis, config::standard()).unwrap()[0], + 0 + ); + let budget = ReadMode::SumBudget(SumBudgetRead { + sum_limit: 1, + max_items_checked: None, + }); + assert_eq!( + bincode::encode_to_vec(&budget, config::standard()).unwrap()[0], + 1 + ); + assert!( + bincode::decode_from_slice::(&[2u8], config::standard()).is_err(), + "unknown mode tag must be rejected" + ); + } + + #[test] + fn sum_budget_validation() { + assert!(SumBudgetRead { + sum_limit: 0, + max_items_checked: None + } + .validate() + .is_err()); + assert!(SumBudgetRead { + sum_limit: 1, + max_items_checked: Some(0) + } + .validate() + .is_err()); + assert!(SumBudgetRead { + sum_limit: 1, + max_items_checked: Some(1) + } + .validate() + .is_ok()); + } + + #[test] + fn has_read_mode_anywhere_walks_subqueries() { + let mut plain = Query::new_single_key(b"k".to_vec()); + assert!(!plain.has_read_mode_anywhere()); + + // Directly on the node. + let mut direct = Query::new(); + direct.read_mode = Some(Box::new(ReadMode::Axis(AxisQuery::top_k( + IndexAxis::Count, + 1, + 0, + true, + )))); + assert!(direct.has_read_mode_anywhere()); + + // Hidden in the default subquery branch. + plain.set_subquery(direct.clone()); + assert!(plain.has_read_mode_anywhere()); + + // Hidden in a conditional subquery branch. + let mut conditional = Query::new_single_key(b"k".to_vec()); + conditional.add_conditional_subquery( + crate::QueryItem::Key(b"k".to_vec()), + None, + Some(direct), + ); + assert!(conditional.has_read_mode_anywhere()); + } +} diff --git a/grovedb-query/tests/query_api_and_serialization.rs b/grovedb-query/tests/query_api_and_serialization.rs index eee577dde..63f40e03a 100644 --- a/grovedb-query/tests/query_api_and_serialization.rs +++ b/grovedb-query/tests/query_api_and_serialization.rs @@ -75,7 +75,7 @@ fn query_encode_decode_and_borrow_decode_round_trip() { #[test] fn query_decode_rejects_unsupported_version() { - let err = decode_from_slice::(&[2_u8], standard()).expect_err("must fail"); + let err = decode_from_slice::(&[3_u8], standard()).expect_err("must fail"); assert!(err .to_string() .contains("unsupported Query encoding version")); @@ -83,7 +83,7 @@ fn query_decode_rejects_unsupported_version() { #[test] fn query_borrow_decode_rejects_unsupported_version() { - let err = borrow_decode_from_slice::(&[2_u8], standard()).expect_err("must fail"); + let err = borrow_decode_from_slice::(&[3_u8], standard()).expect_err("must fail"); assert!(err .to_string() .contains("unsupported Query encoding version")); diff --git a/grovedb-query/tests/query_encoding_golden.rs b/grovedb-query/tests/query_encoding_golden.rs new file mode 100644 index 000000000..32f2faf25 --- /dev/null +++ b/grovedb-query/tests/query_encoding_golden.rs @@ -0,0 +1,132 @@ +//! Golden-byte pins for the `Query` bincode encoding. +//! +//! The `Query` encoding is a public compatibility surface: external +//! callers (rs-drive) round-trip serialized queries, and the verifier +//! interprets the same query the prover used, so the byte layout of +//! every already-expressible query must never change. These tests pin +//! the exact bytes of representative queries; if one fails, the +//! encoding changed for queries that predate the change — which is a +//! wire break, not a refactor. + +use bincode::config; +use grovedb_query::{AxisQuery, IndexAxis, Query, QueryItem, ReadMode, SumBudgetRead}; + +fn encode(query: &Query) -> Vec { + bincode::encode_to_vec(query, config::standard()).expect("query must encode") +} + +fn decode(bytes: &[u8]) -> Query { + let (query, consumed): (Query, usize) = + bincode::decode_from_slice(bytes, config::standard()).expect("query must decode"); + assert_eq!(consumed, bytes.len(), "no trailing bytes"); + query +} + +fn simple_query() -> Query { + let mut query = Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + query.insert_key(b"0".to_vec()); + query +} + +fn nested_query() -> Query { + let mut inner = Query::new_single_key(b"leaf".to_vec()); + inner.left_to_right = false; + + let mut query = Query::new_single_query_item(QueryItem::RangeFrom(b"m".to_vec()..)); + query.set_subquery_path(vec![b"sub".to_vec()]); + query.set_subquery(inner); + query.add_conditional_subquery( + QueryItem::Key(b"cond".to_vec()), + Some(vec![b"p".to_vec()]), + Some(Query::new_single_key(b"ck".to_vec())), + ); + query.add_parent_tree_on_subquery = true; + query +} + +#[test] +fn print_golden_bytes() { + // Helper to (re)generate the literals below; keep it running so the + // encode path stays exercised even when the pins are up to date. + println!("simple: {:?}", encode(&simple_query())); + println!("nested: {:?}", encode(&nested_query())); +} + +#[test] +fn simple_query_bytes_are_pinned() { + let bytes = encode(&simple_query()); + assert_eq!(bytes, GOLDEN_SIMPLE, "simple Query encoding changed"); + assert_eq!(decode(&bytes), simple_query()); +} + +#[test] +fn nested_query_bytes_are_pinned() { + let bytes = encode(&nested_query()); + assert_eq!(bytes, GOLDEN_NESTED, "nested Query encoding changed"); + assert_eq!(decode(&bytes), nested_query()); +} + +#[test] +fn queries_without_read_mode_stay_on_version_1() { + // The version byte is the first encoded byte; every query that was + // expressible before read modes must keep encoding as version 1. + assert_eq!(encode(&simple_query())[0], 1); + assert_eq!(encode(&nested_query())[0], 1); + assert_eq!(encode(&Query::new())[0], 1); +} + +#[test] +fn read_mode_queries_use_version_2_and_round_trip() { + let mut axis_query = Query::new(); + axis_query.read_mode = Some(Box::new(ReadMode::Axis(AxisQuery::top_k( + IndexAxis::Sum, + 10, + 5, + true, + )))); + let bytes = encode(&axis_query); + assert_eq!(bytes[0], 2, "read-mode queries encode as version 2"); + assert_eq!(decode(&bytes), axis_query); + + let mut budget_query = Query::new_single_query_item(QueryItem::RangeFull(..)); + budget_query.read_mode = Some(Box::new(ReadMode::SumBudget(SumBudgetRead { + sum_limit: 500, + max_items_checked: Some(100), + }))); + let bytes = encode(&budget_query); + assert_eq!(bytes[0], 2); + assert_eq!(decode(&bytes), budget_query); + + // A nested query carrying a read mode in its terminal subquery: the + // outer node stays version 1, the inner node is version 2, and the + // whole payload round-trips. + let mut outer = Query::new_single_key(b"branch".to_vec()); + outer.set_subquery_path(vec![b"suffix".to_vec()]); + outer.set_subquery(axis_query); + let bytes = encode(&outer); + assert_eq!( + bytes[0], 1, + "outer node without a read mode stays version 1" + ); + assert_eq!(decode(&bytes), outer); +} + +#[test] +fn version_2_payload_without_read_mode_bytes_is_rejected() { + // Take a version-1 encoding and flip the version byte to 2: the + // decoder now expects read-mode bytes that aren't there. + let mut bytes = encode(&simple_query()); + bytes[0] = 2; + let result: Result<(Query, usize), _> = bincode::decode_from_slice(&bytes, config::standard()); + assert!( + result.is_err(), + "version-2 payload missing its read mode must be rejected" + ); +} + +// Captured from the encoding as of develop @ a2791bbd (pre-read_mode). +const GOLDEN_SIMPLE: &[u8] = &[1, 2, 0, 1, 48, 1, 1, 97, 1, 122, 0, 0, 0, 1, 0]; +const GOLDEN_NESTED: &[u8] = &[ + 1, 1, 4, 1, 109, 1, 1, 3, 115, 117, 98, 1, 1, 1, 0, 4, 108, 101, 97, 102, 0, 0, 0, 0, 0, 1, 1, + 0, 4, 99, 111, 110, 100, 1, 1, 1, 112, 1, 1, 1, 0, 2, 99, 107, 0, 0, 0, 1, 0, 1, 1, +]; diff --git a/grovedb/src/debugger.rs b/grovedb/src/debugger.rs index 8b6ba19d1..c61d00075 100644 --- a/grovedb/src/debugger.rs +++ b/grovedb/src/debugger.rs @@ -747,6 +747,11 @@ fn query_to_grovedb(query: Query) -> crate::Query { ), left_to_right: query.left_to_right, add_parent_tree_on_subquery: query.add_parent_tree_on_subquery, + // The grovedbg wire type has no read-mode vocabulary (the + // debugger UI cannot express axis or sum-budget reads, same as + // it cannot express aggregate items), so debugger-issued + // queries are always plain key selection. + read_mode: None, } } diff --git a/grovedb/src/operations/get/query.rs b/grovedb/src/operations/get/query.rs index 11faf2773..5a4a303b5 100644 --- a/grovedb/src/operations/get/query.rs +++ b/grovedb/src/operations/get/query.rs @@ -1325,6 +1325,14 @@ where { "query_raw", grove_version.grovedb_versions.operations.query.query_raw ); + // Read-mode gate: axis / sum-budget reads are not served by the + // key-selection read path. Fail closed rather than walking an + // axis query's (empty) items and returning an empty result that + // looks like real absence. `query_raw` is the funnel every + // key-selection read entry point flows through. + if let Err(e) = path_query.reject_unserved_read_mode() { + return Err(e).wrap_with_cost(OperationCost::default()); + } Element::get_path_query( &self.db, path_query, diff --git a/grovedb/src/operations/proof/generate.rs b/grovedb/src/operations/proof/generate.rs index b095ee0f5..90bd8ae2a 100644 --- a/grovedb/src/operations/proof/generate.rs +++ b/grovedb/src/operations/proof/generate.rs @@ -109,6 +109,12 @@ impl GroveDb { prove_options: Option, grove_version: &GroveVersion, ) -> CostResult { + // Read-mode gate: axis / sum-budget reads are not served by this + // prover. Fail closed rather than misreading such a query as key + // selection and returning a proof about the wrong thing. + if let Err(e) = path_query.reject_unserved_read_mode() { + return Err(e).wrap_with_cost(OperationCost::default()); + } // Aggregate-count gate: validate at entry so malformed ACOR // queries (invalid inner range, ACOR-hidden-in-subquery, etc.) are // rejected up front instead of being skipped when the recursive diff --git a/grovedb/src/operations/proof/verify.rs b/grovedb/src/operations/proof/verify.rs index ab3db34cd..5987acf4d 100644 --- a/grovedb/src/operations/proof/verify.rs +++ b/grovedb/src/operations/proof/verify.rs @@ -161,6 +161,12 @@ impl GroveDb { ), Error, > { + // Read-mode gate: axis / sum-budget reads are not verified by + // this verifier. Fail closed rather than misreading such a + // query as key selection and accepting a proof about the wrong + // thing. + query.reject_unserved_read_mode()?; + // Offset gate centralized in `apply_count_offset_envelope_gate`: // V0 envelopes reject any non-zero offset (V0 is a shipped // wire format that never supported `SizedQuery::offset`); @@ -301,6 +307,9 @@ impl GroveDb { options: VerifyOptions, grove_version: &GroveVersion, ) -> Result<(CryptoHash, Option, ProvedPathKeyValues), Error> { + // Same fail-closed read-mode gate as `verify_proof_internal`. + query.reject_unserved_read_mode()?; + // Same V0-rejects / V1-relaxes envelope gate as // `verify_proof_internal` — see `apply_count_offset_envelope_gate`. Self::apply_count_offset_envelope_gate(proof, query)?; diff --git a/grovedb/src/query/mod.rs b/grovedb/src/query/mod.rs index 807ed3d4d..c5cd816aa 100644 --- a/grovedb/src/query/mod.rs +++ b/grovedb/src/query/mod.rs @@ -20,7 +20,9 @@ pub use grove_branch_query_result::GroveBranchQueryResult; pub use grove_trunk_query_result::{GroveTrunkQueryResult, LeafInfo}; #[cfg(any(feature = "minimal", feature = "verify"))] use grovedb_merk::proofs::query::query_item::QueryItem; -use grovedb_merk::proofs::query::{Key, SubqueryBranch}; +use grovedb_merk::proofs::query::{ + AxisQuery, IndexAxis, Key, ReadMode, SubqueryBranch, SumBudgetRead, +}; use grovedb_merk::proofs::Query; use grovedb_version::{check_grovedb_v0, version::GroveVersion}; @@ -556,6 +558,144 @@ impl PathQuery { Self::new_unsized(path, Query::new_aggregate_count_and_sum_on_range(range)) } + /// A `Query` node whose whole read is `axis_query` — the terminal + /// node of every axis-read shape. + fn axis_read_node(axis_query: AxisQuery) -> Query { + Query { + read_mode: Some(Box::new(ReadMode::Axis(axis_query))), + ..Query::new() + } + } + + /// An axis-ordered read of the indexed tree at `path`: a page of + /// `k` entries on `axis`, starting at rank `offset` (0 = first + /// page). + /// + /// `descending` chooses which end the ranking starts from: `true` + /// gives the `k` largest by aggregate (top-k), `false` the `k` + /// smallest (bottom-k). + pub fn new_axis_top_k( + path: Vec>, + axis: IndexAxis, + k: u16, + offset: u64, + descending: bool, + ) -> Self { + Self::new_unsized( + path, + Self::axis_read_node(AxisQuery::top_k(axis, k, offset, descending)), + ) + } + + /// An axis-ordered read of the indexed tree at `path`: every entry + /// whose `axis` aggregate is in the inclusive `[lo, hi]`, up to + /// `limit` entries. + pub fn new_axis_bounded( + path: Vec>, + axis: IndexAxis, + lo: i128, + hi: i128, + limit: u16, + descending: bool, + ) -> Self { + Self::new_unsized( + path, + Self::axis_read_node(AxisQuery::bounded(axis, lo, hi, limit, descending)), + ) + } + + /// The rank of `key` in the directional walk over `axis` of the + /// indexed tree at `path`. + pub fn new_axis_rank_of_key( + path: Vec>, + axis: IndexAxis, + key: Vec, + descending: bool, + ) -> Self { + Self::new_unsized( + path, + Self::axis_read_node(AxisQuery::rank_of_key(axis, key, descending)), + ) + } + + /// A single aggregate over every entry of the indexed tree at + /// `path` whose `axis` value is in the inclusive `[lo, hi]`. + /// Count and Sum axes only. + pub fn new_axis_range_aggregate( + path: Vec>, + axis: IndexAxis, + lo: i128, + hi: i128, + ) -> Self { + Self::new_unsized( + path, + Self::axis_read_node(AxisQuery::range_aggregate(axis, lo, hi)), + ) + } + + /// The same axis read fanned over N sibling branches: for each key + /// in `branch_keys` (selected under `prefix`), descend the shared + /// `suffix` to an indexed tree and perform `axis_query` on it. + /// + /// This is the query form of the branched indexed-axis proof: + /// `prefix / branch_key_i / suffix -> axis read`. + pub fn new_branched_axis( + prefix: Vec>, + branch_keys: Vec>, + suffix: Vec>, + axis_query: AxisQuery, + ) -> Self { + let mut query = Query::new(); + for key in branch_keys { + query.insert_key(key); + } + query.set_subquery_path(suffix); + query.set_subquery(Self::axis_read_node(axis_query)); + Self::new_unsized(prefix, query) + } + + /// A key-ordered read of `items` under `path` that stops once the + /// running sum of matched sum-item values reaches `sum_limit` — + /// the unified form of `AggregateSumPathQuery`. + pub fn new_sum_budget( + path: Vec>, + items: Vec, + left_to_right: bool, + sum_limit: u64, + max_items_checked: Option, + ) -> Self { + let mut query = Query::new_with_direction(left_to_right); + query.items = items; + query.read_mode = Some(Box::new(ReadMode::SumBudget(SumBudgetRead { + sum_limit, + max_items_checked, + }))); + Self::new_unsized(path, query) + } + + /// Whether this query — at any nesting level — carries a + /// [`ReadMode`]. Entry points that don't serve read modes use this + /// to fail closed instead of silently running a read-mode query as + /// plain key selection. + pub fn has_read_mode(&self) -> bool { + self.query.query.has_read_mode_anywhere() + } + + /// Fail-closed gate for entry points that don't (yet) serve + /// read-mode queries. Serving arrives with the unified read/prove + /// dispatch; until then every existing entry point rejects rather + /// than misreading an axis or sum-budget query as key selection. + pub(crate) fn reject_unserved_read_mode(&self) -> Result<(), Error> { + if self.has_read_mode() { + Err(Error::NotSupported( + "this entry point does not serve read-mode (axis / sum-budget) path queries" + .to_string(), + )) + } else { + Ok(()) + } + } + /// Validates that this `PathQuery` is a well-formed /// `AggregateCountOnRange` query in either the leaf or carrier shape. /// On success, returns a reference to the leaf inner range item. @@ -799,6 +939,20 @@ impl PathQuery { "merge function requires at least 1 path query", )); } + // Read-mode queries do not merge (yet): the underlying + // Query::merge_multiple machinery would silently drop the read + // mode and mangle an axis or sum-budget read into key + // selection. Merging sibling axis reads into the branched shape + // arrives with explicit read-mode merge rules. + if path_queries + .iter() + .any(|path_query| path_query.has_read_mode()) + { + return Err(Error::NotSupported( + "can not merge path queries carrying read modes (axis / sum-budget reads)" + .to_string(), + )); + } if path_queries.len() == 1 { return Ok(path_queries.remove(0).clone()); } @@ -2033,6 +2187,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; // Constructing the PathQuery @@ -2051,6 +2206,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -2216,6 +2372,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(100), offset: None, @@ -2288,6 +2445,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: Some(conditional_subquery_branches), add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(100), offset: None, @@ -2338,6 +2496,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, })), }, )]); @@ -2356,6 +2515,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, })), }, ), @@ -2374,6 +2534,7 @@ mod tests { ), left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, })), }, ), @@ -2391,6 +2552,7 @@ mod tests { conditional_subquery_branches: Some(conditional_subquery_branches.clone()), left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(100), offset: None, @@ -2520,6 +2682,7 @@ mod tests { conditional_subquery_branches: None, left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -2543,6 +2706,7 @@ mod tests { conditional_subquery_branches: None, left_to_right: false, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(10), offset: Some(2), @@ -2566,6 +2730,7 @@ mod tests { conditional_subquery_branches: None, left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(5), offset: None, @@ -2598,6 +2763,7 @@ mod tests { conditional_subquery_branches: Some(conditional_branches), left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -2642,6 +2808,7 @@ mod tests { conditional_subquery_branches: None, left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -2665,6 +2832,7 @@ mod tests { conditional_subquery_branches: None, left_to_right: false, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(100), offset: Some(10), @@ -2690,6 +2858,7 @@ mod tests { conditional_subquery_branches: None, left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, })), }, ); @@ -2703,6 +2872,7 @@ mod tests { conditional_subquery_branches: None, left_to_right: false, add_parent_tree_on_subquery: false, + read_mode: None, })), }, ); @@ -2716,6 +2886,7 @@ mod tests { conditional_subquery_branches: Some(conditional_branches), left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(50), offset: Some(5), @@ -2743,11 +2914,13 @@ mod tests { conditional_subquery_branches: None, left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, })), }, conditional_subquery_branches: None, left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -2771,6 +2944,7 @@ mod tests { conditional_subquery_branches: None, left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(20), offset: None, diff --git a/grovedb/src/query/shape.rs b/grovedb/src/query/shape.rs index cd5eaa0d7..ac54710be 100644 --- a/grovedb/src/query/shape.rs +++ b/grovedb/src/query/shape.rs @@ -30,7 +30,9 @@ //! provable tree) requires opening the merk and stays at execution / //! verification time. Classification is purely syntactic. -use grovedb_merk::proofs::query::query_item::QueryItem; +use grovedb_merk::proofs::query::{ + query_item::QueryItem, AxisQuery, ReadMode, SumBudgetRead as SumBudgetReadSpec, +}; use crate::{Error, PathQuery}; @@ -84,6 +86,34 @@ pub enum PathQueryShape<'q> { /// The wrapped range of the leaf aggregate inside the carrier. inner: &'q QueryItem, }, + /// An axis-ordered read of the indexed tree the path names: the + /// root query carries `ReadMode::Axis` and nothing else. + AxisRead { + /// The axis read to perform. + axis: &'q AxisQuery, + }, + /// The same axis read fanned over N sibling branch keys: root items + /// are `Key`s selecting the branches, the default subquery branch's + /// path is the shared suffix from each branch key to its indexed + /// tree, and the branch terminal carries `ReadMode::Axis`. + BranchedAxisRead { + /// The `Key` items naming the branches. + branch_items: &'q [QueryItem], + /// The shared path from each branch key to its indexed tree. + suffix: &'q [Vec], + /// The axis read performed under every branch. + axis: &'q AxisQuery, + }, + /// A key-ordered read of the root items that stops on a running-sum + /// budget (the read `AggregateSumPathQuery` serves): the root query + /// carries `ReadMode::SumBudget`. Trusted reads only until the + /// sum-budget proof shape lands. + SumBudget { + /// The stop condition. + budget: &'q SumBudgetReadSpec, + /// The key-ordered items the budget walk scans. + items: &'q [QueryItem], + }, } impl PathQueryShape<'_> { @@ -124,6 +154,13 @@ impl PathQuery { pub fn classify(&self) -> Result, Error> { let query = &self.query.query; + // Read modes are checked first: a query carrying one anywhere is + // one of the three read-mode shapes or malformed — it must never + // fall through and be served as key selection. + if query.has_read_mode_anywhere() { + return self.classify_read_mode_shape(); + } + if query.has_aggregate_count_on_range_anywhere() { // Leaf-vs-carrier is decided exactly the way the validator // dispatcher decides it: owning an aggregate item at the top @@ -183,6 +220,195 @@ impl PathQuery { Ok(PathQueryShape::KeySelection) } + + /// Grammar for queries that carry a [`ReadMode`] anywhere. Strict + /// v1 grammar — exactly three legal placements, everything else is + /// a typed error naming the violated rule. Looser placements + /// (conditional axis branches, range items over branch keys, + /// heterogeneous per-branch reads) can be admitted later; loosening + /// a grammar is additive, tightening one is a break. + fn classify_read_mode_shape(&self) -> Result, Error> { + let sized = &self.query; + let query = &sized.query; + + // No pagination in any read-mode shape: axis traversals carry + // their own caps (`k` / `limit`), and a sum budget is its own + // stop condition. + if sized.limit.is_some() { + return Err(Error::InvalidQuery( + "read-mode queries may not set SizedQuery::limit — the read mode carries its \ + own entry caps", + )); + } + if sized.offset.is_some() { + return Err(Error::InvalidQuery( + "read-mode queries may not set SizedQuery::offset — axis pagination is \ + expressed in the traversal (TopK.offset)", + )); + } + if query.add_parent_tree_on_subquery { + return Err(Error::InvalidQuery( + "read-mode queries may not set add_parent_tree_on_subquery", + )); + } + + match query.read_mode.as_deref() { + // Shape: single-path axis read. The axis query is the whole + // read; the node selects nothing by key. + Some(ReadMode::Axis(axis)) => { + if !query.items.is_empty() { + return Err(Error::InvalidQuery( + "an axis read carries no query items — the axis traversal is the \ + whole read", + )); + } + if query.default_subquery_branch.subquery.is_some() + || query.default_subquery_branch.subquery_path.is_some() + { + return Err(Error::InvalidQuery( + "an axis read carries no subquery branches — it is a terminal read \ + of the indexed tree the path names", + )); + } + if has_conditional_branches(query) { + return Err(Error::InvalidQuery( + "an axis read carries no conditional subquery branches", + )); + } + if self.path.is_empty() { + return Err(Error::InvalidQuery( + "an axis read's path names the indexed tree and cannot be empty — \ + the GroveDB root is always a NormalTree, never an indexed tree", + )); + } + axis.validate().map_err(read_mode_validation_error)?; + Ok(PathQueryShape::AxisRead { axis }) + } + // Shape: sum-budget read of this node's items. + Some(ReadMode::SumBudget(budget)) => { + if query.items.is_empty() { + return Err(Error::InvalidQuery( + "a sum-budget read needs at least one query item to walk", + )); + } + if query.items.iter().any(|item| { + matches!( + item, + QueryItem::AggregateCountOnRange(_) + | QueryItem::AggregateSumOnRange(_) + | QueryItem::AggregateCountAndSumOnRange(_) + ) + }) { + return Err(Error::InvalidQuery( + "a sum-budget read walks plain key/range items — aggregate wrappers \ + have their own query shapes", + )); + } + if query.default_subquery_branch.subquery.is_some() + || query.default_subquery_branch.subquery_path.is_some() + || has_conditional_branches(query) + { + return Err(Error::InvalidQuery( + "a sum-budget read carries no subquery branches — it walks one tree's \ + items in key order", + )); + } + budget.validate().map_err(read_mode_validation_error)?; + Ok(PathQueryShape::SumBudget { + budget, + items: &query.items, + }) + } + // The root has no read mode but something below does: only + // the branched-axis grammar is legal — branch keys at the + // root, one shared suffix, one axis terminal. + None => { + if has_conditional_branches(query) { + return Err(Error::InvalidQuery( + "read modes may not appear under conditional subquery branches — a \ + branched axis read uses Key items and the default subquery branch", + )); + } + if query.items.is_empty() + || !query + .items + .iter() + .all(|item| matches!(item, QueryItem::Key(_))) + { + return Err(Error::InvalidQuery( + "a branched axis read selects its branches with Key items only \ + (at least one)", + )); + } + let branch = &query.default_subquery_branch; + let Some(suffix) = branch.subquery_path.as_deref() else { + return Err(Error::InvalidQuery( + "a branched axis read requires a non-empty subquery_path — the \ + shared suffix from each branch key to its indexed tree", + )); + }; + if suffix.is_empty() || suffix.iter().any(|segment| segment.is_empty()) { + return Err(Error::InvalidQuery( + "a branched axis read's suffix must be non-empty and contain \ + non-empty keys", + )); + } + let Some(inner) = branch.subquery.as_deref() else { + return Err(Error::InvalidQuery( + "a branched axis read requires the default subquery branch to carry \ + the axis-read terminal", + )); + }; + match inner.read_mode.as_deref() { + Some(ReadMode::Axis(axis)) => { + if !inner.items.is_empty() + || inner.default_subquery_branch.subquery.is_some() + || inner.default_subquery_branch.subquery_path.is_some() + || has_conditional_branches(inner) + || inner.add_parent_tree_on_subquery + { + return Err(Error::InvalidQuery( + "a branched axis read's terminal carries only the axis read — \ + no items, subquery branches, or parent-tree flag", + )); + } + axis.validate().map_err(read_mode_validation_error)?; + Ok(PathQueryShape::BranchedAxisRead { + branch_items: &query.items, + suffix, + axis, + }) + } + Some(ReadMode::SumBudget(_)) => Err(Error::InvalidQuery( + "a sum-budget read may only appear at the root query, not under \ + branch keys", + )), + None => Err(Error::InvalidQuery( + "read modes may nest at most one level deep: branch keys at the \ + root, one suffix, one axis-read terminal", + )), + } + } + } + } +} + +/// Whether the query has a non-empty conditional-subquery-branch map. +fn has_conditional_branches(query: &grovedb_merk::proofs::Query) -> bool { + query + .conditional_subquery_branches + .as_ref() + .is_some_and(|branches| !branches.is_empty()) +} + +/// Projects the vocabulary crate's validation error (always +/// `InvalidOperation(&'static str)`) into this crate's `InvalidQuery`, +/// preserving the message. +fn read_mode_validation_error(e: grovedb_query::error::Error) -> Error { + match e { + grovedb_query::error::Error::InvalidOperation(msg) => Error::InvalidQuery(msg), + _ => Error::InvalidQuery("read-mode validation failed"), + } } #[cfg(test)] @@ -472,6 +698,193 @@ mod tests { .expect_err("aggregate hidden beside another kind must fail"); } + // ---------- Read-mode shapes ---------- + + use grovedb_merk::proofs::query::{AxisQuery, IndexAxis, ReadMode}; + + #[test] + fn axis_constructors_classify_as_axis_read() { + let queries = [ + PathQuery::new_axis_top_k(path(), IndexAxis::Count, 5, 0, true), + PathQuery::new_axis_bounded(path(), IndexAxis::Sum, -10, 10, 3, false), + PathQuery::new_axis_rank_of_key(path(), IndexAxis::Avg, b"alice".to_vec(), true), + PathQuery::new_axis_range_aggregate(path(), IndexAxis::Sum, 0, 100), + ]; + for pq in queries { + match pq.classify().expect("axis constructor must classify") { + PathQueryShape::AxisRead { axis } => { + axis.validate().expect("constructed axis must validate"); + } + other => panic!("expected AxisRead, got {other:?} for {pq}"), + } + } + } + + #[test] + fn branched_axis_constructor_classifies_with_its_parts() { + let pq = PathQuery::new_branched_axis( + vec![b"contracts".to_vec()], + vec![b"alice".to_vec(), b"bob".to_vec()], + vec![b"scores".to_vec()], + AxisQuery::top_k(IndexAxis::Count, 3, 0, true), + ); + match pq.classify().expect("branched constructor must classify") { + PathQueryShape::BranchedAxisRead { + branch_items, + suffix, + axis, + } => { + assert_eq!(branch_items.len(), 2); + assert_eq!(suffix, &[b"scores".to_vec()]); + assert_eq!(axis.axis, IndexAxis::Count); + } + other => panic!("expected BranchedAxisRead, got {other:?}"), + } + } + + #[test] + fn sum_budget_constructor_classifies_with_its_parts() { + let pq = PathQuery::new_sum_budget(path(), vec![range_item()], true, 500, Some(20)); + match pq.classify().expect("sum-budget constructor must classify") { + PathQueryShape::SumBudget { budget, items } => { + assert_eq!(budget.sum_limit, 500); + assert_eq!(budget.max_items_checked, Some(20)); + assert_eq!(items.len(), 1); + } + other => panic!("expected SumBudget, got {other:?}"), + } + } + + #[test] + fn read_mode_grammar_rejections_name_the_violated_rule() { + use grovedb_merk::proofs::query::SumBudgetRead; + + fn axis_node() -> Query { + let mut q = Query::new(); + q.read_mode = Some(Box::new(ReadMode::Axis(AxisQuery::top_k( + IndexAxis::Count, + 1, + 0, + true, + )))); + q + } + + let cases: Vec<(&str, PathQuery)> = vec![ + ("axis read carries items", { + let mut q = axis_node(); + q.items.push(QueryItem::Key(b"k".to_vec())); + PathQuery::new_unsized(path(), q) + }), + ("axis read carries a subquery", { + let mut q = axis_node(); + q.set_subquery(Query::new_single_key(b"x".to_vec())); + PathQuery::new_unsized(path(), q) + }), + ("axis read at the root merk", { + PathQuery::new_unsized(vec![], axis_node()) + }), + ("axis read with a limit", { + PathQuery::new(path(), SizedQuery::new(axis_node(), Some(1), None)) + }), + ("axis read with an offset", { + PathQuery::new(path(), SizedQuery::new(axis_node(), None, Some(1))) + }), + ("axis read with parent-tree flag", { + let mut q = axis_node(); + q.add_parent_tree_on_subquery = true; + PathQuery::new_unsized(path(), q) + }), + ("invalid axis payload (k = 0)", { + let mut q = Query::new(); + q.read_mode = Some(Box::new(ReadMode::Axis(AxisQuery::top_k( + IndexAxis::Count, + 0, + 0, + true, + )))); + PathQuery::new_unsized(path(), q) + }), + ("range aggregate on the Avg axis", { + PathQuery::new_axis_range_aggregate(path(), IndexAxis::Avg, 0, 10) + }), + ("branched: range item selecting branches", { + let mut q = Query::new_single_query_item(range_item()); + q.set_subquery_path(vec![b"s".to_vec()]); + q.set_subquery(axis_node()); + PathQuery::new_unsized(path(), q) + }), + ("branched: missing suffix", { + let mut q = Query::new_single_key(b"b".to_vec()); + q.set_subquery(axis_node()); + PathQuery::new_unsized(path(), q) + }), + ("branched: empty suffix segment", { + let mut q = Query::new_single_key(b"b".to_vec()); + q.set_subquery_path(vec![b"".to_vec()]); + q.set_subquery(axis_node()); + PathQuery::new_unsized(path(), q) + }), + ("branched: terminal carries items", { + let mut terminal = axis_node(); + terminal.items.push(QueryItem::Key(b"k".to_vec())); + let mut q = Query::new_single_key(b"b".to_vec()); + q.set_subquery_path(vec![b"s".to_vec()]); + q.set_subquery(terminal); + PathQuery::new_unsized(path(), q) + }), + ("sum budget below the root", { + let mut terminal = Query::new_single_query_item(range_item()); + terminal.read_mode = Some(Box::new(ReadMode::SumBudget(SumBudgetRead { + sum_limit: 1, + max_items_checked: None, + }))); + let mut q = Query::new_single_key(b"b".to_vec()); + q.set_subquery_path(vec![b"s".to_vec()]); + q.set_subquery(terminal); + PathQuery::new_unsized(path(), q) + }), + ("read mode two levels deep", { + let mut middle = Query::new_single_key(b"m".to_vec()); + middle.set_subquery_path(vec![b"s".to_vec()]); + middle.set_subquery(axis_node()); + let mut q = Query::new_single_key(b"b".to_vec()); + q.set_subquery_path(vec![b"t".to_vec()]); + q.set_subquery(middle); + PathQuery::new_unsized(path(), q) + }), + ("read mode under a conditional branch", { + let mut q = Query::new_single_key(b"b".to_vec()); + q.add_conditional_subquery(QueryItem::Key(b"b".to_vec()), None, Some(axis_node())); + PathQuery::new_unsized(path(), q) + }), + ("sum budget without items", { + PathQuery::new_sum_budget(path(), vec![], true, 1, None) + }), + ("sum budget over an aggregate item", { + PathQuery::new_sum_budget( + path(), + vec![QueryItem::AggregateSumOnRange(Box::new(range_item()))], + true, + 1, + None, + ) + }), + ("sum budget of zero", { + PathQuery::new_sum_budget(path(), vec![range_item()], true, 0, None) + }), + ]; + for (label, pq) in cases { + match pq.classify() { + Err(Error::InvalidQuery(_)) => {} + Err(other) => { + panic!("case {label:?}: expected InvalidQuery, got {other:?}") + } + Ok(shape) => panic!("case {label:?}: must be rejected, classified as {shape:?}"), + } + } + } + // ---------- Totality ---------- #[test] @@ -495,11 +908,37 @@ mod tests { QueryItem::AggregateSumOnRange(Box::new(range_item())), ], ]; + let axis_terminal = { + let mut q = Query::new(); + q.read_mode = Some(Box::new(ReadMode::Axis(AxisQuery::top_k( + IndexAxis::Sum, + 2, + 0, + true, + )))); + q + }; let subqueries: Vec> = vec![ None, Some(Query::new_single_key(b"inner".to_vec())), Some(leaf_aggregate_query(AggregateKind::Count)), Some(leaf_aggregate_query(AggregateKind::Sum)), + Some(axis_terminal.clone()), + ]; + let read_modes: Vec>> = vec![ + None, + Some(Box::new(ReadMode::Axis(AxisQuery::top_k( + IndexAxis::Count, + 1, + 0, + false, + )))), + Some(Box::new(ReadMode::SumBudget( + grovedb_merk::proofs::query::SumBudgetRead { + sum_limit: 10, + max_items_checked: None, + }, + ))), ]; let limits = [None, Some(0u16), Some(7)]; let offsets = [None, Some(0u16), Some(7)]; @@ -509,20 +948,30 @@ mod tests { let mut rejected = 0usize; for items in &item_sets { for subquery in &subqueries { - for &limit in &limits { - for &offset in &offsets { - for p in &paths { - let mut q = Query::new(); - q.items = items.clone(); - if let Some(sub) = subquery { - q.set_subquery(sub.clone()); - } - let pq = PathQuery::new(p.clone(), SizedQuery::new(q, limit, offset)); - match pq.classify() { - Ok(_) => classified += 1, - Err(Error::InvalidQuery(_)) => rejected += 1, - Err(other) => { - panic!("classify must only fail with InvalidQuery, got {other:?} for {pq}") + for read_mode in &read_modes { + for &limit in &limits { + for &offset in &offsets { + for p in &paths { + let mut q = Query::new(); + q.items = items.clone(); + if let Some(sub) = subquery { + q.set_subquery(sub.clone()); + if matches!(sub.read_mode.as_deref(), Some(ReadMode::Axis(_))) { + q.set_subquery_path(vec![b"suffix".to_vec()]); + } + } + q.read_mode = read_mode.clone(); + let pq = + PathQuery::new(p.clone(), SizedQuery::new(q, limit, offset)); + match pq.classify() { + Ok(_) => classified += 1, + Err(Error::InvalidQuery(_)) => rejected += 1, + Err(other) => { + panic!( + "classify must only fail with InvalidQuery, got \ + {other:?} for {pq}" + ) + } } } } diff --git a/grovedb/src/tests/commitment_tree_tests.rs b/grovedb/src/tests/commitment_tree_tests.rs index 2d76f08b4..c71c8f301 100644 --- a/grovedb/src/tests/commitment_tree_tests.rs +++ b/grovedb/src/tests/commitment_tree_tests.rs @@ -1561,6 +1561,7 @@ fn test_commitment_tree_prove_query_v1_empty() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1662,6 +1663,7 @@ fn test_commitment_tree_prove_query_v1_buffer_only() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1788,6 +1790,7 @@ fn test_commitment_tree_prove_query_v1_with_chunks() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1906,6 +1909,7 @@ fn test_commitment_tree_prove_query_v1_partial_range() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -2812,6 +2816,7 @@ fn test_commitment_tree_element_count_subset_query_against_note_fetch_proof() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, diff --git a/grovedb/src/tests/coverage_proof_generate_tests.rs b/grovedb/src/tests/coverage_proof_generate_tests.rs index d8f650b71..a76a7a82f 100644 --- a/grovedb/src/tests/coverage_proof_generate_tests.rs +++ b/grovedb/src/tests/coverage_proof_generate_tests.rs @@ -157,6 +157,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit, offset: None, diff --git a/grovedb/src/tests/dense_tree_tests.rs b/grovedb/src/tests/dense_tree_tests.rs index 86967d23f..1120eed04 100644 --- a/grovedb/src/tests/dense_tree_tests.rs +++ b/grovedb/src/tests/dense_tree_tests.rs @@ -1150,6 +1150,7 @@ fn test_dense_tree_v1_proof_range_query() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1258,6 +1259,7 @@ fn test_dense_tree_v1_proof_single_position() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1346,6 +1348,7 @@ fn test_dense_tree_v1_proof_multiple_disjoint_positions() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1456,6 +1459,7 @@ fn test_dense_tree_v1_proof_nested_in_tree() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1544,6 +1548,7 @@ fn test_dense_tree_v1_proof_with_limit() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(3), offset: None, diff --git a/grovedb/src/tests/mmr_tree_tests.rs b/grovedb/src/tests/mmr_tree_tests.rs index 9a1fe32c6..5c5d75d77 100644 --- a/grovedb/src/tests/mmr_tree_tests.rs +++ b/grovedb/src/tests/mmr_tree_tests.rs @@ -1614,6 +1614,7 @@ fn test_mmr_tree_v1_proof_empty() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index 7f130cb0a..cc51649c3 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -81,6 +81,7 @@ mod provable_sum_indexed_tree_tests; mod provable_sum_tree_tests; mod query_indexed_tree_dispatch_tests; mod query_result_type_tests; +mod read_mode_gate_tests; mod reference_path_tests; mod reference_with_sum_item_tests; mod replication_session_tests; @@ -4696,6 +4697,7 @@ mod general_tests { conditional_subquery_branches: None, left_to_right: true, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, diff --git a/grovedb/src/tests/proof_coverage_tests.rs b/grovedb/src/tests/proof_coverage_tests.rs index 386258658..27b452392 100644 --- a/grovedb/src/tests/proof_coverage_tests.rs +++ b/grovedb/src/tests/proof_coverage_tests.rs @@ -1176,6 +1176,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: true, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); @@ -1441,6 +1442,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![], query); @@ -1780,6 +1782,7 @@ mod tests { default_subquery_branch: SubqueryBranch::default(), conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new( vec![TEST_LEAF.to_vec()], @@ -1848,6 +1851,7 @@ mod tests { default_subquery_branch: SubqueryBranch::default(), conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new( vec![b"tree".to_vec()], @@ -2751,6 +2755,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: true, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![b"root".to_vec()], query); @@ -2831,6 +2836,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: true, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![b"root".to_vec()], query); @@ -2914,6 +2920,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![b"root".to_vec()], query); @@ -2995,6 +3002,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![b"root".to_vec()], query); @@ -3075,6 +3083,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![b"root".to_vec()], query); @@ -3201,6 +3210,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: Some(conditional), add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![b"root".to_vec()], query); @@ -3316,6 +3326,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: Some(conditional), add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![b"root".to_vec()], query); @@ -3410,6 +3421,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![b"root".to_vec()], query); @@ -3571,6 +3583,7 @@ mod tests { left_to_right: false, // right to left conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new( vec![b"root".to_vec()], @@ -3956,6 +3969,7 @@ mod tests { default_subquery_branch: SubqueryBranch::default(), conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new( vec![TEST_LEAF.to_vec()], @@ -4015,6 +4029,7 @@ mod tests { default_subquery_branch: SubqueryBranch::default(), conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new( vec![b"tree".to_vec()], @@ -4269,6 +4284,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![], query); @@ -4318,6 +4334,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let path_query = PathQuery::new_unsized(vec![], query); @@ -5183,6 +5200,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; PathQuery::new_unsized(vec![b"root".to_vec()], query) } @@ -5352,6 +5370,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; let query = Query { items: vec![QueryItem::Key(tree_key.to_vec())], @@ -5362,6 +5381,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }; PathQuery::new_unsized(vec![b"root".to_vec()], query) } diff --git a/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs b/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs index 5cd76a458..a7fbd9fa2 100644 --- a/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs +++ b/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs @@ -2182,6 +2182,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -2206,6 +2207,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, diff --git a/grovedb/src/tests/provable_sum_indexed_tree_tests.rs b/grovedb/src/tests/provable_sum_indexed_tree_tests.rs index b168ea635..0e88d68c7 100644 --- a/grovedb/src/tests/provable_sum_indexed_tree_tests.rs +++ b/grovedb/src/tests/provable_sum_indexed_tree_tests.rs @@ -1663,6 +1663,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1686,6 +1687,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1796,6 +1798,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, diff --git a/grovedb/src/tests/query_tests.rs b/grovedb/src/tests/query_tests.rs index 702f8b7cf..f9583f1a7 100644 --- a/grovedb/src/tests/query_tests.rs +++ b/grovedb/src/tests/query_tests.rs @@ -3492,6 +3492,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3539,6 +3540,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3586,6 +3588,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3633,6 +3636,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3680,6 +3684,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3727,6 +3732,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3776,6 +3782,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3825,6 +3832,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3874,6 +3882,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3923,6 +3932,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -3972,6 +3982,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4021,6 +4032,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4070,6 +4082,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4119,6 +4132,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4171,6 +4185,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4223,6 +4238,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4275,6 +4291,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4327,6 +4344,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4379,6 +4397,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4431,6 +4450,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4483,6 +4503,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4535,6 +4556,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4587,6 +4609,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4639,6 +4662,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4691,6 +4715,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4743,6 +4768,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4795,6 +4821,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4853,6 +4880,7 @@ mod tests { }, )])), add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4915,6 +4943,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, @@ -4967,6 +4996,7 @@ mod tests { left_to_right: false, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: Some(2), offset: None, diff --git a/grovedb/src/tests/read_mode_gate_tests.rs b/grovedb/src/tests/read_mode_gate_tests.rs new file mode 100644 index 000000000..98a4a6d9c --- /dev/null +++ b/grovedb/src/tests/read_mode_gate_tests.rs @@ -0,0 +1,169 @@ +//! Fail-closed gates for read-mode (axis / sum-budget) path queries. +//! +//! The read-mode vocabulary can be constructed and round-tripped, but +//! no entry point serves it yet. Until the unified dispatch lands, +//! every existing read / prove / verify / merge entry point must +//! reject a read-mode query with a typed `NotSupported` — never run it +//! as plain key selection (an axis read has empty items, so key +//! selection would return an empty result indistinguishable from real +//! absence, and a proof would attest to the wrong read entirely). + +use grovedb_merk::proofs::query::{AxisQuery, IndexAxis}; +use grovedb_version::version::GroveVersion; + +use crate::{ + query_result_type::QueryResultType, tests::make_empty_grovedb, Error, GroveDb, PathQuery, +}; + +fn axis_path_query() -> PathQuery { + PathQuery::new_axis_top_k(vec![b"tree".to_vec()], IndexAxis::Count, 3, 0, true) +} + +fn sum_budget_path_query() -> PathQuery { + PathQuery::new_sum_budget( + vec![b"tree".to_vec()], + vec![grovedb_merk::proofs::query::query_item::QueryItem::RangeFull(..)], + true, + 100, + None, + ) +} + +fn branched_axis_path_query() -> PathQuery { + PathQuery::new_branched_axis( + vec![b"contracts".to_vec()], + vec![b"alice".to_vec(), b"bob".to_vec()], + vec![b"scores".to_vec()], + AxisQuery::top_k(IndexAxis::Sum, 2, 0, true), + ) +} + +fn all_read_mode_queries() -> Vec { + vec![ + axis_path_query(), + sum_budget_path_query(), + branched_axis_path_query(), + ] +} + +fn assert_not_supported(result: Result, entry_point: &str) { + match result { + Err(Error::NotSupported(_)) => {} + Err(other) => panic!("{entry_point}: expected NotSupported, got {other:?}"), + Ok(value) => panic!("{entry_point}: read-mode query must be rejected, got {value:?}"), + } +} + +#[test] +fn query_raw_rejects_read_mode_queries() { + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + for path_query in all_read_mode_queries() { + assert_not_supported( + db.query_raw( + &path_query, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap(), + "query_raw", + ); + } +} + +#[test] +fn prove_query_rejects_read_mode_queries() { + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + for path_query in all_read_mode_queries() { + assert_not_supported( + db.prove_query(&path_query, None, grove_version).unwrap(), + "prove_query", + ); + } +} + +#[test] +fn verify_query_rejects_read_mode_queries_before_touching_the_proof() { + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + db.insert( + grovedb_path::SubtreePath::empty(), + b"tree", + crate::Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert tree"); + + // A real proof over a plain query — then verified against a + // read-mode query. The read-mode gate must fire before any proof + // decoding or query walking. + let plain = PathQuery::new_single_key(vec![b"tree".to_vec()], b"key".to_vec()); + let proof = db + .prove_query(&plain, None, grove_version) + .unwrap() + .expect("plain proof must generate"); + + for path_query in all_read_mode_queries() { + assert_not_supported( + GroveDb::verify_query(&proof, &path_query, grove_version), + "verify_query", + ); + assert_not_supported( + GroveDb::verify_query_raw(&proof, &path_query, grove_version), + "verify_query_raw", + ); + assert_not_supported( + GroveDb::verify_subset_query(&proof, &path_query, grove_version), + "verify_subset_query", + ); + } +} + +#[test] +fn merge_rejects_read_mode_queries() { + let grove_version = GroveVersion::latest(); + let plain = PathQuery::new_single_key(vec![b"tree".to_vec()], b"key".to_vec()); + for path_query in all_read_mode_queries() { + assert_not_supported( + PathQuery::merge(vec![&plain, &path_query], grove_version), + "merge", + ); + } + // The single-query short-circuit must also refuse: merging one + // read-mode query "successfully" would hand callers a clone they + // then feed to entry points expecting merged key selection. + let axis = axis_path_query(); + assert_not_supported( + PathQuery::merge(vec![&axis], grove_version), + "merge(single)", + ); +} + +#[test] +fn query_many_raw_rejects_read_mode_queries() { + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + let plain = PathQuery::new_single_key(vec![b"tree".to_vec()], b"key".to_vec()); + let axis = axis_path_query(); + assert_not_supported( + db.query_many_raw( + &[&plain, &axis], + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap(), + "query_many_raw", + ); +} diff --git a/grovedb/src/tests/reference_with_sum_item_tests.rs b/grovedb/src/tests/reference_with_sum_item_tests.rs index 7d026a32a..a2b1ebbab 100644 --- a/grovedb/src/tests/reference_with_sum_item_tests.rs +++ b/grovedb/src/tests/reference_with_sum_item_tests.rs @@ -2089,6 +2089,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, ); diff --git a/grovedb/src/tests/v1_cidx_descent_tests.rs b/grovedb/src/tests/v1_cidx_descent_tests.rs index ebbf90d22..8542b553c 100644 --- a/grovedb/src/tests/v1_cidx_descent_tests.rs +++ b/grovedb/src/tests/v1_cidx_descent_tests.rs @@ -76,6 +76,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -256,6 +257,7 @@ mod tests { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, diff --git a/grovedb/src/tests/v1_proof_tests.rs b/grovedb/src/tests/v1_proof_tests.rs index 170535f76..152d1ba84 100644 --- a/grovedb/src/tests/v1_proof_tests.rs +++ b/grovedb/src/tests/v1_proof_tests.rs @@ -63,6 +63,7 @@ fn test_mmr_tree_v1_proof_single_leaf() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -151,6 +152,7 @@ fn test_mmr_tree_v1_proof_multiple_leaves() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -238,6 +240,7 @@ fn test_mmr_tree_v1_proof_wrong_root_detection() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -313,6 +316,7 @@ fn test_bulk_append_tree_v1_proof_buffer_range() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -398,6 +402,7 @@ fn test_bulk_append_tree_v1_proof_chunk_and_buffer() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -494,6 +499,7 @@ fn test_v1_proof_nested_path_with_mmr() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -573,6 +579,7 @@ fn test_v1_proof_serialization_roundtrip() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -661,6 +668,7 @@ fn test_dense_tree_v1_proof_serialization_roundtrip() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -793,6 +801,7 @@ fn test_bulk_append_tree_v1_proof_disjoint_query_succinctness() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -895,6 +904,7 @@ fn test_dense_tree_v1_proof_disjoint_query_succinctness() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -996,6 +1006,7 @@ fn test_mmr_tree_v1_proof_disjoint_query_succinctness() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1107,6 +1118,7 @@ fn test_v0_proof_rejects_mmr_tree_subquery() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1186,6 +1198,7 @@ fn test_v0_proof_rejects_bulk_append_tree_subquery() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1270,6 +1283,7 @@ fn test_v0_proof_rejects_dense_tree_subquery() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1364,6 +1378,7 @@ fn test_v1_proof_supports_count_indexed_tree_subquery() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1451,6 +1466,7 @@ fn test_v1_proof_count_indexed_tree_subquery_with_add_parent_tree() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: true, + read_mode: None, }, limit: None, offset: None, @@ -1531,6 +1547,7 @@ fn pcit_v1_subquery_fixture() -> (TempGroveDb, Vec, PathQuery) { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None, @@ -1669,6 +1686,7 @@ fn test_v0_proof_rejects_count_indexed_tree_subquery() { left_to_right: true, conditional_subquery_branches: None, add_parent_tree_on_subquery: false, + read_mode: None, }, limit: None, offset: None,