Skip to main content

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}