paginator_utils/
lib.rs

1use serde::{Deserialize, Serialize};
2
3/// Filter operators for querying data
4#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
5#[serde(rename_all = "lowercase")]
6pub enum FilterOperator {
7    /// Equal to (=)
8    Eq,
9    /// Not equal to (!=)
10    Ne,
11    /// Greater than (>)
12    Gt,
13    /// Less than (<)
14    Lt,
15    /// Greater than or equal to (>=)
16    Gte,
17    /// Less than or equal to (<=)
18    Lte,
19    /// SQL LIKE pattern matching
20    Like,
21    /// Case-insensitive LIKE (PostgreSQL ILIKE)
22    ILike,
23    /// Value in array (IN)
24    In,
25    /// Value not in array (NOT IN)
26    NotIn,
27    /// Is NULL
28    IsNull,
29    /// Is NOT NULL
30    IsNotNull,
31    /// Between two values
32    Between,
33    /// Contains (for arrays/JSON)
34    Contains,
35}
36
37/// Value types for filters
38#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
39#[serde(untagged)]
40pub enum FilterValue {
41    /// String value
42    String(String),
43    /// Integer value
44    Int(i64),
45    /// Float value
46    Float(f64),
47    /// Boolean value
48    Bool(bool),
49    /// Array of values
50    Array(Vec<FilterValue>),
51    /// Null value
52    Null,
53}
54
55impl FilterValue {
56    /// Convert to SQL string representation
57    pub fn to_sql_string(&self) -> String {
58        match self {
59            FilterValue::String(s) => format!("'{}'", s.replace('\'', "''")),
60            FilterValue::Int(i) => i.to_string(),
61            FilterValue::Float(f) => f.to_string(),
62            FilterValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
63            FilterValue::Array(arr) => {
64                let items: Vec<String> = arr.iter().map(|v| v.to_sql_string()).collect();
65                format!("({})", items.join(", "))
66            }
67            FilterValue::Null => "NULL".to_string(),
68        }
69    }
70}
71
72/// A single filter condition
73#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
74pub struct Filter {
75    /// Field name to filter on
76    pub field: String,
77    /// Filter operator
78    pub operator: FilterOperator,
79    /// Filter value
80    pub value: FilterValue,
81}
82
83impl Filter {
84    /// Create a new filter
85    pub fn new(field: impl Into<String>, operator: FilterOperator, value: FilterValue) -> Self {
86        Self {
87            field: field.into(),
88            operator,
89            value,
90        }
91    }
92
93    /// Convert filter to SQL WHERE clause fragment
94    pub fn to_sql_where(&self) -> String {
95        match &self.operator {
96            FilterOperator::Eq => format!("{} = {}", self.field, self.value.to_sql_string()),
97            FilterOperator::Ne => format!("{} != {}", self.field, self.value.to_sql_string()),
98            FilterOperator::Gt => format!("{} > {}", self.field, self.value.to_sql_string()),
99            FilterOperator::Lt => format!("{} < {}", self.field, self.value.to_sql_string()),
100            FilterOperator::Gte => format!("{} >= {}", self.field, self.value.to_sql_string()),
101            FilterOperator::Lte => format!("{} <= {}", self.field, self.value.to_sql_string()),
102            FilterOperator::Like => format!("{} LIKE {}", self.field, self.value.to_sql_string()),
103            FilterOperator::ILike => format!("{} ILIKE {}", self.field, self.value.to_sql_string()),
104            FilterOperator::In => format!("{} IN {}", self.field, self.value.to_sql_string()),
105            FilterOperator::NotIn => format!("{} NOT IN {}", self.field, self.value.to_sql_string()),
106            FilterOperator::IsNull => format!("{} IS NULL", self.field),
107            FilterOperator::IsNotNull => format!("{} IS NOT NULL", self.field),
108            FilterOperator::Between => {
109                if let FilterValue::Array(arr) = &self.value {
110                    if arr.len() == 2 {
111                        return format!(
112                            "{} BETWEEN {} AND {}",
113                            self.field,
114                            arr[0].to_sql_string(),
115                            arr[1].to_sql_string()
116                        );
117                    }
118                }
119                format!("{} = {}", self.field, self.value.to_sql_string())
120            }
121            FilterOperator::Contains => {
122                format!("{} @> {}", self.field, self.value.to_sql_string())
123            }
124        }
125    }
126
127    /// Convert filter to SurrealQL WHERE clause fragment
128    pub fn to_surrealql_where(&self) -> String {
129        match &self.operator {
130            FilterOperator::Eq => format!("{} = {}", self.field, self.value.to_sql_string()),
131            FilterOperator::Ne => format!("{} != {}", self.field, self.value.to_sql_string()),
132            FilterOperator::Gt => format!("{} > {}", self.field, self.value.to_sql_string()),
133            FilterOperator::Lt => format!("{} < {}", self.field, self.value.to_sql_string()),
134            FilterOperator::Gte => format!("{} >= {}", self.field, self.value.to_sql_string()),
135            FilterOperator::Lte => format!("{} <= {}", self.field, self.value.to_sql_string()),
136            FilterOperator::Like | FilterOperator::ILike => {
137                format!("{} ~ {}", self.field, self.value.to_sql_string())
138            }
139            FilterOperator::In => format!("{} INSIDE {}", self.field, self.value.to_sql_string()),
140            FilterOperator::NotIn => format!("{} NOT INSIDE {}", self.field, self.value.to_sql_string()),
141            FilterOperator::IsNull => format!("{} IS NULL", self.field),
142            FilterOperator::IsNotNull => format!("{} IS NOT NULL", self.field),
143            FilterOperator::Between => {
144                if let FilterValue::Array(arr) = &self.value {
145                    if arr.len() == 2 {
146                        return format!(
147                            "{} >= {} AND {} <= {}",
148                            self.field,
149                            arr[0].to_sql_string(),
150                            self.field,
151                            arr[1].to_sql_string()
152                        );
153                    }
154                }
155                format!("{} = {}", self.field, self.value.to_sql_string())
156            }
157            FilterOperator::Contains => {
158                format!("{} CONTAINS {}", self.field, self.value.to_sql_string())
159            }
160        }
161    }
162}
163
164/// Search parameters for full-text search
165#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
166pub struct SearchParams {
167    /// Search query text
168    pub query: String,
169    /// Fields to search in
170    pub fields: Vec<String>,
171    /// Case-sensitive search (default: false)
172    #[serde(default)]
173    pub case_sensitive: bool,
174    /// Exact match (default: false, uses fuzzy/LIKE)
175    #[serde(default)]
176    pub exact_match: bool,
177}
178
179impl SearchParams {
180    /// Create new search parameters
181    pub fn new(query: impl Into<String>, fields: Vec<String>) -> Self {
182        Self {
183            query: query.into(),
184            fields,
185            case_sensitive: false,
186            exact_match: false,
187        }
188    }
189
190    /// Set case sensitivity
191    pub fn with_case_sensitive(mut self, sensitive: bool) -> Self {
192        self.case_sensitive = sensitive;
193        self
194    }
195
196    /// Set exact match mode
197    pub fn with_exact_match(mut self, exact: bool) -> Self {
198        self.exact_match = exact;
199        self
200    }
201
202    /// Generate SQL WHERE clause for search
203    pub fn to_sql_where(&self) -> String {
204        let pattern = if self.exact_match {
205            format!("'{}'", self.query.replace('\'', "''"))
206        } else {
207            format!("'%{}%'", self.query.replace('\'', "''"))
208        };
209
210        let operator = if self.case_sensitive {
211            "LIKE"
212        } else {
213            "ILIKE" // PostgreSQL, falls back to LOWER() for others
214        };
215
216        let conditions: Vec<String> = self
217            .fields
218            .iter()
219            .map(|field| {
220                if self.case_sensitive || operator == "ILIKE" {
221                    format!("{} {} {}", field, operator, pattern)
222                } else {
223                    format!("LOWER({}) LIKE LOWER({})", field, pattern)
224                }
225            })
226            .collect();
227
228        format!("({})", conditions.join(" OR "))
229    }
230}
231
232/// Pagination parameters for controlling page size and navigation
233#[derive(Clone, Debug, Serialize, Deserialize)]
234pub struct PaginationParams {
235    /// Current page number (1-indexed)
236    pub page: u32,
237    /// Number of items per page
238    pub per_page: u32,
239    /// Optional sorting field
240    pub sort_by: Option<String>,
241    /// Sort direction: "asc" or "desc"
242    pub sort_direction: Option<SortDirection>,
243    /// Filters to apply
244    #[serde(default)]
245    pub filters: Vec<Filter>,
246    /// Search parameters
247    pub search: Option<SearchParams>,
248}
249
250/// Sort direction for ordering results
251#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
252#[serde(rename_all = "lowercase")]
253pub enum SortDirection {
254    Asc,
255    Desc,
256}
257
258impl Default for PaginationParams {
259    fn default() -> Self {
260        Self {
261            page: 1,
262            per_page: 20,
263            sort_by: None,
264            sort_direction: None,
265            filters: Vec::new(),
266            search: None,
267        }
268    }
269}
270
271impl PaginationParams {
272    /// Create new pagination parameters
273    pub fn new(page: u32, per_page: u32) -> Self {
274        Self {
275            page: page.max(1),
276            per_page: per_page.max(1).min(100),
277            sort_by: None,
278            sort_direction: None,
279            filters: Vec::new(),
280            search: None,
281        }
282    }
283
284    /// Set sorting field
285    pub fn with_sort(mut self, field: impl Into<String>) -> Self {
286        self.sort_by = Some(field.into());
287        self
288    }
289
290    /// Set sort direction
291    pub fn with_direction(mut self, direction: SortDirection) -> Self {
292        self.sort_direction = Some(direction);
293        self
294    }
295
296    /// Add a filter
297    pub fn with_filter(mut self, filter: Filter) -> Self {
298        self.filters.push(filter);
299        self
300    }
301
302    /// Add multiple filters
303    pub fn with_filters(mut self, filters: Vec<Filter>) -> Self {
304        self.filters.extend(filters);
305        self
306    }
307
308    /// Set search parameters
309    pub fn with_search(mut self, search: SearchParams) -> Self {
310        self.search = Some(search);
311        self
312    }
313
314    /// Calculate offset for database queries
315    pub fn offset(&self) -> u32 {
316        (self.page - 1) * self.per_page
317    }
318
319    /// Get limit for database queries
320    pub fn limit(&self) -> u32 {
321        self.per_page
322    }
323
324    /// Generate SQL WHERE clause from filters and search
325    pub fn to_sql_where(&self) -> Option<String> {
326        let mut conditions = Vec::new();
327
328        // Add filter conditions
329        for filter in &self.filters {
330            conditions.push(filter.to_sql_where());
331        }
332
333        // Add search condition
334        if let Some(ref search) = self.search {
335            conditions.push(search.to_sql_where());
336        }
337
338        if conditions.is_empty() {
339            None
340        } else {
341            Some(conditions.join(" AND "))
342        }
343    }
344
345    /// Generate SurrealQL WHERE clause from filters and search
346    pub fn to_surrealql_where(&self) -> Option<String> {
347        let mut conditions = Vec::new();
348
349        // Add filter conditions
350        for filter in &self.filters {
351            conditions.push(filter.to_surrealql_where());
352        }
353
354        // Add search condition (SurrealDB uses similar LIKE syntax)
355        if let Some(ref search) = self.search {
356            let search_conditions: Vec<String> = search
357                .fields
358                .iter()
359                .map(|field| {
360                    let pattern = if search.exact_match {
361                        format!("'{}'", search.query.replace('\'', "''"))
362                    } else {
363                        format!("'%{}%'", search.query.replace('\'', "''"))
364                    };
365                    format!("{} ~ {}", field, pattern)
366                })
367                .collect();
368            conditions.push(format!("({})", search_conditions.join(" OR ")));
369        }
370
371        if conditions.is_empty() {
372            None
373        } else {
374            Some(conditions.join(" AND "))
375        }
376    }
377}
378
379/// Paginated response containing data and metadata
380#[derive(Serialize, Deserialize, Debug)]
381pub struct PaginatorResponse<T> {
382    /// The paginated data items
383    pub data: Vec<T>,
384    /// Pagination metadata
385    pub meta: PaginatorResponseMeta,
386}
387
388/// Metadata about the pagination state
389#[derive(Serialize, Deserialize, Debug)]
390pub struct PaginatorResponseMeta {
391    /// Current page number
392    pub page: u32,
393    /// Items per page
394    pub per_page: u32,
395    /// Total number of items
396    pub total: u32,
397    /// Total number of pages
398    pub total_pages: u32,
399    /// Whether there is a next page
400    pub has_next: bool,
401    /// Whether there is a previous page
402    pub has_prev: bool,
403}
404
405impl PaginatorResponseMeta {
406    /// Create new metadata with computed fields
407    pub fn new(page: u32, per_page: u32, total: u32) -> Self {
408        let total_pages = (total as f32 / per_page as f32).ceil() as u32;
409        Self {
410            page,
411            per_page,
412            total,
413            total_pages,
414            has_next: page < total_pages,
415            has_prev: page > 1,
416        }
417    }
418}