Skip to main content

shelly_data/
query.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::hash::{Hash, Hasher};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum FilterOperator {
8    Eq,
9    Neq,
10    Gt,
11    Gte,
12    Lt,
13    Lte,
14    Contains,
15}
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Filter {
19    pub field: String,
20    pub op: FilterOperator,
21    pub value: Value,
22}
23
24impl Filter {
25    pub fn eq(field: impl Into<String>, value: Value) -> Self {
26        Self {
27            field: field.into(),
28            op: FilterOperator::Eq,
29            value,
30        }
31    }
32
33    pub fn contains(field: impl Into<String>, value: impl Into<String>) -> Self {
34        Self {
35            field: field.into(),
36            op: FilterOperator::Contains,
37            value: Value::String(value.into()),
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum SortDirection {
45    Asc,
46    Desc,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct Sort {
51    pub field: String,
52    pub direction: SortDirection,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Pagination {
57    pub page: usize,
58    pub per_page: usize,
59}
60
61impl Pagination {
62    pub fn new(page: usize, per_page: usize) -> Self {
63        Self {
64            page: page.max(1),
65            per_page: per_page.max(1),
66        }
67    }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum KeysetDirection {
73    Forward,
74    Backward,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct KeysetCursor {
79    pub field: String,
80    pub value: Value,
81    pub direction: KeysetDirection,
82}
83
84impl KeysetCursor {
85    pub fn forward(field: impl Into<String>, value: Value) -> Self {
86        Self {
87            field: field.into(),
88            value,
89            direction: KeysetDirection::Forward,
90        }
91    }
92
93    pub fn backward(field: impl Into<String>, value: Value) -> Self {
94        Self {
95            field: field.into(),
96            value,
97            direction: KeysetDirection::Backward,
98        }
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct KeysetPagination {
104    pub cursor: Option<KeysetCursor>,
105    pub limit: usize,
106}
107
108impl KeysetPagination {
109    pub fn new(limit: usize) -> Self {
110        Self {
111            cursor: None,
112            limit: limit.max(1),
113        }
114    }
115
116    pub fn with_cursor(mut self, cursor: KeysetCursor) -> Self {
117        self.cursor = Some(cursor);
118        self
119    }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123pub struct QueryWindow {
124    pub start: usize,
125    pub end: usize,
126    pub overscan: usize,
127}
128
129impl QueryWindow {
130    pub fn new(start: usize, end: usize, overscan: usize) -> Self {
131        let normalized_end = end.max(start.saturating_add(1));
132        Self {
133            start,
134            end: normalized_end,
135            overscan,
136        }
137    }
138
139    pub fn span(&self) -> usize {
140        self.end.saturating_sub(self.start)
141    }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
145#[serde(rename_all = "snake_case")]
146pub enum WireFormatProfile {
147    #[default]
148    Json,
149    Compact,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct WindowToken {
154    pub query_fingerprint: String,
155    pub offset: usize,
156    pub limit: usize,
157    pub issued_at_ms: u64,
158    pub nonce: u64,
159}
160
161impl WindowToken {
162    pub fn new(
163        query_fingerprint: impl Into<String>,
164        offset: usize,
165        limit: usize,
166        issued_at_ms: u64,
167        nonce: u64,
168    ) -> Self {
169        Self {
170            query_fingerprint: query_fingerprint.into(),
171            offset,
172            limit: limit.max(1),
173            issued_at_ms,
174            nonce,
175        }
176    }
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
180pub struct Query {
181    pub filters: Vec<Filter>,
182    pub sorts: Vec<Sort>,
183    pub pagination: Option<Pagination>,
184    pub keyset: Option<KeysetPagination>,
185    pub window: Option<QueryWindow>,
186    pub window_token: Option<WindowToken>,
187    #[serde(default)]
188    pub wire_format: WireFormatProfile,
189    pub preloads: Vec<String>,
190}
191
192impl Query {
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    pub fn where_filter(mut self, filter: Filter) -> Self {
198        self.filters.push(filter);
199        self
200    }
201
202    pub fn order_by(mut self, field: impl Into<String>, direction: SortDirection) -> Self {
203        self.sorts.push(Sort {
204            field: field.into(),
205            direction,
206        });
207        self
208    }
209
210    pub fn paginate(mut self, page: usize, per_page: usize) -> Self {
211        self.pagination = Some(Pagination::new(page, per_page));
212        self.keyset = None;
213        self
214    }
215
216    pub fn keyset_paginate(mut self, limit: usize) -> Self {
217        self.keyset = Some(KeysetPagination::new(limit));
218        self.pagination = None;
219        self
220    }
221
222    pub fn keyset_after(mut self, field: impl Into<String>, value: Value, limit: usize) -> Self {
223        self.keyset =
224            Some(KeysetPagination::new(limit).with_cursor(KeysetCursor::forward(field, value)));
225        self.pagination = None;
226        self
227    }
228
229    pub fn keyset_before(mut self, field: impl Into<String>, value: Value, limit: usize) -> Self {
230        self.keyset =
231            Some(KeysetPagination::new(limit).with_cursor(KeysetCursor::backward(field, value)));
232        self.pagination = None;
233        self
234    }
235
236    pub fn window(mut self, start: usize, end: usize, overscan: usize) -> Self {
237        self.window = Some(QueryWindow::new(start, end, overscan));
238        self
239    }
240
241    pub fn with_window_token(mut self, token: WindowToken) -> Self {
242        self.window_token = Some(token);
243        self
244    }
245
246    pub fn wire_format(mut self, profile: WireFormatProfile) -> Self {
247        self.wire_format = profile;
248        self
249    }
250
251    pub fn preload(mut self, relation: impl Into<String>) -> Self {
252        self.preloads.push(relation.into());
253        self
254    }
255
256    pub fn fingerprint(&self) -> String {
257        let canonical = serde_json::to_string(&serde_json::json!({
258            "filters": self.filters,
259            "sorts": self.sorts,
260            "pagination": self.pagination,
261            "keyset": self.keyset,
262            "window": self.window,
263            "wire_format": self.wire_format,
264            "preloads": self.preloads
265        }))
266        .unwrap_or_default();
267        let mut hasher = std::collections::hash_map::DefaultHasher::new();
268        canonical.hash(&mut hasher);
269        format!("q{:016x}", hasher.finish())
270    }
271
272    pub fn next_window_token(
273        &self,
274        offset: usize,
275        limit: usize,
276        issued_at_ms: u64,
277        nonce: u64,
278    ) -> WindowToken {
279        WindowToken::new(self.fingerprint(), offset, limit, issued_at_ms, nonce)
280    }
281
282    pub fn has_valid_window_token(&self) -> bool {
283        self.window_token
284            .as_ref()
285            .map(|token| token.query_fingerprint == self.fingerprint())
286            .unwrap_or(true)
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::{
293        Filter, FilterOperator, KeysetDirection, Pagination, Query, QueryWindow, SortDirection,
294        WindowToken, WireFormatProfile,
295    };
296    use serde_json::json;
297
298    #[test]
299    fn pagination_clamps_zero_values_to_one() {
300        let pagination = Pagination::new(0, 0);
301        assert_eq!(pagination.page, 1);
302        assert_eq!(pagination.per_page, 1);
303    }
304
305    #[test]
306    fn filter_builders_create_expected_filters() {
307        assert_eq!(
308            Filter::eq("title", json!("Hello")),
309            Filter {
310                field: "title".to_string(),
311                op: FilterOperator::Eq,
312                value: json!("Hello"),
313            }
314        );
315        assert_eq!(
316            Filter::contains("body", "rust"),
317            Filter {
318                field: "body".to_string(),
319                op: FilterOperator::Contains,
320                value: json!("rust"),
321            }
322        );
323    }
324
325    #[test]
326    fn query_builder_collects_filters_sorts_pagination_and_preloads() {
327        let query = Query::new()
328            .where_filter(Filter::eq("id", json!(10)))
329            .where_filter(Filter::contains("title", "post"))
330            .order_by("inserted_at", SortDirection::Desc)
331            .order_by("title", SortDirection::Asc)
332            .keyset_after("id", json!(10), 50)
333            .window(100, 260, 40)
334            .wire_format(WireFormatProfile::Compact)
335            .preload("author")
336            .preload("comments");
337
338        assert_eq!(query.filters.len(), 2);
339        assert_eq!(query.sorts.len(), 2);
340        assert_eq!(query.pagination, None);
341        assert_eq!(query.keyset.as_ref().map(|value| value.limit), Some(50));
342        assert_eq!(
343            query
344                .keyset
345                .as_ref()
346                .and_then(|value| value.cursor.as_ref())
347                .map(|cursor| cursor.direction),
348            Some(KeysetDirection::Forward)
349        );
350        assert_eq!(
351            query.window,
352            Some(QueryWindow {
353                start: 100,
354                end: 260,
355                overscan: 40
356            })
357        );
358        assert_eq!(query.wire_format, WireFormatProfile::Compact);
359        assert_eq!(
360            query.preloads,
361            vec!["author".to_string(), "comments".to_string()]
362        );
363    }
364
365    #[test]
366    fn query_window_normalizes_empty_end_range() {
367        assert_eq!(
368            QueryWindow::new(42, 1, 10),
369            QueryWindow {
370                start: 42,
371                end: 43,
372                overscan: 10
373            }
374        );
375    }
376
377    #[test]
378    fn query_fingerprint_and_window_tokens_are_deterministic() {
379        let query = Query::new()
380            .where_filter(Filter::eq("status", json!("healthy")))
381            .order_by("id", SortDirection::Asc)
382            .paginate(1, 240);
383        let fp1 = query.fingerprint();
384        let fp2 = query.fingerprint();
385        assert_eq!(fp1, fp2);
386
387        let token = query.next_window_token(0, 240, 1_718_000_000_000, 7);
388        assert_eq!(token, WindowToken::new(fp1, 0, 240, 1_718_000_000_000, 7));
389        assert!(query
390            .clone()
391            .with_window_token(token)
392            .has_valid_window_token());
393    }
394
395    #[test]
396    fn invalid_window_token_is_detected() {
397        let query = Query::new()
398            .where_filter(Filter::eq("status", json!("healthy")))
399            .paginate(1, 50)
400            .with_window_token(WindowToken::new("qdeadbeef", 0, 50, 10, 1));
401        assert!(!query.has_valid_window_token());
402    }
403}