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}