Skip to main content

plato_tile_query/
lib.rs

1//! # plato-tile-query
2//!
3//! Advanced tile query builder — filters, search, sort, pagination.
4//!
5//! Part of the PLATO framework.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Sort direction for query results.
11#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
12pub enum SortOrder {
13    Asc,
14    Desc,
15}
16
17/// Filter comparison operators.
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19pub enum FilterOp {
20    Eq,
21    Neq,
22    Gt,
23    Gte,
24    Lt,
25    Lte,
26    Contains,
27    StartsWith,
28    EndsWith,
29    In,
30    NotIn,
31    Regex,
32    Exists,
33    TypeIs,
34}
35
36impl FilterOp {
37    /// String representation matching the Python API.
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            FilterOp::Eq => "eq",
41            FilterOp::Neq => "neq",
42            FilterOp::Gt => "gt",
43            FilterOp::Gte => "gte",
44            FilterOp::Lt => "lt",
45            FilterOp::Lte => "lte",
46            FilterOp::Contains => "contains",
47            FilterOp::StartsWith => "starts_with",
48            FilterOp::EndsWith => "ends_with",
49            FilterOp::In => "in",
50            FilterOp::NotIn => "not_in",
51            FilterOp::Regex => "regex",
52            FilterOp::Exists => "exists",
53            FilterOp::TypeIs => "type_is",
54        }
55    }
56}
57
58/// A single filter clause.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Filter {
61    pub field: String,
62    pub op: String,
63    pub value: Option<serde_json::Value>,
64}
65
66/// Execution plan with cost estimates and optimization hints.
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct QueryPlan {
69    pub filters: Vec<Filter>,
70    pub search_terms: Vec<String>,
71    pub sort_field: String,
72    pub sort_order: String,
73    pub page: usize,
74    pub page_size: usize,
75    pub estimated_cost: f64,
76    pub index_used: String,
77    pub optimization_hints: Vec<String>,
78}
79
80/// Cached query entry.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct QueryCache {
83    pub key: String,
84    pub result: Vec<HashMap<String, serde_json::Value>>,
85    pub total: usize,
86    pub created_at: f64,
87    pub ttl: f64,
88    pub hit_count: usize,
89    pub query_plan_hash: String,
90}
91
92/// Result of executing a tile query.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct QueryResult {
95    pub tiles: Vec<HashMap<String, serde_json::Value>>,
96    pub total: usize,
97    pub page: usize,
98    pub page_size: usize,
99    pub query_time_ms: f64,
100    pub facets: HashMap<String, HashMap<String, usize>>,
101    pub cache_hit: bool,
102    pub plan: Option<QueryPlan>,
103    pub timed_out: bool,
104}
105
106impl Default for QueryResult {
107    fn default() -> Self {
108        Self {
109            tiles: Vec::new(),
110            total: 0,
111            page: 1,
112            page_size: 20,
113            query_time_ms: 0.0,
114            facets: HashMap::new(),
115            cache_hit: false,
116            plan: None,
117            timed_out: false,
118        }
119    }
120}
121
122/// Fluent builder for tile queries.
123pub struct TileQueryBuilder {
124    filters: Vec<Filter>,
125    domain: String,
126    search: String,
127    tags: Vec<String>,
128    sort_by: String,
129    sort_order: SortOrder,
130    page: usize,
131    page_size: usize,
132    fields: Vec<String>,
133    include_deleted: bool,
134    explain: bool,
135    cache_ttl: f64,
136    timeout_ms: f64,
137    facet_fields: Vec<String>,
138    post_filter_fn: Option<Box<dyn Fn(&HashMap<String, serde_json::Value>) -> bool + Send + Sync>>,
139}
140
141impl Default for TileQueryBuilder {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl TileQueryBuilder {
148    pub fn new() -> Self {
149        Self {
150            filters: Vec::new(),
151            domain: String::new(),
152            search: String::new(),
153            tags: Vec::new(),
154            sort_by: "created_at".to_string(),
155            sort_order: SortOrder::Desc,
156            page: 1,
157            page_size: 20,
158            fields: Vec::new(),
159            include_deleted: false,
160            explain: false,
161            cache_ttl: 0.0,
162            timeout_ms: 5000.0,
163            facet_fields: Vec::new(),
164            post_filter_fn: None,
165        }
166    }
167
168    pub fn filter(mut self, field: &str, op: FilterOp, value: Option<serde_json::Value>) -> Self {
169        self.filters.push(Filter {
170            field: field.to_string(),
171            op: op.as_str().to_string(),
172            value,
173        });
174        self
175    }
176
177    pub fn search(mut self, query: &str) -> Self {
178        self.search = query.to_string();
179        self
180    }
181
182    pub fn in_domain(mut self, domain: &str) -> Self {
183        self.domain = domain.to_string();
184        self
185    }
186
187    pub fn with_tags(mut self, tags: &[&str]) -> Self {
188        self.tags.extend(tags.iter().map(|t| t.to_string()));
189        self
190    }
191
192    pub fn sort_by(mut self, field: &str, order: SortOrder) -> Self {
193        self.sort_by = field.to_string();
194        self.sort_order = order;
195        self
196    }
197
198    pub fn page(mut self, num: usize, size: usize) -> Self {
199        self.page = num.max(1);
200        self.page_size = size.clamp(1, 100);
201        self
202    }
203
204    pub fn select(mut self, fields: &[&str]) -> Self {
205        self.fields = fields.iter().map(|f| f.to_string()).collect();
206        self
207    }
208
209    pub fn include_deleted(mut self) -> Self {
210        self.include_deleted = true;
211        self
212    }
213
214    pub fn explain(mut self) -> Self {
215        self.explain = true;
216        self
217    }
218
219    pub fn cache(mut self, ttl: f64) -> Self {
220        self.cache_ttl = ttl;
221        self
222    }
223
224    pub fn timeout(mut self, ms: f64) -> Self {
225        self.timeout_ms = ms;
226        self
227    }
228
229    pub fn facet(mut self, fields: &[&str]) -> Self {
230        self.facet_fields = fields.iter().map(|f| f.to_string()).collect();
231        self
232    }
233
234    pub fn post_filter<F>(mut self, f: F) -> Self
235    where
236        F: Fn(&HashMap<String, serde_json::Value>) -> bool + Send + Sync + 'static,
237    {
238        self.post_filter_fn = Some(Box::new(f));
239        self
240    }
241
242    pub fn reset(&mut self) -> &mut Self {
243        self.filters.clear();
244        self.domain.clear();
245        self.search.clear();
246        self.tags.clear();
247        self.sort_by = "created_at".to_string();
248        self.sort_order = SortOrder::Desc;
249        self.page = 1;
250        self.page_size = 20;
251        self.fields.clear();
252        self.include_deleted = false;
253        self.explain = false;
254        self.cache_ttl = 0.0;
255        self.facet_fields.clear();
256        self.post_filter_fn = None;
257        self
258    }
259
260    pub fn build_plan(&self) -> QueryPlan {
261        let mut plan = QueryPlan {
262            filters: self.filters.clone(),
263            search_terms: if self.search.is_empty() {
264                Vec::new()
265            } else {
266                self.search
267                    .split(|c: char| !c.is_alphanumeric())
268                    .filter(|s| !s.is_empty())
269                    .map(|s| s.to_string())
270                    .collect()
271            },
272            sort_field: self.sort_by.clone(),
273            sort_order: if self.sort_order == SortOrder::Asc {
274                "asc".to_string()
275            } else {
276                "desc".to_string()
277            },
278            page: self.page,
279            page_size: self.page_size,
280            ..Default::default()
281        };
282
283        plan.estimated_cost = self.filters.len() as f64 * 0.1;
284        if !self.search.is_empty() {
285            plan.estimated_cost += 0.3;
286        }
287        if self.post_filter_fn.is_some() {
288            plan.estimated_cost += 0.2;
289        }
290        plan.estimated_cost = plan.estimated_cost.min(1.0);
291
292        if self.filters.is_empty() && self.search.is_empty() {
293            plan.optimization_hints
294                .push("Full table scan — add filters to reduce cost".to_string());
295        }
296        if self.page > 10 {
297            plan.optimization_hints
298                .push("Deep pagination — consider cursor-based approach".to_string());
299        }
300        if self.page_size > 50 {
301            plan.optimization_hints
302                .push("Large page size — may impact latency".to_string());
303        }
304
305        plan
306    }
307
308    pub fn to_dict(&self) -> HashMap<String, serde_json::Value> {
309        let mut map = HashMap::new();
310        map.insert(
311            "filters".to_string(),
312            serde_json::to_value(&self.filters).unwrap_or_default(),
313        );
314        map.insert("domain".to_string(), self.domain.clone().into());
315        map.insert("search".to_string(), self.search.clone().into());
316        map.insert("tags".to_string(), self.tags.clone().into());
317        map.insert("sort_by".to_string(), self.sort_by.clone().into());
318        map.insert(
319            "sort_order".to_string(),
320            if self.sort_order == SortOrder::Asc {
321                "asc"
322            } else {
323                "desc"
324            }
325            .into(),
326        );
327        map.insert("page".to_string(), self.page.into());
328        map.insert("page_size".to_string(), self.page_size.into());
329        map.insert("fields".to_string(), self.fields.clone().into());
330        map.insert("include_deleted".to_string(), self.include_deleted.into());
331        map.insert("facet_fields".to_string(), self.facet_fields.clone().into());
332        map
333    }
334
335    pub fn filter_count(&self) -> usize {
336        self.filters.len()
337    }
338
339    pub fn has_search(&self) -> bool {
340        !self.search.trim().is_empty()
341    }
342
343    pub fn complexity(&self) -> &'static str {
344        let n = self.filters.len()
345            + if self.search.is_empty() { 0 } else { 1 }
346            + self.tags.len();
347        if n == 0 {
348            "simple"
349        } else if n <= 3 {
350            "moderate"
351        } else {
352            "complex"
353        }
354    }
355}
356
357/// Simple in-memory cache for query results.
358pub struct QueryCacheManager {
359    cache: HashMap<String, QueryCache>,
360    max_size: usize,
361    default_ttl: f64,
362    hits: usize,
363    misses: usize,
364}
365
366impl Default for QueryCacheManager {
367    fn default() -> Self {
368        Self::new(100, 60.0)
369    }
370}
371
372impl QueryCacheManager {
373    pub fn new(max_size: usize, default_ttl: f64) -> Self {
374        Self {
375            cache: HashMap::new(),
376            max_size,
377            default_ttl,
378            hits: 0,
379            misses: 0,
380        }
381    }
382
383    fn make_key(&self, query_dict: &HashMap<String, serde_json::Value>) -> String {
384        let raw = serde_json::to_string(query_dict).unwrap_or_default();
385        md5::compute(raw.as_bytes())
386            .iter()
387            .map(|b| format!("{:02x}", b))
388            .collect::<String>()[..16]
389            .to_string()
390    }
391
392    pub fn get(&mut self, query_dict: &HashMap<String, serde_json::Value>) -> Option<&QueryCache> {
393        let key = self.make_key(query_dict);
394        let now = current_time();
395        if let Some(entry) = self.cache.get(&key) {
396            if now - entry.created_at < entry.ttl {
397                // We can't increment hit_count on an immutable reference,
398                // so we do a second mutable lookup for stats.
399                if let Some(e) = self.cache.get_mut(&key) {
400                    e.hit_count += 1;
401                }
402                self.hits += 1;
403                return self.cache.get(&key);
404            }
405        }
406        self.misses += 1;
407        None
408    }
409
410    pub fn put(
411        &mut self,
412        query_dict: &HashMap<String, serde_json::Value>,
413        tiles: Vec<HashMap<String, serde_json::Value>>,
414        total: usize,
415        ttl: f64,
416    ) {
417        if self.cache.len() >= self.max_size {
418            self.evict();
419        }
420        let key = self.make_key(query_dict);
421        self.cache.insert(
422            key.clone(),
423            QueryCache {
424                key,
425                result: tiles,
426                total,
427                created_at: current_time(),
428                ttl: if ttl > 0.0 { ttl } else { self.default_ttl },
429                hit_count: 0,
430                query_plan_hash: self.make_key(query_dict),
431            },
432        );
433    }
434
435    fn evict(&mut self) {
436        if self.cache.is_empty() {
437            return;
438        }
439        let oldest = self
440            .cache
441            .iter()
442            .min_by(|a, b| a.1.created_at.partial_cmp(&b.1.created_at).unwrap())
443            .map(|(k, _)| k.clone());
444        if let Some(k) = oldest {
445            self.cache.remove(&k);
446        }
447    }
448
449    pub fn invalidate(&mut self, query_dict: Option<&HashMap<String, serde_json::Value>>) {
450        if let Some(qd) = query_dict {
451            let key = self.make_key(qd);
452            self.cache.remove(&key);
453        } else {
454            self.cache.clear();
455        }
456    }
457
458    pub fn clear(&mut self) {
459        self.cache.clear();
460    }
461
462    pub fn stats(&self) -> HashMap<String, serde_json::Value> {
463        let total = self.hits + self.misses;
464        let mut map = HashMap::new();
465        map.insert("size".to_string(), self.cache.len().into());
466        map.insert("max_size".to_string(), self.max_size.into());
467        map.insert("hits".to_string(), self.hits.into());
468        map.insert("misses".to_string(), self.misses.into());
469        map.insert(
470            "hit_rate".to_string(),
471            if total > 0 {
472                (self.hits as f64) / (total as f64)
473            } else {
474                0.0
475            }
476            .into(),
477        );
478        map
479    }
480}
481
482fn current_time() -> f64 {
483    use std::time::{SystemTime, UNIX_EPOCH};
484    SystemTime::now()
485        .duration_since(UNIX_EPOCH)
486        .map(|d| d.as_secs_f64())
487        .unwrap_or(0.0)
488}
489
490// Simple md5 implementation to avoid adding a dependency
491mod md5 {
492    pub fn compute(input: &[u8]) -> [u8; 16] {
493        // FNV-1a based fallback for deterministic hashing
494        let mut hash: u64 = 0xcbf29ce484222325;
495        for &byte in input {
496            hash ^= byte as u64;
497            hash = hash.wrapping_mul(0x100000001b3);
498        }
499        let mut out = [0u8; 16];
500        out[..8].copy_from_slice(&hash.to_le_bytes());
501        out[8..].copy_from_slice(&hash.to_le_bytes());
502        out
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_builder_chain() {
512        let builder = TileQueryBuilder::new()
513            .filter("status", FilterOp::Eq, Some("active".into()))
514            .search("hello world")
515            .in_domain("main")
516            .with_tags(&["a", "b"])
517            .sort_by("name", SortOrder::Asc)
518            .page(2, 50)
519            .select(&["id", "name"]);
520
521        assert_eq!(builder.filter_count(), 1);
522        assert!(builder.has_search());
523        assert_eq!(builder.complexity(), "complex");
524    }
525
526    #[test]
527    fn test_build_plan() {
528        let builder = TileQueryBuilder::new()
529            .filter("x", FilterOp::Gt, Some(10.into()))
530            .search("term");
531        let plan = builder.build_plan();
532        assert_eq!(plan.filters.len(), 1);
533        assert_eq!(plan.search_terms, vec!["term"]);
534        assert!(plan.estimated_cost > 0.0);
535    }
536
537    #[test]
538    fn test_cache_manager() {
539        let mut cm = QueryCacheManager::new(10, 60.0);
540        let mut qd = HashMap::new();
541        qd.insert("x".to_string(), serde_json::Value::from(1));
542        cm.put(&qd, vec![HashMap::new()], 1, 0.0);
543        assert!(cm.get(&qd).is_some());
544        assert_eq!(cm.stats()["hits"], serde_json::Value::from(1));
545    }
546}