Skip to main content

laser_wire/
query.rs

1use crate::codes::QUERY_OP_VERSION;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5/// One exact-match constraint: the indexed `field` must equal `value`.
6#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
7pub struct KeyMatch {
8    pub field: String,
9    pub value: String,
10}
11
12impl KeyMatch {
13    /// An exact-match predicate, `field == value`.
14    pub fn new(field: impl Into<String>, value: impl Into<String>) -> Self {
15        Self {
16            field: field.into(),
17            value: value.into(),
18        }
19    }
20}
21
22/// A query against a materialized index. Build it fluently via the SDK's
23/// `Laser::query`, or directly through [`Query::builder`].
24#[derive(Clone, Debug, Default, Serialize, Deserialize)]
25#[cfg_attr(feature = "builders", derive(bon::Builder))]
26pub struct Query {
27    // A materialized index name (produced by a projection), not a raw topic.
28    #[cfg_attr(feature = "builders", builder(into))]
29    pub index: String,
30    #[cfg_attr(feature = "builders", builder(default))]
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub by_key: Vec<KeyMatch>,
33    #[cfg_attr(feature = "builders", builder(into))]
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub message_type: Option<String>,
36    // (start, end) in epoch microseconds.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub time_range: Option<(u64, u64)>,
39    // Predicate tree. `None` plus empty sugar is an unfiltered scan. Build
40    // trees with `Filter::all`/`any`/`not`/`pred`.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub filter: Option<Filter>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub vector: Option<VectorQuery>,
45    // Lexical relevance search over the text-hinted indexed fields, additive
46    // like `vector`. An unaware server would silently drop it (the A8 additive
47    // hazard), so the client refuses an unadvertised `text` before sending.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub text: Option<TextQuery>,
50    #[cfg_attr(feature = "builders", builder(default))]
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub order: Vec<Sort>,
53    // NOTE the asymmetry, current behavior moved as-is: the builder defaults
54    // `limit` to 50, serde to 0 (a `0` limit means "a full page" managed-side).
55    #[cfg_attr(feature = "builders", builder(default = 50))]
56    pub limit: usize,
57    #[cfg_attr(feature = "builders", builder(default))]
58    #[serde(default)]
59    pub offset: usize,
60    // Analytics, mutually exclusive with row selection.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub aggregate: Option<Aggregate>,
63    // Filter on aggregate output (predicate fields reference an alias or group
64    // key). Only meaningful with `aggregate`.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub having: Option<Filter>,
67    // DISTINCT over the selected fields.
68    #[cfg_attr(feature = "builders", builder(default))]
69    #[serde(default, skip_serializing_if = "is_false")]
70    pub distinct: bool,
71    #[cfg_attr(feature = "builders", builder(default))]
72    #[serde(default)]
73    pub select: Select,
74    // Resolve against a fork's copy-on-write view (trunk overlaid with the fork's
75    // speculative rows) instead of the trunk. Absent on the wire for a trunk
76    // query, so the pre-fork contract is unchanged.
77    #[cfg_attr(feature = "builders", builder(into))]
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub fork: Option<String>,
80    // Opt-in raw-SQL escape hatch. SQL backends only, read-only single SELECT.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub raw_sql: Option<RawSql>,
83    // Read-consistency level. Absent on the wire for the default (`Eventual`),
84    // so the pre-consistency contract is unchanged.
85    #[cfg_attr(feature = "builders", builder(default))]
86    #[serde(default, skip_serializing_if = "Consistency::is_eventual")]
87    pub consistency: Consistency,
88    // Whether `page.total` must be an exact count (a `COUNT(*)` query).
89    // Default false: the server fetches one extra row past `limit` as an
90    // existence probe instead, cheaper for large result sets. `page.total` is
91    // then absent and `page.has_more` remains exact.
92    #[cfg_attr(feature = "builders", builder(default))]
93    #[serde(default, skip_serializing_if = "is_false")]
94    pub want_total: bool,
95}
96
97fn is_false(value: &bool) -> bool {
98    !*value
99}
100
101/// How fresh a query's view of the materialized index must be. A materialized
102/// view is a read model a projector builds by tailing the log, so it is
103/// eventually consistent: a record is queryable once the projector has applied
104/// it, not the instant it is appended. This level says what the query requires
105/// of that lag, and the contract is fail-not-downgrade: a level that cannot be
106/// met returns [`QueryError::Stale`] rather than silently serving older data.
107// `Ord` follows the declaration order, which is the strength ladder
108// (Eventual < ReadYourWrites < Strong), so a stronger level compares greater and
109// a capability check is `want <= served`. A new variant must be appended to keep
110// the order meaningful.
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113#[non_exhaustive]
114pub enum Consistency {
115    /// Serve from the index as-is, whatever the projector has applied so far.
116    /// The default and the cheapest: no wait, best for dashboards and scans
117    /// where a little lag is fine.
118    #[default]
119    Eventual,
120    /// Wait until the projector has applied the source log up to its current
121    /// head before serving, so a query issued after a publish sees that write
122    /// (read-your-writes). Bounded: if the projector cannot catch up within the
123    /// managed deadline the query returns [`QueryError::Stale`] instead of
124    /// downgrading to a stale read. Backend-gated by `read_your_writes`.
125    ReadYourWrites,
126    /// The strongest level: a linearizable read across replicas. Backend-gated
127    /// by `strong_consistency`. Where unavailable the query returns a clean
128    /// unsupported error. Semantics past read-your-writes are still being
129    /// pinned, so treat it as read-your-writes plus cross-replica agreement.
130    Strong,
131}
132
133impl Consistency {
134    /// Whether this is the default `Eventual` level (omitted on the wire).
135    pub fn is_eventual(&self) -> bool {
136        matches!(self, Consistency::Eventual)
137    }
138}
139
140/// The server-side gate that enforces a [`Consistency`] level the same way on
141/// every backend. The client refuses an unadvertised level before sending,
142/// but a backend that does advertise `read_your_writes` or `strong_consistency`
143/// still has to honor the level, and the rule is fail-not-downgrade: serve only
144/// when the projector's `applied` offset for the queried source has reached the
145/// `required` offset (the source log head at query time), else return
146/// [`QueryError::Stale`] rather than a silently older read.
147///
148/// This is the offset obligation common to both non-`Eventual` levels.
149/// `Strong` is read-your-writes plus cross-replica agreement, so a backend
150/// serving `Strong` layers its own cross-replica check on top of a passing
151/// gate. `Eventual` always passes.
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub struct ConsistencyGate {
154    /// The projector's applied offset for the queried source.
155    pub applied: u64,
156    /// The offset the read must reach before serving (the source log head).
157    pub required: u64,
158}
159
160impl ConsistencyGate {
161    /// A gate for a source whose projector has applied up to `applied` against a
162    /// head of `required`.
163    pub fn new(applied: u64, required: u64) -> Self {
164        Self { applied, required }
165    }
166
167    /// Whether the projector has caught up to the required offset.
168    pub fn is_caught_up(&self) -> bool {
169        self.applied >= self.required
170    }
171
172    /// Enforce `level` for the source named `what`. `Eventual` always passes. A
173    /// non-`Eventual` level passes only when [`is_caught_up`](Self::is_caught_up),
174    /// else returns [`QueryError::Stale`] carrying the offsets so the caller can
175    /// retry while the projector catches up.
176    pub fn check(&self, level: Consistency, what: impl Into<String>) -> Result<(), QueryError> {
177        if level.is_eventual() || self.is_caught_up() {
178            return Ok(());
179        }
180        Err(QueryError::Stale {
181            what: what.into(),
182            applied: self.applied,
183            required: self.required,
184        })
185    }
186}
187
188/// A predicate tree. `All`/`Any` are n-ary, `Not` negates, `Pred` is a single
189/// comparison leaf. Externally tagged on the wire:
190/// `{"all":[{"pred":{...}}]}`.
191#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum Filter {
194    All(Vec<Filter>),
195    Any(Vec<Filter>),
196    Not(Box<Filter>),
197    Pred(Predicate),
198}
199
200impl Filter {
201    /// AND of `filters`.
202    pub fn all(filters: impl IntoIterator<Item = Filter>) -> Self {
203        Filter::All(filters.into_iter().collect())
204    }
205
206    /// OR of `filters`.
207    pub fn any(filters: impl IntoIterator<Item = Filter>) -> Self {
208        Filter::Any(filters.into_iter().collect())
209    }
210
211    /// Negate `filter`.
212    pub fn negate(filter: Filter) -> Self {
213        Filter::Not(Box::new(filter))
214    }
215
216    /// A single comparison leaf, `field op value`.
217    pub fn pred(field: impl Into<String>, op: CmpOp, value: impl Into<Value>) -> Self {
218        Filter::Pred(Predicate {
219            field: field.into(),
220            op,
221            value: value.into(),
222        })
223    }
224}
225
226/// A filter leaf: a field, a comparison op, and a value.
227#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228pub struct Predicate {
229    pub field: String,
230    pub op: CmpOp,
231    pub value: Value,
232}
233
234/// Raw-SQL escape hatch. `sql` must be a single read-only SELECT. `params` bind
235/// positionally. SQL backends only.
236#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
237pub struct RawSql {
238    pub sql: String,
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub params: Vec<Value>,
241}
242
243/// A comparison operator for a `Predicate`.
244#[derive(
245    Clone,
246    Copy,
247    Debug,
248    PartialEq,
249    Eq,
250    Serialize,
251    Deserialize,
252    strum::Display,
253    strum::EnumString,
254    strum::VariantArray,
255)]
256#[serde(rename_all = "snake_case")]
257#[strum(serialize_all = "snake_case")]
258pub enum CmpOp {
259    Eq,
260    Ne,
261    Lt,
262    Lte,
263    Gt,
264    Gte,
265    In,
266    Contains,
267    Prefix,
268}
269
270/// An order-by clause: a field and a direction.
271#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
272pub struct Sort {
273    pub field: String,
274    #[serde(default)]
275    pub dir: Dir,
276}
277
278/// Sort direction (ascending or descending).
279#[derive(
280    Clone,
281    Copy,
282    Debug,
283    Default,
284    PartialEq,
285    Eq,
286    Serialize,
287    Deserialize,
288    strum::Display,
289    strum::EnumString,
290    strum::VariantArray,
291)]
292#[serde(rename_all = "snake_case")]
293#[strum(serialize_all = "snake_case")]
294pub enum Dir {
295    #[default]
296    Asc,
297    Desc,
298}
299
300/// A lexical relevance search: the text to match and, optionally, the one
301/// indexed field to match it in (`None` searches every text-hinted field).
302/// Relevance lands in the reply's `Row.score` slot exactly as vector distance
303/// does, so the reply shape never changes. Capability-gated by
304/// `KEYWORD_SEARCH`: a backend without a lexical index answers unsupported,
305/// never a contains approximation.
306#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
307pub struct TextQuery {
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub field: Option<String>,
310    pub query: String,
311}
312
313/// A nearest-neighbour search: the query embedding and how many rows to return.
314#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
315pub struct VectorQuery {
316    pub field: String,
317    pub embedding: Vec<f32>,
318    pub top_k: usize,
319}
320
321/// A grouped aggregation carrying one or more [`AggCall`]s, so a single query
322/// can return several aggregates grouped by the same keys. An optional `window`
323/// adds a time-bucket key.
324#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
325pub struct Aggregate {
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub group_by: Vec<String>,
328    pub funcs: Vec<AggCall>,
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub window: Option<Window>,
331}
332
333/// One aggregate in an [`Aggregate`]. `field` is `None` only for `Count`, and `arg`
334/// is the fraction for `Percentile` (e.g. 0.95). `alias` is the output header
335/// key on each result row.
336#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
337pub struct AggCall {
338    pub func: AggFunc,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub field: Option<String>,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub arg: Option<f64>,
343    pub alias: String,
344}
345
346/// An aggregate function. `Percentile` and `StdDev` are backend-gated (the
347/// embedded engine does not provide them, a columnar backend does).
348#[derive(
349    Clone,
350    Copy,
351    Debug,
352    PartialEq,
353    Eq,
354    Serialize,
355    Deserialize,
356    strum::Display,
357    strum::EnumString,
358    strum::VariantArray,
359)]
360#[serde(rename_all = "snake_case")]
361#[strum(serialize_all = "snake_case")]
362pub enum AggFunc {
363    Count,
364    CountDistinct,
365    Sum,
366    Avg,
367    Min,
368    Max,
369    Percentile,
370    StdDev,
371}
372
373/// A tumbling window of `every_micros` over the timestamp `field`.
374#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
375pub struct Window {
376    pub field: String,
377    pub every_micros: u64,
378}
379
380/// Which columns and payload a query returns.
381#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
382pub struct Select {
383    // Empty selects every indexed field.
384    #[serde(default, skip_serializing_if = "Vec::is_empty")]
385    pub fields: Vec<String>,
386    // Return the opaque payload bytes alongside the indexed fields.
387    #[serde(default)]
388    pub payload: bool,
389}
390
391/// A scalar value in a predicate or result row. `#[serde(untagged)]`, riding the
392/// wire as a bare scalar. Variant order matters for untagged decode: `Int`
393/// before `Uint` keeps small/negative integers as `i64`, and a value past
394/// `i64::MAX` falls through to `Uint` before `Float` (never a lossy `f64`).
395/// `Null` is a unit variant matching a bare `null`.
396#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
397#[serde(untagged)]
398pub enum Value {
399    Str(String),
400    Int(i64),
401    Uint(u64),
402    Float(f64),
403    Bool(bool),
404    Null,
405    List(Vec<Value>),
406}
407
408impl From<&str> for Value {
409    fn from(value: &str) -> Self {
410        Self::Str(value.to_owned())
411    }
412}
413
414impl From<String> for Value {
415    fn from(value: String) -> Self {
416        Self::Str(value)
417    }
418}
419
420impl From<&String> for Value {
421    fn from(value: &String) -> Self {
422        Self::Str(value.clone())
423    }
424}
425
426impl From<i64> for Value {
427    fn from(value: i64) -> Self {
428        Self::Int(value)
429    }
430}
431
432impl From<u64> for Value {
433    fn from(value: u64) -> Self {
434        Self::Uint(value)
435    }
436}
437
438impl From<i32> for Value {
439    fn from(value: i32) -> Self {
440        Self::Int(value as i64)
441    }
442}
443
444impl From<u32> for Value {
445    fn from(value: u32) -> Self {
446        Self::Int(value as i64)
447    }
448}
449
450impl From<f64> for Value {
451    fn from(value: f64) -> Self {
452        Self::Float(value)
453    }
454}
455
456impl From<f32> for Value {
457    fn from(value: f32) -> Self {
458        Self::Float(value as f64)
459    }
460}
461
462impl From<bool> for Value {
463    fn from(value: bool) -> Self {
464        Self::Bool(value)
465    }
466}
467
468impl<T: Into<Value>> From<Vec<T>> for Value {
469    fn from(values: Vec<T>) -> Self {
470        Self::List(values.into_iter().map(Into::into).collect())
471    }
472}
473
474impl Value {
475    /// Infer a scalar from a user-typed string, the inverse of [`std::fmt::Display`] for a
476    /// UI input box. The narrowest type wins: `"null"` is [`Value::Null`],
477    /// `"true"`/`"false"` are [`Value::Bool`], a bare integer is
478    /// [`Value::Int`] (or [`Value::Uint`] past `i64::MAX`), a digits-and-dot
479    /// decimal is [`Value::Float`], and everything else is [`Value::Str`].
480    /// Lists are built structurally (e.g. for [`CmpOp::In`]), never inferred
481    /// here, so this never fails. Round-trips for every non-string scalar. A
482    /// string that happens to look like a number narrows to that number (so
483    /// `Display` then `from_input` is not the identity for a [`Value::Str`] of
484    /// numeric text, by design).
485    pub fn from_input(input: &str) -> Self {
486        match input {
487            "null" => return Value::Null,
488            "true" => return Value::Bool(true),
489            "false" => return Value::Bool(false),
490            _ => {}
491        }
492        if let Ok(int) = input.parse::<i64>() {
493            return Value::Int(int);
494        }
495        if let Ok(uint) = input.parse::<u64>() {
496            return Value::Uint(uint);
497        }
498        // Only digit-and-dot decimals narrow to a float. This rejects the
499        // float parser's `inf`/`nan`/`1e9` surprises a plain word would hit.
500        if !input.is_empty()
501            && input
502                .bytes()
503                .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-' || b == b'+')
504            && let Ok(float) = input.parse::<f64>()
505        {
506            return Value::Float(float);
507        }
508        Value::Str(input.to_owned())
509    }
510}
511
512impl std::fmt::Display for Value {
513    /// Renders a scalar as its bare form (no quotes), so it reads naturally in a
514    /// UI cell or a predicate echo. A [`Value::List`] renders as `[a, b, c]`
515    /// over its elements' own `Display`.
516    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517        match self {
518            Value::Str(value) => f.write_str(value),
519            Value::Int(value) => write!(f, "{value}"),
520            Value::Uint(value) => write!(f, "{value}"),
521            Value::Float(value) => write!(f, "{value}"),
522            Value::Bool(value) => write!(f, "{value}"),
523            Value::Null => f.write_str("null"),
524            Value::List(values) => {
525                f.write_str("[")?;
526                for (index, value) in values.iter().enumerate() {
527                    if index > 0 {
528                        f.write_str(", ")?;
529                    }
530                    write!(f, "{value}")?;
531                }
532                f.write_str("]")
533            }
534        }
535    }
536}
537
538impl std::str::FromStr for Value {
539    type Err = std::convert::Infallible;
540
541    fn from_str(s: &str) -> Result<Self, Self::Err> {
542        Ok(Value::from_input(s))
543    }
544}
545
546/// A page of result rows plus pagination info.
547#[derive(Clone, Debug, Default, Serialize, Deserialize)]
548pub struct QueryResult {
549    pub rows: Vec<Row>,
550    // Pagination metadata for this page of `rows` (total matches, more available).
551    #[serde(default)]
552    pub page: Page,
553}
554
555/// Pagination info for a query result.
556///
557/// The default reply costs one page: the server fetches `limit + 1` rows and
558/// answers `has_more` exactly from the probe row, never counting the rest.
559/// `total` is present only when the request set `want_total`, which runs a
560/// real `COUNT(*)` over the filter (unbounded work on a large index). A
561/// caller that never asked cannot misread a page-bounded number as a count:
562/// there is no number.
563#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
564pub struct Page {
565    // The offset this page started at, echoed back.
566    pub offset: usize,
567    // The effective limit applied (the query's `limit`, clamped to the page cap).
568    pub limit: usize,
569    // Exact match count, present only when the request set `want_total`.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub total: Option<u64>,
572    // Whether rows beyond this page exist. Always exact, always free.
573    pub has_more: bool,
574}
575
576impl Page {
577    /// Rows known to exist so far: the position one past this page's last
578    /// row. A cheap display lower bound (`"1234+ rows"`), paired with
579    /// [`Self::has_more`] for the trailing `+`.
580    pub fn at_least(&self, rows_on_page: usize) -> usize {
581        self.offset + rows_on_page
582    }
583
584    /// Pages implied by the exact total, when one was requested. `None`
585    /// without `want_total` or with a zero limit (page math is undefined
586    /// without a page size).
587    pub fn total_pages(&self) -> Option<u64> {
588        match (self.total, self.limit) {
589            (Some(total), limit) if limit > 0 => Some(total.div_ceil(limit as u64)),
590            _ => None,
591        }
592    }
593}
594
595/// One materialized row: indexed fields, metadata, log position, and optional payload/score.
596#[derive(Clone, Debug, Default, Serialize, Deserialize)]
597pub struct Row {
598    // Indexed fields + provenance, keyed by the name after `agdx.idx.`.
599    pub headers: BTreeMap<String, String>,
600    // Ride-along publisher headers (content_type, schema_id, any custom user
601    // metadata). Always returned, free to inspect even when the payload was
602    // not requested.
603    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
604    pub metadata: BTreeMap<String, String>,
605    // The Iggy partition this row was projected from. Skipped when
606    // serializing if the backend does not populate it.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub partition: Option<u32>,
609    // The Iggy offset this row was projected from.
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub offset: Option<u64>,
612    // Source stream and topic ids, paired with partition/offset to address the
613    // origin log message.
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub stream: Option<u32>,
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub topic: Option<u32>,
618    // Inline payload bytes, present only when the publisher inlined the body
619    // AND the query asked for it. Owned `Vec<u8>` so the public API never
620    // leaks the `bytes` crate. On the wire it is a CBOR byte string.
621    #[serde(
622        default,
623        skip_serializing_if = "Option::is_none",
624        with = "crate::encoding::opt_bin_bytes"
625    )]
626    pub payload: Option<Vec<u8>>,
627    // Set for vector queries: the similarity score of the row.
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub score: Option<f32>,
630}
631
632/// Internal on-wire envelope: a versioned wrapper around `Query`. Workers and
633/// clients use it, app code does not.
634#[derive(Clone, Debug, Serialize, Deserialize)]
635#[non_exhaustive]
636pub struct QueryEnvelope {
637    pub v: u32,
638    pub query: Query,
639}
640
641impl QueryEnvelope {
642    /// Constructor for the non-exhaustive wire struct.
643    pub fn new(query: Query) -> Self {
644        Self {
645            v: QUERY_OP_VERSION,
646            query,
647        }
648    }
649}
650
651/// A query reply: `Ok(QueryResult)` or `Err(QueryError)`.
652#[derive(Clone, Debug, Serialize, Deserialize)]
653#[non_exhaustive]
654pub enum QueryReply {
655    Ok(QueryResult),
656    Err(QueryError),
657}
658
659/// Why a query failed.
660#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
661#[non_exhaustive]
662pub enum QueryError {
663    #[error("query not supported: {0}")]
664    Unsupported(String),
665    #[error("unauthorized: {0}")]
666    Unauthorized(String),
667    #[error("index not found: {0}")]
668    IndexNotFound(String),
669    #[error("fork not found: {0}")]
670    ForkNotFound(String),
671    #[error("backend error: {0}")]
672    Backend(String),
673    /// The query asked for more than a single reply may carry: a `limit`
674    /// above the page cap, or a result whose inline payloads exceed the
675    /// LaserData Cloud's reply-byte budget. `what` names the bound hit ("limit" /
676    /// "reply bytes"), `size` is what was requested or reached, `cap` is the
677    /// ceiling. Page with `limit`/`offset` (or drop the payload request)
678    /// rather than retrying unchanged.
679    #[error("result too large: {what} {size} exceeds cap {cap}")]
680    TooLarge {
681        what: String,
682        size: usize,
683        cap: usize,
684    },
685    #[error("unsupported envelope version (expected {expected}, got {got})")]
686    Version { expected: u32, got: u32 },
687    /// A [`Consistency`] level could not be met within the managed deadline: the
688    /// projector's applied offset for the queried source sits at `applied` while
689    /// the level required `required`. Fail-not-downgrade, so the caller retries
690    /// (the projector is catching up) rather than unknowingly reading stale
691    /// data. `what` names the source (index or partition) that lagged.
692    #[error("stale read: {what} applied {applied}, required {required}")]
693    Stale {
694        what: String,
695        applied: u64,
696        required: u64,
697    },
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn given_dsl_enums_when_displayed_then_should_be_snake_case() {
706        assert_eq!(CmpOp::Gte.to_string(), "gte");
707        assert_eq!(CmpOp::Prefix.to_string(), "prefix");
708        assert_eq!("ne".parse::<CmpOp>().expect("ne parses"), CmpOp::Ne);
709        assert_eq!(Dir::Desc.to_string(), "desc");
710        assert_eq!(AggFunc::Count.to_string(), "count");
711    }
712
713    #[test]
714    fn given_a_consistency_gate_when_checked_then_should_fail_not_downgrade() {
715        // Eventual always passes, regardless of lag.
716        assert!(
717            ConsistencyGate::new(0, 100)
718                .check(Consistency::Eventual, "orders")
719                .is_ok()
720        );
721        // A non-Eventual level passes only once caught up.
722        assert!(
723            ConsistencyGate::new(100, 100)
724                .check(Consistency::ReadYourWrites, "orders")
725                .is_ok()
726        );
727        let stale = ConsistencyGate::new(41, 57)
728            .check(Consistency::Strong, "orders")
729            .expect_err("a lagging projector must fail, never downgrade");
730        assert!(matches!(
731            stale,
732            QueryError::Stale {
733                applied: 41,
734                required: 57,
735                ..
736            }
737        ));
738    }
739
740    #[test]
741    fn given_a_page_when_computing_total_pages_then_should_divide_by_limit() {
742        let page = Page {
743            offset: 0,
744            limit: 3,
745            total: Some(10),
746            has_more: true,
747        };
748        assert_eq!(page.total_pages(), Some(4));
749        assert_eq!(page.at_least(3), 3, "offset 0 plus this page's rows");
750        // Without a requested exact total there is no number to misread.
751        assert_eq!(Page::default().total_pages(), None);
752    }
753}
754
755#[cfg(all(test, feature = "codecs"))]
756mod serde_tests {
757    use super::*;
758    #[cfg(feature = "builders")]
759    use crate::codes::QUERY_OP_VERSION;
760    use crate::framing::{decode_named, encode_named};
761
762    #[test]
763    fn given_dsl_enums_when_serialized_then_serde_should_match_display() {
764        assert_eq!(
765            serde_json::to_string(&CmpOp::Lte).expect("CmpOp serializes"),
766            "\"lte\""
767        );
768        assert_eq!(
769            serde_json::from_str::<CmpOp>("\"in\"").expect("CmpOp deserializes"),
770            CmpOp::In
771        );
772        assert_eq!(
773            serde_json::to_string(&Dir::Asc).expect("Dir serializes"),
774            "\"asc\""
775        );
776    }
777
778    #[test]
779    #[cfg(feature = "builders")]
780    fn given_a_query_when_round_tripped_through_the_envelope_then_should_be_unchanged() {
781        let query = Query::builder()
782            .index("orders")
783            .by_key(vec![KeyMatch::new("customer_id", "abc")])
784            .filter(Filter::pred("status", CmpOp::Eq, "paid"))
785            .order(vec![Sort {
786                field: "ts".to_owned(),
787                dir: Dir::Desc,
788            }])
789            .limit(20)
790            .build();
791        let request = QueryEnvelope::new(query);
792
793        let json = serde_json::to_string(&request).expect("the request serializes");
794        let back: QueryEnvelope = serde_json::from_str(&json).expect("the request deserializes");
795        assert_eq!(back.v, QUERY_OP_VERSION);
796        assert_eq!(back.query.index, "orders");
797        assert_eq!(back.query.limit, 20);
798        assert_eq!(back.query.by_key, vec![KeyMatch::new("customer_id", "abc")]);
799        let Some(Filter::Pred(predicate)) = &back.query.filter else {
800            panic!("expected a single predicate filter");
801        };
802        assert_eq!(predicate.value, Value::Str("paid".to_owned()));
803        assert_eq!(back.query.order[0].dir, Dir::Desc);
804    }
805
806    #[test]
807    #[cfg(feature = "builders")]
808    fn given_each_consistency_level_when_round_tripped_then_should_preserve_it_and_skip_eventual() {
809        for level in [
810            Consistency::Eventual,
811            Consistency::ReadYourWrites,
812            Consistency::Strong,
813        ] {
814            let query = Query::builder().index("orders").consistency(level).build();
815            let bytes = encode_named(&QueryEnvelope::new(query)).expect("serializes");
816            let back: QueryEnvelope = decode_named(&bytes).expect("deserializes");
817            assert_eq!(back.query.consistency, level);
818        }
819        // The default `Eventual` is omitted on the wire so the pre-consistency
820        // contract stays byte-identical.
821        let default = Query::builder().index("orders").build();
822        assert_eq!(default.consistency, Consistency::Eventual);
823        let json = serde_json::to_string(&default).expect("json");
824        assert!(
825            !json.contains("consistency"),
826            "default Eventual must be omitted: {json}"
827        );
828    }
829
830    #[test]
831    fn given_a_stale_reply_when_round_tripped_then_should_preserve_the_offsets() {
832        let reply = QueryReply::Err(QueryError::Stale {
833            what: "orders".to_owned(),
834            applied: 41,
835            required: 57,
836        });
837        let bytes = encode_named(&reply).expect("serializes");
838        let back: QueryReply = decode_named(&bytes).expect("deserializes");
839        let QueryReply::Err(QueryError::Stale {
840            what,
841            applied,
842            required,
843        }) = back
844        else {
845            panic!("expected a Stale error");
846        };
847        assert_eq!((what.as_str(), applied, required), ("orders", 41, 57));
848    }
849
850    #[test]
851    #[cfg(feature = "builders")]
852    fn given_a_vector_query_when_round_tripped_then_should_preserve_the_embedding() {
853        let query = Query::builder()
854            .index("mem:conv-1")
855            .vector(VectorQuery {
856                field: "embedding".to_owned(),
857                embedding: vec![0.1, 0.2, 0.3],
858                top_k: 5,
859            })
860            .build();
861        let json = serde_json::to_string(&query).expect("the query serializes");
862        let back: Query = serde_json::from_str(&json).expect("the query deserializes");
863        let vector = back.vector.expect("the vector survives the round-trip");
864        assert_eq!(vector.embedding, vec![0.1, 0.2, 0.3]);
865        assert_eq!(vector.top_k, 5);
866    }
867
868    #[test]
869    fn given_a_reply_with_a_payload_row_when_round_tripped_then_should_preserve_the_bytes() {
870        let mut headers = BTreeMap::new();
871        headers.insert("order_id".to_owned(), "123".to_owned());
872        let reply = QueryReply::Ok(QueryResult {
873            rows: vec![Row {
874                headers,
875                metadata: BTreeMap::from([("agdx.ct".to_owned(), "1".to_owned())]),
876                partition: Some(2),
877                offset: Some(17),
878                stream: Some(5),
879                topic: Some(3),
880                payload: Some(b"{\"total\":42}".to_vec()),
881                score: None,
882            }],
883            page: Page {
884                offset: 0,
885                limit: 50,
886                total: Some(1),
887                has_more: false,
888            },
889        });
890        let bytes = encode_named(&reply).expect("the reply serializes");
891        let back: QueryReply = decode_named(&bytes).expect("the reply deserializes");
892        let QueryReply::Ok(result) = back else {
893            panic!("the reply should decode as Ok");
894        };
895        assert_eq!(result.rows[0].headers["order_id"], "123");
896        assert_eq!(
897            result.rows[0].payload.as_deref(),
898            Some(b"{\"total\":42}".as_ref())
899        );
900        assert_eq!(result.page.total, Some(1));
901        assert!(!result.page.has_more);
902    }
903}