Skip to main content

keelson_models/
column.rs

1use std::fmt;
2use std::marker::PhantomData;
3
4use keelson_core::clause::HasWhere;
5use keelson_core::expr::{Chain, Expr, IntoExpr, IntoExprList};
6use keelson_core::{Mod, ToValue};
7
8/// A model's column: the **one** entry point for everything a column is used
9/// for.
10///
11/// bob splits a column across four generated surfaces (`ColumnNames`,
12/// `Columns`, `SelectWhere`, `Preload`); keelson deliberately unifies them.
13/// `users::age()` is
14///
15/// - the column *expression* — [`IntoExpr`] renders the qualified, quoted
16///   identifier, so it drops into any Layer 1 slot (`select::columns`,
17///   `select::order_by`, a join condition);
18/// - the *filter origin* — [`gte`](Column::gte) and friends take the column's
19///   Rust type, so `age().gte(21)` compiles and `age().gte("x")` does not;
20/// - the *alias carrier* — [`aliased_as`](Column::aliased_as) re-qualifies it
21///   when the query aliases the table.
22///
23/// The type parameter is the column's Rust type from
24/// `docs/type-mappings.md` (the base type; nullability lives on the row
25/// struct's `Option`, not here — a comparison against `NULL` is never what
26/// `= $1` means in SQL, which is why [`is_null`](Column::is_null) is its own
27/// method rather than `eq(None)`).
28pub struct Column<T> {
29    table: &'static str,
30    name: &'static str,
31    _type: PhantomData<fn() -> T>,
32}
33
34impl<T> fmt::Debug for Column<T> {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.debug_struct("Column")
37            .field("table", &self.table)
38            .field("name", &self.name)
39            .finish()
40    }
41}
42
43impl<T> Clone for Column<T> {
44    fn clone(&self) -> Self {
45        *self
46    }
47}
48
49impl<T> Copy for Column<T> {}
50
51impl<T> Column<T> {
52    /// The column `table`.`name`. Generated code calls this; nothing checks
53    /// the names — the schema they came from is the generator's authority.
54    pub const fn new(table: &'static str, name: &'static str) -> Column<T> {
55        Column {
56            table,
57            name,
58            _type: PhantomData,
59        }
60    }
61
62    /// The same column under a table alias: when the query says
63    /// `FROM "users" AS "u"`, `users::age().aliased_as("u")` renders
64    /// `"u"."age"`. The type is carried along — an aliased column is exactly
65    /// as typed as the original.
66    pub const fn aliased_as(self, alias: &'static str) -> Column<T> {
67        Column {
68            table: alias,
69            ..self
70        }
71    }
72
73    /// The table (or alias) this column renders under.
74    pub const fn table(&self) -> &'static str {
75        self.table
76    }
77
78    /// The bare column name — what the result-set column is called, and what
79    /// `FromRow` reads it back by.
80    pub const fn name(&self) -> &'static str {
81        self.name
82    }
83
84    /// The qualified, quoted identifier expression: `"users"."age"`.
85    pub fn expr(self) -> Expr {
86        Expr::ident((self.table, self.name))
87    }
88
89    /// `self IS NULL`. On any column: nullability is the schema's fact, and a
90    /// filter on a `NOT NULL` column is merely always false.
91    pub fn is_null(self) -> Filter {
92        Filter::from_expr(self.expr()).is_null()
93    }
94
95    /// `self IS NOT NULL`.
96    pub fn is_not_null(self) -> Filter {
97        Filter::from_expr(self.expr()).is_not_null()
98    }
99}
100
101impl<T: ToValue> Column<T> {
102    fn cmp(self, op: &'static str, rhs: T) -> Filter {
103        Filter::from_expr(self.expr()).op(op, Expr::arg(rhs))
104    }
105
106    /// `self = $n`, binding the value.
107    ///
108    /// The comparison is typed by the column: the value must convert into the
109    /// column's Rust type, so a mistyped literal fails to compile —
110    ///
111    /// ```compile_fail
112    /// let age: keelson_models::Column<i32> = keelson_models::Column::new("users", "age");
113    /// age.gte("x"); // a &str is not an i32: compile error
114    /// ```
115    pub fn eq(self, value: impl Into<T>) -> Filter {
116        self.cmp("=", value.into())
117    }
118
119    /// `self <> $n`.
120    pub fn ne(self, value: impl Into<T>) -> Filter {
121        self.cmp("<>", value.into())
122    }
123
124    /// `self < $n`.
125    pub fn lt(self, value: impl Into<T>) -> Filter {
126        self.cmp("<", value.into())
127    }
128
129    /// `self <= $n`.
130    pub fn lte(self, value: impl Into<T>) -> Filter {
131        self.cmp("<=", value.into())
132    }
133
134    /// `self > $n`.
135    pub fn gt(self, value: impl Into<T>) -> Filter {
136        self.cmp(">", value.into())
137    }
138
139    /// `self >= $n`.
140    pub fn gte(self, value: impl Into<T>) -> Filter {
141        self.cmp(">=", value.into())
142    }
143
144    /// `self IN ($1, $2, …)`, every element bound.
145    pub fn in_(self, values: impl IntoIterator<Item = impl Into<T>>) -> Filter {
146        let vals: Vec<Expr> = values.into_iter().map(|v| Expr::arg(v.into())).collect();
147        Filter::from_expr(self.expr()).in_(vals)
148    }
149
150    /// `self NOT IN ($1, $2, …)`.
151    pub fn not_in(self, values: impl IntoIterator<Item = impl Into<T>>) -> Filter {
152        let vals: Vec<Expr> = values.into_iter().map(|v| Expr::arg(v.into())).collect();
153        Filter::from_expr(self.expr()).not_in(vals)
154    }
155
156    /// `self BETWEEN $1 AND $2`.
157    pub fn between(self, low: impl Into<T>, high: impl Into<T>) -> Filter {
158        Filter::from_expr(self.expr()).between(Expr::arg(low.into()), Expr::arg(high.into()))
159    }
160}
161
162impl Column<String> {
163    /// `self LIKE $n`. Text columns only — a pattern match against a number
164    /// is a type error here rather than an engine surprise.
165    pub fn like(self, pattern: impl Into<String>) -> Filter {
166        Filter::from_expr(self.expr()).like(Expr::arg(pattern.into()))
167    }
168}
169
170impl<T> IntoExpr for Column<T> {
171    fn into_expr(self) -> Expr {
172        self.expr()
173    }
174}
175
176impl<T> IntoExprList for Column<T> {
177    fn into_expr_list(self) -> Vec<Expr> {
178        vec![self.expr()]
179    }
180}
181
182/// A typed condition on its way to a `WHERE`.
183///
184/// What a [`Column`] comparison produces. It is three things at once, which is
185/// what lets typed filters mix with Layer 1 anywhere:
186///
187/// - a [`Mod`] on anything with a `WHERE` ([`HasWhere`]) — so it sits directly
188///   in `users::table().query((users::age().gte(21), …))`, and equally in a
189///   raw dialect statement's mod tuple;
190/// - an [`IntoExpr`] — so it drops into any expression slot (`select::where_`,
191///   a join's `on`, a `CASE` arm);
192/// - a [`Chain`] — so `.and(…)`, `.or(…)` and every other Layer 1 operator
193///   keep working after the typed comparison started the chain.
194#[derive(Debug, Clone)]
195pub struct Filter(Expr);
196
197impl Filter {
198    /// Wrap any expression — a raw `&str` fragment included — as a filter, so
199    /// hand-written SQL rides the same `WHERE` path the typed comparisons use.
200    pub fn new(condition: impl IntoExpr) -> Filter {
201        Filter(condition.into_expr())
202    }
203}
204
205impl IntoExpr for Filter {
206    fn into_expr(self) -> Expr {
207        self.0
208    }
209}
210
211impl IntoExprList for Filter {
212    fn into_expr_list(self) -> Vec<Expr> {
213        vec![self.0]
214    }
215}
216
217impl Chain for Filter {
218    fn from_expr(e: Expr) -> Filter {
219        Filter(e)
220    }
221}
222
223/// Appends to the `WHERE` clause; several filters `AND` together, matching
224/// `Where`'s own contract.
225impl<Q: HasWhere> Mod<Q> for Filter {
226    fn apply(self, q: &mut Q) {
227        q.where_mut().append_where(self.0);
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use keelson_core::Value;
234    use keelson_core::clause::Where;
235    use keelson_sqlcheck::testing::{assert_frag, render};
236
237    use super::*;
238
239    const COND: &str = r#"SELECT "id" FROM users WHERE {}"#;
240
241    fn age() -> Column<i32> {
242        Column::new("users", "age")
243    }
244
245    fn name() -> Column<String> {
246        Column::new("users", "name")
247    }
248
249    #[test]
250    fn a_column_is_its_qualified_quoted_identifier() {
251        assert_frag(r#"SELECT {} FROM users"#, &age().expr(), r#""users"."age""#);
252    }
253
254    #[test]
255    fn typed_comparisons_bind_the_column_type() {
256        let args = assert_frag(COND, &age().gte(21).into_expr(), r#"("users"."age" >= $1)"#);
257        assert_eq!(args, vec![Value::I32(21)]);
258
259        // Into<T> does the lifting: a &str lands in a String column.
260        let args = assert_frag(
261            COND,
262            &name().eq("ada").into_expr(),
263            r#"("users"."name" = $1)"#,
264        );
265        assert_eq!(args, vec![Value::Text("ada".into())]);
266    }
267
268    #[test]
269    fn in_binds_every_element() {
270        let args = assert_frag(
271            COND,
272            &age().in_([1, 2, 3]).into_expr(),
273            r#"("users"."age" IN ($1, $2, $3))"#,
274        );
275        assert_eq!(args.len(), 3);
276    }
277
278    #[test]
279    fn null_tests_and_like_have_their_sql_shapes() {
280        assert_frag(
281            COND,
282            &age().is_null().into_expr(),
283            r#"("users"."age" IS NULL)"#,
284        );
285        assert_frag(
286            COND,
287            &name().like("a%").into_expr(),
288            r#"("users"."name" LIKE $1)"#,
289        );
290        assert_frag(
291            COND,
292            &age().between(1, 9).into_expr(),
293            r#"("users"."age" BETWEEN $1 AND $2)"#,
294        );
295    }
296
297    #[test]
298    fn a_filter_chains_on_with_layer_1_operators() {
299        // The typed comparison starts the chain; Layer 1's Chain continues it.
300        let f = age().gte(21).and(name().like("a%"));
301        assert_frag(
302            COND,
303            &f.into_expr(),
304            r#"(("users"."age" >= $1) AND ("users"."name" LIKE $2))"#,
305        );
306    }
307
308    #[test]
309    fn aliased_as_requalifies_and_keeps_the_type() {
310        let f = age().aliased_as("u").gte(21);
311        assert_frag(
312            r#"SELECT "id" FROM users AS u WHERE {}"#,
313            &f.into_expr(),
314            r#"("u"."age" >= $1)"#,
315        );
316        assert_eq!(age().aliased_as("u").name(), "age");
317    }
318
319    #[test]
320    fn a_filter_is_a_mod_on_anything_with_a_where() {
321        let mut w = Where::default();
322        age().gte(21).apply(&mut w);
323        name().eq("ada").apply(&mut w);
324        let (sql, args) = render(&w);
325        assert_eq!(
326            sql,
327            r#"WHERE ("users"."age" >= $1) AND ("users"."name" = $2)"#
328        );
329        assert_eq!(args.len(), 2);
330    }
331
332    #[test]
333    fn filter_new_wraps_a_raw_fragment() {
334        assert_frag(COND, &Filter::new("age > 21").into_expr(), "age > 21");
335    }
336}