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