Skip to main content

queryweave_sql/backend/
sea_query.rs

1//! sea-query's statements as queries the SQL is written into.
2//!
3//! A `SelectStatement`, `UpdateStatement` or `DeleteStatement` (`sea-orm`'s,
4//! through `QueryTrait::query`), rendered for Postgres, MySQL/MariaDB or SQLite
5//! by sea-query.
6//!
7//! The data becomes sea-query expressions rather than SQL text: conditions one
8//! `Condition` (`all` for `AND`, `any` for `OR`) added with `cond_where`,
9//! which writes the `WHERE` itself; sort keys `order_by`; a page `limit` and
10//! `offset`; assignments `value`. sea-query binds every value and quotes the
11//! column names.
12//!
13//! Public as `queryweave_sql::sea_query`: [`check_column_types`], which checks
14//! a `SeaORM` entity's column types against a model's value types.
15
16mod check;
17
18pub use check::{ColumnTypeMismatch, MismatchReason, accepted_kinds, check_column_types};
19
20#[cfg(feature = "merge-patch")]
21use crate::AssignmentsTarget;
22use crate::sql::{LIKE_ESCAPE, like_pattern, page_offset};
23use crate::{Columns, ConditionsTarget, Junction, OrderByTarget, PageTarget};
24use queryweave::{
25    Conditions, FilterCondition, FilterOperator, SortDirectionModel, SortKey, SortKeys, Value,
26};
27#[cfg(feature = "merge-patch")]
28use queryweave::{FieldValue, FieldValues};
29#[cfg(feature = "merge-patch")]
30use sea_query::UpdateStatement;
31use sea_query::{
32    BinOper, Condition, DeleteStatement, Expr, ExprTrait, LikeExpr, Order, SelectStatement,
33};
34
35use super::column;
36
37/// The sea-query value of a domain raw value, with its own SQL type (an absent
38/// one a `NULL` of that type).
39fn sea_value(value: Value) -> sea_query::Value {
40    match value {
41        Value::Bool(value) => value.into(),
42        Value::I16(value) => value.into(),
43        Value::I32(value) => value.into(),
44        Value::I64(value) => value.into(),
45        Value::F32(value) => value.into(),
46        Value::F64(value) => value.into(),
47        Value::Text(value) => value.into(),
48        Value::DateTimeUtc(value) => value.into(),
49        Value::NaiveDateTime(value) => value.into(),
50        Value::NaiveDate(value) => value.into(),
51        Value::NaiveTime(value) => value.into(),
52    }
53}
54
55/// `column operator value`, the value bound as it is.
56fn comparison(column: Expr, operator: FilterOperator, value: Value) -> Expr {
57    let value = sea_value(value);
58    match operator {
59        FilterOperator::LessThan => column.lt(value),
60        FilterOperator::Equal => column.eq(value),
61        FilterOperator::GreaterThan => column.gt(value),
62        FilterOperator::StartsWith | FilterOperator::Contains | FilterOperator::EndsWith => {
63            column.binary(BinOper::Like, value)
64        },
65    }
66}
67
68/// The expression for a condition, against the column `C` stores its domain
69/// field in.
70///
71/// Like the sqlx backend: comparisons bind the value with its own SQL type;
72/// the `LIKE` operators bind an escaped `%` pattern of the text and name the
73/// escape character.
74fn condition_expr<C: Columns + ?Sized>(condition: FilterCondition) -> Expr {
75    let FilterCondition {
76        field,
77        operator,
78        value,
79    } = condition;
80    let column = Expr::col(column::<C>(field));
81
82    // The `Text` filter kind guarantees the `LIKE` operators only ever carry
83    // text; anything else is bound as it is.
84    match value {
85        Value::Text(Some(text)) => match like_pattern(operator, &text) {
86            Some(pattern) => column.like(LikeExpr::new(pattern).escape(LIKE_ESCAPE)),
87            None => comparison(column, operator, Value::Text(Some(text))),
88        },
89        value => comparison(column, operator, value),
90    }
91}
92
93/// The conditions as one sea-query condition joined by `junction`; `None`
94/// when there are none, so no `WHERE` is written.
95fn condition<C: Columns + ?Sized, M>(
96    conditions: Conditions<M>,
97    junction: Junction,
98) -> Option<Condition> {
99    if conditions.is_empty() {
100        return None;
101    }
102
103    let joined = match junction {
104        Junction::And => Condition::all(),
105        Junction::Or => Condition::any(),
106    };
107    Some(conditions.into_iter().fold(joined, |joined, condition| {
108        joined.add(condition_expr::<C>(condition))
109    }))
110}
111
112/// `ConditionsTarget` for the sea-query statements taking a `WHERE` clause.
113macro_rules! conditions_target {
114    ($($statement:ty),+ $(,)?) => {$(
115        impl ConditionsTarget for $statement {
116            /// Nothing: `cond_where` writes the `WHERE` itself.
117            fn write_where(&mut self) {}
118
119            fn write_conditions<C: Columns + ?Sized, M>(
120                &mut self,
121                conditions: Conditions<M>,
122                junction: Junction,
123            ) {
124                if let Some(condition) = condition::<C, M>(conditions, junction) {
125                    self.cond_where(condition);
126                }
127            }
128        }
129    )+};
130}
131
132#[cfg(feature = "merge-patch")]
133conditions_target!(SelectStatement, UpdateStatement, DeleteStatement);
134#[cfg(not(feature = "merge-patch"))]
135conditions_target!(SelectStatement, DeleteStatement);
136
137impl OrderByTarget for SelectStatement {
138    fn write_order_by<C: Columns + ?Sized, M>(&mut self, sort_keys: SortKeys<M>) {
139        for SortKey { field, direction } in sort_keys {
140            let order = match direction {
141                SortDirectionModel::Ascending => Order::Asc,
142                SortDirectionModel::Descending => Order::Desc,
143            };
144            self.order_by(column::<C>(field), order);
145        }
146    }
147}
148
149impl PageTarget for SelectStatement {
150    fn write_page(&mut self, page: Option<u32>, page_size: Option<u32>) {
151        let (Some(page), Some(page_size)) = (page, page_size) else {
152            return;
153        };
154
155        // `page_offset` saturates at `i64::MAX` (every database's signed row
156        // count), so the offset is never negative.
157        self.limit(u64::from(page_size))
158            .offset(page_offset(page, page_size).unsigned_abs());
159    }
160}
161
162#[cfg(feature = "merge-patch")]
163impl AssignmentsTarget for UpdateStatement {
164    type Assignments<'target> = &'target mut Self;
165
166    fn write_assignments<C: Columns + ?Sized, M>(
167        &mut self,
168        assignments: FieldValues<M>,
169    ) -> Self::Assignments<'_> {
170        for FieldValue { field, value } in assignments {
171            self.value(column::<C>(field), sea_value(value));
172        }
173
174        self
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use crate::{Columns, Junction, push_page};
181    use pretty_assertions::assert_eq;
182    #[cfg(feature = "merge-patch")]
183    use queryweave::FieldValue;
184    use queryweave::{
185        FilterCondition, FilterOperator, PersistedFields, SortDirectionModel, SortKey, Value,
186    };
187    use rstest::rstest;
188    use sea_query::{
189        MysqlQueryBuilder, PostgresQueryBuilder, Query, QueryBuilder, SelectStatement,
190        SqliteQueryBuilder, Values,
191    };
192
193    /// Each field stored in the column of its name, except `delivery_date`,
194    /// stored in `delivered_at`.
195    struct Table;
196
197    impl Columns for Table {
198        const NAME: &'static str = "Table";
199        const COLUMNS: &'static [(&'static str, &'static str)] = &[
200            ("number", "number"),
201            ("client", "client"),
202            ("value", "value"),
203            ("delivery_date", "delivered_at"),
204        ];
205    }
206
207    /// A model whose every field has a column on [`Table`].
208    struct Model;
209
210    impl PersistedFields for Model {
211        const FIELD_NAMES: &'static [&'static str] =
212            &["number", "client", "value", "delivery_date"];
213    }
214
215    fn condition(field: &'static str, operator: FilterOperator) -> FilterCondition {
216        FilterCondition {
217            field,
218            operator,
219            value: Value::Text(Some(String::from("needle"))),
220        }
221    }
222
223    fn select() -> SelectStatement {
224        Query::select().column("id").from("orders").to_owned()
225    }
226
227    fn where_sql(conditions: Vec<FilterCondition>, junction: Junction) -> (String, Values) {
228        let mut statement = select();
229        Table::push_conditions(
230            &mut statement,
231            queryweave::__private::model_data::<Model, _>(conditions),
232            junction,
233        );
234        statement.build(PostgresQueryBuilder)
235    }
236
237    /// Conditions, sorting and a page, as one select.
238    fn select_sql(builder: &impl QueryBuilder) -> String {
239        let mut statement = select();
240        Table::push_conditions(
241            &mut statement,
242            queryweave::__private::model_data::<Model, _>(vec![
243                condition("number", FilterOperator::Equal),
244                condition("client", FilterOperator::StartsWith),
245            ]),
246            Junction::And,
247        );
248        Table::push_order_by(
249            &mut statement,
250            queryweave::__private::model_data::<Model, _>(vec![SortKey {
251                field: "delivery_date",
252                direction: SortDirectionModel::Descending,
253            }]),
254        );
255        push_page(&mut statement, Some(2), Some(10));
256        statement.build_any(builder).0
257    }
258
259    // Every database gets its own placeholders and identifier quotes.
260    #[test]
261    fn every_database_gets_its_own_placeholders_and_quotes() {
262        assert_eq!(
263            select_sql(&PostgresQueryBuilder),
264            r#"SELECT "id" FROM "orders" WHERE "number" = $1 AND "client" LIKE $2 ESCAPE '!' ORDER BY "delivered_at" DESC LIMIT $3 OFFSET $4"#
265        );
266        assert_eq!(
267            select_sql(&MysqlQueryBuilder),
268            "SELECT `id` FROM `orders` WHERE `number` = ? AND `client` LIKE ? ESCAPE '!' ORDER BY `delivered_at` DESC LIMIT ? OFFSET ?"
269        );
270        assert_eq!(
271            select_sql(&SqliteQueryBuilder),
272            r#"SELECT "id" FROM "orders" WHERE "number" = ? AND "client" LIKE ? ESCAPE '!' ORDER BY "delivered_at" DESC LIMIT ? OFFSET ?"#
273        );
274    }
275
276    // The values are bound: the comparison's as it is, the `LIKE` one as its
277    // escaped pattern, the page as its size and offset.
278    #[test]
279    fn the_values_are_bound() {
280        let mut statement = select();
281        Table::push_conditions(
282            &mut statement,
283            queryweave::__private::model_data::<Model, _>(vec![
284                FilterCondition {
285                    field: "value",
286                    operator: FilterOperator::LessThan,
287                    value: Value::F64(Some(9.5)),
288                },
289                FilterCondition {
290                    field: "client",
291                    operator: FilterOperator::Contains,
292                    value: Value::Text(Some(String::from("50%"))),
293                },
294            ]),
295            Junction::And,
296        );
297        push_page(&mut statement, Some(3), Some(10));
298
299        let (_, values) = statement.build(PostgresQueryBuilder);
300        assert_eq!(
301            values,
302            Values(vec![
303                9.5_f64.into(),
304                String::from("%50!%%").into(),
305                10_u64.into(),
306                30_u64.into(),
307            ])
308        );
309    }
310
311    // Conditions are joined by the junction, and without any there is no
312    // `WHERE` at all.
313    #[rstest]
314    #[case(
315        Junction::And,
316        r#"SELECT "id" FROM "orders" WHERE "number" = $1 AND "client" = $2"#
317    )]
318    #[case(
319        Junction::Or,
320        r#"SELECT "id" FROM "orders" WHERE "number" = $1 OR "client" = $2"#
321    )]
322    fn conditions_are_joined_by_the_junction(#[case] junction: Junction, #[case] expected: &str) {
323        let conditions = vec![
324            condition("number", FilterOperator::Equal),
325            condition("client", FilterOperator::Equal),
326        ];
327
328        assert_eq!(where_sql(conditions, junction).0, expected);
329        assert_eq!(
330            where_sql(Vec::new(), junction).0,
331            r#"SELECT "id" FROM "orders""#
332        );
333    }
334
335    // The operator selects the comparison; the `LIKE` ones name the escape
336    // character of their pattern.
337    #[rstest]
338    #[case(FilterOperator::LessThan, r#""value" < $1"#)]
339    #[case(FilterOperator::GreaterThan, r#""value" > $1"#)]
340    #[case(FilterOperator::Equal, r#""value" = $1"#)]
341    #[case(FilterOperator::StartsWith, r#""value" LIKE $1 ESCAPE '!'"#)]
342    #[case(FilterOperator::Contains, r#""value" LIKE $1 ESCAPE '!'"#)]
343    #[case(FilterOperator::EndsWith, r#""value" LIKE $1 ESCAPE '!'"#)]
344    fn the_operator_selects_the_comparison(
345        #[case] operator: FilterOperator,
346        #[case] expected: &str,
347    ) {
348        let (sql, _) = where_sql(vec![condition("value", operator)], Junction::And);
349
350        assert_eq!(
351            sql,
352            format!(r#"SELECT "id" FROM "orders" WHERE {expected}"#)
353        );
354    }
355
356    // A renamed field is filtered and sorted by its column.
357    #[test]
358    fn a_renamed_field_is_written_as_its_column() {
359        let (sql, _) = where_sql(
360            vec![condition("delivery_date", FilterOperator::GreaterThan)],
361            Junction::And,
362        );
363        assert_eq!(
364            sql,
365            r#"SELECT "id" FROM "orders" WHERE "delivered_at" > $1"#
366        );
367    }
368
369    // Without sort keys or a whole page there is no `ORDER BY` or `LIMIT`.
370    #[rstest]
371    #[case(None, Some(10))]
372    #[case(Some(1), None)]
373    fn nothing_is_written_without_sort_keys_or_a_whole_page(
374        #[case] page: Option<u32>,
375        #[case] page_size: Option<u32>,
376    ) {
377        let mut statement = select();
378        Table::push_order_by(
379            &mut statement,
380            queryweave::__private::model_data::<Model, _>(Vec::new()),
381        );
382        push_page(&mut statement, page, page_size);
383
384        assert_eq!(
385            statement.to_string(PostgresQueryBuilder),
386            r#"SELECT "id" FROM "orders""#
387        );
388    }
389
390    // Assignments set each column, and more can follow on the returned
391    // statement.
392    #[cfg(feature = "merge-patch")]
393    #[test]
394    fn assignments_set_each_column_and_are_extendable() {
395        let mut statement = Query::update().table("orders").to_owned();
396        Table::push_assignments(
397            &mut statement,
398            queryweave::__private::model_data::<Model, _>(vec![
399                FieldValue {
400                    field: "number",
401                    value: Value::Text(None),
402                },
403                FieldValue {
404                    field: "delivery_date",
405                    value: Value::I32(Some(3)),
406                },
407            ]),
408        )
409        .value("last_modified_at", 1_i64);
410
411        assert_eq!(
412            statement.build(PostgresQueryBuilder).0,
413            r#"UPDATE "orders" SET "number" = $1, "delivered_at" = $2, "last_modified_at" = $3"#
414        );
415    }
416}