Skip to main content

post_archiver/query/
filter.rs

1//! Query filters: reusable SQL WHERE condition builder stamps.
2//!
3//! Each query builder (e.g. [`PostQuery`](super::post::PostQuery)) exposes its filters
4//! as public fields. Callers invoke methods on those fields; the filters write their
5//! conditions into [`RawSql`](super::RawSql) via [`build_sql()`] when
6//! [`BaseFilter::update_sql()`](super::BaseFilter::update_sql) is called.
7//!
8//! | Filter | Use case |
9//! |--------|----------|
10//! | [`TextFilter`] | `LIKE` fuzzy matching on string columns |
11//! | [`DateFilter`] | Date range filtering on `DateTime<Utc>` columns |
12//! | [`IdFilter`] | Exact ID matching (`= ?` or `IN (…)`) |
13//! | [`RelationshipsFilter`] | All-of matching across a many-to-many join table |
14
15use std::{collections::HashSet, fmt::Display, hash::Hash, ops::Deref, rc::Rc};
16
17use chrono::{DateTime, Utc};
18use rusqlite::ToSql;
19use serde::Serialize;
20
21use crate::query::RawSql;
22
23/// SQL `LIKE` filter for string columns.
24///
25/// After specifying the target column, set the match pattern via one of the methods below.
26/// Special characters `%` and `_` are auto-escaped unless you use
27/// [`like()`](TextFilter::like) to supply a raw pattern.
28///
29/// [`deref()`](Deref::deref) exposes the inner pattern string, useful for checking
30/// whether the filter has been set.
31#[derive(Debug)]
32pub struct TextFilter {
33    col: &'static str,
34    text: String,
35}
36
37impl Deref for TextFilter {
38    type Target = String;
39
40    fn deref(&self) -> &Self::Target {
41        &self.text
42    }
43}
44
45impl TextFilter {
46    /// Create an empty filter for the given column (no match pattern — generates no WHERE clause).
47    pub fn new(col: &'static str) -> Self {
48        TextFilter {
49            col,
50            text: String::new(),
51        }
52    }
53
54    /// Match rows where the column contains `text` (`%text%`). Special chars are auto-escaped.
55    pub fn contains(&mut self, text: &str) -> &mut Self {
56        self.text = format!("%{}%", Self::safe_escape(text));
57        self
58    }
59
60    /// Match rows where the column starts with `text` (`text%`). Special chars are auto-escaped.
61    pub fn starts_with(&mut self, text: &str) -> &mut Self {
62        self.text = format!("{}%", Self::safe_escape(text));
63        self
64    }
65
66    /// Match rows where the column ends with `text` (`%text`). Special chars are auto-escaped.
67    pub fn ends_with(&mut self, text: &str) -> &mut Self {
68        self.text = format!("%{}", Self::safe_escape(text));
69        self
70    }
71
72    /// Exact equality match (equivalent to `LIKE 'text'` with no wildcards).
73    pub fn equals(&mut self, text: &str) -> &mut Self {
74        self.text = Self::safe_escape(text);
75        self
76    }
77
78    /// Set the raw `LIKE` pattern directly (`%` / `_` are **not** escaped).
79    /// Use this when you need manual control over wildcards.
80    pub fn like(&mut self, t: &str) -> &mut Self {
81        self.text = t.to_string();
82        self
83    }
84
85    fn safe_escape(text: &str) -> String {
86        text.replace('%', "\\%").replace('_', "\\_")
87    }
88
89    pub fn build_sql<T>(&self, mut sql: RawSql<T>) -> RawSql<T> {
90        if self.text.is_empty() {
91            return sql;
92        }
93
94        let (wheres, params) = &mut sql.where_clause;
95        wheres.push(format!("{} LIKE ?", self.col));
96        params.push(Rc::new(self.text.clone()));
97        sql
98    }
99}
100
101/// Date range filter for `DateTime<Utc>` columns.
102///
103/// Supports setting an upper bound, a lower bound, or both (which collapses to an
104/// equality check when the two values are equal). Unset bounds are silently ignored.
105#[derive(Debug)]
106pub struct DateFilter {
107    col: &'static str,
108    before: Option<DateTime<Utc>>,
109    after: Option<DateTime<Utc>>,
110}
111
112impl DateFilter {
113    /// Create an empty filter for the given column (no bounds — generates no WHERE clause).
114    pub fn new(col: &'static str) -> Self {
115        DateFilter {
116            col,
117            before: None,
118            after: None,
119        }
120    }
121
122    /// Set the upper bound: equivalent to `col <= date`.
123    pub fn before(&mut self, date: DateTime<Utc>) -> &mut Self {
124        self.before = Some(date);
125        self
126    }
127
128    /// Set the lower bound: equivalent to `col >= date`.
129    pub fn after(&mut self, date: DateTime<Utc>) -> &mut Self {
130        self.after = Some(date);
131        self
132    }
133
134    /// Set both bounds to the same date, collapsing to an equality check `col = date`.
135    pub fn equals(&mut self, date: DateTime<Utc>) -> &mut Self {
136        self.before = Some(date);
137        self.after = Some(date);
138        self
139    }
140
141    pub fn build_sql<T>(&self, mut sql: RawSql<T>) -> RawSql<T> {
142        let (wheres, params) = &mut sql.where_clause;
143        match (self.before, self.after) {
144            (None, None) => {}
145            (Some(before), None) => {
146                wheres.push(format!("{} <= ?", self.col));
147                params.push(Rc::new(before));
148            }
149            (None, Some(after)) => {
150                wheres.push(format!("{} >= ?", self.col));
151                params.push(Rc::new(after));
152            }
153            (Some(before), Some(after)) => {
154                if before == after {
155                    wheres.push(format!("{} = ?", self.col));
156                    params.push(Rc::new(before));
157                } else {
158                    wheres.push(format!("{} BETWEEN ? AND ?", self.col));
159                    params.push(Rc::new(after));
160                    params.push(Rc::new(before));
161                }
162            }
163        }
164        sql
165    }
166}
167
168/// Exact-match filter for ID columns.
169///
170/// - Empty set: generates no WHERE clause.
171/// - Single ID: generates `col = ?`.
172/// - Multiple IDs: generates `col IN (SELECT value FROM json_each(?))` with a JSON array parameter.
173///
174/// [`deref()`](Deref::deref) exposes the inner `HashSet<T>`, useful for inspecting added IDs.
175#[derive(Debug)]
176pub struct IdFilter<T> {
177    col: &'static str,
178    ids: HashSet<T>,
179}
180
181impl<T> Deref for IdFilter<T> {
182    type Target = HashSet<T>;
183
184    fn deref(&self) -> &Self::Target {
185        &self.ids
186    }
187}
188
189impl<T> IdFilter<T>
190where
191    T: Hash + PartialEq + Eq + ToSql + Serialize + Display + Clone + 'static,
192{
193    /// Create an empty filter for the given column.
194    pub fn new(col: &'static str) -> Self {
195        IdFilter {
196            col,
197            ids: HashSet::new(),
198        }
199    }
200
201    /// Add a single ID to the match set.
202    pub fn insert(&mut self, id: T) -> &mut Self {
203        self.ids.insert(id);
204        self
205    }
206
207    /// Add multiple IDs to the match set.
208    pub fn extend(&mut self, ids: impl IntoIterator<Item = T>) -> &mut Self {
209        self.ids.extend(ids);
210        self
211    }
212
213    /// Translate the current ID set into a SQL WHERE condition and write it into `sql`.
214    pub fn build_sql<U>(&self, mut sql: RawSql<U>) -> RawSql<U> {
215        let (wheres, params) = &mut sql.where_clause;
216        match self.ids.len() {
217            0 => {}
218            1 => {
219                wheres.push(format!("{} = ?", self.col));
220                params.push(Rc::new(self.ids.iter().next().unwrap().clone()));
221            }
222            _ => {
223                wheres.push(format!("{} IN (SELECT value FROM json_each(?))", self.col));
224                let json_array = serde_json::to_string(&self.ids).unwrap();
225                params.push(Rc::new(json_array));
226            }
227        }
228        sql
229    }
230}
231
232/// All-of relational filter that works through a many-to-many join table.
233///
234/// Designed for filtering posts by their associated tags, authors, or collections.
235///
236/// - Empty set: generates no WHERE clause.
237/// - Single ID: uses `EXISTS (SELECT 1 FROM <table> WHERE post = posts.id AND <col> = ?)`.
238/// - Multiple IDs: ensures **all** specified IDs are present (intersection match)
239///   via a count sub-query.
240///
241/// [`deref()`](Deref::deref) exposes the inner `HashSet<T>`.
242#[derive(Debug)]
243pub struct RelationshipsFilter<T> {
244    table: &'static str,
245    col: &'static str,
246    ids: HashSet<T>,
247}
248
249impl<T> Deref for RelationshipsFilter<T> {
250    type Target = HashSet<T>;
251
252    fn deref(&self) -> &Self::Target {
253        &self.ids
254    }
255}
256
257impl<T> RelationshipsFilter<T> {
258    /// Create an empty filter specifying the join table name (`table`) and the related ID column (`col`).
259    pub fn new(table: &'static str, col: &'static str) -> Self {
260        RelationshipsFilter {
261            table,
262            col,
263            ids: HashSet::new(),
264        }
265    }
266}
267
268impl<T: Eq + std::hash::Hash> RelationshipsFilter<T> {
269    /// Add a single related ID to the match set.
270    pub fn insert(&mut self, id: T) -> &mut Self {
271        self.ids.insert(id);
272        self
273    }
274
275    /// Add multiple related IDs to the match set.
276    pub fn extend(&mut self, ids: impl IntoIterator<Item = T>) -> &mut Self {
277        self.ids.extend(ids);
278        self
279    }
280}
281
282impl<T: Display + ToSql + Serialize + Clone + 'static> RelationshipsFilter<T>
283where
284    T: Eq + std::hash::Hash,
285{
286    pub fn build_sql<U>(&self, mut sql: RawSql<U>) -> RawSql<U> {
287        let (wheres, params) = &mut sql.where_clause;
288        match self.len() {
289            0 => {}
290            1 => {
291                wheres.push(format!(
292                    "EXISTS (SELECT 1 FROM {} WHERE post = posts.id AND {} = ?)",
293                    self.table, self.col
294                ));
295                params.push(Rc::new(self.iter().next().unwrap().clone()));
296            }
297            n => {
298                wheres.push(format!(
299                  "? == (SELECT COUNT(*) FROM {} WHERE post = posts.id AND {} IN (SELECT value FROM json_each(?)))",
300                  self.table, self.col
301                ));
302                params.push(Rc::new(n));
303                let json_array = serde_json::to_string(&self.ids).unwrap();
304                params.push(Rc::new(json_array));
305            }
306        }
307        sql
308    }
309}