dash_platform_queries/documents/document_query.rs
1//! Method to query documents from the Drive.
2
3use std::sync::Arc;
4
5use crate::error::Error;
6use dapi_grpc::platform::v0::get_documents_request::Version::{V0, V1};
7use dapi_grpc::platform::v0::{
8 self as platform_proto,
9 get_documents_request::{
10 document_field_value,
11 get_documents_request_v0::Start,
12 get_documents_request_v1::{select, Select as ProtoSelect, Start as V1Start},
13 having_aggregate, having_clause, order_clause,
14 DocumentFieldValue as ProtoDocumentFieldValue, GetDocumentsRequestV0,
15 GetDocumentsRequestV1, HavingAggregate as ProtoHavingAggregate,
16 HavingClause as ProtoHavingClause, OrderClause as ProtoOrderClause,
17 WhereClause as ProtoWhereClause, WhereOperator as ProtoWhereOperator,
18 },
19 GetDocumentsRequest, Proof, ResponseMetadata,
20};
21use dash_context_provider::ContextProvider;
22use dpp::dashcore::Network;
23use dpp::version::{PlatformVersion, TryFromPlatformVersioned};
24use dpp::{
25 data_contract::{
26 accessors::v0::DataContractV0Getters, document_type::accessors::DocumentTypeV0Getters,
27 },
28 document::Document,
29 platform_value::{platform_value, Value},
30 prelude::{DataContract, Identifier},
31 InvalidVectorSizeError, ProtocolError,
32};
33use drive::config::DEFAULT_QUERY_LIMIT;
34use drive::query::drive_document_ranked_query::mode_detection::ranked_order_key;
35use drive::query::{
36 resolve_time_range_bucket_clause, validate_resolved_time_range_clause_shapes,
37 DriveDocumentQuery, HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator,
38 HavingRightOperand, InternalClauses, OrderClause, ResolvedTimeRange, SelectFunction,
39 SelectProjection, TimeRangeGridSpec, TimeRangeSelector, WhereClause, WhereOperator,
40};
41use drive_proof_verifier::{types::Documents, FromProof};
42
43// TODO: remove DocumentQuery once ContextProvider that provides data contracts is merged.
44
45/// One pending `IN_TIME_RANGE` selection: the timestamp field, the
46/// `"newest"` / `"oldest"` selector, and — when the contract buckets the
47/// field with more than one `timeRange` grid — the grid it targets (`None`
48/// means the field's sole grid, and the server rejects the bare form on a
49/// multi-grid field as ambiguous).
50#[derive(Debug, Clone, PartialEq, Eq)]
51#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
52pub struct TimeRangeClause {
53 /// The bucketed timestamp field the selection is on.
54 pub field: String,
55 /// Which active window to resolve to.
56 pub selector: TimeRangeSelector,
57 /// The grid targeted, in the contract's declared seconds; `None` when
58 /// the field carries a single grid.
59 #[cfg_attr(feature = "mocks", serde(default))]
60 pub grid: Option<TimeRangeGridSpec>,
61}
62
63/// Request that is used to query documents from the Dash Platform.
64///
65/// This is an abstraction layer built on top of [GetDocumentsRequest] to address issues with missing details
66/// required to correctly verify proofs returned by the Dash Platform.
67///
68/// Conversions are implemented between this type, [GetDocumentsRequest] and [DriveDocumentQuery] using [TryFrom] trait.
69#[derive(Debug, Clone, PartialEq, dash_platform_macros::Mockable)]
70#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))]
71pub struct DocumentQuery {
72 /// SQL-shaped `SELECT` projection — `(function, field)` pair.
73 /// `Documents` returns matched rows; `Count` / `Sum` / `Avg`
74 /// return either a single aggregate (empty `group_by`) or
75 /// per-group entries (non-empty `group_by`). Defaults to
76 /// `SelectProjection::documents()` so callers that don't opt
77 /// into the SQL-shaped surface get plain document-fetch
78 /// semantics.
79 ///
80 /// `#[serde(default)]` here (and on `group_by` / `having`
81 /// below) is wire-format-compat for mock vectors captured
82 /// before the SQL-shaped surface was added: default
83 /// `SelectProjection` is `documents()`, `Vec` defaults to
84 /// empty — together those mean an old fixture without these
85 /// fields deserializes to the documents-fetch shape it was
86 /// originally captured under. New fixtures should serialize
87 /// the fields explicitly.
88 #[cfg_attr(feature = "mocks", serde(default))]
89 pub select: SelectProjection,
90 /// Data contract
91 pub data_contract: Arc<DataContract>,
92 /// Document type for the data contract
93 pub document_type_name: String,
94 /// `where` clauses for the query
95 pub where_clauses: Vec<WhereClause>,
96 /// Time-range (`IN_TIME_RANGE`) selections on a timestamp field covered
97 /// by a `timeRange` index. These are emitted as `IN_TIME_RANGE` clauses
98 /// on the v1 wire and resolved server-side from the current block time;
99 /// the verifier re-derives the same bucket from the quorum-signed
100 /// response metadata time. v1-only (the v0 wire has no `IN_TIME_RANGE`
101 /// operator). See [`Self::with_time_range`] and
102 /// [`Self::with_time_range_grid`].
103 #[cfg_attr(feature = "mocks", serde(default))]
104 pub time_range_clauses: Vec<TimeRangeClause>,
105 /// SQL `GROUP BY` field names, in left-to-right order. Empty =
106 /// no explicit grouping (aggregate count for `select=Count`).
107 /// Only meaningful when `select=Count`; non-empty with
108 /// `select=Documents` is rejected by the server as unsupported.
109 #[cfg_attr(feature = "mocks", serde(default))]
110 pub group_by: Vec<String>,
111 /// SQL `HAVING` clauses — **boolean** aggregate filters that apply
112 /// to the grouped rows produced by `select = Count | Sum | Avg`
113 /// with a non-empty `group_by`. Unlike `where_clauses`, the left
114 /// side is an aggregate (`COUNT(*)`, `SUM(field)`, `AVG(field)`)
115 /// rather than a raw row field. See [`HavingClause`] /
116 /// [`drive::query::HavingAggregate`] /
117 /// [`drive::query::HavingOperator`] for the catalogs. Multiple
118 /// entries combine with implicit `AND`.
119 ///
120 /// **Served from protocol version 14, for exactly one clause
121 /// bounding the selected aggregate** with a contiguous-range
122 /// operator (`=`, `>`, `>=`, `<`, `<=`, `BETWEEN*`) — the
123 /// having-range surface, fetched as
124 /// [`DocumentHavingEntries`](drive_proof_verifier::DocumentHavingEntries)
125 /// and served as a value-bounded range read of the covering ranked
126 /// index's axis secondary (the index must declare the matching
127 /// `rankedCountable` / `rankedSummable` / `rankedAverageable`
128 /// keyword). Everything else — multiple clauses (implicit AND), a
129 /// clause on an aggregate the select does not project, `!=` / `IN`
130 /// — is still rejected with `QuerySyntaxError::Unsupported`, as is
131 /// any non-empty value at protocol version 13 and earlier.
132 ///
133 /// **`having` does not express ranking.** "The n highest-scoring
134 /// groups" is [`Self::order_by_selected_aggregate`] +
135 /// [`Self::with_limit`] — SQL's own `ORDER BY <agg> DESC LIMIT n`
136 /// — which is also served from protocol version 14. The two
137 /// compose only in the one shape the having grammar allows: an
138 /// `ORDER BY` naming the selected aggregate sets the having
139 /// range's walk direction.
140 #[cfg_attr(feature = "mocks", serde(default))]
141 pub having: Vec<HavingClause>,
142 /// `order_by` clauses for the query.
143 ///
144 /// For `select = Documents` these order the matched rows. For the
145 /// **ranked** surface a single clause naming the selected
146 /// aggregate orders the *groups* — see
147 /// [`Self::order_by_selected_aggregate`], which builds it.
148 pub order_by_clauses: Vec<OrderClause>,
149 /// queryset limit. `0` is the sentinel for "unset / default" and
150 /// is translated to `None` on the V1 wire (`optional uint32`).
151 pub limit: u32,
152 /// SQL `OFFSET` — how many result rows to skip before the returned
153 /// page. `None` leaves the field unset on the wire.
154 ///
155 /// Served on exactly one path: the **ranked** surface (protocol
156 /// version 14+), where it skips that many *ranks*, so
157 /// `.order_by_selected_aggregate(Descending).with_limit(1)
158 /// .with_offset(4)` is the 5th-best group. Everywhere else the
159 /// server rejects a set offset with `Unsupported("OFFSET
160 /// pagination is not yet implemented")`.
161 ///
162 /// `#[serde(default)]` for the same mock-vector compatibility
163 /// reason as `select` / `group_by` / `having`: a fixture captured
164 /// before offsets existed deserializes to `None`.
165 #[cfg_attr(feature = "mocks", serde(default))]
166 pub offset: Option<u32>,
167 /// first object to start with
168 pub start: Option<Start>,
169}
170
171/// Which end of a ranking a
172/// [`DocumentQuery::order_by_selected_aggregate`] call walks from.
173///
174/// A named pair rather than a bare `ascending: bool`, because the two
175/// readings of a ranked query — "the best n" and "the worst n" — are
176/// what callers actually think in, and `false` meaning "best first" at
177/// a call site is exactly the sort of thing that gets flipped in
178/// review without anyone noticing.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum RankingDirection {
181 /// `ORDER BY <aggregate> DESC` — walk from the largest aggregate
182 /// down. The "top n" reading: entry 0 is the highest-scoring group.
183 Descending,
184 /// `ORDER BY <aggregate> ASC` — walk from the smallest aggregate
185 /// up. The "bottom n" reading: entry 0 is the lowest-scoring group.
186 Ascending,
187}
188
189impl DocumentQuery {
190 /// Create new DocumentQuery for provided contract and document type name.
191 pub fn new<C: Into<Arc<DataContract>>>(
192 contract: C,
193 document_type_name: &str,
194 ) -> Result<Self, Error> {
195 let contract = contract.into();
196 // ensure document type name is correct
197 contract
198 .document_type_for_name(document_type_name)
199 .map_err(ProtocolError::DataContractError)?;
200
201 Ok(Self {
202 select: SelectProjection::documents(),
203 data_contract: Arc::clone(&contract),
204 document_type_name: document_type_name.to_string(),
205 where_clauses: vec![],
206 time_range_clauses: vec![],
207 group_by: Vec::new(),
208 having: Vec::new(),
209 order_by_clauses: vec![],
210 limit: 0,
211 offset: None,
212 start: None,
213 })
214 }
215
216 /// Create new document query based on a [DriveDocumentQuery].
217 ///
218 /// Fails when the drive query carries time-range resolution provenance
219 /// (`resolved_time_ranges`): the resolved bucket equality cannot be
220 /// represented without it — see the `TryFrom` impl. Build the query
221 /// with [`Self::with_time_range`] / [`Self::with_time_range_grid`]
222 /// instead for time-range selections.
223 pub fn new_with_drive_query(d: &DriveDocumentQuery) -> Result<Self, crate::error::Error> {
224 Self::try_from(d)
225 }
226
227 /// Point to a specific document ID.
228 pub fn with_document_id(self, document_id: &Identifier) -> Self {
229 let clause = WhereClause {
230 field: "$id".to_string(),
231 operator: WhereOperator::Equal,
232 value: platform_value!(document_id),
233 };
234
235 self.with_where(clause)
236 }
237
238 /// Add new where clause to the query.
239 ///
240 /// Existing where clauses will be preserved.
241 pub fn with_where(mut self, clause: WhereClause) -> Self {
242 self.where_clauses.push(clause);
243
244 self
245 }
246
247 /// Restrict the query to a single time-range bucket of `field`
248 /// (a timestamp covered by a `timeRange` index), selecting either the
249 /// [`TimeRangeSelector::Newest`] or [`TimeRangeSelector::Oldest`] currently
250 /// active range. Emitted as an `IN_TIME_RANGE` clause on the v1 wire and
251 /// resolved server-side from the current block time; the proof verifier
252 /// re-derives the identical bucket from the quorum-signed response
253 /// metadata time. Requires protocol version 14+ — the first version
254 /// whose contract grammar hosts `timeRange` indexes.
255 ///
256 /// The bare selector is unambiguous only while exactly one grid buckets
257 /// `field`; when the contract declares several grids over it, use
258 /// [`Self::with_time_range_grid`] to name one.
259 ///
260 /// Existing time-range selections are preserved.
261 pub fn with_time_range(
262 mut self,
263 field: impl Into<String>,
264 selector: TimeRangeSelector,
265 ) -> Self {
266 self.time_range_clauses.push(TimeRangeClause {
267 field: field.into(),
268 selector,
269 grid: None,
270 });
271 self
272 }
273
274 /// [`Self::with_time_range`] naming a specific grid — required when the
275 /// contract buckets `field` with more than one `timeRange` grid. The
276 /// spec's `range` / `step` / `phase` are the contract's own declared
277 /// seconds, verbatim.
278 ///
279 /// Existing time-range selections are preserved.
280 pub fn with_time_range_grid(
281 mut self,
282 field: impl Into<String>,
283 selector: TimeRangeSelector,
284 grid: TimeRangeGridSpec,
285 ) -> Self {
286 self.time_range_clauses.push(TimeRangeClause {
287 field: field.into(),
288 selector,
289 grid: Some(grid),
290 });
291 self
292 }
293
294 /// Add order by clause to the query.
295 ///
296 /// Existing order by clauses will be preserved.
297 pub fn with_order_by(mut self, clause: OrderClause) -> Self {
298 self.order_by_clauses.push(clause);
299
300 self
301 }
302
303 /// Set the SQL-shaped `SELECT` projection.
304 ///
305 /// Construct the [`SelectProjection`] via its helpers:
306 /// [`SelectProjection::documents`] (the default — matched
307 /// rows), [`SelectProjection::count_star`] for `COUNT(*)`,
308 /// [`SelectProjection::count_field`] for `COUNT(field)`,
309 /// [`SelectProjection::sum`] for `SUM(field)`,
310 /// [`SelectProjection::avg`] for `AVG(field)`. Pair the
311 /// count/sum/avg projections with [`DocumentCount::fetch`]
312 /// (single aggregate, empty `group_by`) or
313 /// [`DocumentSplitCounts::fetch`] (per-group entries,
314 /// non-empty `group_by`).
315 ///
316 /// Server capability today: `Documents`, `COUNT(*)`,
317 /// `SUM(<field>)`, and `AVG(<field>)` are evaluated
318 /// end-to-end. `COUNT(<field>)`, `MIN(<field>)`, and
319 /// `MAX(<field>)` are accepted by the SDK but rejected by the
320 /// server with `Unsupported("SELECT … is not yet
321 /// implemented")` — the surface is shipped first and
322 /// execution lands later.
323 pub fn with_select(mut self, select: SelectProjection) -> Self {
324 self.select = select;
325 self
326 }
327
328 /// Set the `GROUP BY` field to a single field name.
329 ///
330 /// Convenience wrapper around [`Self::with_group_by_fields`].
331 /// Replaces any previously set `group_by`. Pair with
332 /// [`Self::with_select`] (e.g.
333 /// `with_select(SelectProjection::count_star())`) for the
334 /// per-group entries shape.
335 pub fn with_group_by<S: Into<String>>(mut self, field: S) -> Self {
336 self.group_by = vec![field.into()];
337 self
338 }
339
340 /// Set the full `GROUP BY` field list (replaces any previously
341 /// set `group_by`).
342 ///
343 /// Multi-field `group_by` is only accepted by the server for
344 /// `(in_field, range_field)` matching a compound `In + range`
345 /// where clause against a `rangeCountable: true` index. Other
346 /// non-empty shapes return `QuerySyntaxError::Unsupported`.
347 pub fn with_group_by_fields<I, S>(mut self, fields: I) -> Self
348 where
349 I: IntoIterator<Item = S>,
350 S: Into<String>,
351 {
352 self.group_by = fields.into_iter().map(Into::into).collect();
353 self
354 }
355
356 /// Set the `HAVING` clauses (replaces any prior value).
357 ///
358 /// From protocol version 14, a grouped aggregate query carrying
359 /// **exactly one** clause that bounds the selected aggregate with
360 /// a contiguous-range operator (`=`, `>`, `>=`, `<`, `<=`, the
361 /// `BETWEEN` variants) is served as a value-bounded range read of
362 /// the covering ranked index's axis secondary — fetch the result
363 /// through `DocumentHavingEntries::fetch`, which verifies the
364 /// proof including its completeness. The server still rejects
365 /// multiple clauses, a clause on a different aggregate than the
366 /// select's, and the non-contiguous operators (`!=`, `IN`);
367 /// protocol version 13 and earlier reject every non-empty
368 /// `having`.
369 ///
370 /// This is **not** how you ask for a ranking — see
371 /// [`Self::order_by_selected_aggregate`].
372 pub fn with_having(mut self, having: Vec<HavingClause>) -> Self {
373 self.having = having;
374 self
375 }
376
377 /// Order the `GROUP BY` groups by the aggregate this query
378 /// selects — the **ranked** surface, `ORDER BY <the selected
379 /// aggregate> [ASC|DESC]` (protocol version 14+).
380 ///
381 /// Replaces any previously set `order_by`, because a ranked query
382 /// takes exactly one ordering clause and a second one is rejected
383 /// rather than combined.
384 ///
385 /// The ordered field name is derived from the current
386 /// [`Self::select`] by rs-drive's own
387 /// [`ranked_order_key`] — `SUM(f)` / `AVG(f)` are named by `f`,
388 /// and `COUNT(*)` by the `$count` sentinel. Calling
389 /// [`Self::with_select`] *after* this method leaves a stale field
390 /// name behind and the server will refuse the request; set the
391 /// select first, which is also how the query reads.
392 ///
393 /// Pair with [`Self::with_limit`] (the ranking's `n`, `1 ..= 100`)
394 /// and optionally [`Self::with_offset`], then fetch with
395 /// [`DocumentRankedEntries`](drive_proof_verifier::DocumentRankedEntries).
396 ///
397 /// # The 5th-best group
398 ///
399 /// ```rust,ignore
400 /// # use dash_sdk::platform::{DataContract, DocumentQuery};
401 /// # use dash_sdk::platform::documents::document_query::RankingDirection;
402 /// # use dash_sdk::drive::query::SelectProjection;
403 /// # fn example(contract: DataContract) -> Result<(), dash_sdk::Error> {
404 /// // SELECT avg(grade) GROUP BY restaurantId
405 /// // ORDER BY avg(grade) DESC LIMIT 1 OFFSET 4
406 /// let query = DocumentQuery::new(contract, "review")?
407 /// .with_select(SelectProjection::avg("grade"))
408 /// .with_group_by("restaurantId")
409 /// .order_by_selected_aggregate(RankingDirection::Descending)
410 /// .with_limit(1)
411 /// .with_offset(4);
412 /// # Ok(())
413 /// # }
414 /// ```
415 pub fn order_by_selected_aggregate(mut self, direction: RankingDirection) -> Self {
416 self.order_by_clauses = vec![OrderClause {
417 field: ranked_order_key(&self.select).to_string(),
418 ascending: matches!(direction, RankingDirection::Ascending),
419 }];
420 self
421 }
422
423 /// Set the SQL `OFFSET` — how many ranks to skip before the
424 /// returned page.
425 ///
426 /// Only the ranked surface honours it (see
427 /// [`Self::order_by_selected_aggregate`]); on every other path the
428 /// server rejects a set offset with `Unsupported`. There is no
429 /// ceiling: grovedb counts the skipped region from the subtree
430 /// aggregates instead of walking it, on both `prove` settings, so
431 /// the cost of a deep offset does not scale with the offset. It is
432 /// not identical to a shallow one — `offset = 0` keeps a sequential
433 /// fast path, a positive offset descends the tree in `O(log n)`,
434 /// and an offset at or past the population is answered from the
435 /// root without descending at all — but nothing here grows with how
436 /// far you page, which is why there is no ceiling. Only a proved
437 /// response additionally attests the count.
438 ///
439 /// An offset past the end of the ranking is a legitimate answer
440 /// rather than an error — the page comes back empty, and on a
441 /// proved fetch its `starting_rank` is the ranking's attested total
442 /// population.
443 pub fn with_offset(mut self, offset: u32) -> Self {
444 self.offset = Some(offset);
445 self
446 }
447
448 /// Set the query limit. `0` means "unset" — translated to
449 /// `None` on the V1 wire (the proto field is `optional uint32`).
450 ///
451 /// On `select=Count` with non-empty `group_by` against the
452 /// prove path, the server validates rather than clamps:
453 /// `limit > max_query_limit` is rejected with
454 /// `InvalidLimit` rather than silently truncated, since
455 /// clamping would invisibly break proof verification.
456 /// Leaving the limit unset (`0`) falls back to
457 /// `drive::config::DEFAULT_QUERY_LIMIT` on the proof verifier
458 /// side, keeping proof bytes deterministic across operators.
459 pub fn with_limit(mut self, limit: u32) -> Self {
460 self.limit = limit;
461 self
462 }
463
464 /// Convert into the wire-format [`GetDocumentsRequest`] using a
465 /// specific [`PlatformVersion`] to pick V0 vs V1. The dispatch
466 /// boundary is the document_query feature-version on the
467 /// platform_version: `0` → V0, `1` → V1.
468 pub fn try_into_request_for_version(
469 self,
470 platform_version: &PlatformVersion,
471 ) -> Result<GetDocumentsRequest, Error> {
472 GetDocumentsRequest::try_from_platform_versioned(self, platform_version)
473 }
474}
475
476impl FromProof<DocumentQuery> for Document {
477 type Request = DocumentQuery;
478 type Response = platform_proto::GetDocumentsResponse;
479 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
480 request: I,
481 response: O,
482 network: Network,
483 platform_version: &PlatformVersion,
484 provider: &'a dyn ContextProvider,
485 ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
486 where
487 Self: Sized + 'a,
488 {
489 let request: Self::Request = request.into();
490
491 let (documents, metadata, proof): (Option<Documents>, ResponseMetadata, Proof) =
492 <Documents as FromProof<Self::Request>>::maybe_from_proof_with_metadata(
493 request,
494 response,
495 network,
496 platform_version,
497 provider,
498 )?;
499
500 match documents {
501 None => Ok((None, metadata, proof)),
502 Some(docs) => match docs.len() {
503 0 | 1 => Ok((
504 docs.into_iter().next().and_then(|(_, v)| v),
505 metadata,
506 proof,
507 )),
508 n => Err(drive_proof_verifier::Error::ResponseDecodeError {
509 error: format!("expected 1 element, got {}", n),
510 }),
511 },
512 }
513 }
514}
515
516impl FromProof<DocumentQuery> for drive_proof_verifier::types::Documents {
517 type Request = DocumentQuery;
518 type Response = platform_proto::GetDocumentsResponse;
519 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
520 request: I,
521 response: O,
522 network: Network,
523 platform_version: &PlatformVersion,
524 provider: &'a dyn ContextProvider,
525 ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
526 where
527 Self: Sized + 'a,
528 {
529 let mut request: Self::Request = request.into();
530 let response: Self::Response = response.into();
531
532 // A time-range (`IN_TIME_RANGE`) selection is resolved to a concrete
533 // bucket using the **quorum-signed** response metadata time — the same
534 // authoritative block time the server used to resolve it — so the
535 // reconstructed query matches the proof exactly. Resolve (and run the
536 // provenance-vs-shape guard, via the one shared normalization helper
537 // the aggregate verifiers also use) before the `DriveDocumentQuery`
538 // conversion so the engine sees ordinary equality clauses.
539 let mut resolved_time_ranges = Vec::new();
540 if !request.time_range_clauses.is_empty() {
541 // The generated `VersionedGrpcResponse::metadata()` handles both
542 // response envelopes (and any future one), so no hand-written
543 // version match is needed here.
544 use dapi_grpc::platform::VersionedGrpcResponse;
545 let time_ms = response
546 .metadata()
547 .map(|metadata| metadata.time_ms)
548 .map_err(|_| drive_proof_verifier::Error::ResponseDecodeError {
549 error: "time range query proof response is missing block-time metadata"
550 .to_string(),
551 })?;
552 resolved_time_ranges =
553 normalize_time_range_clauses_with_metadata_time(&mut request, time_ms)?;
554 }
555
556 let mut drive_query: DriveDocumentQuery =
557 (&request)
558 .try_into()
559 .map_err(|e| drive_proof_verifier::Error::RequestError {
560 error: format!("Failed to convert DocumentQuery to DriveQuery: {}", e),
561 })?;
562 // The conversion cannot recover which equalities came from resolution,
563 // so the provenance is carried across here; index selection reads it
564 // to pin the query to the index that buckets the field.
565 drive_query.resolved_time_ranges = resolved_time_ranges;
566
567 <drive_proof_verifier::types::Documents as FromProof<DriveDocumentQuery>>::maybe_from_proof_with_metadata(
568 drive_query,
569 response,
570 network,
571 platform_version,
572 provider,
573 )
574 }
575}
576
577/// Resolve a request's pending time-range (`IN_TIME_RANGE`) selections into
578/// concrete bucket-equality clauses on `request.where_clauses`, using the
579/// **quorum-signed** response metadata block time — the same authoritative
580/// time the server used — so the reconstructed query matches the proof
581/// exactly.
582///
583/// Every proof-verification path that rebuilds a drive query from a
584/// [`DocumentQuery`] must call this (or perform the identical resolution)
585/// *before* reading `request.where_clauses` for mode detection, covering-index
586/// selection, or query reconstruction: the documents path does it inline in
587/// its `FromProof` impl, and the count / sum / average aggregate helpers call
588/// this before resolving their mode. Skipping it would rebuild the query from
589/// a different shape than the prover used.
590///
591/// Returns the resolution provenance — one [`ResolvedTimeRange`] (field +
592/// exact grid) per selection, whose pushed clause is a bucket equality
593/// rather than a raw-timestamp one. Callers must carry them into index
594/// selection (`DriveDocumentQuery::resolved_time_ranges`, or the
595/// `resolved_time_ranges` argument of the aggregate index pickers):
596/// the pushed clause is an ordinary equality and nothing downstream can
597/// otherwise tell that it must be matched against the resolved grid's
598/// bucket starts.
599/// [`resolve_time_range_clauses_with_metadata_time`] followed immediately by
600/// the provenance-vs-shape guard — the two-step normalization every
601/// proof-verification path must run, in this order, before mode detection,
602/// covering-index selection, or query reconstruction. One definition so a
603/// future verifier path cannot omit either step or run them out of order:
604/// the aggregate paths once omitted the resolution entirely (valid proofs
605/// were rejected), and a path that resolved without the guard would let a
606/// caller-provided `In`/range clause on the resolved field reach the index
607/// pickers as if its raw values were bucket starts.
608pub(super) fn normalize_time_range_clauses_with_metadata_time(
609 request: &mut DocumentQuery,
610 time_ms: u64,
611) -> Result<Vec<ResolvedTimeRange>, drive_proof_verifier::Error> {
612 let resolved_time_ranges = resolve_time_range_clauses_with_metadata_time(request, time_ms)?;
613 validate_resolved_time_range_clause_shapes(&request.where_clauses, &resolved_time_ranges)
614 .map_err(|e| drive_proof_verifier::Error::RequestError {
615 error: format!("invalid time range query shape: {}", e),
616 })?;
617 Ok(resolved_time_ranges)
618}
619
620pub(super) fn resolve_time_range_clauses_with_metadata_time(
621 request: &mut DocumentQuery,
622 time_ms: u64,
623) -> Result<Vec<ResolvedTimeRange>, drive_proof_verifier::Error> {
624 if request.time_range_clauses.is_empty() {
625 return Ok(Vec::new());
626 }
627 let data_contract = Arc::clone(&request.data_contract);
628 let document_type = data_contract
629 .document_type_for_name(&request.document_type_name)
630 .map_err(|e| drive_proof_verifier::Error::RequestError {
631 error: format!("document type not found for time range query: {}", e),
632 })?;
633 let time_range_clauses = std::mem::take(&mut request.time_range_clauses);
634 let mut resolved_time_ranges = Vec::with_capacity(time_range_clauses.len());
635 for TimeRangeClause {
636 field,
637 selector,
638 grid,
639 } in time_range_clauses
640 {
641 let (clause, resolved) =
642 resolve_time_range_bucket_clause(&field, selector, grid, document_type, time_ms)
643 .map_err(|e| drive_proof_verifier::Error::RequestError {
644 error: format!("failed to resolve time range clause: {}", e),
645 })?;
646 request.where_clauses.push(clause);
647 resolved_time_ranges.push(resolved);
648 }
649 Ok(resolved_time_ranges)
650}
651
652/// Version-aware encoder. The dispatch is driven by the
653/// `drive_abci.query.document_query` feature-version on
654/// [`PlatformVersion`]: `0` → V0 wire (used by v3.0 testnet), `1` →
655/// V1 wire (introduced in v3.1).
656///
657/// V0 lacks `selects` / `group_by` / `having` / `offset` and the
658/// optional-limit semantics — callers that set those features get
659/// `Error::Config` with a clear "requires Platform v3.1+" message
660/// rather than a silently-truncated request. Time-range clauses are
661/// additionally gated on the v14 contract grammar — see the `1 =>` arm.
662impl TryFromPlatformVersioned<DocumentQuery> for GetDocumentsRequest {
663 type Error = Error;
664
665 fn try_from_platform_versioned(
666 value: DocumentQuery,
667 platform_version: &PlatformVersion,
668 ) -> Result<Self, Self::Error> {
669 let DocumentQuery {
670 select,
671 data_contract,
672 document_type_name,
673 where_clauses,
674 time_range_clauses,
675 group_by,
676 having,
677 order_by_clauses,
678 limit,
679 offset,
680 start,
681 } = value;
682
683 let feature_version = platform_version
684 .drive_abci
685 .query
686 .document_query
687 .default_current_version;
688
689 tracing::debug!(
690 target: "dash_sdk::query_encoder",
691 feature_version,
692 protocol_version = platform_version.protocol_version,
693 "encoding GetDocumentsRequest"
694 );
695
696 match feature_version {
697 0 => {
698 if !time_range_clauses.is_empty() {
699 return Err(Error::Config(
700 "time range (IN_TIME_RANGE) queries require protocol version 14+; the \
701 v0 getDocuments wire has no time-range operator"
702 .to_string(),
703 ));
704 }
705 encode_v0(
706 data_contract.id().to_vec(),
707 document_type_name,
708 where_clauses,
709 order_by_clauses,
710 limit,
711 offset,
712 start,
713 &select,
714 &group_by,
715 &having,
716 )
717 }
718 1 => {
719 // The v1 wire predates time-range indexes: protocol
720 // versions 12 and 13 also serve it, but their contract
721 // grammar (document meta-schema generations 1 and 2)
722 // cannot host a `timeRange` index. Gate on the grammar
723 // generation — the same table the server's parser reads —
724 // rather than emitting an operator a pre-v14 server
725 // rejects as an unknown discriminant.
726 let grammar_generation = platform_version
727 .dpp
728 .contract_versions
729 .document_type_versions
730 .schema
731 .document_type_schema;
732 if !time_range_clauses.is_empty() && grammar_generation < 3 {
733 return Err(Error::Config(format!(
734 "time range (IN_TIME_RANGE) queries require protocol version 14+ — the \
735 first version whose contract grammar hosts `timeRange` indexes; this \
736 network runs protocol version {}",
737 platform_version.protocol_version
738 )));
739 }
740 encode_v1(
741 data_contract.id().to_vec(),
742 document_type_name,
743 where_clauses,
744 time_range_clauses,
745 order_by_clauses,
746 limit,
747 offset,
748 start,
749 select,
750 group_by,
751 having,
752 )
753 }
754 n => Err(Error::Config(format!(
755 "GetDocumentsRequest wire encoder does not support feature_version={n} \
756 (drive_abci.query.document_query) on PlatformVersion v{}",
757 platform_version.protocol_version
758 ))),
759 }
760 }
761}
762
763#[allow(clippy::too_many_arguments)]
764fn encode_v1(
765 data_contract_id: Vec<u8>,
766 document_type: String,
767 where_clauses: Vec<WhereClause>,
768 time_range_clauses: Vec<TimeRangeClause>,
769 order_by_clauses: Vec<OrderClause>,
770 limit: u32,
771 offset: Option<u32>,
772 start: Option<Start>,
773 select: SelectProjection,
774 group_by: Vec<String>,
775 having: Vec<HavingClause>,
776) -> Result<GetDocumentsRequest, Error> {
777 let mut where_clauses = where_clauses
778 .into_iter()
779 .map(where_clause_to_proto)
780 .collect::<Result<Vec<_>, _>>()?;
781 // Append time-range selections as `IN_TIME_RANGE` clauses. A grid-less
782 // selection rides as the bare `"newest"`/`"oldest"` text operand; a
783 // grid-targeted one as the list operand `[selector, range, step]` /
784 // `[selector, range, step, phase]` in the contract's declared seconds
785 // (zero phase spelled by omission — one wire spelling per grid, the
786 // same rule the contract grammar and the storage key follow). The
787 // server resolves them to a concrete bucket from current block time;
788 // the verifier re-derives the same bucket from the signed response
789 // metadata time.
790 for TimeRangeClause {
791 field,
792 selector,
793 grid,
794 } in time_range_clauses
795 {
796 let selector_value = ProtoDocumentFieldValue {
797 variant: Some(document_field_value::Variant::Text(
798 selector.as_str().to_string(),
799 )),
800 };
801 let operand = match grid {
802 None => selector_value,
803 Some(spec) => {
804 let uint = |n: u64| ProtoDocumentFieldValue {
805 variant: Some(document_field_value::Variant::Uint64Value(n)),
806 };
807 let mut values = vec![
808 selector_value,
809 uint(spec.range_seconds),
810 uint(spec.step_seconds),
811 ];
812 if spec.phase_seconds != 0 {
813 values.push(uint(spec.phase_seconds));
814 }
815 ProtoDocumentFieldValue {
816 variant: Some(document_field_value::Variant::List(
817 document_field_value::ValueList { values },
818 )),
819 }
820 }
821 };
822 where_clauses.push(ProtoWhereClause {
823 field,
824 operator: ProtoWhereOperator::InTimeRange as i32,
825 value: Some(operand),
826 });
827 }
828 let order_by = order_by_clauses
829 .into_iter()
830 .map(order_clause_to_proto)
831 .collect();
832 let having = having
833 .into_iter()
834 .map(having_clause_to_proto)
835 .collect::<Result<Vec<_>, _>>()?;
836 // `limit: u32` with `0` sentinel → `optional uint32` on the V1
837 // wire. `None` lets the server apply its own default; explicit
838 // `0` would be a strange "return zero rows" request.
839 let limit = if limit == 0 { None } else { Some(limit) };
840 // V0 and V1 ship separate `Start` enums even though the shape
841 // is identical. Translate at the wire boundary so the
842 // `DocumentQuery.start` field stays stable for callers already
843 // using the V0 type.
844 let start_v1 = start.map(|s| match s {
845 Start::StartAfter(b) => V1Start::StartAfter(b),
846 Start::StartAt(b) => V1Start::StartAt(b),
847 });
848
849 Ok(GetDocumentsRequest {
850 version: Some(V1(GetDocumentsRequestV1 {
851 data_contract_id,
852 document_type,
853 where_clauses,
854 order_by,
855 limit,
856 // Document fetch always proves via this conversion.
857 // Count fetch uses the same wire shape; both paths go
858 // through the `FromProof` decoders which expect the
859 // `Proof(...)` response variant. `SdkBuilder::with_proofs(false)`
860 // is consequently a no-op for both — see the blanket
861 // `Query<T> for T` impl in `packages/rs-sdk/src/platform/query.rs`
862 // for the `tracing::warn!` emitted at fetch time when
863 // proofs are disabled.
864 prove: true,
865 start: start_v1,
866 // `repeated Select selects` on the wire — single
867 // projection wraps in a one-element vec; the SDK's
868 // `DocumentQuery` carries a single `SelectProjection`
869 // because multi-projection is wire-only today.
870 selects: vec![select_to_proto(select)],
871 group_by,
872 having,
873 // Honoured on the ranked path (it is the `OFFSET` of
874 // `ORDER BY <agg> DESC LIMIT n OFFSET m`) and rejected by
875 // the server everywhere else. Passed straight through:
876 // deciding here which paths may carry an offset would put
877 // a second copy of that rule in the SDK.
878 offset,
879 })),
880 })
881}
882
883#[allow(clippy::too_many_arguments)]
884fn encode_v0(
885 data_contract_id: Vec<u8>,
886 document_type: String,
887 where_clauses: Vec<WhereClause>,
888 order_by_clauses: Vec<OrderClause>,
889 limit: u32,
890 offset: Option<u32>,
891 start: Option<Start>,
892 select: &SelectProjection,
893 group_by: &[String],
894 having: &[HavingClause],
895) -> Result<GetDocumentsRequest, Error> {
896 // V0 only carries plain `getDocuments` semantics — reject the
897 // v1-only SQL-shaped surfaces with a typed error rather than
898 // letting the server reject them after a round-trip.
899 if !matches!(select.function, SelectFunction::Documents) {
900 return Err(Error::Config(format!(
901 "select={:?} requires Platform v3.1+ (V1 documents wire); pin/upgrade \
902 to a v3.1+ network or rebuild the query with SelectProjection::documents()",
903 select.function
904 )));
905 }
906 if !group_by.is_empty() {
907 return Err(Error::Config(
908 "group_by requires Platform v3.1+ (V1 documents wire); not supported on V0".to_string(),
909 ));
910 }
911 if !having.is_empty() {
912 return Err(Error::Config(
913 "having clauses require Platform v3.1+ (V1 documents wire); not supported on V0"
914 .to_string(),
915 ));
916 }
917 if offset.is_some() {
918 // The V0 request message has no `offset` field at all, so
919 // silently dropping it would page from rank 0 while the caller
920 // believed they had skipped ahead — the one failure mode worth
921 // an extra branch here.
922 return Err(Error::Config(
923 "offset requires Platform v3.1+ (V1 documents wire); not supported on V0".to_string(),
924 ));
925 }
926
927 // V0 carries CBOR-serialized arrays of clause components. The
928 // server decodes them via `ciborium::de::from_reader` into a
929 // `Value`, then expects `Value::Array(clauses)` where each
930 // inner clause is `[field_text, operator_text, value]` (where)
931 // or `[field_text, "asc"|"desc"]` (order_by). Build the same
932 // shape via the existing `From<WhereClause> for Value` /
933 // `From<OrderClause> for Value` impls, then serialize the
934 // top-level array.
935 let where_bytes = if where_clauses.is_empty() {
936 Vec::new()
937 } else {
938 let where_value = Value::Array(where_clauses.into_iter().map(Value::from).collect());
939 where_value.to_cbor_buffer().map_err(|e| {
940 Error::Protocol(dpp::ProtocolError::EncodingError(format!(
941 "failed to CBOR-encode v0 where clauses: {e}"
942 )))
943 })?
944 };
945 let order_by_bytes = if order_by_clauses.is_empty() {
946 Vec::new()
947 } else {
948 let order_value = Value::Array(order_by_clauses.into_iter().map(Value::from).collect());
949 order_value.to_cbor_buffer().map_err(|e| {
950 Error::Protocol(dpp::ProtocolError::EncodingError(format!(
951 "failed to CBOR-encode v0 order_by clauses: {e}"
952 )))
953 })?
954 };
955
956 Ok(GetDocumentsRequest {
957 version: Some(V0(GetDocumentsRequestV0 {
958 data_contract_id,
959 document_type,
960 r#where: where_bytes,
961 order_by: order_by_bytes,
962 // V0's `limit` is a plain u32 with 0 = "server default".
963 // V1's `optional uint32` keeps 0 as a structurally
964 // meaningless explicit-zero; we translate by clamping
965 // to 0 only when the caller meant "unset".
966 limit,
967 start,
968 // `prove: true` hardcoded — same rationale as `encode_v1`:
969 // document fetch always proves; `SdkBuilder::with_proofs(false)`
970 // is a no-op for this path because the `FromProof` decoder
971 // expects the `Proof(...)` response variant.
972 prove: true,
973 })),
974 })
975}
976
977impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery {
978 type Error = crate::error::Error;
979
980 /// Fallible by necessity: a drive query carrying `resolved_time_ranges`
981 /// holds bucket-start equalities whose meaning lives in the provenance,
982 /// and `DocumentQuery` has no field to carry it — the original
983 /// `IN_TIME_RANGE` selector cannot be reconstructed from the resolved
984 /// query. Serializing such a query would silently demote the bucket
985 /// equality to a raw-timestamp predicate: a transformed-index-only
986 /// contract then rejects the request, while a contract with a competing
987 /// plain index returns a different — but validly proven — result.
988 fn try_from(value: &'a DriveDocumentQuery<'a>) -> Result<Self, Self::Error> {
989 if !value.resolved_time_ranges.is_empty() {
990 return Err(Error::Config(
991 "a drive query carrying time-range resolution provenance cannot be \
992 converted to a DocumentQuery: the resolved bucket equality would be \
993 demoted to a raw-timestamp predicate. Build the DocumentQuery with \
994 `with_time_range` / `with_time_range_grid` instead, so the selector \
995 is resolved against the signed response metadata"
996 .to_string(),
997 ));
998 }
999 let data_contract = value.contract.clone();
1000 let document_type_name = value.document_type.name();
1001 let where_clauses = value.internal_clauses.clone().into();
1002 let order_by_clauses = value.order_by.iter().map(|(_, v)| v.clone()).collect();
1003 let limit = value.limit.unwrap_or(0) as u32;
1004 let offset = value.offset.map(u32::from);
1005
1006 let start = if let Some(start_at) = value.start_at {
1007 match value.start_at_included {
1008 true => Some(Start::StartAt(start_at.to_vec())),
1009 false => Some(Start::StartAfter(start_at.to_vec())),
1010 }
1011 } else {
1012 None
1013 };
1014
1015 Ok(Self {
1016 // `DriveDocumentQuery` has no SELECT/GROUP BY/HAVING/time-range
1017 // concept — it's a documents-only query. Default to the
1018 // v1 documents shape.
1019 select: SelectProjection::documents(),
1020 data_contract: Arc::new(data_contract),
1021 document_type_name: document_type_name.to_string(),
1022 where_clauses,
1023 time_range_clauses: Vec::new(),
1024 group_by: Vec::new(),
1025 having: Vec::new(),
1026 order_by_clauses,
1027 limit,
1028 offset,
1029 start,
1030 })
1031 }
1032}
1033
1034impl<'a> TryFrom<DriveDocumentQuery<'a>> for DocumentQuery {
1035 type Error = crate::error::Error;
1036
1037 /// By-value twin of the by-reference conversion above — same
1038 /// provenance rejection, same rationale.
1039 fn try_from(value: DriveDocumentQuery<'a>) -> Result<Self, Self::Error> {
1040 DocumentQuery::try_from(&value)
1041 }
1042}
1043
1044impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> {
1045 type Error = crate::error::Error;
1046
1047 fn try_from(request: &'a DocumentQuery) -> Result<Self, Self::Error> {
1048 // A pending (unresolved) time-range selection MUST be resolved into a
1049 // concrete bucket-equality clause before a drive query can be built —
1050 // see `resolve_time_range_clauses_with_metadata_time`. Silently
1051 // dropping it here would rebuild (and verify against) a strictly
1052 // broader query than the prover ran, so refuse instead: this makes
1053 // "forgot to resolve" a loud error on every present and future call
1054 // path rather than a silent verification hole.
1055 if !request.time_range_clauses.is_empty() {
1056 return Err(Error::Config(
1057 "the query's time range (IN_TIME_RANGE) selections have not been resolved into \
1058 bucket equalities; resolve them against the response's quorum-signed metadata \
1059 time before building a drive query"
1060 .to_string(),
1061 ));
1062 }
1063
1064 // let data_contract = request.data_contract.clone();
1065 let document_type = request
1066 .data_contract
1067 .document_type_for_name(&request.document_type_name)
1068 .map_err(ProtocolError::DataContractError)?;
1069
1070 // Client-side construction groups under the latest grammar; the
1071 // server and the proof verifier enforce the network's protocol
1072 // version at path-query lowering.
1073 let internal_clauses = InternalClauses::extract_from_clauses(
1074 request.where_clauses.clone(),
1075 PlatformVersion::latest(),
1076 )
1077 .map_err(Error::Drive)?;
1078
1079 // Mirror the limit contract of the server's
1080 // `DriveDocumentQuery::from_typed_clauses` exactly: `0` (this
1081 // struct's "unset" sentinel — V0's `limit: 0`, V1's
1082 // `limit: None`) falls back to the server default, and anything
1083 // above `DEFAULT_QUERY_LIMIT` (the `config.default_query_limit`
1084 // every deployed server runs with) is refused with the server's
1085 // own `QuerySyntaxError::InvalidLimit` rather than truncated or
1086 // passed through. A `u16::try_from` alone would not do: limits
1087 // 101..=65535 fit a `u16` but the server refuses them, so a raw
1088 // `DriveDocumentQuery` carrying one would verify a proof no
1089 // honest server could have produced.
1090 let limit = match request.limit {
1091 0 => Some(DEFAULT_QUERY_LIMIT),
1092 limit if limit > u32::from(DEFAULT_QUERY_LIMIT) => {
1093 return Err(Error::Drive(drive::error::Error::Query(
1094 drive::error::query::QuerySyntaxError::InvalidLimit(format!(
1095 "limit {} greater than max limit {}",
1096 limit, DEFAULT_QUERY_LIMIT
1097 )),
1098 )));
1099 }
1100 limit => Some(limit as u16),
1101 };
1102
1103 let (start_at, start_at_included) = match request.start.as_ref() {
1104 None => (None, false),
1105 Some(Start::StartAt(at)) => (
1106 Some(at.clone().try_into().map_err(|_| {
1107 ProtocolError::InvalidVectorSizeError(InvalidVectorSizeError::new(32, at.len()))
1108 })?),
1109 true,
1110 ),
1111 Some(Start::StartAfter(after)) => (
1112 Some(after.clone().try_into().map_err(|_| {
1113 ProtocolError::InvalidVectorSizeError(InvalidVectorSizeError::new(
1114 32,
1115 after.len(),
1116 ))
1117 })?),
1118 false,
1119 ),
1120 };
1121
1122 // `DriveDocumentQuery`'s offset is a `u16`; the wire's is a
1123 // `u32` because the ranked path takes an unbounded one. A
1124 // documents query that overflows `u16` is refused rather than
1125 // truncated — silently paging from a different rank than the
1126 // caller asked for is the worst available outcome.
1127 let offset = request
1128 .offset
1129 .map(|offset| {
1130 u16::try_from(offset).map_err(|_| {
1131 Error::Config(format!(
1132 "offset {offset} does not fit a documents query's u16 offset \
1133 (max {}); offsets above that are only meaningful on the ranked \
1134 surface, which does not route through DriveDocumentQuery",
1135 u16::MAX
1136 ))
1137 })
1138 })
1139 .transpose()?;
1140
1141 let query = Self {
1142 contract: &request.data_contract,
1143 document_type,
1144 internal_clauses,
1145 offset,
1146 limit,
1147 order_by: request
1148 .order_by_clauses
1149 .clone()
1150 .into_iter()
1151 .map(|v| (v.field.clone(), v))
1152 .collect(),
1153 start_at,
1154 start_at_included,
1155 block_time_ms: None,
1156 // A `DocumentQuery` reaching here carries no unresolved
1157 // time-range selection (rejected above) and cannot tell which of
1158 // its equalities came from resolution. Callers that resolved
1159 // selections assign the fields they resolved onto the returned
1160 // query; everything else is a raw query.
1161 resolved_time_ranges: vec![],
1162 };
1163
1164 Ok(query)
1165 }
1166}
1167
1168/// Convert a drive [`WhereClause`] into its wire-format proto
1169/// counterpart. The proto value variant is picked from the
1170/// `dpp::platform_value::Value` variant by primitive type — schema-
1171/// agnostic, matching the inverse direction the rs-drive-abci v1
1172/// handler runs via its `conversions::value_from_proto`.
1173///
1174/// Errors only on `Value` variants that have no wire-format
1175/// counterpart (`Map`, `EnumU8`, `EnumString`) — these aren't
1176/// produced by the SDK's typical WhereClause builders, so a
1177/// rejection here flags an unsupported caller construction at the
1178/// wire boundary rather than silently dropping the value.
1179fn where_clause_to_proto(clause: WhereClause) -> Result<ProtoWhereClause, Error> {
1180 Ok(ProtoWhereClause {
1181 field: clause.field,
1182 operator: where_operator_to_proto(clause.operator) as i32,
1183 value: Some(value_to_proto(clause.value)?),
1184 })
1185}
1186
1187fn order_clause_to_proto(clause: OrderClause) -> ProtoOrderClause {
1188 // Drive's `OrderClause` carries a plain `field: String` —
1189 // emit the field-target variant of the wire's `target` oneof.
1190 // The aggregate-target variant (`ORDER BY COUNT(*)`) is
1191 // wire-only today; when drive's `OrderClause` gains an
1192 // aggregate target the SDK gets a parallel builder.
1193 ProtoOrderClause {
1194 target: Some(order_clause::Target::Field(clause.field)),
1195 ascending: clause.ascending,
1196 }
1197}
1198
1199/// Convert a drive [`HavingClause`] into its wire-format proto
1200/// counterpart. The inverse of `rs-drive-abci`'s
1201/// `having_clause_from_proto`. Errors only on `Value` variants
1202/// the underlying `value_to_proto` can't represent — every
1203/// `HavingOperator` / `HavingAggregateFunction` discriminant has a
1204/// 1:1 wire counterpart and is always convertible.
1205fn having_clause_to_proto(clause: HavingClause) -> Result<ProtoHavingClause, Error> {
1206 let right = match clause.right {
1207 HavingRightOperand::Value(v) => having_clause::Right::Value(value_to_proto(v)?),
1208 };
1209 Ok(ProtoHavingClause {
1210 aggregate: Some(having_aggregate_to_proto(clause.aggregate)),
1211 operator: having_operator_to_proto(clause.operator) as i32,
1212 right: Some(right),
1213 })
1214}
1215
1216fn having_aggregate_to_proto(aggregate: HavingAggregate) -> ProtoHavingAggregate {
1217 ProtoHavingAggregate {
1218 function: having_function_to_proto(aggregate.function) as i32,
1219 field: aggregate.field,
1220 }
1221}
1222
1223fn having_function_to_proto(function: HavingAggregateFunction) -> having_aggregate::Function {
1224 match function {
1225 HavingAggregateFunction::Count => having_aggregate::Function::Count,
1226 HavingAggregateFunction::Sum => having_aggregate::Function::Sum,
1227 HavingAggregateFunction::Avg => having_aggregate::Function::Avg,
1228 }
1229}
1230
1231/// Convert a drive [`SelectProjection`] into its wire-format
1232/// proto counterpart. Inverse of `rs-drive-abci`'s
1233/// `select_from_proto`. Always succeeds — every
1234/// `SelectFunction` discriminant has a 1:1 wire counterpart.
1235fn select_to_proto(select: SelectProjection) -> ProtoSelect {
1236 ProtoSelect {
1237 function: select_function_to_proto(select.function) as i32,
1238 field: select.field,
1239 }
1240}
1241
1242fn select_function_to_proto(function: SelectFunction) -> select::Function {
1243 match function {
1244 SelectFunction::Documents => select::Function::Documents,
1245 SelectFunction::Count => select::Function::Count,
1246 SelectFunction::Sum => select::Function::Sum,
1247 SelectFunction::Avg => select::Function::Avg,
1248 SelectFunction::Min => select::Function::Min,
1249 SelectFunction::Max => select::Function::Max,
1250 }
1251}
1252
1253fn having_operator_to_proto(op: HavingOperator) -> having_clause::Operator {
1254 match op {
1255 HavingOperator::Equal => having_clause::Operator::Equal,
1256 HavingOperator::NotEqual => having_clause::Operator::NotEqual,
1257 HavingOperator::GreaterThan => having_clause::Operator::GreaterThan,
1258 HavingOperator::GreaterThanOrEquals => having_clause::Operator::GreaterThanOrEquals,
1259 HavingOperator::LessThan => having_clause::Operator::LessThan,
1260 HavingOperator::LessThanOrEquals => having_clause::Operator::LessThanOrEquals,
1261 HavingOperator::Between => having_clause::Operator::Between,
1262 HavingOperator::BetweenExcludeBounds => having_clause::Operator::BetweenExcludeBounds,
1263 HavingOperator::BetweenExcludeLeft => having_clause::Operator::BetweenExcludeLeft,
1264 HavingOperator::BetweenExcludeRight => having_clause::Operator::BetweenExcludeRight,
1265 HavingOperator::In => having_clause::Operator::In,
1266 }
1267}
1268
1269fn where_operator_to_proto(op: WhereOperator) -> ProtoWhereOperator {
1270 match op {
1271 WhereOperator::Equal => ProtoWhereOperator::Equal,
1272 WhereOperator::GreaterThan => ProtoWhereOperator::GreaterThan,
1273 WhereOperator::GreaterThanOrEquals => ProtoWhereOperator::GreaterThanOrEquals,
1274 WhereOperator::LessThan => ProtoWhereOperator::LessThan,
1275 WhereOperator::LessThanOrEquals => ProtoWhereOperator::LessThanOrEquals,
1276 WhereOperator::Between => ProtoWhereOperator::Between,
1277 WhereOperator::BetweenExcludeBounds => ProtoWhereOperator::BetweenExcludeBounds,
1278 WhereOperator::BetweenExcludeLeft => ProtoWhereOperator::BetweenExcludeLeft,
1279 WhereOperator::BetweenExcludeRight => ProtoWhereOperator::BetweenExcludeRight,
1280 WhereOperator::In => ProtoWhereOperator::In,
1281 WhereOperator::StartsWith => ProtoWhereOperator::StartsWith,
1282 }
1283}
1284
1285/// Map `dpp::platform_value::Value` onto the wire-shape
1286/// [`ProtoDocumentFieldValue`]. The schema-driven decode on the
1287/// server side resolves the actual indexed type — this layer just
1288/// names the primitive.
1289///
1290/// Mapping rules:
1291/// - `Bool` → `BoolValue`
1292/// - `I8`/`I16`/`I32`/`I64` → `Int64Value` (widened)
1293/// - `U8`/`U16`/`U32`/`U64` → `Uint64Value` (widened)
1294/// - `Float` → `DoubleValue`
1295/// - `Text` → `Text`
1296/// - `Bytes`/`Bytes20`/`Bytes32`/`Bytes36`/`Identifier` → `BytesValue`
1297/// - `U128`/`I128` → `Text` (decimal string). **Not yet
1298/// round-trippable against `U128`/`I128`-typed indexed fields**:
1299/// the v1 typed-decode path (`v1/conversions.rs::value_from_proto`)
1300/// passes the text through as `Value::Text`, and the
1301/// downstream executor's strict `Value::to_integer()` then
1302/// rejects it. Schema-aware coercion (the
1303/// `DocumentPropertyType::value_from_string` path the v0 SQL
1304/// parser uses) hasn't been threaded through to the typed
1305/// path yet. The encoding is shipped because the proto needs a
1306/// home for 128-bit values; no production system contract
1307/// indexes `U128`/`I128` today. Tracked in the v1 follow-up
1308/// issue.
1309/// - `Array` → `List` (recursive, but only one level deep —
1310/// `value_to_proto` rejects nested arrays with
1311/// `EncodingError("nested DocumentFieldValue.list …")` to
1312/// match the server-side depth cap in
1313/// `v1/conversions.rs::value_from_proto_at_depth`, so wire-
1314/// malformed shapes fail at request-construction time with a
1315/// deterministic local error rather than after a transport
1316/// round-trip.
1317/// - `Null` → `NullValue(true)` (the `bool` payload is a
1318/// placeholder per the proto-side comment; only the variant
1319/// discriminant carries meaning)
1320/// - `Map`/`EnumU8`/`EnumString` → `Error` (no wire-format
1321/// counterpart for these shapes in a WhereClause operand)
1322fn value_to_proto(value: Value) -> Result<ProtoDocumentFieldValue, Error> {
1323 value_to_proto_at_depth(value, 0)
1324}
1325
1326/// Recursion-bounded form of [`value_to_proto`]. Mirrors the
1327/// server-side `value_from_proto_at_depth` contract so encoder
1328/// and decoder agree on the supported `Value` subset: `depth = 0`
1329/// is the clause-level operand; `Array` is legal once (the flat
1330/// list of scalars for `IN` / `BETWEEN*`); any deeper nesting
1331/// rejects locally instead of producing a request the server
1332/// would round-trip just to reject.
1333fn value_to_proto_at_depth(value: Value, depth: u8) -> Result<ProtoDocumentFieldValue, Error> {
1334 let variant = match value {
1335 Value::Null => document_field_value::Variant::NullValue(true),
1336 Value::Bool(b) => document_field_value::Variant::BoolValue(b),
1337 Value::I8(i) => document_field_value::Variant::Int64Value(i as i64),
1338 Value::I16(i) => document_field_value::Variant::Int64Value(i as i64),
1339 Value::I32(i) => document_field_value::Variant::Int64Value(i as i64),
1340 Value::I64(i) => document_field_value::Variant::Int64Value(i),
1341 Value::U8(u) => document_field_value::Variant::Uint64Value(u as u64),
1342 Value::U16(u) => document_field_value::Variant::Uint64Value(u as u64),
1343 Value::U32(u) => document_field_value::Variant::Uint64Value(u as u64),
1344 Value::U64(u) => document_field_value::Variant::Uint64Value(u),
1345 Value::Float(f) => document_field_value::Variant::DoubleValue(f),
1346 Value::Text(s) => document_field_value::Variant::Text(s),
1347 Value::Bytes(b) => document_field_value::Variant::BytesValue(b),
1348 Value::Bytes20(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1349 Value::Bytes32(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1350 Value::Bytes36(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1351 Value::Identifier(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1352 // u128 / i128 don't fit in `int64_value`/`uint64_value`;
1353 // encode as a decimal string. See the function-level
1354 // docstring for the U128/I128 round-trip caveat.
1355 Value::U128(u) => document_field_value::Variant::Text(u.to_string()),
1356 Value::I128(i) => document_field_value::Variant::Text(i.to_string()),
1357 Value::Array(items) => {
1358 if depth >= 1 {
1359 return Err(Error::Protocol(dpp::ProtocolError::EncodingError(
1360 "nested DocumentFieldValue.list is not supported on the v1 \
1361 query surface; `IN` / `BETWEEN*` candidate lists are flat \
1362 scalars only"
1363 .to_string(),
1364 )));
1365 }
1366 document_field_value::Variant::List(document_field_value::ValueList {
1367 values: items
1368 .into_iter()
1369 .map(|v| value_to_proto_at_depth(v, depth + 1))
1370 .collect::<Result<Vec<_>, _>>()?,
1371 })
1372 }
1373 // Catches both `Value::Map(_)` / `Value::EnumU8(_)` /
1374 // `Value::EnumString(_)` (no wire-format counterpart for
1375 // these shapes in a WhereClause operand) and any
1376 // future-added variant — `dpp::platform_value::Value` is
1377 // `#[non_exhaustive]`, so the SDK fails loudly rather
1378 // than silently dropping data the moment upstream adds a
1379 // variant we don't yet know how to encode.
1380 _ => {
1381 return Err(Error::Protocol(dpp::ProtocolError::EncodingError(format!(
1382 "Value variant has no `DocumentFieldValue` wire-format counterpart: {value:?}"
1383 ))));
1384 }
1385 };
1386 Ok(ProtoDocumentFieldValue {
1387 variant: Some(variant),
1388 })
1389}
1390
1391#[cfg(test)]
1392mod encode_version_gate_tests {
1393 //! The `IN_TIME_RANGE` operator is emitted only for protocol versions
1394 //! whose contract grammar hosts `timeRange` indexes (document
1395 //! meta-schema generation 3, first pinned by protocol version 14).
1396 //! Protocol versions 12 and 13 serve the same v1 getDocuments wire but
1397 //! predate the grammar — encoding for them must refuse up front
1398 //! instead of sending an operator the server rejects as an unknown
1399 //! discriminant.
1400
1401 use super::*;
1402 use dpp::data_contract::DataContractFactory;
1403 use dpp::platform_value::platform_value;
1404 use dpp::prelude::{DataContract, Identifier};
1405 use drive::query::TimeRangeSelector;
1406
1407 fn post_contract() -> Arc<DataContract> {
1408 let schemas = platform_value!({
1409 "post": {
1410 "type": "object",
1411 "properties": {
1412 "hashtag": { "type": "string", "maxLength": 63, "position": 0 },
1413 },
1414 "required": ["hashtag"],
1415 "additionalProperties": false,
1416 }
1417 });
1418 let contract = DataContractFactory::new(PlatformVersion::latest().protocol_version)
1419 .expect("expected a factory")
1420 .create_with_value_config(Identifier::new([7u8; 32]), 0, schemas, None, None)
1421 .expect("the post contract is well-formed")
1422 .data_contract_owned();
1423 Arc::new(contract)
1424 }
1425
1426 fn newest_time_range_query() -> DocumentQuery {
1427 DocumentQuery::new(post_contract(), "post")
1428 .expect("the fixture has this document type")
1429 .with_time_range("$createdAt", TimeRangeSelector::Newest)
1430 }
1431
1432 #[test]
1433 fn a_time_range_query_refuses_to_encode_for_protocol_version_13() {
1434 let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists");
1435 let error = GetDocumentsRequest::try_from_platform_versioned(
1436 newest_time_range_query(),
1437 platform_version,
1438 )
1439 .expect_err("protocol version 13's contract grammar has no timeRange indexes");
1440 let message = match error {
1441 Error::Config(message) => message,
1442 other => panic!("expected Error::Config, got {other:?}"),
1443 };
1444 assert!(
1445 message.contains("protocol version 14"),
1446 "the refusal must name the real version floor, got: {message}"
1447 );
1448 }
1449
1450 #[test]
1451 fn a_time_range_query_encodes_the_operator_for_protocol_version_14() {
1452 let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists");
1453 let request = GetDocumentsRequest::try_from_platform_versioned(
1454 newest_time_range_query(),
1455 platform_version,
1456 )
1457 .expect("protocol version 14 hosts timeRange indexes");
1458 let Some(V1(v1)) = request.version else {
1459 panic!("protocol version 14 encodes on the v1 wire");
1460 };
1461 let operators: Vec<i32> = v1
1462 .where_clauses
1463 .iter()
1464 .map(|clause| clause.operator)
1465 .collect();
1466 assert_eq!(
1467 operators,
1468 vec![ProtoWhereOperator::InTimeRange as i32],
1469 "the pending selector must ride as exactly one IN_TIME_RANGE clause"
1470 );
1471 }
1472
1473 #[test]
1474 fn a_query_without_time_range_clauses_still_encodes_for_protocol_version_13() {
1475 let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists");
1476 let query = DocumentQuery::new(post_contract(), "post")
1477 .expect("the fixture has this document type");
1478 GetDocumentsRequest::try_from_platform_versioned(query, platform_version)
1479 .expect("the gate only refuses queries that carry a time-range selection");
1480 }
1481}