use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FilterOperator {
Eq,
Neq,
Gt,
Gte,
Lt,
Lte,
Contains,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Filter {
pub field: String,
pub op: FilterOperator,
pub value: Value,
}
impl Filter {
pub fn eq(field: impl Into<String>, value: Value) -> Self {
Self {
field: field.into(),
op: FilterOperator::Eq,
value,
}
}
pub fn contains(field: impl Into<String>, value: impl Into<String>) -> Self {
Self {
field: field.into(),
op: FilterOperator::Contains,
value: Value::String(value.into()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortDirection {
Asc,
Desc,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sort {
pub field: String,
pub direction: SortDirection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pagination {
pub page: usize,
pub per_page: usize,
}
impl Pagination {
pub fn new(page: usize, per_page: usize) -> Self {
Self {
page: page.max(1),
per_page: per_page.max(1),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KeysetDirection {
Forward,
Backward,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KeysetCursor {
pub field: String,
pub value: Value,
pub direction: KeysetDirection,
}
impl KeysetCursor {
pub fn forward(field: impl Into<String>, value: Value) -> Self {
Self {
field: field.into(),
value,
direction: KeysetDirection::Forward,
}
}
pub fn backward(field: impl Into<String>, value: Value) -> Self {
Self {
field: field.into(),
value,
direction: KeysetDirection::Backward,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KeysetPagination {
pub cursor: Option<KeysetCursor>,
pub limit: usize,
}
impl KeysetPagination {
pub fn new(limit: usize) -> Self {
Self {
cursor: None,
limit: limit.max(1),
}
}
pub fn with_cursor(mut self, cursor: KeysetCursor) -> Self {
self.cursor = Some(cursor);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueryWindow {
pub start: usize,
pub end: usize,
pub overscan: usize,
}
impl QueryWindow {
pub fn new(start: usize, end: usize, overscan: usize) -> Self {
let normalized_end = end.max(start.saturating_add(1));
Self {
start,
end: normalized_end,
overscan,
}
}
pub fn span(&self) -> usize {
self.end.saturating_sub(self.start)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum WireFormatProfile {
#[default]
Json,
Compact,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowToken {
pub query_fingerprint: String,
pub offset: usize,
pub limit: usize,
pub issued_at_ms: u64,
pub nonce: u64,
}
impl WindowToken {
pub fn new(
query_fingerprint: impl Into<String>,
offset: usize,
limit: usize,
issued_at_ms: u64,
nonce: u64,
) -> Self {
Self {
query_fingerprint: query_fingerprint.into(),
offset,
limit: limit.max(1),
issued_at_ms,
nonce,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Query {
pub filters: Vec<Filter>,
pub sorts: Vec<Sort>,
pub pagination: Option<Pagination>,
pub keyset: Option<KeysetPagination>,
pub window: Option<QueryWindow>,
pub window_token: Option<WindowToken>,
#[serde(default)]
pub wire_format: WireFormatProfile,
pub preloads: Vec<String>,
}
impl Query {
pub fn new() -> Self {
Self::default()
}
pub fn where_filter(mut self, filter: Filter) -> Self {
self.filters.push(filter);
self
}
pub fn order_by(mut self, field: impl Into<String>, direction: SortDirection) -> Self {
self.sorts.push(Sort {
field: field.into(),
direction,
});
self
}
pub fn paginate(mut self, page: usize, per_page: usize) -> Self {
self.pagination = Some(Pagination::new(page, per_page));
self.keyset = None;
self
}
pub fn keyset_paginate(mut self, limit: usize) -> Self {
self.keyset = Some(KeysetPagination::new(limit));
self.pagination = None;
self
}
pub fn keyset_after(mut self, field: impl Into<String>, value: Value, limit: usize) -> Self {
self.keyset =
Some(KeysetPagination::new(limit).with_cursor(KeysetCursor::forward(field, value)));
self.pagination = None;
self
}
pub fn keyset_before(mut self, field: impl Into<String>, value: Value, limit: usize) -> Self {
self.keyset =
Some(KeysetPagination::new(limit).with_cursor(KeysetCursor::backward(field, value)));
self.pagination = None;
self
}
pub fn window(mut self, start: usize, end: usize, overscan: usize) -> Self {
self.window = Some(QueryWindow::new(start, end, overscan));
self
}
pub fn with_window_token(mut self, token: WindowToken) -> Self {
self.window_token = Some(token);
self
}
pub fn wire_format(mut self, profile: WireFormatProfile) -> Self {
self.wire_format = profile;
self
}
pub fn preload(mut self, relation: impl Into<String>) -> Self {
self.preloads.push(relation.into());
self
}
pub fn fingerprint(&self) -> String {
let canonical = serde_json::to_string(&serde_json::json!({
"filters": self.filters,
"sorts": self.sorts,
"pagination": self.pagination,
"keyset": self.keyset,
"window": self.window,
"wire_format": self.wire_format,
"preloads": self.preloads
}))
.unwrap_or_default();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
canonical.hash(&mut hasher);
format!("q{:016x}", hasher.finish())
}
pub fn next_window_token(
&self,
offset: usize,
limit: usize,
issued_at_ms: u64,
nonce: u64,
) -> WindowToken {
WindowToken::new(self.fingerprint(), offset, limit, issued_at_ms, nonce)
}
pub fn has_valid_window_token(&self) -> bool {
self.window_token
.as_ref()
.map(|token| token.query_fingerprint == self.fingerprint())
.unwrap_or(true)
}
}
#[cfg(test)]
mod tests {
use super::{
Filter, FilterOperator, KeysetDirection, Pagination, Query, QueryWindow, SortDirection,
WindowToken, WireFormatProfile,
};
use serde_json::json;
#[test]
fn pagination_clamps_zero_values_to_one() {
let pagination = Pagination::new(0, 0);
assert_eq!(pagination.page, 1);
assert_eq!(pagination.per_page, 1);
}
#[test]
fn filter_builders_create_expected_filters() {
assert_eq!(
Filter::eq("title", json!("Hello")),
Filter {
field: "title".to_string(),
op: FilterOperator::Eq,
value: json!("Hello"),
}
);
assert_eq!(
Filter::contains("body", "rust"),
Filter {
field: "body".to_string(),
op: FilterOperator::Contains,
value: json!("rust"),
}
);
}
#[test]
fn query_builder_collects_filters_sorts_pagination_and_preloads() {
let query = Query::new()
.where_filter(Filter::eq("id", json!(10)))
.where_filter(Filter::contains("title", "post"))
.order_by("inserted_at", SortDirection::Desc)
.order_by("title", SortDirection::Asc)
.keyset_after("id", json!(10), 50)
.window(100, 260, 40)
.wire_format(WireFormatProfile::Compact)
.preload("author")
.preload("comments");
assert_eq!(query.filters.len(), 2);
assert_eq!(query.sorts.len(), 2);
assert_eq!(query.pagination, None);
assert_eq!(query.keyset.as_ref().map(|value| value.limit), Some(50));
assert_eq!(
query
.keyset
.as_ref()
.and_then(|value| value.cursor.as_ref())
.map(|cursor| cursor.direction),
Some(KeysetDirection::Forward)
);
assert_eq!(
query.window,
Some(QueryWindow {
start: 100,
end: 260,
overscan: 40
})
);
assert_eq!(query.wire_format, WireFormatProfile::Compact);
assert_eq!(
query.preloads,
vec!["author".to_string(), "comments".to_string()]
);
}
#[test]
fn query_window_normalizes_empty_end_range() {
assert_eq!(
QueryWindow::new(42, 1, 10),
QueryWindow {
start: 42,
end: 43,
overscan: 10
}
);
}
#[test]
fn query_fingerprint_and_window_tokens_are_deterministic() {
let query = Query::new()
.where_filter(Filter::eq("status", json!("healthy")))
.order_by("id", SortDirection::Asc)
.paginate(1, 240);
let fp1 = query.fingerprint();
let fp2 = query.fingerprint();
assert_eq!(fp1, fp2);
let token = query.next_window_token(0, 240, 1_718_000_000_000, 7);
assert_eq!(token, WindowToken::new(fp1, 0, 240, 1_718_000_000_000, 7));
assert!(query
.clone()
.with_window_token(token)
.has_valid_window_token());
}
#[test]
fn invalid_window_token_is_detected() {
let query = Query::new()
.where_filter(Filter::eq("status", json!("healthy")))
.paginate(1, 50)
.with_window_token(WindowToken::new("qdeadbeef", 0, 50, 10, 1));
assert!(!query.has_valid_window_token());
}
}