Skip to main content

rainier_database/
criteria.rs

1//! Composable query scopes — [`Criteria`].
2//!
3//! A repository method cannot take "the filters" as an argument unless filters
4//! are a *value*. `Criteria` is that value: it records predicates, joins,
5//! ordering and paging, and [`statement`](crate::statement) replays them into
6//! SQL at execution time.
7//!
8//! Making the filters data rather than a partially-applied builder buys three
9//! things: a scope can be named and reused, two scopes can be merged by code
10//! that owns neither, and [`Repository`](crate::Repository) stays `dyn`-safe
11//! because its methods take one concrete parameter instead of a closure.
12//!
13//! ```
14//! # use rainier_database::Criteria;
15//! let published = Criteria::new().where_eq("published", true);
16//! let newest = Criteria::new().order_by_desc("created_at").limit(10);
17//!
18//! let front_page = published.merge(newest);
19//! assert_eq!(front_page.limit_value(), Some(10));
20//! ```
21
22use rainier_orm::sea_query::{ColumnRef, Expr, SimpleExpr, Value};
23
24/// One recorded predicate.
25#[derive(Debug, Clone)]
26pub enum Constraint {
27    /// `column = value`.
28    Eq(String, Value),
29    /// `column <> value`.
30    Ne(String, Value),
31    /// `column > value`.
32    Gt(String, Value),
33    /// `column >= value`.
34    Gte(String, Value),
35    /// `column < value`.
36    Lt(String, Value),
37    /// `column <= value`.
38    Lte(String, Value),
39    /// `column LIKE pattern`.
40    Like(String, String),
41    /// `column NOT LIKE pattern`.
42    NotLike(String, String),
43    /// `column IN (values)`.
44    In(String, Vec<Value>),
45    /// `column NOT IN (values)`.
46    NotIn(String, Vec<Value>),
47    /// `column IS NULL`.
48    Null(String),
49    /// `column IS NOT NULL`.
50    NotNull(String),
51}
52
53impl Constraint {
54    /// The column this constraint applies to.
55    pub fn column(&self) -> &str {
56        match self {
57            Constraint::Eq(column, _)
58            | Constraint::Ne(column, _)
59            | Constraint::Gt(column, _)
60            | Constraint::Gte(column, _)
61            | Constraint::Lt(column, _)
62            | Constraint::Lte(column, _)
63            | Constraint::Like(column, _)
64            | Constraint::NotLike(column, _)
65            | Constraint::In(column, _)
66            | Constraint::NotIn(column, _)
67            | Constraint::Null(column)
68            | Constraint::NotNull(column) => column,
69        }
70    }
71
72    /// The `(column, value)` pair if this is an equality, else `None`.
73    ///
74    /// Shard routing keys off equalities specifically: `user_id = 42` names one
75    /// shard, whereas `user_id > 42` could span all of them.
76    pub fn as_equality(&self) -> Option<(&str, &Value)> {
77        match self {
78            Constraint::Eq(column, value) => Some((column, value)),
79            _ => None,
80        }
81    }
82
83    /// Render as a `sea_query` expression against an already-resolved column.
84    pub fn to_expr(&self, column: ColumnRef) -> SimpleExpr {
85        let expr = Expr::col(column);
86        match self {
87            Constraint::Eq(_, value) => expr.eq(value.clone()),
88            Constraint::Ne(_, value) => expr.ne(value.clone()),
89            Constraint::Gt(_, value) => expr.gt(value.clone()),
90            Constraint::Gte(_, value) => expr.gte(value.clone()),
91            Constraint::Lt(_, value) => expr.lt(value.clone()),
92            Constraint::Lte(_, value) => expr.lte(value.clone()),
93            Constraint::Like(_, pattern) => expr.like(pattern.as_str()),
94            Constraint::NotLike(_, pattern) => expr.not_like(pattern.as_str()),
95            Constraint::In(_, values) => expr.is_in(values.clone()),
96            Constraint::NotIn(_, values) => expr.is_not_in(values.clone()),
97            Constraint::Null(_) => expr.is_null(),
98            Constraint::NotNull(_) => expr.is_not_null(),
99        }
100    }
101}
102
103/// A recorded, replayable set of query constraints.
104///
105/// Predicates are AND-combined. A column is `"name"` (qualified to the model's
106/// table) or `"table.name"` (a joined table).
107#[derive(Debug, Clone, Default)]
108pub struct Criteria {
109    constraints: Vec<Constraint>,
110    /// `(table, local_column, foreign_column)`.
111    joins: Vec<(String, String, String)>,
112    /// `(column, descending)`.
113    orders: Vec<(String, bool)>,
114    limit: Option<u64>,
115    offset: Option<u64>,
116}
117
118impl Criteria {
119    /// No constraints — every row.
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// `column = value`.
125    pub fn where_eq(mut self, column: impl Into<String>, value: impl Into<Value>) -> Self {
126        self.constraints.push(Constraint::Eq(column.into(), value.into()));
127        self
128    }
129
130    /// `column <> value`.
131    pub fn where_ne(mut self, column: impl Into<String>, value: impl Into<Value>) -> Self {
132        self.constraints.push(Constraint::Ne(column.into(), value.into()));
133        self
134    }
135
136    /// `column > value`.
137    pub fn where_gt(mut self, column: impl Into<String>, value: impl Into<Value>) -> Self {
138        self.constraints.push(Constraint::Gt(column.into(), value.into()));
139        self
140    }
141
142    /// `column >= value`.
143    pub fn where_gte(mut self, column: impl Into<String>, value: impl Into<Value>) -> Self {
144        self.constraints.push(Constraint::Gte(column.into(), value.into()));
145        self
146    }
147
148    /// `column < value`.
149    pub fn where_lt(mut self, column: impl Into<String>, value: impl Into<Value>) -> Self {
150        self.constraints.push(Constraint::Lt(column.into(), value.into()));
151        self
152    }
153
154    /// `column <= value`.
155    pub fn where_lte(mut self, column: impl Into<String>, value: impl Into<Value>) -> Self {
156        self.constraints.push(Constraint::Lte(column.into(), value.into()));
157        self
158    }
159
160    /// `column LIKE pattern` — use `%` and `_` wildcards.
161    pub fn where_like(mut self, column: impl Into<String>, pattern: impl Into<String>) -> Self {
162        self.constraints.push(Constraint::Like(column.into(), pattern.into()));
163        self
164    }
165
166    /// `column NOT LIKE pattern`.
167    pub fn where_not_like(mut self, column: impl Into<String>, pattern: impl Into<String>) -> Self {
168        self.constraints.push(Constraint::NotLike(column.into(), pattern.into()));
169        self
170    }
171
172    /// `column IN (values)`. An empty set matches nothing.
173    pub fn where_in<V: Into<Value>>(
174        mut self,
175        column: impl Into<String>,
176        values: impl IntoIterator<Item = V>,
177    ) -> Self {
178        let values = values.into_iter().map(Into::into).collect();
179        self.constraints.push(Constraint::In(column.into(), values));
180        self
181    }
182
183    /// `column NOT IN (values)`.
184    pub fn where_not_in<V: Into<Value>>(
185        mut self,
186        column: impl Into<String>,
187        values: impl IntoIterator<Item = V>,
188    ) -> Self {
189        let values = values.into_iter().map(Into::into).collect();
190        self.constraints.push(Constraint::NotIn(column.into(), values));
191        self
192    }
193
194    /// `column IS NULL`.
195    pub fn where_null(mut self, column: impl Into<String>) -> Self {
196        self.constraints.push(Constraint::Null(column.into()));
197        self
198    }
199
200    /// `column IS NOT NULL`.
201    pub fn where_not_null(mut self, column: impl Into<String>) -> Self {
202        self.constraints.push(Constraint::NotNull(column.into()));
203        self
204    }
205
206    /// `INNER JOIN table ON model.local = table.foreign`.
207    ///
208    /// For filtering the root model only — the joined table is not decoded, and
209    /// the join cannot span backends. Compose across entities in your own code
210    /// instead; see Rainier ORM's notes on why relationships stay explicit.
211    pub fn join(
212        mut self,
213        table: impl Into<String>,
214        local: impl Into<String>,
215        foreign: impl Into<String>,
216    ) -> Self {
217        self.joins.push((table.into(), local.into(), foreign.into()));
218        self
219    }
220
221    /// `ORDER BY column ASC`.
222    pub fn order_by(mut self, column: impl Into<String>) -> Self {
223        self.orders.push((column.into(), false));
224        self
225    }
226
227    /// `ORDER BY column DESC`.
228    pub fn order_by_desc(mut self, column: impl Into<String>) -> Self {
229        self.orders.push((column.into(), true));
230        self
231    }
232
233    /// `LIMIT n`.
234    ///
235    /// Clamped to `i64::MAX`. Drivers bind these as a signed 64-bit integer and
236    /// **panic** on anything larger — so `limit(u64::MAX)`, the obvious way to
237    /// write "no limit", used to take the process down inside sea-query rather
238    /// than return an error. No table has more than `i64::MAX` rows, so the
239    /// clamp changes no result.
240    pub fn limit(mut self, n: u64) -> Self {
241        self.limit = Some(n.min(i64::MAX as u64));
242        self
243    }
244
245    /// `OFFSET n`. Clamped like [`limit`](Self::limit), and for the same
246    /// reason.
247    pub fn offset(mut self, n: u64) -> Self {
248        self.offset = Some(n.min(i64::MAX as u64));
249        self
250    }
251
252    /// Combine with `other`: its constraints, joins and orders are appended,
253    /// and its paging wins wherever it sets any.
254    pub fn merge(mut self, other: Criteria) -> Self {
255        self.constraints.extend(other.constraints);
256        self.joins.extend(other.joins);
257        self.orders.extend(other.orders);
258        self.limit = other.limit.or(self.limit);
259        self.offset = other.offset.or(self.offset);
260        self
261    }
262
263    /// Apply `scope` only if `condition` holds.
264    pub fn when(self, condition: bool, scope: impl FnOnce(Self) -> Self) -> Self {
265        if condition {
266            scope(self)
267        } else {
268            self
269        }
270    }
271
272    /// Whether any filter or join is recorded. Ordering and paging alone do
273    /// not make a criteria non-empty.
274    pub fn is_empty(&self) -> bool {
275        self.constraints.is_empty() && self.joins.is_empty()
276    }
277
278    /// The recorded predicates.
279    pub fn constraints(&self) -> &[Constraint] {
280        &self.constraints
281    }
282
283    /// The recorded joins, as `(table, local, foreign)`.
284    pub fn joins(&self) -> impl Iterator<Item = (&str, &str, &str)> {
285        self.joins.iter().map(|(t, l, f)| (t.as_str(), l.as_str(), f.as_str()))
286    }
287
288    /// The recorded ordering, as `(column, descending)`.
289    pub fn orders(&self) -> &[(String, bool)] {
290        &self.orders
291    }
292
293    /// The recorded limit.
294    pub fn limit_value(&self) -> Option<u64> {
295        self.limit
296    }
297
298    /// The recorded offset.
299    pub fn offset_value(&self) -> Option<u64> {
300        self.offset
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn starts_empty() {
310        let criteria = Criteria::new();
311        assert!(criteria.is_empty());
312        assert_eq!(criteria.limit_value(), None);
313        assert!(criteria.constraints().is_empty());
314    }
315
316    #[test]
317    fn a_limit_beyond_what_a_driver_can_bind_is_clamped() {
318        // `u64::MAX` is how you write "everything", and the sqlite and postgres
319        // binders both `unwrap` the conversion to `i64` — so this used to be a
320        // panic from inside the driver, several layers below the caller.
321        let criteria = Criteria::new().limit(u64::MAX).offset(u64::MAX);
322
323        assert_eq!(criteria.limit_value(), Some(i64::MAX as u64));
324        assert_eq!(criteria.offset_value(), Some(i64::MAX as u64));
325    }
326
327    #[test]
328    fn records_constraints_in_order() {
329        let criteria = Criteria::new().where_eq("published", true).where_gt("views", 100_i64);
330
331        assert!(!criteria.is_empty());
332        assert_eq!(criteria.constraints().len(), 2);
333        assert_eq!(criteria.constraints()[0].column(), "published");
334        assert_eq!(criteria.constraints()[1].column(), "views");
335    }
336
337    #[test]
338    fn only_equalities_report_a_routable_pair() {
339        let criteria = Criteria::new().where_eq("user_id", 1_u64).where_gt("user_id", 1_u64);
340
341        assert!(criteria.constraints()[0].as_equality().is_some());
342        assert!(
343            criteria.constraints()[1].as_equality().is_none(),
344            "a range could span every shard"
345        );
346    }
347
348    #[test]
349    fn ordering_and_paging_alone_leave_it_empty() {
350        let criteria = Criteria::new().order_by_desc("id").limit(5);
351        assert!(criteria.is_empty(), "nothing is being filtered");
352        assert_eq!(criteria.limit_value(), Some(5));
353    }
354
355    #[test]
356    fn merge_appends_and_prefers_the_others_paging() {
357        let base = Criteria::new().where_eq("a", 1_i64).limit(5).offset(10);
358        let extra = Criteria::new().where_eq("b", 2_i64).limit(20);
359
360        let merged = base.merge(extra);
361        assert_eq!(merged.constraints().len(), 2);
362        assert_eq!(merged.limit_value(), Some(20), "the merged-in limit wins");
363        assert_eq!(merged.offset_value(), Some(10), "the original offset survives");
364    }
365
366    #[test]
367    fn merge_keeps_the_original_paging_when_the_other_sets_none() {
368        let merged = Criteria::new().limit(5).merge(Criteria::new().where_eq("a", 1_i64));
369        assert_eq!(merged.limit_value(), Some(5));
370    }
371
372    #[test]
373    fn when_applies_a_scope_conditionally() {
374        assert_eq!(Criteria::new().when(true, |c| c.where_eq("a", 1_i64)).constraints().len(), 1);
375        assert!(Criteria::new().when(false, |c| c.where_eq("a", 1_i64)).is_empty());
376    }
377
378    #[test]
379    fn joins_are_recorded_with_both_sides() {
380        let criteria = Criteria::new().join("authors", "author_id", "id");
381        assert!(!criteria.is_empty());
382        assert_eq!(criteria.joins().collect::<Vec<_>>(), vec![("authors", "author_id", "id")]);
383    }
384
385    #[test]
386    fn ordering_records_its_direction() {
387        let criteria = Criteria::new().order_by("a").order_by_desc("b");
388        assert_eq!(criteria.orders(), &[("a".to_string(), false), ("b".to_string(), true)]);
389    }
390}