diff --git a/.github/package-filters/rs-packages-direct.yml b/.github/package-filters/rs-packages-direct.yml index 441c8137023..0ea1d150f9f 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -83,6 +83,9 @@ simple-signer: strategy-tests: - packages/strategy-tests/** +platform-query-wire: + - packages/rs-platform-query-wire/** + drive-abci: - packages/rs-drive-abci/** diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index 90835d0429f..ac56d193773 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -86,11 +86,16 @@ strategy-tests: &strategy-tests - *simple-signer - *drive +platform-query-wire: &platform_query_wire + - packages/rs-platform-query-wire/** + - *drive + drive-abci: - packages/rs-drive-abci/** - *drive - *simple-signer - *strategy-tests + - *platform_query_wire dash-async: &dash_async - packages/rs-dash-async/** diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 6fae2aa84ab..79ca9186615 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -102,12 +102,18 @@ strategy-tests: &strategy-tests - *simple-signer - *drive +platform-query-wire: &platform_query_wire + - .github/workflows/tests* + - packages/rs-platform-query-wire/** + - *drive + drive-abci: - .github/workflows/tests* - packages/rs-drive-abci/** - *drive - *simple-signer - *strategy-tests + - *platform_query_wire dash-async: &dash_async - .github/workflows/tests* diff --git a/Cargo.lock b/Cargo.lock index 92026cb5b5d..332b7db6a4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2254,6 +2254,7 @@ dependencies = [ "metrics-exporter-prometheus", "mockall", "nonempty", + "platform-query-wire", "platform-version", "prost 0.14.4", "rand 0.8.6", @@ -5144,6 +5145,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "platform-query-wire" +version = "4.2.0-dev.1" +dependencies = [ + "dapi-grpc", + "dpp", + "drive", + "thiserror 2.0.18", +] + [[package]] name = "platform-serialization" version = "4.2.0-dev.1" diff --git a/Cargo.toml b/Cargo.toml index 23dcd8d051c..0b10cd9e188 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "packages/rs-dash-platform-macros", "packages/rs-dpp", "packages/rs-drive", + "packages/rs-platform-query-wire", "packages/rs-platform-value", "packages/rs-platform-serialization", "packages/rs-platform-serialization-derive", diff --git a/Dockerfile b/Dockerfile index 846150203f7..6180dd691ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -387,6 +387,7 @@ COPY --parents \ packages/rs-platform-serialization-derive \ packages/rs-platform-version \ packages/rs-platform-versioning \ + packages/rs-platform-query-wire \ packages/rs-platform-value-convertible \ packages/rs-platform-wallet-ffi \ packages/rs-drive-abci \ @@ -515,6 +516,7 @@ COPY --parents \ packages/rs-platform-serialization-derive \ packages/rs-platform-version \ packages/rs-platform-versioning \ + packages/rs-platform-query-wire \ packages/rs-platform-value-convertible \ packages/rs-platform-wallet-ffi \ packages/rs-drive-abci \ @@ -681,6 +683,7 @@ COPY --parents \ packages/rs-platform-serialization-derive \ packages/rs-platform-version \ packages/rs-platform-versioning \ + packages/rs-platform-query-wire \ packages/rs-platform-value-convertible \ packages/rs-platform-wallet-ffi \ packages/rs-unified-sdk-ffi \ @@ -939,6 +942,7 @@ COPY --parents \ packages/rs-platform-serialization-derive \ packages/rs-platform-version \ packages/rs-platform-versioning \ + packages/rs-platform-query-wire \ packages/rs-platform-value-convertible \ packages/rs-platform-wallet-ffi \ packages/rs-drive-abci \ diff --git a/packages/rs-drive-abci/Cargo.toml b/packages/rs-drive-abci/Cargo.toml index 23069f0158e..ea7aa18e745 100644 --- a/packages/rs-drive-abci/Cargo.toml +++ b/packages/rs-drive-abci/Cargo.toml @@ -42,6 +42,7 @@ dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ "server", "platform", ] } +platform-query-wire = { path = "../rs-platform-query-wire" } tracing-subscriber = { version = "0.3.22", default-features = false, features = [ "env-filter", "ansi", diff --git a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs index fa5c4da140b..7ada45f547f 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs @@ -1,367 +1,77 @@ //! Wire-protobuf → drive type conversions for the v1 document //! query surface. //! -//! Lives next to the v1 handler because rs-drive-abci is the only -//! crate that needs the proto-decode direction (the SDK ships the -//! inverse direction in -//! `rs-sdk/src/platform/documents/document_query.rs`). Keeping the -//! two directions in their respective crates avoids forcing -//! `dapi-grpc` into rs-drive's dependency graph just to host shared -//! conversion code. -//! -//! Conversion contract: -//! - Every fallible case maps to [`QueryError::InvalidArgument`] -//! (malformed wire input, **not** future capability). The v1 -//! handler distinguishes this from -//! [`QuerySyntaxError::Unsupported`] (valid request shape, server -//! capability not yet wired) — see `v1/mod.rs`'s +//! The decode logic itself lives in +//! `platform_query_wire::proto_conversions`, shared with client-side +//! proof verifiers so the bytes the server decodes and the bytes a +//! verifier decodes cannot drift. This module only maps the shared +//! crate's neutral [`DecodeError`] onto this crate's [`QueryError`] +//! surface: +//! - [`DecodeError::InvalidArgument`] (malformed wire input) → +//! [`QueryError::InvalidArgument`]. The v1 handler distinguishes +//! this from [`QuerySyntaxError::Unsupported`] (valid request +//! shape, server capability not yet wired) — see `v1/mod.rs`'s //! `not_yet_implemented` helper. -//! - Conversion is schema-agnostic. `DocumentFieldValue` variants -//! map 1:1 to `dpp::platform_value::Value` variants without -//! consulting the document type's schema. The schema-driven -//! coercion (`document_type.serialize_value_for_key`) runs -//! downstream as it does for the CBOR-shaped v0 path — a `text` -//! variant against an identifier field decodes via base58, a -//! `bytes_value` against the same field decodes as raw 32-byte -//! identifier, and so on. The wire layer just names the -//! primitive; the schema decides the indexed type. +//! - [`DecodeError::Unsupported`] (well-formed shape the decoder +//! deliberately refuses, e.g. `ORDER BY` on aggregate keys) → +//! [`QueryError::Query`]\([`QuerySyntaxError::Unsupported`]\). +//! +//! Both mappings preserve the exact message strings this module +//! produced when it owned the decode logic, so the server's error +//! surface is unchanged. use crate::error::query::QueryError; use dapi_grpc::platform::v0::get_documents_request::{ - document_field_value, - get_documents_request_v1::{select, Select as ProtoSelect}, - having_aggregate, having_clause, order_clause, DocumentFieldValue as ProtoDocumentFieldValue, - HavingAggregate as ProtoHavingAggregate, HavingClause as ProtoHavingClause, + get_documents_request_v1::Select as ProtoSelect, HavingClause as ProtoHavingClause, OrderClause as ProtoOrderClause, WhereClause as ProtoWhereClause, - WhereOperator as ProtoWhereOperator, }; -use dpp::platform_value::Value; -use drive::query::{ - HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, - OrderClause, SelectFunction, SelectProjection, WhereClause, WhereOperator, -}; - -/// Map a wire-level [`ProtoWhereOperator`] discriminant onto -/// drive's [`WhereOperator`]. Unknown discriminants are wire-level -/// garbage (no future protocol value would map a malformed integer -/// to a valid behavior), so they surface as -/// [`QueryError::InvalidArgument`] — not `not_yet_implemented`. -pub(super) fn where_operator_from_proto(op: i32) -> Result { - let proto_op = ProtoWhereOperator::try_from(op).map_err(|_| { - QueryError::InvalidArgument(format!( - "unknown WhereOperator discriminant: {} (valid values: 0..=10, see \ - `get_documents_request::WhereOperator`)", - op - )) - })?; - Ok(match proto_op { - ProtoWhereOperator::Equal => WhereOperator::Equal, - ProtoWhereOperator::GreaterThan => WhereOperator::GreaterThan, - ProtoWhereOperator::GreaterThanOrEquals => WhereOperator::GreaterThanOrEquals, - ProtoWhereOperator::LessThan => WhereOperator::LessThan, - ProtoWhereOperator::LessThanOrEquals => WhereOperator::LessThanOrEquals, - ProtoWhereOperator::Between => WhereOperator::Between, - ProtoWhereOperator::BetweenExcludeBounds => WhereOperator::BetweenExcludeBounds, - ProtoWhereOperator::BetweenExcludeLeft => WhereOperator::BetweenExcludeLeft, - ProtoWhereOperator::BetweenExcludeRight => WhereOperator::BetweenExcludeRight, - ProtoWhereOperator::In => WhereOperator::In, - ProtoWhereOperator::StartsWith => WhereOperator::StartsWith, - }) -} - -/// Map a wire [`ProtoDocumentFieldValue`] onto a -/// `dpp::platform_value::Value`. Schema-agnostic — variants map -/// 1:1 by primitive type and recurse for `list` up to a depth of -/// 1 (the only nesting level the query surface needs: `IN` / -/// `BETWEEN*` take a flat list of scalars). Anything deeper is -/// rejected as malformed wire input rather than recursed into, -/// so a hostile client can't blow the call stack with -/// `list(list(list(...)))` before schema validation. -/// -/// `None` (oneof unset on the wire) is rejected — a where-clause -/// operand is always concrete; empty where-clauses are expressed -/// by an empty `where_clauses` field at the request level, not by -/// sending an empty `DocumentFieldValue`. -pub(super) fn value_from_proto(value: ProtoDocumentFieldValue) -> Result { - value_from_proto_at_depth(value, 0) -} - -/// Recursion-bounded form of [`value_from_proto`]. `depth = 0` is -/// the request-level operand; the only legal child shape is a -/// flat list (`depth = 1` for `IN` / `BETWEEN*` candidates), so a -/// `list` encountered at `depth >= 1` is wire-malformed. -fn value_from_proto_at_depth( - value: ProtoDocumentFieldValue, - depth: u8, -) -> Result { - let variant = value.variant.ok_or_else(|| { - QueryError::InvalidArgument( - "DocumentFieldValue has no variant set; a where-clause operand must \ - be a concrete value" - .to_string(), - ) - })?; - Ok(match variant { - document_field_value::Variant::BoolValue(b) => Value::Bool(b), - document_field_value::Variant::Int64Value(i) => Value::I64(i), - document_field_value::Variant::Uint64Value(u) => Value::U64(u), - document_field_value::Variant::DoubleValue(f) => Value::Float(f), - document_field_value::Variant::Text(s) => Value::Text(s), - document_field_value::Variant::BytesValue(b) => Value::Bytes(b), - document_field_value::Variant::List(list) => { - if depth >= 1 { - return Err(QueryError::InvalidArgument( - "nested DocumentFieldValue.list is not supported; the v1 \ - query surface accepts at most one level of nesting \ - (`IN` / `BETWEEN*` candidate lists of scalars)" - .to_string(), - )); - } - Value::Array( - list.values - .into_iter() - .map(|v| value_from_proto_at_depth(v, depth + 1)) - .collect::, _>>()?, - ) - } - // The bool payload is a placeholder — picking the - // `null_value` variant means "this operand is null" and - // the bool itself is ignored. See the proto-side comment - // on the field for the rationale. - document_field_value::Variant::NullValue(_) => Value::Null, - }) -} - -/// Map a wire [`ProtoWhereClause`] onto drive's structured -/// [`WhereClause`]. Errors surface as -/// [`QueryError::InvalidArgument`] for both operator-discriminant -/// and value-shape failures. -pub(super) fn where_clause_from_proto(clause: ProtoWhereClause) -> Result { - let operator = where_operator_from_proto(clause.operator)?; - let value = clause.value.ok_or_else(|| { - QueryError::InvalidArgument(format!( - "WhereClause on field '{}' has no value set; every clause must carry a \ - concrete `DocumentFieldValue`", - clause.field - )) - })?; - let value = value_from_proto(value)?; - Ok(WhereClause { - field: clause.field, - operator, - value, - }) +use drive::error::query::QuerySyntaxError; +use drive::query::{HavingClause, OrderClause, SelectProjection, WhereClause}; +use platform_query_wire::proto_conversions::{self as shared, DecodeError}; + +fn map_decode_error(error: DecodeError) -> QueryError { + match error { + DecodeError::InvalidArgument(msg) => QueryError::InvalidArgument(msg), + DecodeError::Unsupported(msg) => QueryError::Query(QuerySyntaxError::Unsupported(msg)), + } } -/// Plural form of [`where_clause_from_proto`] for the request-level -/// `repeated WhereClause` field. Returns an error on the first -/// malformed clause; the v1 handler surfaces this through +/// Decode the request-level `repeated WhereClause` field via the +/// shared decoder. Returns an error on the first malformed clause; +/// the v1 handler surfaces this through /// `QueryValidationResult::new_with_error` so the caller sees the /// rejection on the same response shape as a downstream validation /// failure. pub(super) fn where_clauses_from_proto( clauses: Vec, ) -> Result, QueryError> { - clauses.into_iter().map(where_clause_from_proto).collect() + shared::where_clauses_from_proto(clauses).map_err(map_decode_error) } -/// Map a wire [`ProtoOrderClause`] onto drive's [`OrderClause`]. -/// -/// The `target` oneof currently has two variants on the wire: -/// `field` (plain column name — evaluated today) and `aggregate` -/// (aggregate function applied to a field — wire-only, rejected -/// at routing time with `Unsupported("ORDER BY on aggregate …")`). -/// Unset (`None`) is rejected as malformed wire input. -pub(super) fn order_clause_from_proto(clause: ProtoOrderClause) -> Result { - let ascending = clause.ascending; - match clause.target { - Some(order_clause::Target::Field(field)) => Ok(OrderClause { field, ascending }), - Some(order_clause::Target::Aggregate(_)) => Err(QueryError::Query( - drive::error::query::QuerySyntaxError::Unsupported( - "ORDER BY on aggregate keys is not yet implemented".to_string(), - ), - )), - None => Err(QueryError::InvalidArgument( - "OrderClause has no target set; every clause must carry either a \ - `field` (plain column name) or an `aggregate` (aggregate-function \ - ordering target)" - .to_string(), - )), - } -} - -/// Plural form of [`order_clause_from_proto`] for the request-level -/// `repeated OrderClause` field. Returns the first error -/// encountered. +/// Decode the request-level `repeated OrderClause` field via the +/// shared decoder. Aggregate ordering targets are rejected with +/// `Unsupported("ORDER BY on aggregate keys is not yet implemented")`. pub(super) fn order_clauses_from_proto( clauses: Vec, ) -> Result, QueryError> { - clauses.into_iter().map(order_clause_from_proto).collect() -} - -// The `having_*_from_proto` family below decodes clauses the server -// then refuses: `having` evaluation is not implemented, so every -// non-empty HAVING is rejected at routing. Decoding still runs first -// (see `query_documents_v1`) so wire-malformed clauses surface as -// `InvalidArgument` rather than being masked by the capability -// rejection. The inner helpers keep a per-function -// `#[allow(dead_code)]` — rather than module-wide — so any future -// addition outside this family still trips the lint. - -/// Map a wire [`having_aggregate::Function`] discriminant onto -/// drive's [`HavingAggregateFunction`]. Unknown discriminants are -/// wire-level garbage (no future protocol value would map a -/// malformed integer to a valid behavior), so they surface as -/// [`QueryError::InvalidArgument`]. -#[allow(dead_code)] -fn having_function_from_proto(function: i32) -> Result { - let proto = having_aggregate::Function::try_from(function).map_err(|_| { - QueryError::InvalidArgument(format!( - "unknown HavingAggregate.Function discriminant: {} (valid values: 0..=2, see \ - `get_documents_request::having_aggregate::Function`)", - function - )) - })?; - Ok(match proto { - having_aggregate::Function::Count => HavingAggregateFunction::Count, - having_aggregate::Function::Sum => HavingAggregateFunction::Sum, - having_aggregate::Function::Avg => HavingAggregateFunction::Avg, - }) -} - -/// Map a wire [`having_clause::Operator`] discriminant onto -/// drive's [`HavingOperator`]. Same error contract as -/// [`having_function_from_proto`]. -#[allow(dead_code)] -fn having_operator_from_proto(operator: i32) -> Result { - let proto = having_clause::Operator::try_from(operator).map_err(|_| { - QueryError::InvalidArgument(format!( - "unknown HavingClause.Operator discriminant: {} (valid values: 0..=10, see \ - `get_documents_request::having_clause::Operator`)", - operator - )) - })?; - Ok(match proto { - having_clause::Operator::Equal => HavingOperator::Equal, - having_clause::Operator::NotEqual => HavingOperator::NotEqual, - having_clause::Operator::GreaterThan => HavingOperator::GreaterThan, - having_clause::Operator::GreaterThanOrEquals => HavingOperator::GreaterThanOrEquals, - having_clause::Operator::LessThan => HavingOperator::LessThan, - having_clause::Operator::LessThanOrEquals => HavingOperator::LessThanOrEquals, - having_clause::Operator::Between => HavingOperator::Between, - having_clause::Operator::BetweenExcludeBounds => HavingOperator::BetweenExcludeBounds, - having_clause::Operator::BetweenExcludeLeft => HavingOperator::BetweenExcludeLeft, - having_clause::Operator::BetweenExcludeRight => HavingOperator::BetweenExcludeRight, - having_clause::Operator::In => HavingOperator::In, - }) + shared::order_clauses_from_proto(clauses).map_err(map_decode_error) } -/// Map a wire [`ProtoHavingAggregate`] onto drive's -/// [`HavingAggregate`]. The aggregate-function ↔ field -/// consistency check (`field` required for everything except -/// `Count`) runs inside the evaluator when HAVING execution -/// lands; the converter only enforces that the proto shape is -/// well-formed. -#[allow(dead_code)] -fn having_aggregate_from_proto( - aggregate: ProtoHavingAggregate, -) -> Result { - Ok(HavingAggregate { - function: having_function_from_proto(aggregate.function)?, - field: aggregate.field, - }) -} - -/// Map a wire [`ProtoHavingClause`] onto drive's structured -/// [`HavingClause`]. Errors surface as -/// [`QueryError::InvalidArgument`] for any wire-level -/// malformation: unknown discriminant on the aggregate function or -/// operator; missing aggregate; missing right operand (oneof unset -/// on the wire); inner value-shape failures on the literal-value -/// branch. -/// -/// `HAVING` is a boolean per-group predicate and nothing else, so the -/// wire's `right` oneof has exactly one arm and this function has -/// exactly one thing to decode. Cross-group ranking is expressed with -/// SQL's own ordering surface — `ORDER BY DESC -/// LIMIT n [OFFSET m]` — which arrives as an `OrderClause` and never -/// reaches here. -#[allow(dead_code)] -pub(super) fn having_clause_from_proto( - clause: ProtoHavingClause, -) -> Result { - let aggregate = clause.aggregate.ok_or_else(|| { - QueryError::InvalidArgument( - "HavingClause has no aggregate set; every clause must carry an \ - aggregate function + field operand" - .to_string(), - ) - })?; - let aggregate = having_aggregate_from_proto(aggregate)?; - let operator = having_operator_from_proto(clause.operator)?; - let right = clause.right.ok_or_else(|| { - QueryError::InvalidArgument( - "HavingClause has no right operand set; every clause must carry a \ - concrete `DocumentFieldValue` (`right.value`)" - .to_string(), - ) - })?; - let right = match right { - having_clause::Right::Value(v) => HavingRightOperand::Value(value_from_proto(v)?), - }; - Ok(HavingClause { - aggregate, - operator, - right, - }) -} - -/// Plural form of [`having_clause_from_proto`] for the request- -/// level `repeated HavingClause` field. Returns an error on the -/// first malformed clause. -#[allow(dead_code)] +/// Decode the request-level `repeated HavingClause` field via the +/// shared decoder. Decoding runs before the capability rejection +/// (HAVING evaluation is not implemented) so wire-malformed clauses +/// surface as `InvalidArgument` rather than being masked by the +/// blanket "not yet implemented". pub(super) fn having_clauses_from_proto( clauses: Vec, ) -> Result, QueryError> { - clauses.into_iter().map(having_clause_from_proto).collect() -} - -/// Map a wire [`select::Function`] discriminant onto drive's -/// [`SelectFunction`]. Unknown discriminants are wire-level -/// garbage (no future protocol value would map a malformed -/// integer to a valid behavior), so they surface as -/// [`QueryError::InvalidArgument`]. -fn select_function_from_proto(function: i32) -> Result { - let proto = select::Function::try_from(function).map_err(|_| { - QueryError::InvalidArgument(format!( - "unknown Select.Function discriminant: {} (valid values: 0..=5, see \ - `get_documents_request::get_documents_request_v1::select::Function`)", - function - )) - })?; - Ok(match proto { - select::Function::Documents => SelectFunction::Documents, - select::Function::Count => SelectFunction::Count, - select::Function::Sum => SelectFunction::Sum, - select::Function::Avg => SelectFunction::Avg, - select::Function::Min => SelectFunction::Min, - select::Function::Max => SelectFunction::Max, - }) + shared::having_clauses_from_proto(clauses).map_err(map_decode_error) } -/// Map a wire [`ProtoSelect`] onto drive's [`SelectProjection`]. -/// An unset `select` field on the request decodes as the proto- -/// default `Select { function: DOCUMENTS, field: "" }`, which -/// maps to [`SelectProjection::documents()`] — keeps callers that -/// don't set the field on the v0-style document-fetch path. -/// -/// Per-function field constraints (e.g. `DOCUMENTS` must have -/// empty `field`, `SUM`/`AVG` require non-empty) are checked at -/// routing time in `validate_and_route`, not here, so the -/// converter only enforces well-formed proto. +/// Decode a wire `Select` into drive's [`SelectProjection`] via the +/// shared decoder. Per-function field constraints (e.g. `DOCUMENTS` +/// must have empty `field`, `SUM`/`AVG` require non-empty) are +/// checked at routing time in `validate_and_route`, not here. pub(super) fn select_from_proto(select: ProtoSelect) -> Result { - Ok(SelectProjection { - function: select_function_from_proto(select.function)?, - field: select.field, - }) + shared::select_from_proto(select).map_err(map_decode_error) } diff --git a/packages/rs-platform-query-wire/Cargo.toml b/packages/rs-platform-query-wire/Cargo.toml new file mode 100644 index 00000000000..c70cfe7181b --- /dev/null +++ b/packages/rs-platform-query-wire/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "platform-query-wire" +description = "Shared wire-request decoders mapping Dash Platform query protos onto drive query types, used by drive-abci and client-side proof verifiers" +version.workspace = true +edition = "2021" +rust-version.workspace = true +license = "MIT" + +[dependencies] +dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ + "platform", + "client", +] } +dpp = { path = "../rs-dpp", default-features = false } +drive = { path = "../rs-drive", default-features = false, features = [ + "verify", +] } +thiserror = "2.0.17" diff --git a/packages/rs-platform-query-wire/README.md b/packages/rs-platform-query-wire/README.md new file mode 100644 index 00000000000..9d3d190e936 --- /dev/null +++ b/packages/rs-platform-query-wire/README.md @@ -0,0 +1,35 @@ +# platform-query-wire + +Shared wire→drive decoding for Dash Platform queries. + +This micro-crate is the single home of the decoders that map query +wire-proto types (from `dapi-grpc`) onto `drive::query` types — +currently the v1 `getDocuments` surface (WHERE / ORDER BY / HAVING / +SELECT clauses and their field values). + +## Scope + +- **Decode only.** No transport, no networking, no proof + verification, no async runtime. +- Errors surface through a neutral [`DecodeError`] (`InvalidArgument` + for malformed wire input, `Unsupported` for well-formed input naming + a capability the target cannot represent yet); each consumer maps it + onto its own error surface. + +## Why a dedicated crate + +The decode of a wire request into a rich query is an equivalence +contract at a trust boundary: a client-side proof verifier must +interpret a request exactly as the server does, or a proof could +verify against a different query than the server answered. + +- The **server** (`rs-drive-abci`) decodes every incoming v1 + `getDocuments` request through this crate. +- **Client-side verifiers** (SDK proof verification) are intended to + decode through the same functions, so server and client wire + interpretation cannot drift. + +Hosting the shared code here — rather than in a client/SDK crate — +keeps the consensus server's dependency graph free of client-flavored +dependencies: this crate's dependencies are a strict subset of what +`rs-drive-abci` already carries. diff --git a/packages/rs-platform-query-wire/src/lib.rs b/packages/rs-platform-query-wire/src/lib.rs new file mode 100644 index 00000000000..c1dca04363c --- /dev/null +++ b/packages/rs-platform-query-wire/src/lib.rs @@ -0,0 +1,14 @@ +//! Shared wire→drive decoding for Dash Platform queries. +//! +//! This crate is the single home of the decoders that map query +//! wire-proto types (from `dapi-grpc`) onto `drive::query` types. It +//! is consumed by the server (rs-drive-abci decodes incoming requests +//! through it) and is intended for client-side proof verifiers, so +//! the server's and a verifier's interpretation of the same request +//! bytes cannot drift. +//! +//! Scope is deliberately narrow: decode only. No transport, no proof +//! verification, no networking — the dependency graph is a strict +//! subset of what rs-drive-abci already carries. + +pub mod proto_conversions; diff --git a/packages/rs-platform-query-wire/src/proto_conversions.rs b/packages/rs-platform-query-wire/src/proto_conversions.rs new file mode 100644 index 00000000000..47533f2667a --- /dev/null +++ b/packages/rs-platform-query-wire/src/proto_conversions.rs @@ -0,0 +1,376 @@ +//! Wire-protobuf → drive type conversions for the `getDocuments` +//! query surface. +//! +//! This is the **single** proto-decode implementation, shared by: +//! - rs-drive-abci's v1 request handler (server side — decodes the +//! incoming request before routing/execution), and +//! - client-side proof verifiers (client side — they rebuild the +//! rich query from the wire request so a proved response can be +//! verified against exactly what was asked). +//! +//! Both directions living on one implementation is the point: the +//! bytes the server decodes and the bytes the verifier decodes must +//! agree clause-for-clause, or a proof could verify against a +//! different query than the server answered. +//! +//! Conversion contract: +//! - Every fallible case maps to [`DecodeError::InvalidArgument`] +//! (malformed wire input, **not** future capability), except the +//! aggregate `ORDER BY` target which maps to +//! [`DecodeError::Unsupported`] (valid request shape, server +//! capability not yet wired). rs-drive-abci maps these onto its +//! `QueryError::InvalidArgument` / `QuerySyntaxError::Unsupported` +//! respectively, preserving its historical error surface. +//! - Conversion is schema-agnostic. `DocumentFieldValue` variants +//! map 1:1 to `dpp::platform_value::Value` variants without +//! consulting the document type's schema. The schema-driven +//! coercion (`document_type.serialize_value_for_key`) runs +//! downstream as it does for the CBOR-shaped v0 path — a `text` +//! variant against an identifier field decodes via base58, a +//! `bytes_value` against the same field decodes as raw 32-byte +//! identifier, and so on. The wire layer just names the +//! primitive; the schema decides the indexed type. + +use dapi_grpc::platform::v0::get_documents_request::{ + document_field_value, + get_documents_request_v1::{select, Select as ProtoSelect}, + having_aggregate, having_clause, order_clause, DocumentFieldValue as ProtoDocumentFieldValue, + HavingAggregate as ProtoHavingAggregate, HavingClause as ProtoHavingClause, + OrderClause as ProtoOrderClause, WhereClause as ProtoWhereClause, + WhereOperator as ProtoWhereOperator, +}; +use dpp::platform_value::Value; +use drive::query::{ + HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, + OrderClause, SelectFunction, SelectProjection, WhereClause, WhereOperator, +}; + +/// Neutral decode error for the shared proto → drive conversions. +/// +/// Deliberately not a server or client error type: rs-drive-abci +/// maps it onto its `QueryError`, and client-side consumers map it +/// onto their own error surface, each preserving its own error +/// contract. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DecodeError { + /// Malformed wire input — bad discriminant, missing oneof arm, + /// over-deep list nesting. No future protocol version would make + /// this input valid. + #[error("{0}")] + InvalidArgument(String), + /// Well-formed wire input naming a capability the decode target + /// cannot represent yet (e.g. `ORDER BY` on an aggregate key). + /// The wording signals future capability, not malformed request. + #[error("{0}")] + Unsupported(String), +} + +/// Map a wire-level [`ProtoWhereOperator`] discriminant onto +/// drive's [`WhereOperator`]. Unknown discriminants are wire-level +/// garbage (no future protocol value would map a malformed integer +/// to a valid behavior), so they surface as +/// [`DecodeError::InvalidArgument`]. +pub(crate) fn where_operator_from_proto(op: i32) -> Result { + let proto_op = ProtoWhereOperator::try_from(op).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown WhereOperator discriminant: {} (valid values: 0..=10, see \ + `get_documents_request::WhereOperator`)", + op + )) + })?; + Ok(match proto_op { + ProtoWhereOperator::Equal => WhereOperator::Equal, + ProtoWhereOperator::GreaterThan => WhereOperator::GreaterThan, + ProtoWhereOperator::GreaterThanOrEquals => WhereOperator::GreaterThanOrEquals, + ProtoWhereOperator::LessThan => WhereOperator::LessThan, + ProtoWhereOperator::LessThanOrEquals => WhereOperator::LessThanOrEquals, + ProtoWhereOperator::Between => WhereOperator::Between, + ProtoWhereOperator::BetweenExcludeBounds => WhereOperator::BetweenExcludeBounds, + ProtoWhereOperator::BetweenExcludeLeft => WhereOperator::BetweenExcludeLeft, + ProtoWhereOperator::BetweenExcludeRight => WhereOperator::BetweenExcludeRight, + ProtoWhereOperator::In => WhereOperator::In, + ProtoWhereOperator::StartsWith => WhereOperator::StartsWith, + }) +} + +/// Map a wire [`ProtoDocumentFieldValue`] onto a +/// `dpp::platform_value::Value`. Schema-agnostic — variants map +/// 1:1 by primitive type and recurse for `list` up to a depth of +/// 1 (the only nesting level the query surface needs: `IN` / +/// `BETWEEN*` take a flat list of scalars). Anything deeper is +/// rejected as malformed wire input rather than recursed into, +/// so a hostile client can't blow the call stack with +/// `list(list(list(...)))` before schema validation. +/// +/// `None` (oneof unset on the wire) is rejected — a where-clause +/// operand is always concrete; empty where-clauses are expressed +/// by an empty `where_clauses` field at the request level, not by +/// sending an empty `DocumentFieldValue`. +pub(crate) fn value_from_proto(value: ProtoDocumentFieldValue) -> Result { + value_from_proto_at_depth(value, 0) +} + +/// Recursion-bounded form of [`value_from_proto`]. `depth = 0` is +/// the request-level operand; the only legal child shape is a +/// flat list (`depth = 1` for `IN` / `BETWEEN*` candidates), so a +/// `list` encountered at `depth >= 1` is wire-malformed. +fn value_from_proto_at_depth( + value: ProtoDocumentFieldValue, + depth: u8, +) -> Result { + let variant = value.variant.ok_or_else(|| { + DecodeError::InvalidArgument( + "DocumentFieldValue has no variant set; a where-clause operand must \ + be a concrete value" + .to_string(), + ) + })?; + Ok(match variant { + document_field_value::Variant::BoolValue(b) => Value::Bool(b), + document_field_value::Variant::Int64Value(i) => Value::I64(i), + document_field_value::Variant::Uint64Value(u) => Value::U64(u), + document_field_value::Variant::DoubleValue(f) => Value::Float(f), + document_field_value::Variant::Text(s) => Value::Text(s), + document_field_value::Variant::BytesValue(b) => Value::Bytes(b), + document_field_value::Variant::List(list) => { + if depth >= 1 { + return Err(DecodeError::InvalidArgument( + "nested DocumentFieldValue.list is not supported; the v1 \ + query surface accepts at most one level of nesting \ + (`IN` / `BETWEEN*` candidate lists of scalars)" + .to_string(), + )); + } + Value::Array( + list.values + .into_iter() + .map(|v| value_from_proto_at_depth(v, depth + 1)) + .collect::, _>>()?, + ) + } + // The bool payload is a placeholder — picking the + // `null_value` variant means "this operand is null" and + // the bool itself is ignored. See the proto-side comment + // on the field for the rationale. + document_field_value::Variant::NullValue(_) => Value::Null, + }) +} + +/// Map a wire [`ProtoWhereClause`] onto drive's structured +/// [`WhereClause`]. Errors surface as +/// [`DecodeError::InvalidArgument`] for both operator-discriminant +/// and value-shape failures. +pub(crate) fn where_clause_from_proto( + clause: ProtoWhereClause, +) -> Result { + let operator = where_operator_from_proto(clause.operator)?; + let value = clause.value.ok_or_else(|| { + DecodeError::InvalidArgument(format!( + "WhereClause on field '{}' has no value set; every clause must carry a \ + concrete `DocumentFieldValue`", + clause.field + )) + })?; + let value = value_from_proto(value)?; + Ok(WhereClause { + field: clause.field, + operator, + value, + }) +} + +/// Plural form of `where_clause_from_proto` for the request-level +/// `repeated WhereClause` field. Returns an error on the first +/// malformed clause. +pub fn where_clauses_from_proto( + clauses: Vec, +) -> Result, DecodeError> { + clauses.into_iter().map(where_clause_from_proto).collect() +} + +/// Map a wire [`ProtoOrderClause`] onto drive's [`OrderClause`]. +/// +/// The `target` oneof currently has two variants on the wire: +/// `field` (plain column name — evaluated today) and `aggregate` +/// (aggregate function applied to a field — wire-only, rejected +/// with [`DecodeError::Unsupported`]). Unset (`None`) is rejected +/// as malformed wire input. +pub(crate) fn order_clause_from_proto( + clause: ProtoOrderClause, +) -> Result { + let ascending = clause.ascending; + match clause.target { + Some(order_clause::Target::Field(field)) => Ok(OrderClause { field, ascending }), + Some(order_clause::Target::Aggregate(_)) => Err(DecodeError::Unsupported( + "ORDER BY on aggregate keys is not yet implemented".to_string(), + )), + None => Err(DecodeError::InvalidArgument( + "OrderClause has no target set; every clause must carry either a \ + `field` (plain column name) or an `aggregate` (aggregate-function \ + ordering target)" + .to_string(), + )), + } +} + +/// Plural form of `order_clause_from_proto` for the request-level +/// `repeated OrderClause` field. Returns the first error +/// encountered. +pub fn order_clauses_from_proto( + clauses: Vec, +) -> Result, DecodeError> { + clauses.into_iter().map(order_clause_from_proto).collect() +} + +/// Map a wire [`having_aggregate::Function`] discriminant onto +/// drive's [`HavingAggregateFunction`]. Unknown discriminants are +/// wire-level garbage (no future protocol value would map a +/// malformed integer to a valid behavior), so they surface as +/// [`DecodeError::InvalidArgument`]. +fn having_function_from_proto(function: i32) -> Result { + let proto = having_aggregate::Function::try_from(function).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown HavingAggregate.Function discriminant: {} (valid values: 0..=2, see \ + `get_documents_request::having_aggregate::Function`)", + function + )) + })?; + Ok(match proto { + having_aggregate::Function::Count => HavingAggregateFunction::Count, + having_aggregate::Function::Sum => HavingAggregateFunction::Sum, + having_aggregate::Function::Avg => HavingAggregateFunction::Avg, + }) +} + +/// Map a wire [`having_clause::Operator`] discriminant onto +/// drive's [`HavingOperator`]. Same error contract as +/// [`having_function_from_proto`]. +fn having_operator_from_proto(operator: i32) -> Result { + let proto = having_clause::Operator::try_from(operator).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown HavingClause.Operator discriminant: {} (valid values: 0..=10, see \ + `get_documents_request::having_clause::Operator`)", + operator + )) + })?; + Ok(match proto { + having_clause::Operator::Equal => HavingOperator::Equal, + having_clause::Operator::NotEqual => HavingOperator::NotEqual, + having_clause::Operator::GreaterThan => HavingOperator::GreaterThan, + having_clause::Operator::GreaterThanOrEquals => HavingOperator::GreaterThanOrEquals, + having_clause::Operator::LessThan => HavingOperator::LessThan, + having_clause::Operator::LessThanOrEquals => HavingOperator::LessThanOrEquals, + having_clause::Operator::Between => HavingOperator::Between, + having_clause::Operator::BetweenExcludeBounds => HavingOperator::BetweenExcludeBounds, + having_clause::Operator::BetweenExcludeLeft => HavingOperator::BetweenExcludeLeft, + having_clause::Operator::BetweenExcludeRight => HavingOperator::BetweenExcludeRight, + having_clause::Operator::In => HavingOperator::In, + }) +} + +/// Map a wire [`ProtoHavingAggregate`] onto drive's +/// [`HavingAggregate`]. The aggregate-function ↔ field +/// consistency check (`field` required for everything except +/// `Count`) runs inside the evaluator when HAVING execution +/// lands; the converter only enforces that the proto shape is +/// well-formed. +fn having_aggregate_from_proto( + aggregate: ProtoHavingAggregate, +) -> Result { + Ok(HavingAggregate { + function: having_function_from_proto(aggregate.function)?, + field: aggregate.field, + }) +} + +/// Map a wire [`ProtoHavingClause`] onto drive's structured +/// [`HavingClause`]. Errors surface as +/// [`DecodeError::InvalidArgument`] for any wire-level +/// malformation: unknown discriminant on the aggregate function or +/// operator; missing aggregate; missing right operand (oneof unset +/// on the wire); inner value-shape failures on the literal-value +/// branch. +/// +/// `HAVING` is a boolean per-group predicate and nothing else, so the +/// wire's `right` oneof has exactly one arm and this function has +/// exactly one thing to decode. Cross-group ranking is expressed with +/// SQL's own ordering surface — `ORDER BY DESC +/// LIMIT n [OFFSET m]` — which arrives as an `OrderClause` and never +/// reaches here. +pub(crate) fn having_clause_from_proto( + clause: ProtoHavingClause, +) -> Result { + let aggregate = clause.aggregate.ok_or_else(|| { + DecodeError::InvalidArgument( + "HavingClause has no aggregate set; every clause must carry an \ + aggregate function + field operand" + .to_string(), + ) + })?; + let aggregate = having_aggregate_from_proto(aggregate)?; + let operator = having_operator_from_proto(clause.operator)?; + let right = clause.right.ok_or_else(|| { + DecodeError::InvalidArgument( + "HavingClause has no right operand set; every clause must carry a \ + concrete `DocumentFieldValue` (`right.value`)" + .to_string(), + ) + })?; + let right = match right { + having_clause::Right::Value(v) => HavingRightOperand::Value(value_from_proto(v)?), + }; + Ok(HavingClause { + aggregate, + operator, + right, + }) +} + +/// Plural form of `having_clause_from_proto` for the request- +/// level `repeated HavingClause` field. Returns an error on the +/// first malformed clause. +pub fn having_clauses_from_proto( + clauses: Vec, +) -> Result, DecodeError> { + clauses.into_iter().map(having_clause_from_proto).collect() +} + +/// Map a wire [`select::Function`] discriminant onto drive's +/// [`SelectFunction`]. Unknown discriminants are wire-level +/// garbage (no future protocol value would map a malformed +/// integer to a valid behavior), so they surface as +/// [`DecodeError::InvalidArgument`]. +fn select_function_from_proto(function: i32) -> Result { + let proto = select::Function::try_from(function).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown Select.Function discriminant: {} (valid values: 0..=5, see \ + `get_documents_request::get_documents_request_v1::select::Function`)", + function + )) + })?; + Ok(match proto { + select::Function::Documents => SelectFunction::Documents, + select::Function::Count => SelectFunction::Count, + select::Function::Sum => SelectFunction::Sum, + select::Function::Avg => SelectFunction::Avg, + select::Function::Min => SelectFunction::Min, + select::Function::Max => SelectFunction::Max, + }) +} + +/// Map a wire [`ProtoSelect`] onto drive's [`SelectProjection`]. +/// An unset `select` field on the request decodes as the proto- +/// default `Select { function: DOCUMENTS, field: "" }`, which +/// maps to [`SelectProjection::documents()`] — keeps callers that +/// don't set the field on the v0-style document-fetch path. +/// +/// Per-function field constraints (e.g. `DOCUMENTS` must have +/// empty `field`, `SUM`/`AVG` require non-empty) are checked at +/// routing time by the server's `validate_and_route`, not here, so +/// the converter only enforces well-formed proto. +pub fn select_from_proto(select: ProtoSelect) -> Result { + Ok(SelectProjection { + function: select_function_from_proto(select.function)?, + field: select.field, + }) +}