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
580 fn check_request(
581 request: &Self::Request,
582 platform_version: &PlatformVersion,
583 ) -> Result<(), drive_proof_verifier::Error> {
584 <Documents as FromProof<Self::Request>>::check_request(request, platform_version)
585 }
586
587 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
588 request: I,
589 response: O,
590 network: Network,
591 platform_version: &PlatformVersion,
592 provider: &'a dyn ContextProvider,
593 ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
594 where
595 Self: Sized + 'a,
596 {
597 let request: Self::Request = request.into();
598
599 let (documents, metadata, proof): (Option<Documents>, ResponseMetadata, Proof) =
600 <Documents as FromProof<Self::Request>>::maybe_from_proof_with_metadata(
601 request,
602 response,
603 network,
604 platform_version,
605 provider,
606 )?;
607
608 match documents {
609 None => Ok((None, metadata, proof)),
610 Some(docs) => match docs.len() {
611 0 | 1 => Ok((
612 docs.into_iter().next().and_then(|(_, v)| v),
613 metadata,
614 proof,
615 )),
616 n => Err(drive_proof_verifier::Error::ResponseDecodeError {
617 error: format!("expected 1 element, got {}", n),
618 }),
619 },
620 }
621 }
622}
623
624impl FromProof<DocumentQuery> for drive_proof_verifier::types::Documents {
625 type Request = DocumentQuery;
626 type Response = platform_proto::GetDocumentsResponse;
627
628 /// The verifier below rebuilds a documents query whatever the request selects, so an
629 /// aggregate select, which the server answers with an aggregate proof, can never verify.
630 /// A projection field on the documents select is left alone: the server refuses it on
631 /// the V1 wire, and the V0 encoder drops it, so the documents served verify.
632 fn check_request(
633 request: &Self::Request,
634 _platform_version: &PlatformVersion,
635 ) -> Result<(), drive_proof_verifier::Error> {
636 request.ensure_no_sub_queries()?;
637 if request.select.function != SelectFunction::Documents {
638 return Err(drive_proof_verifier::Error::RequestError {
639 error: format!(
640 "Document / Documents require the documents projection; got {:?}. Fetch \
641 an aggregate as DocumentCount, DocumentSum, DocumentAverage or one of \
642 their split, ranked or having views",
643 request.select
644 ),
645 });
646 }
647 Ok(())
648 }
649
650 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
651 request: I,
652 response: O,
653 network: Network,
654 platform_version: &PlatformVersion,
655 provider: &'a dyn ContextProvider,
656 ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
657 where
658 Self: Sized + 'a,
659 {
660 let mut request: Self::Request = request.into();
661 request.ensure_no_sub_queries()?;
662 let response: Self::Response = response.into();
663
664 // A time-range (`IN_TIME_RANGE`) selection is resolved to a concrete
665 // bucket using the **quorum-signed** response metadata time — the same
666 // authoritative block time the server used to resolve it — so the
667 // reconstructed query matches the proof exactly. Resolve (and run the
668 // provenance-vs-shape guard, via the one shared normalization helper
669 // the aggregate verifiers also use) before the `DriveDocumentQuery`
670 // conversion so the engine sees ordinary equality clauses.
671 let mut resolved_time_ranges = Vec::new();
672 if !request.time_range_clauses.is_empty() || !request.integer_range_clauses.is_empty() {
673 // The generated `VersionedGrpcResponse::metadata()` handles both
674 // response envelopes (and any future one), so no hand-written
675 // version match is needed here.
676 use dapi_grpc::platform::VersionedGrpcResponse;
677 let time_ms = response
678 .metadata()
679 .map(|metadata| metadata.time_ms)
680 .map_err(|_| drive_proof_verifier::Error::ResponseDecodeError {
681 error: "window selection query proof response is missing its metadata"
682 .to_string(),
683 })?;
684 resolved_time_ranges =
685 normalize_time_range_clauses_with_metadata_time(&mut request, time_ms)?;
686 }
687
688 let mut drive_query: DriveDocumentQuery =
689 (&request)
690 .try_into()
691 .map_err(|e| drive_proof_verifier::Error::RequestError {
692 error: format!("Failed to convert DocumentQuery to DriveQuery: {}", e),
693 })?;
694 // The conversion cannot recover which equalities came from resolution,
695 // so the provenance is carried across here; index selection reads it
696 // to pin the query to the index that buckets the field.
697 drive_query.resolved_time_ranges = resolved_time_ranges;
698
699 <drive_proof_verifier::types::Documents as FromProof<DriveDocumentQuery>>::maybe_from_proof_with_metadata(
700 drive_query,
701 response,
702 network,
703 platform_version,
704 provider,
705 )
706 }
707}
708
709/// Resolve a request's pending time-range (`IN_TIME_RANGE`) selections into
710/// concrete bucket-equality clauses on `request.where_clauses`, using the
711/// **quorum-signed** response metadata block time — the same authoritative
712/// time the server used — so the reconstructed query matches the proof
713/// exactly.
714///
715/// Every proof-verification path that rebuilds a drive query from a
716/// [`DocumentQuery`] must call this (or perform the identical resolution)
717/// *before* reading `request.where_clauses` for mode detection, covering-index
718/// selection, or query reconstruction: the documents path does it inline in
719/// its `FromProof` impl, and the count / sum / average aggregate helpers call
720/// this before resolving their mode. Skipping it would rebuild the query from
721/// a different shape than the prover used.
722///
723/// Returns the resolution provenance — one [`ResolvedTimeRange`] (field +
724/// exact grid) per selection, whose pushed clause is a bucket equality
725/// rather than a raw-timestamp one. Callers must carry them into index
726/// selection (`DriveDocumentQuery::resolved_time_ranges`, or the
727/// `resolved_time_ranges` argument of the aggregate index pickers):
728/// the pushed clause is an ordinary equality and nothing downstream can
729/// otherwise tell that it must be matched against the resolved grid's
730/// bucket starts.
731/// [`resolve_time_range_clauses_with_metadata_time`] followed immediately by
732/// the provenance-vs-shape guard — the two-step normalization every
733/// proof-verification path must run, in this order, before mode detection,
734/// covering-index selection, or query reconstruction. One definition so a
735/// future verifier path cannot omit either step or run them out of order:
736/// the aggregate paths once omitted the resolution entirely (valid proofs
737/// were rejected), and a path that resolved without the guard would let a
738/// caller-provided `In`/range clause on the resolved field reach the index
739/// pickers as if its raw values were bucket starts.
740pub(super) fn normalize_time_range_clauses_with_metadata_time(
741 request: &mut DocumentQuery,
742 time_ms: u64,
743) -> Result<Vec<ResolvedTimeRange>, drive_proof_verifier::Error> {
744 let resolved_time_ranges = resolve_time_range_clauses_with_metadata_time(request, time_ms)?;
745 validate_resolved_time_range_clause_shapes(&request.where_clauses, &resolved_time_ranges)
746 .map_err(|e| drive_proof_verifier::Error::RequestError {
747 error: format!("invalid window selection query shape: {}", e),
748 })?;
749 Ok(resolved_time_ranges)
750}
751
752pub(super) fn resolve_time_range_clauses_with_metadata_time(
753 request: &mut DocumentQuery,
754 time_ms: u64,
755) -> Result<Vec<ResolvedTimeRange>, drive_proof_verifier::Error> {
756 if request.time_range_clauses.is_empty() && request.integer_range_clauses.is_empty() {
757 return Ok(Vec::new());
758 }
759 let data_contract = Arc::clone(&request.data_contract);
760 let document_type = data_contract
761 .document_type_for_name(&request.document_type_name)
762 .map_err(|e| drive_proof_verifier::Error::RequestError {
763 error: format!("document type not found for window selection query: {}", e),
764 })?;
765 let time_range_clauses = std::mem::take(&mut request.time_range_clauses);
766 let mut resolved_time_ranges = Vec::with_capacity(time_range_clauses.len());
767 for TimeRangeClause {
768 field,
769 selector,
770 grid,
771 } in time_range_clauses
772 {
773 let (clause, resolved) =
774 resolve_time_range_bucket_clause(&field, selector, grid, document_type, time_ms)
775 .map_err(|e| drive_proof_verifier::Error::RequestError {
776 error: format!("failed to resolve time range clause: {}", e),
777 })?;
778 request.where_clauses.push(clause);
779 resolved_time_ranges.push(resolved);
780 }
781 // Integer-range selections name their window absolutely: resolved
782 // from the query alone, exactly as the server resolved them.
783 let integer_range_clauses = std::mem::take(&mut request.integer_range_clauses);
784 for IntegerRangeClause { field, start, grid } in integer_range_clauses {
785 let (clause, resolved) =
786 resolve_integer_range_bucket_clause(&field, &start, grid, document_type).map_err(
787 |e| drive_proof_verifier::Error::RequestError {
788 error: format!("failed to resolve integer range clause: {}", e),
789 },
790 )?;
791 request.where_clauses.push(clause);
792 resolved_time_ranges.push(resolved);
793 }
794 Ok(resolved_time_ranges)
795}
796
797/// Version-aware encoder. The dispatch is driven by the
798/// `drive_abci.query.document_query` feature-version on
799/// [`PlatformVersion`]: `0` → V0 wire (used by v3.0 testnet), `1` →
800/// V1 wire (introduced in v3.1).
801///
802/// V0 lacks `selects` / `group_by` / `having` / `offset` and the
803/// optional-limit semantics — callers that set those features get
804/// `Error::Config` with a clear "requires Platform v3.1+" message
805/// rather than a silently-truncated request. Time-range clauses are
806/// additionally gated on the v14 contract grammar — see the `1 =>` arm.
807impl TryFromPlatformVersioned<DocumentQuery> for GetDocumentsRequest {
808 type Error = Error;
809
810 fn try_from_platform_versioned(
811 value: DocumentQuery,
812 platform_version: &PlatformVersion,
813 ) -> Result<Self, Self::Error> {
814 if !value.sub_queries.is_empty() {
815 value.check_composite_shape()?;
816 }
817 let DocumentQuery {
818 select,
819 data_contract,
820 document_type_name,
821 where_clauses,
822 time_range_clauses,
823 integer_range_clauses,
824 group_by,
825 having,
826 order_by_clauses,
827 limit,
828 offset,
829 start,
830 sub_queries,
831 } = value;
832
833 let feature_version = platform_version
834 .drive_abci
835 .query
836 .document_query
837 .default_current_version;
838
839 tracing::debug!(
840 target: "dash_sdk::query_encoder",
841 feature_version,
842 protocol_version = platform_version.protocol_version,
843 "encoding GetDocumentsRequest"
844 );
845
846 // The elements of a typed array are first indexed at protocol version
847 // 14, the first whose contract grammar (document meta-schema
848 // generation 3) admits an index on a typed array. A server before it
849 // knows neither operator, in the page or in any sub-query, which
850 // rides the same wire mapping.
851 let reads_array_elements = where_clauses
852 .iter()
853 .chain(
854 sub_queries
855 .iter()
856 .flat_map(|sub_query| sub_query.where_clauses.iter()),
857 )
858 .any(|clause| clause.operator.reads_array_elements());
859 if reads_array_elements
860 && platform_version
861 .dpp
862 .contract_versions
863 .document_type_versions
864 .schema
865 .document_type_schema
866 < 3
867 {
868 return Err(Error::Config(format!(
869 "contains and containsAny queries require protocol version 14+, the first \
870 version whose contract grammar indexes the elements of a typed array; this \
871 network runs protocol version {}",
872 platform_version.protocol_version
873 )));
874 }
875
876 match feature_version {
877 0 => {
878 if !sub_queries.is_empty() {
879 return Err(Error::Config(
880 "composite document queries require the V1 documents wire (Platform v3.1+)"
881 .to_string(),
882 ));
883 }
884 if !time_range_clauses.is_empty() {
885 return Err(Error::Config(
886 "time range (IN_TIME_RANGE) queries require protocol version 14+; the \
887 v0 getDocuments wire has no time-range operator"
888 .to_string(),
889 ));
890 }
891 if !integer_range_clauses.is_empty() {
892 return Err(Error::Config(
893 "integer range (IN_INTEGER_RANGE) queries require protocol version \
894 14+; the v0 getDocuments wire has no integer-range operator"
895 .to_string(),
896 ));
897 }
898 encode_v0(
899 data_contract.id().to_vec(),
900 document_type_name,
901 where_clauses,
902 order_by_clauses,
903 limit,
904 offset,
905 start,
906 &select,
907 &group_by,
908 &having,
909 )
910 }
911 1 => {
912 // The v1 wire predates time-range indexes: protocol
913 // versions 12 and 13 also serve it, but their contract
914 // grammar (document meta-schema generations 1 and 2)
915 // cannot host a `timeRange` index. Gate on the grammar
916 // generation — the same table the server's parser reads —
917 // rather than emitting an operator a pre-v14 server
918 // rejects as an unknown discriminant.
919 let grammar_generation = platform_version
920 .dpp
921 .contract_versions
922 .document_type_versions
923 .schema
924 .document_type_schema;
925 if !time_range_clauses.is_empty() && grammar_generation < 3 {
926 return Err(Error::Config(format!(
927 "time range (IN_TIME_RANGE) queries require protocol version 14+ — the \
928 first version whose contract grammar hosts `timeRange` indexes; this \
929 network runs protocol version {}",
930 platform_version.protocol_version
931 )));
932 }
933 if !integer_range_clauses.is_empty() && grammar_generation < 3 {
934 return Err(Error::Config(format!(
935 "integer range (IN_INTEGER_RANGE) queries require protocol version 14+ \
936 — the first version whose contract grammar hosts `integerRange` \
937 indexes; this network runs protocol version {}",
938 platform_version.protocol_version
939 )));
940 }
941 encode_v1(
942 data_contract.id().to_vec(),
943 document_type_name,
944 where_clauses,
945 time_range_clauses,
946 integer_range_clauses,
947 order_by_clauses,
948 limit,
949 offset,
950 start,
951 select,
952 group_by,
953 having,
954 sub_queries_to_proto(sub_queries)?,
955 )
956 }
957 n => Err(Error::Config(format!(
958 "GetDocumentsRequest wire encoder does not support feature_version={n} \
959 (drive_abci.query.document_query) on PlatformVersion v{}",
960 platform_version.protocol_version
961 ))),
962 }
963 }
964}
965
966#[allow(clippy::too_many_arguments)]
967fn encode_v1(
968 data_contract_id: Vec<u8>,
969 document_type: String,
970 where_clauses: Vec<WhereClause>,
971 time_range_clauses: Vec<TimeRangeClause>,
972 integer_range_clauses: Vec<IntegerRangeClause>,
973 order_by_clauses: Vec<OrderClause>,
974 limit: u32,
975 offset: Option<u32>,
976 start: Option<Start>,
977 select: SelectProjection,
978 group_by: Vec<String>,
979 having: Vec<HavingClause>,
980 sub_queries: Vec<platform_proto::get_documents_request::get_documents_request_v1::SubQuery>,
981) -> Result<GetDocumentsRequest, Error> {
982 let mut where_clauses = where_clauses
983 .into_iter()
984 .map(where_clause_to_proto)
985 .collect::<Result<Vec<_>, _>>()?;
986 // Append time-range selections as `IN_TIME_RANGE` clauses carrying the
987 // typed `time_range` operand (`value` stays unset — the selection is
988 // not a field value). The grid, when named, repeats the contract's
989 // declared seconds verbatim; a zero phase is proto3's default, so a
990 // phaseless grid has exactly one wire spelling by construction. The
991 // server resolves the relative selectors to a concrete bucket from
992 // current block time and the verifier re-derives the same bucket from
993 // the signed response metadata time; a `ByStart` selection carries its
994 // window's start in the query itself, so both sides read it verbatim.
995 for TimeRangeClause {
996 field,
997 selector,
998 grid,
999 } in time_range_clauses
1000 {
1001 let (proto_selector, start_ms) = match selector {
1002 TimeRangeSelector::Newest => (ProtoTimeRangeSelector::Newest, None),
1003 TimeRangeSelector::Oldest => (ProtoTimeRangeSelector::Oldest, None),
1004 TimeRangeSelector::ByStart { start_ms } => {
1005 (ProtoTimeRangeSelector::ByStart, Some(start_ms))
1006 }
1007 };
1008 where_clauses.push(ProtoWhereClause {
1009 field,
1010 operator: ProtoWhereOperator::InTimeRange as i32,
1011 value: None,
1012 time_range: Some(ProtoTimeRangeSelection {
1013 selector: proto_selector as i32,
1014 start_ms,
1015 grid: grid.map(|spec| ProtoTimeRangeGrid {
1016 range: spec.range_seconds,
1017 step: spec.step_seconds,
1018 phase: spec.phase_seconds,
1019 }),
1020 }),
1021 integer_range: None,
1022 });
1023 }
1024 // Integer-range selections ride as `IN_INTEGER_RANGE` clauses carrying
1025 // the typed `integer_range` operand: the window start (coerced through
1026 // the schema on the server like any value) and, when named, the grid.
1027 for IntegerRangeClause { field, start, grid } in integer_range_clauses {
1028 where_clauses.push(ProtoWhereClause {
1029 field,
1030 operator: ProtoWhereOperator::InIntegerRange as i32,
1031 value: None,
1032 time_range: None,
1033 integer_range: Some(ProtoIntegerRangeSelection {
1034 start: Some(value_to_proto(start)?),
1035 grid: grid.map(|spec| ProtoIntegerRangeGrid {
1036 range: spec.range,
1037 step: spec.step,
1038 phase: spec.phase,
1039 }),
1040 }),
1041 });
1042 }
1043 let order_by = order_by_clauses
1044 .into_iter()
1045 .map(order_clause_to_proto)
1046 .collect();
1047 let having = having
1048 .into_iter()
1049 .map(having_clause_to_proto)
1050 .collect::<Result<Vec<_>, _>>()?;
1051 // `limit: u32` with `0` sentinel → `optional uint32` on the V1
1052 // wire. `None` lets the server apply its own default; explicit
1053 // `0` would be a strange "return zero rows" request.
1054 let limit = if limit == 0 { None } else { Some(limit) };
1055 // V0 and V1 ship separate `Start` enums even though the shape
1056 // is identical. Translate at the wire boundary so the
1057 // `DocumentQuery.start` field stays stable for callers already
1058 // using the V0 type.
1059 let start_v1 = start.map(|s| match s {
1060 Start::StartAfter(b) => V1Start::StartAfter(b),
1061 Start::StartAt(b) => V1Start::StartAt(b),
1062 });
1063
1064 Ok(GetDocumentsRequest {
1065 version: Some(V1(GetDocumentsRequestV1 {
1066 data_contract_id,
1067 document_type,
1068 where_clauses,
1069 order_by,
1070 limit,
1071 // Document fetch always proves via this conversion.
1072 // Count fetch uses the same wire shape; both paths go
1073 // through the `FromProof` decoders which expect the
1074 // `Proof(...)` response variant. `SdkBuilder::with_proofs(false)`
1075 // is consequently a no-op for both — see the blanket
1076 // `Query<T> for T` impl in `packages/rs-sdk/src/platform/query.rs`
1077 // for the `tracing::warn!` emitted at fetch time when
1078 // proofs are disabled.
1079 prove: true,
1080 start: start_v1,
1081 // `repeated Select selects` on the wire — single
1082 // projection wraps in a one-element vec; the SDK's
1083 // `DocumentQuery` carries a single `SelectProjection`
1084 // because multi-projection is wire-only today.
1085 selects: vec![select_to_proto(select)],
1086 group_by,
1087 having,
1088 // Honoured on the ranked path (it is the `OFFSET` of
1089 // `ORDER BY <agg> DESC LIMIT n OFFSET m`) and rejected by
1090 // the server everywhere else. Passed straight through:
1091 // deciding here which paths may carry an offset would put
1092 // a second copy of that rule in the SDK.
1093 offset,
1094 chained: None,
1095 sub_queries,
1096 })),
1097 })
1098}
1099
1100#[allow(clippy::too_many_arguments)]
1101fn encode_v0(
1102 data_contract_id: Vec<u8>,
1103 document_type: String,
1104 where_clauses: Vec<WhereClause>,
1105 order_by_clauses: Vec<OrderClause>,
1106 limit: u32,
1107 offset: Option<u32>,
1108 start: Option<Start>,
1109 select: &SelectProjection,
1110 group_by: &[String],
1111 having: &[HavingClause],
1112) -> Result<GetDocumentsRequest, Error> {
1113 // V0 only carries plain `getDocuments` semantics — reject the
1114 // v1-only SQL-shaped surfaces with a typed error rather than
1115 // letting the server reject them after a round-trip.
1116 if !matches!(select.function, SelectFunction::Documents) {
1117 return Err(Error::Config(format!(
1118 "select={:?} requires Platform v3.1+ (V1 documents wire); pin/upgrade \
1119 to a v3.1+ network or rebuild the query with SelectProjection::documents()",
1120 select.function
1121 )));
1122 }
1123 if !group_by.is_empty() {
1124 return Err(Error::Config(
1125 "group_by requires Platform v3.1+ (V1 documents wire); not supported on V0".to_string(),
1126 ));
1127 }
1128 if !having.is_empty() {
1129 return Err(Error::Config(
1130 "having clauses require Platform v3.1+ (V1 documents wire); not supported on V0"
1131 .to_string(),
1132 ));
1133 }
1134 if offset.is_some() {
1135 // The V0 request message has no `offset` field at all, so
1136 // silently dropping it would page from rank 0 while the caller
1137 // believed they had skipped ahead — the one failure mode worth
1138 // an extra branch here.
1139 return Err(Error::Config(
1140 "offset requires Platform v3.1+ (V1 documents wire); not supported on V0".to_string(),
1141 ));
1142 }
1143
1144 // V0 carries CBOR-serialized arrays of clause components. The
1145 // server decodes them via `ciborium::de::from_reader` into a
1146 // `Value`, then expects `Value::Array(clauses)` where each
1147 // inner clause is `[field_text, operator_text, value]` (where)
1148 // or `[field_text, "asc"|"desc"]` (order_by). Build the same
1149 // shape via the existing `From<WhereClause> for Value` /
1150 // `From<OrderClause> for Value` impls, then serialize the
1151 // top-level array.
1152 let where_bytes = if where_clauses.is_empty() {
1153 Vec::new()
1154 } else {
1155 let where_value = Value::Array(where_clauses.into_iter().map(Value::from).collect());
1156 where_value.to_cbor_buffer().map_err(|e| {
1157 Error::Protocol(dpp::ProtocolError::EncodingError(format!(
1158 "failed to CBOR-encode v0 where clauses: {e}"
1159 )))
1160 })?
1161 };
1162 let order_by_bytes = if order_by_clauses.is_empty() {
1163 Vec::new()
1164 } else {
1165 let order_value = Value::Array(order_by_clauses.into_iter().map(Value::from).collect());
1166 order_value.to_cbor_buffer().map_err(|e| {
1167 Error::Protocol(dpp::ProtocolError::EncodingError(format!(
1168 "failed to CBOR-encode v0 order_by clauses: {e}"
1169 )))
1170 })?
1171 };
1172
1173 Ok(GetDocumentsRequest {
1174 version: Some(V0(GetDocumentsRequestV0 {
1175 data_contract_id,
1176 document_type,
1177 r#where: where_bytes,
1178 order_by: order_by_bytes,
1179 // V0's `limit` is a plain u32 with 0 = "server default".
1180 // V1's `optional uint32` keeps 0 as a structurally
1181 // meaningless explicit-zero; we translate by clamping
1182 // to 0 only when the caller meant "unset".
1183 limit,
1184 start,
1185 // `prove: true` hardcoded — same rationale as `encode_v1`:
1186 // document fetch always proves; `SdkBuilder::with_proofs(false)`
1187 // is a no-op for this path because the `FromProof` decoder
1188 // expects the `Proof(...)` response variant.
1189 prove: true,
1190 })),
1191 })
1192}
1193
1194impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery {
1195 type Error = crate::error::Error;
1196
1197 /// Preserves sub-queries through SDK request construction and proof verification.
1198 ///
1199 /// Fallible by necessity: a drive query carrying `resolved_time_ranges`
1200 /// holds bucket-start equalities whose meaning lives in the provenance,
1201 /// and `DocumentQuery` has no field to carry it — the original
1202 /// `IN_TIME_RANGE` selector cannot be reconstructed from the resolved
1203 /// query. Serializing such a query would silently demote the bucket
1204 /// equality to a raw-timestamp predicate: a transformed-index-only
1205 /// contract then rejects the request, while a contract with a competing
1206 /// plain index returns a different — but validly proven — result.
1207 fn try_from(value: &'a DriveDocumentQuery<'a>) -> Result<Self, Self::Error> {
1208 if let Some(resolved) = value.resolved_time_ranges.first() {
1209 let builders = match resolved.transform {
1210 IndexBucketing::Time(_) => {
1211 "`with_time_range` / `with_time_range_grid` instead, so the selector is \
1212 resolved against the signed response metadata"
1213 }
1214 IndexBucketing::Integer(_) => {
1215 "`with_integer_range` / `with_integer_range_grid` instead, so the window is \
1216 resolved from the query as the server resolves it"
1217 }
1218 };
1219 return Err(Error::Config(format!(
1220 "a drive query carrying {} resolution provenance cannot be converted to a \
1221 DocumentQuery: the resolved window equality would be demoted to a raw-value \
1222 predicate. Build the DocumentQuery with {}",
1223 resolved.kind(),
1224 builders
1225 )));
1226 }
1227 let data_contract = value.contract.clone();
1228 let document_type_name = value.document_type.name();
1229 let where_clauses = value.internal_clauses.clone().into();
1230 let order_by_clauses = value.order_by.iter().map(|(_, v)| v.clone()).collect();
1231 let limit = value.limit.unwrap_or(0) as u32;
1232 let offset = value.offset.map(u32::from);
1233
1234 let start = if let Some(start_at) = value.start_at {
1235 match value.start_at_included {
1236 true => Some(Start::StartAt(start_at.to_vec())),
1237 false => Some(Start::StartAfter(start_at.to_vec())),
1238 }
1239 } else {
1240 None
1241 };
1242
1243 Ok(Self {
1244 // `DriveDocumentQuery` has no SELECT/GROUP BY/HAVING/time-range
1245 // concept — it's a documents-only query. Default to the
1246 // v1 documents shape.
1247 select: SelectProjection::documents(),
1248 data_contract: Arc::new(data_contract),
1249 document_type_name: document_type_name.to_string(),
1250 where_clauses,
1251 time_range_clauses: Vec::new(),
1252 integer_range_clauses: Vec::new(),
1253 group_by: Vec::new(),
1254 having: Vec::new(),
1255 order_by_clauses,
1256 limit,
1257 offset,
1258 start,
1259 sub_queries: value
1260 .sub_queries
1261 .iter()
1262 .map(CompositeSubQuery::from)
1263 .collect(),
1264 })
1265 }
1266}
1267
1268impl<'a> TryFrom<DriveDocumentQuery<'a>> for DocumentQuery {
1269 type Error = crate::error::Error;
1270
1271 /// By-value twin of the by-reference conversion above — same
1272 /// sub-query preservation and provenance rejection, same rationale.
1273 fn try_from(value: DriveDocumentQuery<'a>) -> Result<Self, Self::Error> {
1274 DocumentQuery::try_from(&value)
1275 }
1276}
1277
1278impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> {
1279 type Error = crate::error::Error;
1280
1281 fn try_from(request: &'a DocumentQuery) -> Result<Self, Self::Error> {
1282 if !request.sub_queries.is_empty() {
1283 request.check_composite_shape()?;
1284 }
1285 // A pending (unresolved) time-range selection MUST be resolved into a
1286 // concrete bucket-equality clause before a drive query can be built —
1287 // see `resolve_time_range_clauses_with_metadata_time`. Silently
1288 // dropping it here would rebuild (and verify against) a strictly
1289 // broader query than the prover ran, so refuse instead: this makes
1290 // "forgot to resolve" a loud error on every present and future call
1291 // path rather than a silent verification hole.
1292 if !request.time_range_clauses.is_empty() || !request.integer_range_clauses.is_empty() {
1293 return Err(Error::Config(
1294 "the query's window selections (IN_TIME_RANGE / IN_INTEGER_RANGE) have not been \
1295 resolved into window-start equalities; resolve them (time selections against \
1296 the response's quorum-signed metadata time) before building a drive query"
1297 .to_string(),
1298 ));
1299 }
1300
1301 // let data_contract = request.data_contract.clone();
1302 let document_type = request
1303 .data_contract
1304 .document_type_for_name(&request.document_type_name)
1305 .map_err(ProtocolError::DataContractError)?;
1306
1307 // Client-side construction groups under the latest grammar; the
1308 // server and the proof verifier enforce the network's protocol
1309 // version at path-query lowering.
1310 let internal_clauses = InternalClauses::extract_from_clauses(
1311 request.where_clauses.clone(),
1312 PlatformVersion::latest(),
1313 )
1314 .map_err(Error::Drive)?;
1315
1316 // Mirror the limit contract of the server's
1317 // `DriveDocumentQuery::from_typed_clauses` exactly: `0` (this
1318 // struct's "unset" sentinel — V0's `limit: 0`, V1's
1319 // `limit: None`) falls back to the server default, and anything
1320 // above `DEFAULT_QUERY_LIMIT` (the `config.default_query_limit`
1321 // every deployed server runs with) is refused with the server's
1322 // own `QuerySyntaxError::InvalidLimit` rather than truncated or
1323 // passed through. A `u16::try_from` alone would not do: limits
1324 // 101..=65535 fit a `u16` but the server refuses them, so a raw
1325 // `DriveDocumentQuery` carrying one would verify a proof no
1326 // honest server could have produced.
1327 let limit = match request.limit {
1328 0 => Some(DEFAULT_QUERY_LIMIT),
1329 limit if limit > u32::from(DEFAULT_QUERY_LIMIT) => {
1330 return Err(Error::Drive(drive::error::Error::Query(
1331 drive::error::query::QuerySyntaxError::InvalidLimit(format!(
1332 "limit {} greater than max limit {}",
1333 limit, DEFAULT_QUERY_LIMIT
1334 )),
1335 )));
1336 }
1337 limit => Some(limit as u16),
1338 };
1339
1340 let (start_at, start_at_included) = match request.start.as_ref() {
1341 None => (None, false),
1342 Some(Start::StartAt(at)) => (
1343 Some(at.clone().try_into().map_err(|_| {
1344 ProtocolError::InvalidVectorSizeError(InvalidVectorSizeError::new(32, at.len()))
1345 })?),
1346 true,
1347 ),
1348 Some(Start::StartAfter(after)) => (
1349 Some(after.clone().try_into().map_err(|_| {
1350 ProtocolError::InvalidVectorSizeError(InvalidVectorSizeError::new(
1351 32,
1352 after.len(),
1353 ))
1354 })?),
1355 false,
1356 ),
1357 };
1358
1359 // `DriveDocumentQuery`'s offset is a `u16`; the wire's is a
1360 // `u32` because the ranked path takes an unbounded one. A
1361 // documents query that overflows `u16` is refused rather than
1362 // truncated — silently paging from a different rank than the
1363 // caller asked for is the worst available outcome.
1364 let offset = request
1365 .offset
1366 .map(|offset| {
1367 u16::try_from(offset).map_err(|_| {
1368 Error::Config(format!(
1369 "offset {offset} does not fit a documents query's u16 offset \
1370 (max {}); offsets above that are only meaningful on the ranked \
1371 surface, which does not route through DriveDocumentQuery",
1372 u16::MAX
1373 ))
1374 })
1375 })
1376 .transpose()?;
1377
1378 let query = Self {
1379 contract: &request.data_contract,
1380 document_type,
1381 internal_clauses,
1382 offset,
1383 limit,
1384 order_by: request
1385 .order_by_clauses
1386 .clone()
1387 .into_iter()
1388 .map(|v| (v.field.clone(), v))
1389 .collect(),
1390 start_at,
1391 start_at_included,
1392 block_time_ms: None,
1393 // A `DocumentQuery` reaching here carries no unresolved
1394 // time-range selection (rejected above) and cannot tell which of
1395 // its equalities came from resolution. Callers that resolved
1396 // selections assign the fields they resolved onto the returned
1397 // query; everything else is a raw query.
1398 resolved_time_ranges: vec![],
1399 sub_queries: drive_sub_queries(request)?,
1400 };
1401
1402 Ok(query)
1403 }
1404}
1405
1406/// Convert a drive [`WhereClause`] into its wire-format proto
1407/// counterpart. The proto value variant is picked from the
1408/// `dpp::platform_value::Value` variant by primitive type — schema-
1409/// agnostic, matching the inverse direction the rs-drive-abci v1
1410/// handler runs via its `conversions::value_from_proto`.
1411///
1412/// Errors only on `Value` variants that have no wire-format
1413/// counterpart (`Map`, `EnumU8`, `EnumString`) — these aren't
1414/// produced by the SDK's typical WhereClause builders, so a
1415/// rejection here flags an unsupported caller construction at the
1416/// wire boundary rather than silently dropping the value.
1417pub(crate) fn where_clause_to_proto(clause: WhereClause) -> Result<ProtoWhereClause, Error> {
1418 Ok(ProtoWhereClause {
1419 field: clause.field,
1420 operator: where_operator_to_proto(clause.operator) as i32,
1421 value: Some(value_to_proto(clause.value)?),
1422 // The typed IN_TIME_RANGE operand; never set on an ordinary value
1423 // clause (time-range selections are encoded by `encode_v1` itself,
1424 // from `time_range_clauses`).
1425 time_range: None,
1426 // Likewise the typed IN_INTEGER_RANGE operand.
1427 integer_range: None,
1428 })
1429}
1430
1431pub(crate) fn order_clause_to_proto(clause: OrderClause) -> ProtoOrderClause {
1432 // Drive's `OrderClause` carries a plain `field: String` —
1433 // emit the field-target variant of the wire's `target` oneof.
1434 // The aggregate-target variant (`ORDER BY COUNT(*)`) is
1435 // wire-only today; when drive's `OrderClause` gains an
1436 // aggregate target the SDK gets a parallel builder.
1437 ProtoOrderClause {
1438 target: Some(order_clause::Target::Field(clause.field)),
1439 ascending: clause.ascending,
1440 }
1441}
1442
1443/// Convert a drive [`HavingClause`] into its wire-format proto
1444/// counterpart. The inverse of `rs-drive-abci`'s
1445/// `having_clause_from_proto`. Errors only on `Value` variants
1446/// the underlying `value_to_proto` can't represent — every
1447/// `HavingOperator` / `HavingAggregateFunction` discriminant has a
1448/// 1:1 wire counterpart and is always convertible.
1449fn having_clause_to_proto(clause: HavingClause) -> Result<ProtoHavingClause, Error> {
1450 let right = match clause.right {
1451 HavingRightOperand::Value(v) => having_clause::Right::Value(value_to_proto(v)?),
1452 };
1453 Ok(ProtoHavingClause {
1454 aggregate: Some(having_aggregate_to_proto(clause.aggregate)),
1455 operator: having_operator_to_proto(clause.operator) as i32,
1456 right: Some(right),
1457 })
1458}
1459
1460fn having_aggregate_to_proto(aggregate: HavingAggregate) -> ProtoHavingAggregate {
1461 ProtoHavingAggregate {
1462 function: having_function_to_proto(aggregate.function) as i32,
1463 field: aggregate.field,
1464 }
1465}
1466
1467fn having_function_to_proto(function: HavingAggregateFunction) -> having_aggregate::Function {
1468 match function {
1469 HavingAggregateFunction::Count => having_aggregate::Function::Count,
1470 HavingAggregateFunction::Sum => having_aggregate::Function::Sum,
1471 HavingAggregateFunction::Avg => having_aggregate::Function::Avg,
1472 }
1473}
1474
1475/// Convert a drive [`SelectProjection`] into its wire-format
1476/// proto counterpart. Inverse of `rs-drive-abci`'s
1477/// `select_from_proto`. Always succeeds — every
1478/// `SelectFunction` discriminant has a 1:1 wire counterpart.
1479fn select_to_proto(select: SelectProjection) -> ProtoSelect {
1480 ProtoSelect {
1481 function: select_function_to_proto(select.function) as i32,
1482 field: select.field,
1483 }
1484}
1485
1486fn select_function_to_proto(function: SelectFunction) -> select::Function {
1487 match function {
1488 SelectFunction::Documents => select::Function::Documents,
1489 SelectFunction::Count => select::Function::Count,
1490 SelectFunction::Sum => select::Function::Sum,
1491 SelectFunction::Avg => select::Function::Avg,
1492 SelectFunction::Min => select::Function::Min,
1493 SelectFunction::Max => select::Function::Max,
1494 }
1495}
1496
1497fn having_operator_to_proto(op: HavingOperator) -> having_clause::Operator {
1498 match op {
1499 HavingOperator::Equal => having_clause::Operator::Equal,
1500 HavingOperator::NotEqual => having_clause::Operator::NotEqual,
1501 HavingOperator::GreaterThan => having_clause::Operator::GreaterThan,
1502 HavingOperator::GreaterThanOrEquals => having_clause::Operator::GreaterThanOrEquals,
1503 HavingOperator::LessThan => having_clause::Operator::LessThan,
1504 HavingOperator::LessThanOrEquals => having_clause::Operator::LessThanOrEquals,
1505 HavingOperator::Between => having_clause::Operator::Between,
1506 HavingOperator::BetweenExcludeBounds => having_clause::Operator::BetweenExcludeBounds,
1507 HavingOperator::BetweenExcludeLeft => having_clause::Operator::BetweenExcludeLeft,
1508 HavingOperator::BetweenExcludeRight => having_clause::Operator::BetweenExcludeRight,
1509 HavingOperator::In => having_clause::Operator::In,
1510 }
1511}
1512
1513fn where_operator_to_proto(op: WhereOperator) -> ProtoWhereOperator {
1514 match op {
1515 WhereOperator::Equal => ProtoWhereOperator::Equal,
1516 WhereOperator::GreaterThan => ProtoWhereOperator::GreaterThan,
1517 WhereOperator::GreaterThanOrEquals => ProtoWhereOperator::GreaterThanOrEquals,
1518 WhereOperator::LessThan => ProtoWhereOperator::LessThan,
1519 WhereOperator::LessThanOrEquals => ProtoWhereOperator::LessThanOrEquals,
1520 WhereOperator::Between => ProtoWhereOperator::Between,
1521 WhereOperator::BetweenExcludeBounds => ProtoWhereOperator::BetweenExcludeBounds,
1522 WhereOperator::BetweenExcludeLeft => ProtoWhereOperator::BetweenExcludeLeft,
1523 WhereOperator::BetweenExcludeRight => ProtoWhereOperator::BetweenExcludeRight,
1524 WhereOperator::In => ProtoWhereOperator::In,
1525 WhereOperator::StartsWith => ProtoWhereOperator::StartsWith,
1526 WhereOperator::Contains => ProtoWhereOperator::Contains,
1527 WhereOperator::ContainsAny => ProtoWhereOperator::ContainsAny,
1528 }
1529}
1530
1531/// Map `dpp::platform_value::Value` onto the wire-shape
1532/// [`ProtoDocumentFieldValue`]. The schema-driven decode on the
1533/// server side resolves the actual indexed type — this layer just
1534/// names the primitive.
1535///
1536/// Mapping rules:
1537/// - `Bool` → `BoolValue`
1538/// - `I8`/`I16`/`I32`/`I64` → `Int64Value` (widened)
1539/// - `U8`/`U16`/`U32`/`U64` → `Uint64Value` (widened)
1540/// - `Float` → `DoubleValue`
1541/// - `Text` → `Text`
1542/// - `Bytes`/`Bytes20`/`Bytes32`/`Bytes36`/`Identifier` → `BytesValue`
1543/// - `U128`/`I128` → `Text` (decimal string). **Not yet
1544/// round-trippable against `U128`/`I128`-typed indexed fields**:
1545/// the v1 typed-decode path (`v1/conversions.rs::value_from_proto`)
1546/// passes the text through as `Value::Text`, and the
1547/// downstream executor's strict `Value::to_integer()` then
1548/// rejects it. Schema-aware coercion (the
1549/// `DocumentPropertyType::value_from_string` path the v0 SQL
1550/// parser uses) hasn't been threaded through to the typed
1551/// path yet. The encoding is shipped because the proto needs a
1552/// home for 128-bit values; no production system contract
1553/// indexes `U128`/`I128` today. Tracked in the v1 follow-up
1554/// issue.
1555/// - `Array` → `List` (recursive, but only one level deep —
1556/// `value_to_proto` rejects nested arrays with
1557/// `EncodingError("nested DocumentFieldValue.list …")` to
1558/// match the server-side depth cap in
1559/// `v1/conversions.rs::value_from_proto_at_depth`, so wire-
1560/// malformed shapes fail at request-construction time with a
1561/// deterministic local error rather than after a transport
1562/// round-trip.
1563/// - `Null` → `NullValue(true)` (the `bool` payload is a
1564/// placeholder per the proto-side comment; only the variant
1565/// discriminant carries meaning)
1566/// - `Map`/`EnumU8`/`EnumString` → `Error` (no wire-format
1567/// counterpart for these shapes in a WhereClause operand)
1568fn value_to_proto(value: Value) -> Result<ProtoDocumentFieldValue, Error> {
1569 value_to_proto_at_depth(value, 0)
1570}
1571
1572/// Recursion-bounded form of [`value_to_proto`]. Mirrors the
1573/// server-side `value_from_proto_at_depth` contract so encoder
1574/// and decoder agree on the supported `Value` subset: `depth = 0`
1575/// is the clause-level operand; `Array` is legal once (the flat
1576/// list of scalars for `IN` / `BETWEEN*`); any deeper nesting
1577/// rejects locally instead of producing a request the server
1578/// would round-trip just to reject.
1579fn value_to_proto_at_depth(value: Value, depth: u8) -> Result<ProtoDocumentFieldValue, Error> {
1580 let variant = match value {
1581 Value::Null => document_field_value::Variant::NullValue(true),
1582 Value::Bool(b) => document_field_value::Variant::BoolValue(b),
1583 Value::I8(i) => document_field_value::Variant::Int64Value(i as i64),
1584 Value::I16(i) => document_field_value::Variant::Int64Value(i as i64),
1585 Value::I32(i) => document_field_value::Variant::Int64Value(i as i64),
1586 Value::I64(i) => document_field_value::Variant::Int64Value(i),
1587 Value::U8(u) => document_field_value::Variant::Uint64Value(u as u64),
1588 Value::U16(u) => document_field_value::Variant::Uint64Value(u as u64),
1589 Value::U32(u) => document_field_value::Variant::Uint64Value(u as u64),
1590 Value::U64(u) => document_field_value::Variant::Uint64Value(u),
1591 Value::Float(f) => document_field_value::Variant::DoubleValue(f),
1592 Value::Text(s) => document_field_value::Variant::Text(s),
1593 Value::Bytes(b) => document_field_value::Variant::BytesValue(b),
1594 Value::Bytes20(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1595 Value::Bytes32(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1596 Value::Bytes36(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1597 Value::Identifier(b) => document_field_value::Variant::BytesValue(b.to_vec()),
1598 // u128 / i128 don't fit in `int64_value`/`uint64_value`;
1599 // encode as a decimal string. See the function-level
1600 // docstring for the U128/I128 round-trip caveat.
1601 Value::U128(u) => document_field_value::Variant::Text(u.to_string()),
1602 Value::I128(i) => document_field_value::Variant::Text(i.to_string()),
1603 Value::Array(items) => {
1604 if depth >= 1 {
1605 return Err(Error::Protocol(dpp::ProtocolError::EncodingError(
1606 "nested DocumentFieldValue.list is not supported on the v1 \
1607 query surface; `IN` / `BETWEEN*` candidate lists are flat \
1608 scalars only"
1609 .to_string(),
1610 )));
1611 }
1612 document_field_value::Variant::List(document_field_value::ValueList {
1613 values: items
1614 .into_iter()
1615 .map(|v| value_to_proto_at_depth(v, depth + 1))
1616 .collect::<Result<Vec<_>, _>>()?,
1617 })
1618 }
1619 // Catches both `Value::Map(_)` / `Value::EnumU8(_)` /
1620 // `Value::EnumString(_)` (no wire-format counterpart for
1621 // these shapes in a WhereClause operand) and any
1622 // future-added variant — `dpp::platform_value::Value` is
1623 // `#[non_exhaustive]`, so the SDK fails loudly rather
1624 // than silently dropping data the moment upstream adds a
1625 // variant we don't yet know how to encode.
1626 _ => {
1627 return Err(Error::Protocol(dpp::ProtocolError::EncodingError(format!(
1628 "Value variant has no `DocumentFieldValue` wire-format counterpart: {value:?}"
1629 ))));
1630 }
1631 };
1632 Ok(ProtoDocumentFieldValue {
1633 variant: Some(variant),
1634 })
1635}
1636
1637#[cfg(test)]
1638mod encode_version_gate_tests {
1639 //! The `IN_TIME_RANGE` operator is emitted only for protocol versions
1640 //! whose contract grammar hosts `timeRange` indexes (document
1641 //! meta-schema generation 3, first pinned by protocol version 14).
1642 //! Protocol versions 12 and 13 serve the same v1 getDocuments wire but
1643 //! predate the grammar — encoding for them must refuse up front
1644 //! instead of sending an operator the server rejects as an unknown
1645 //! discriminant.
1646
1647 use super::*;
1648 use dpp::data_contract::DataContractFactory;
1649 use dpp::platform_value::platform_value;
1650 use dpp::prelude::{DataContract, Identifier};
1651 use drive::query::TimeRangeSelector;
1652
1653 fn post_contract() -> Arc<DataContract> {
1654 let schemas = platform_value!({
1655 "post": {
1656 "type": "object",
1657 "properties": {
1658 "hashtag": { "type": "string", "maxLength": 63, "position": 0 },
1659 },
1660 "required": ["hashtag"],
1661 "additionalProperties": false,
1662 }
1663 });
1664 let contract = DataContractFactory::new(PlatformVersion::latest().protocol_version)
1665 .expect("expected a factory")
1666 .create_with_value_config(Identifier::new([7u8; 32]), 0, schemas, None, None)
1667 .expect("the post contract is well-formed")
1668 .data_contract_owned();
1669 Arc::new(contract)
1670 }
1671
1672 fn newest_time_range_query() -> DocumentQuery {
1673 DocumentQuery::new(post_contract(), "post")
1674 .expect("the fixture has this document type")
1675 .with_time_range("$createdAt", TimeRangeSelector::Newest)
1676 }
1677
1678 #[test]
1679 fn a_time_range_query_refuses_to_encode_for_protocol_version_13() {
1680 let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists");
1681 let error = GetDocumentsRequest::try_from_platform_versioned(
1682 newest_time_range_query(),
1683 platform_version,
1684 )
1685 .expect_err("protocol version 13's contract grammar has no timeRange indexes");
1686 let message = match error {
1687 Error::Config(message) => message,
1688 other => panic!("expected Error::Config, got {other:?}"),
1689 };
1690 assert!(
1691 message.contains("protocol version 14"),
1692 "the refusal must name the real version floor, got: {message}"
1693 );
1694 }
1695
1696 #[test]
1697 fn a_time_range_query_encodes_the_typed_operand_for_protocol_version_14() {
1698 let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists");
1699 let request = GetDocumentsRequest::try_from_platform_versioned(
1700 newest_time_range_query(),
1701 platform_version,
1702 )
1703 .expect("protocol version 14 hosts timeRange indexes");
1704 let Some(V1(v1)) = request.version else {
1705 panic!("protocol version 14 encodes on the v1 wire");
1706 };
1707 let [clause] = v1.where_clauses.as_slice() else {
1708 panic!("the pending selector must ride as exactly one IN_TIME_RANGE clause");
1709 };
1710 assert_eq!(clause.operator, ProtoWhereOperator::InTimeRange as i32);
1711 assert_eq!(
1712 clause.value, None,
1713 "the selection is not a field value; the generic operand stays unset"
1714 );
1715 let selection = clause
1716 .time_range
1717 .as_ref()
1718 .expect("the typed operand carries the selection");
1719 assert_eq!(selection.selector, ProtoTimeRangeSelector::Newest as i32);
1720 assert_eq!(selection.start_ms, None);
1721 assert_eq!(selection.grid, None, "a grid-less selection names no grid");
1722 }
1723
1724 fn contains_query() -> DocumentQuery {
1725 DocumentQuery::new(post_contract(), "post")
1726 .expect("the fixture has this document type")
1727 .with_where(WhereClause {
1728 field: "hashtag".to_string(),
1729 operator: WhereOperator::Contains,
1730 value: platform_value!("dash"),
1731 })
1732 }
1733
1734 #[test]
1735 fn should_refuse_to_encode_an_element_query_for_protocol_version_13() {
1736 let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists");
1737 let error =
1738 GetDocumentsRequest::try_from_platform_versioned(contains_query(), platform_version)
1739 .expect_err("protocol version 13's contract grammar indexes no typed array");
1740 let message = match error {
1741 Error::Config(message) => message,
1742 other => panic!("expected Error::Config, got {other:?}"),
1743 };
1744 assert!(
1745 message.contains("protocol version 14"),
1746 "the refusal must name the real version floor, got: {message}"
1747 );
1748 }
1749
1750 /// A sub-query's clauses ride the same wire mapping as the page's, so an
1751 /// element operator only there is refused before protocol version 14 too.
1752 #[test]
1753 fn should_refuse_to_encode_an_element_sub_query_for_protocol_version_13() {
1754 use super::super::composite_document_query::CompositeSubQueryKind;
1755
1756 let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists");
1757 for operator in [WhereOperator::Contains, WhereOperator::ContainsAny] {
1758 let value = match operator {
1759 WhereOperator::ContainsAny => platform_value!(["dash"]),
1760 _ => platform_value!("dash"),
1761 };
1762 let query = DocumentQuery::new(post_contract(), "post")
1763 .expect("the fixture has this document type")
1764 .with_limit(10)
1765 .with_sub_query(CompositeSubQuery {
1766 data_contract: post_contract(),
1767 document_type_name: "post".to_string(),
1768 kind: CompositeSubQueryKind::Documents,
1769 where_clauses: vec![WhereClause {
1770 field: "hashtag".to_string(),
1771 operator,
1772 value,
1773 }],
1774 order_by_clauses: vec![],
1775 limit: Some(10),
1776 binding: None,
1777 });
1778 let error = GetDocumentsRequest::try_from_platform_versioned(query, platform_version)
1779 .expect_err("protocol version 13's contract grammar indexes no typed array");
1780 let message = match error {
1781 Error::Config(message) => message,
1782 other => panic!("expected Error::Config, got {other:?}"),
1783 };
1784 assert!(
1785 message.contains("contains and containsAny queries require protocol version 14"),
1786 "{operator:?} in a sub-query must be refused with the version floor, got: \
1787 {message}"
1788 );
1789 }
1790 }
1791
1792 #[test]
1793 fn should_encode_the_element_operators_for_protocol_version_14() {
1794 let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists");
1795 for (operator, expected) in [
1796 (WhereOperator::Contains, ProtoWhereOperator::Contains),
1797 (WhereOperator::ContainsAny, ProtoWhereOperator::ContainsAny),
1798 ] {
1799 let mut query = contains_query();
1800 query.where_clauses[0].operator = operator;
1801 let request = GetDocumentsRequest::try_from_platform_versioned(query, platform_version)
1802 .expect("protocol version 14 indexes typed arrays");
1803 let Some(V1(v1)) = request.version else {
1804 panic!("protocol version 14 encodes on the v1 wire");
1805 };
1806 assert_eq!(v1.where_clauses[0].operator, expected as i32);
1807 }
1808 }
1809
1810 #[test]
1811 fn a_by_start_selection_encodes_its_window_start_and_grid() {
1812 let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists");
1813 let query = DocumentQuery::new(post_contract(), "post")
1814 .expect("the fixture has this document type")
1815 .with_time_range_grid(
1816 "$createdAt",
1817 TimeRangeSelector::ByStart {
1818 start_ms: 1_756_684_800_000,
1819 },
1820 TimeRangeGridSpec {
1821 range_seconds: 86_400,
1822 step_seconds: 86_400,
1823 phase_seconds: 0,
1824 },
1825 );
1826 let request = GetDocumentsRequest::try_from_platform_versioned(query, platform_version)
1827 .expect("protocol version 14 hosts timeRange indexes");
1828 let Some(V1(v1)) = request.version else {
1829 panic!("protocol version 14 encodes on the v1 wire");
1830 };
1831 let [clause] = v1.where_clauses.as_slice() else {
1832 panic!("the selection must ride as exactly one IN_TIME_RANGE clause");
1833 };
1834 let selection = clause
1835 .time_range
1836 .as_ref()
1837 .expect("the typed operand carries the selection");
1838 assert_eq!(selection.selector, ProtoTimeRangeSelector::ByStart as i32);
1839 assert_eq!(
1840 selection.start_ms,
1841 Some(1_756_684_800_000),
1842 "the absolute window start rides in the query itself"
1843 );
1844 assert_eq!(
1845 selection.grid,
1846 Some(ProtoTimeRangeGrid {
1847 range: 86_400,
1848 step: 86_400,
1849 phase: 0,
1850 }),
1851 "the grid repeats the contract's declared seconds verbatim"
1852 );
1853 }
1854
1855 #[test]
1856 fn a_query_without_time_range_clauses_still_encodes_for_protocol_version_13() {
1857 let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists");
1858 let query = DocumentQuery::new(post_contract(), "post")
1859 .expect("the fixture has this document type");
1860 GetDocumentsRequest::try_from_platform_versioned(query, platform_version)
1861 .expect("the gate only refuses queries that carry a time-range selection");
1862 }
1863}