Skip to main content

queryweave_sql/
sql.rs

1//! The SQL written for the domain's database-agnostic data, portable across
2//! the databases sqlx supports (Postgres, MySQL/MariaDB, SQLite).
3//!
4//! Only standard SQL is written: comparisons, `LIKE … ESCAPE`, `ORDER BY`,
5//! `col = value` assignments and `LIMIT … OFFSET …`. Placeholders come from
6//! the driver's [`QueryBuilder`] (`$1` on Postgres, `?` elsewhere), and every
7//! value is bound, never interpolated.
8
9use crate::{BindValues, Columns};
10use queryweave::{
11    Conditions, FilterCondition, FilterOperator, SortDirectionModel, SortKey, SortKeys, Value,
12};
13#[cfg(feature = "merge-patch")]
14use queryweave::{FieldValue, FieldValues};
15use sqlx::QueryBuilder;
16#[cfg(feature = "merge-patch")]
17use sqlx::query_builder::Separated;
18
19/// The character escaping the `LIKE` metacharacters in a bound pattern.
20///
21/// Not the backslash: MySQL reads `'\'` as an escape inside string literals
22/// (so `ESCAPE '\'` does not even parse), and SQLite has no default escape
23/// character at all. `!` means the same everywhere.
24const LIKE_ESCAPE: char = '!';
25
26/// The SQL comparison operator for a filter operator.
27#[must_use]
28pub const fn sql_operator(operator: FilterOperator) -> &'static str {
29    match operator {
30        FilterOperator::LessThan => "<",
31        FilterOperator::Equal => "=",
32        FilterOperator::GreaterThan => ">",
33        FilterOperator::StartsWith | FilterOperator::Contains | FilterOperator::EndsWith => "LIKE",
34    }
35}
36
37/// The SQL keyword for a sort direction.
38#[must_use]
39pub const fn sql_direction(direction: SortDirectionModel) -> &'static str {
40    match direction {
41        SortDirectionModel::Ascending => "ASC",
42        SortDirectionModel::Descending => "DESC",
43    }
44}
45
46/// The `LIKE` pattern to bind for a `LIKE` operator: the escaped text wrapped
47/// in `%` wildcards. `None` for the comparison operators, which bind the value
48/// itself.
49///
50/// Kept separate so the wildcard placement can be unit-tested directly: the
51/// value is pushed as a bound parameter and therefore never appears in the
52/// rendered SQL.
53pub fn like_pattern(operator: FilterOperator, text: &str) -> Option<String> {
54    let text = escape_like(text);
55
56    match operator {
57        FilterOperator::StartsWith => Some(format!("{text}%")),
58        FilterOperator::Contains => Some(format!("%{text}%")),
59        FilterOperator::EndsWith => Some(format!("%{text}")),
60        FilterOperator::LessThan | FilterOperator::Equal | FilterOperator::GreaterThan => None,
61    }
62}
63
64/// Escape the `LIKE` metacharacters (`%`, `_` and [`LIKE_ESCAPE`] itself) so
65/// user input matches literally instead of acting as wildcards.
66pub fn escape_like(value: &str) -> String {
67    let mut escaped = String::with_capacity(value.len());
68
69    for character in value.chars() {
70        if matches!(character, '%' | '_') || character == LIKE_ESCAPE {
71            escaped.push(LIKE_ESCAPE);
72        }
73        escaped.push(character);
74    }
75
76    escaped
77}
78
79/// The column `C` stores a domain field in.
80///
81/// The writers below take the data of a model `M` (a
82/// `queryweave::ModelData<M, _>`, whose items only name fields of `M`), and
83/// `Columns`' methods check at compile time that every field of `M` has a
84/// column on `C`, so the lookup cannot miss. The fallback to the field's own
85/// name only satisfies the types (a runtime panic is not allowed here).
86fn column<C: Columns + ?Sized>(field: &'static str) -> &'static str {
87    C::column(field).unwrap_or(field)
88}
89
90/// Push a `column operator value` comparison and bind the value, the column
91/// being the one `C` stores the condition's domain field in.
92///
93/// Comparisons bind the value with its own SQL type (binding its text form
94/// would compare e.g. an integer column against text, which Postgres
95/// rejects); the `LIKE` operators bind an escaped `%` pattern of the text and
96/// name the escape character.
97fn push_condition<C: Columns + ?Sized, DB: BindValues>(
98    query_builder: &mut QueryBuilder<'_, DB>,
99    condition: FilterCondition,
100) {
101    let FilterCondition {
102        field,
103        operator,
104        value,
105    } = condition;
106
107    query_builder.push(format!(
108        "{} {} ",
109        column::<C>(field),
110        sql_operator(operator)
111    ));
112
113    // The `Text` filter kind guarantees the `LIKE` operators only ever carry
114    // text; anything else is bound as it is.
115    match value {
116        Value::Text(Some(text)) => match like_pattern(operator, &text) {
117            Some(pattern) => {
118                DB::push_bind_value(query_builder, Value::Text(Some(pattern)));
119                query_builder.push(format!(" ESCAPE '{LIKE_ESCAPE}'"));
120            },
121            None => DB::push_bind_value(query_builder, Value::Text(Some(text))),
122        },
123        value => DB::push_bind_value(query_builder, value),
124    }
125    query_builder.push(" ");
126}
127
128/// See [`Columns::push_conditions`].
129pub fn push_conditions<C: Columns + ?Sized, DB: BindValues, M>(
130    query_builder: &mut QueryBuilder<'_, DB>,
131    conditions: Conditions<M>,
132    separator: &str,
133) {
134    for (index, condition) in conditions.into_iter().enumerate() {
135        if index > 0 {
136            query_builder.push(format!("{separator} "));
137        }
138        push_condition::<C, DB>(query_builder, condition);
139    }
140}
141
142/// See [`Columns::push_order_by`].
143pub fn push_order_by<C: Columns + ?Sized, DB: BindValues, M>(
144    query_builder: &mut QueryBuilder<'_, DB>,
145    sort_keys: SortKeys<M>,
146) {
147    if sort_keys.is_empty() {
148        return;
149    }
150
151    let order_by = sort_keys
152        .into_iter()
153        .map(|SortKey { field, direction }| {
154            format!("{} {}", column::<C>(field), sql_direction(direction))
155        })
156        .collect::<Vec<_>>()
157        .join(", ");
158    query_builder.push(format!("ORDER BY {order_by} "));
159}
160
161/// See [`Columns::push_assignments`].
162#[cfg(feature = "merge-patch")]
163pub fn push_assignments<'qb, 'args, C: Columns + ?Sized, DB: BindValues, M>(
164    query_builder: &'qb mut QueryBuilder<'args, DB>,
165    assignments: FieldValues<M>,
166) -> Separated<'qb, 'args, DB, &'static str> {
167    let mut separated = query_builder.separated(", ");
168
169    for FieldValue { field, value } in assignments {
170        separated.push(format!("{} = ", column::<C>(field)));
171        DB::push_bind_value_unseparated(&mut separated, value);
172    }
173
174    separated
175}
176
177/// The row offset of page `page` (0 based) of `page_size` rows, saturated at
178/// `i64::MAX`: `u32 * u32` always fits a `u64`, and an offset past every
179/// database's signed 64-bit row count just yields an empty page.
180fn offset(page: u32, page_size: u32) -> i64 {
181    i64::try_from(u64::from(page) * u64::from(page_size)).unwrap_or(i64::MAX)
182}
183
184/// Push a `LIMIT page_size OFFSET page * page_size` clause, binding both, when
185/// both a page and a page size are requested; nothing otherwise.
186pub fn push_page<DB: BindValues>(
187    query_builder: &mut QueryBuilder<'_, DB>,
188    page: Option<u32>,
189    page_size: Option<u32>,
190) {
191    let (Some(page), Some(page_size)) = (page, page_size) else {
192        return;
193    };
194
195    query_builder.push("LIMIT ");
196    DB::push_bind_value(query_builder, Value::I64(Some(i64::from(page_size))));
197    query_builder.push(" OFFSET ");
198    DB::push_bind_value(query_builder, Value::I64(Some(offset(page, page_size))));
199}
200
201#[cfg(test)]
202mod tests {
203    use super::{escape_like, like_pattern, offset};
204    use crate::{BindValues, Columns, push_page};
205    use queryweave::{
206        FieldValue, FilterCondition, FilterOperator, PersistedFields, SortDirectionModel, SortKey,
207        Value,
208    };
209    use rstest::rstest;
210    use sqlx::{MySql, Postgres, QueryBuilder, Sqlite};
211
212    /// Each field stored in the column of its name, except `delivery_date`,
213    /// stored in `delivered_at`.
214    struct Table;
215
216    impl Columns for Table {
217        const NAME: &'static str = "Table";
218        const COLUMNS: &'static [(&'static str, &'static str)] = &[
219            ("number", "number"),
220            ("client", "client"),
221            ("capacity", "capacity"),
222            ("value", "value"),
223            ("is_closed", "is_closed"),
224            ("user_count", "user_count"),
225            ("is_sweet", "is_sweet"),
226            ("is_equal_to", "is_equal_to"),
227            ("name_c", "name_c"),
228            ("delivery_date", "delivered_at"),
229        ];
230    }
231
232    /// A model whose every field has a column on [`Table`].
233    struct Model;
234
235    impl PersistedFields for Model {
236        const FIELD_NAMES: &'static [&'static str] = &[
237            "number",
238            "client",
239            "capacity",
240            "value",
241            "is_closed",
242            "user_count",
243            "is_sweet",
244            "is_equal_to",
245            "name_c",
246            "delivery_date",
247        ];
248    }
249
250    fn condition(field: &'static str, operator: FilterOperator) -> FilterCondition {
251        FilterCondition {
252            field,
253            operator,
254            value: Value::Text(Some(String::from("needle"))),
255        }
256    }
257
258    fn conditions_sql<DB: BindValues>(conditions: Vec<FilterCondition>) -> String {
259        let mut query_builder = QueryBuilder::<DB>::default();
260        Table::push_conditions(
261            &mut query_builder,
262            queryweave::__private::model_data::<Model, _>(conditions),
263            "AND",
264        );
265        query_builder.sql().to_string()
266    }
267
268    fn order_by_sql<DB: BindValues>(sort_keys: Vec<SortKey>) -> String {
269        let mut query_builder = QueryBuilder::<DB>::default();
270        Table::push_order_by(
271            &mut query_builder,
272            queryweave::__private::model_data::<Model, _>(sort_keys),
273        );
274        query_builder.sql().to_string()
275    }
276
277    fn page_sql<DB: BindValues>(page: Option<u32>, page_size: Option<u32>) -> String {
278        let mut query_builder = QueryBuilder::<DB>::default();
279        push_page(&mut query_builder, page, page_size);
280        query_builder.sql().to_string()
281    }
282
283    /// Conditions, sorting and a page, as one `WHERE … ORDER BY … LIMIT …`
284    /// tail.
285    fn query_tail_sql<DB: BindValues>() -> String {
286        let mut query_builder = QueryBuilder::<DB>::default();
287        Table::push_conditions(
288            &mut query_builder,
289            queryweave::__private::model_data::<Model, _>(vec![
290                condition("number", FilterOperator::Equal),
291                condition("client", FilterOperator::StartsWith),
292            ]),
293            "AND",
294        );
295        Table::push_order_by(
296            &mut query_builder,
297            queryweave::__private::model_data::<Model, _>(vec![SortKey {
298                field: "delivery_date",
299                direction: SortDirectionModel::Descending,
300            }]),
301        );
302        push_page(&mut query_builder, Some(2), Some(10));
303        query_builder.sql().to_string()
304    }
305
306    // The same data renders the same SQL on every database, with its own
307    // placeholders.
308    #[test]
309    fn every_database_gets_the_same_sql_with_its_own_placeholders() {
310        assert_eq!(
311            query_tail_sql::<Postgres>(),
312            "number = $1 AND client LIKE $2 ESCAPE '!' ORDER BY delivered_at DESC LIMIT $3 OFFSET $4"
313        );
314        let with_question_marks =
315            "number = ? AND client LIKE ? ESCAPE '!' ORDER BY delivered_at DESC LIMIT ? OFFSET ?";
316        assert_eq!(query_tail_sql::<MySql>(), with_question_marks);
317        assert_eq!(query_tail_sql::<Sqlite>(), with_question_marks);
318    }
319
320    #[test]
321    fn assignments_render_on_every_database() {
322        fn assignments_sql<DB: BindValues>() -> String {
323            let mut query_builder = QueryBuilder::<DB>::new("UPDATE t SET ");
324            let _ = Table::push_assignments(
325                &mut query_builder,
326                queryweave::__private::model_data::<Model, _>(vec![
327                    FieldValue {
328                        field: "number",
329                        value: Value::Text(None),
330                    },
331                    FieldValue {
332                        field: "delivery_date",
333                        value: Value::DateTimeUtc(None),
334                    },
335                ]),
336            );
337            query_builder.sql().to_string()
338        }
339
340        assert_eq!(
341            assignments_sql::<Postgres>(),
342            "UPDATE t SET number = $1, delivered_at = $2"
343        );
344        assert_eq!(
345            assignments_sql::<MySql>(),
346            "UPDATE t SET number = ?, delivered_at = ?"
347        );
348        assert_eq!(
349            assignments_sql::<Sqlite>(),
350            "UPDATE t SET number = ?, delivered_at = ?"
351        );
352    }
353
354    // A renamed field is filtered, sorted and written by its column.
355    #[test]
356    fn a_renamed_field_is_written_as_its_column() {
357        assert_eq!(
358            conditions_sql::<Postgres>(vec![condition(
359                "delivery_date",
360                FilterOperator::GreaterThan
361            )]),
362            "delivered_at > $1 "
363        );
364        assert_eq!(
365            order_by_sql::<Postgres>(vec![SortKey {
366                field: "delivery_date",
367                direction: SortDirectionModel::Descending,
368            }]),
369            "ORDER BY delivered_at DESC "
370        );
371    }
372
373    // The operator selects the SQL comparison; the `LIKE` ones name the
374    // escape character of their pattern.
375    #[rstest]
376    #[case(FilterOperator::LessThan, "value < $1 ")]
377    #[case(FilterOperator::GreaterThan, "value > $1 ")]
378    #[case(FilterOperator::Equal, "value = $1 ")]
379    #[case(FilterOperator::StartsWith, "value LIKE $1 ESCAPE '!' ")]
380    #[case(FilterOperator::Contains, "value LIKE $1 ESCAPE '!' ")]
381    #[case(FilterOperator::EndsWith, "value LIKE $1 ESCAPE '!' ")]
382    fn the_operator_selects_the_sql_comparison(
383        #[case] operator: FilterOperator,
384        #[case] expected_sql: &str,
385    ) {
386        assert_eq!(
387            conditions_sql::<Postgres>(vec![condition("value", operator)]),
388            expected_sql
389        );
390    }
391
392    // The column is written verbatim, even when its name merely contains a
393    // filter suffix (the old suffix-stripping turned `is_closed` into
394    // `islosed`).
395    #[rstest]
396    #[case("is_closed")]
397    #[case("user_count")]
398    #[case("is_sweet")]
399    #[case("is_equal_to")]
400    #[case("name_c")]
401    fn the_column_is_written_verbatim(#[case] column: &'static str) {
402        assert_eq!(
403            conditions_sql::<Postgres>(vec![condition(column, FilterOperator::Equal)]),
404            format!("{column} = $1 ")
405        );
406    }
407
408    // Conditions are joined by the separator, and only between them.
409    #[test]
410    fn conditions_are_joined_by_the_separator() {
411        assert_eq!(
412            conditions_sql::<Postgres>(vec![
413                condition("number", FilterOperator::Equal),
414                condition("client", FilterOperator::Equal),
415            ]),
416            "number = $1 AND client = $2 "
417        );
418        assert_eq!(conditions_sql::<Postgres>(Vec::new()), "");
419    }
420
421    // A non-text value is bound as it is, whatever the operator.
422    #[test]
423    fn a_non_text_value_is_bound_with_its_own_type() {
424        let sql = conditions_sql::<Postgres>(vec![FilterCondition {
425            field: "value",
426            operator: FilterOperator::LessThan,
427            value: Value::F64(Some(9.5)),
428        }]);
429
430        assert_eq!(sql, "value < $1 ");
431    }
432
433    // The `LIKE` operators bind the value wrapped in `%`; the comparisons bind
434    // the typed value itself, so they have no pattern. The pattern is a bound
435    // parameter and never appears in the rendered SQL, so it is asserted
436    // through the extracted helper.
437    #[rstest]
438    #[case(FilterOperator::StartsWith, Some("needle%"))]
439    #[case(FilterOperator::Contains, Some("%needle%"))]
440    #[case(FilterOperator::EndsWith, Some("%needle"))]
441    #[case(FilterOperator::Equal, None)]
442    #[case(FilterOperator::LessThan, None)]
443    #[case(FilterOperator::GreaterThan, None)]
444    fn the_like_pattern_matches_the_operator(
445        #[case] operator: FilterOperator,
446        #[case] expected_pattern: Option<&str>,
447    ) {
448        assert_eq!(
449            like_pattern(operator, "needle").as_deref(),
450            expected_pattern
451        );
452    }
453
454    // `LIKE` metacharacters (and the escape character itself) are escaped
455    // with `!`, so they match literally; a backslash is an ordinary character.
456    #[rstest]
457    #[case("%", "!%")]
458    #[case("_", "!_")]
459    #[case("!", "!!")]
460    #[case(r"\", r"\")]
461    #[case(r"50%_off!\now", r"50!%!_off!!\now")]
462    #[case("plain", "plain")]
463    fn metacharacters_are_escaped_with_the_escape_character(
464        #[case] value: &str,
465        #[case] expected: &str,
466    ) {
467        assert_eq!(escape_like(value), expected);
468    }
469
470    // Only the wildcards the operator adds stay live.
471    #[test]
472    fn the_pattern_wraps_the_escaped_text() {
473        assert_eq!(
474            like_pattern(FilterOperator::Contains, "50%").as_deref(),
475            Some("%50!%%")
476        );
477    }
478
479    // Assignments are comma separated, and more can follow on the returned
480    // builder.
481    #[test]
482    fn assignments_are_comma_separated_and_extendable() {
483        let mut query_builder = QueryBuilder::<Postgres>::new("UPDATE orders SET ");
484        let mut separated = Table::push_assignments(
485            &mut query_builder,
486            queryweave::__private::model_data::<Model, _>(vec![
487                FieldValue {
488                    field: "number",
489                    value: Value::Text(None),
490                },
491                FieldValue {
492                    field: "capacity",
493                    value: Value::I32(Some(3)),
494                },
495            ]),
496        );
497        separated
498            .push("last_modified_at = ")
499            .push_bind_unseparated(1_i64);
500
501        assert_eq!(
502            query_builder.sql(),
503            "UPDATE orders SET number = $1, capacity = $2, last_modified_at = $3"
504        );
505    }
506
507    // Sort keys are written in order, each with its direction, comma
508    // separated only between them.
509    #[test]
510    fn order_by_lists_the_sort_keys_in_order() {
511        assert_eq!(
512            order_by_sql::<Postgres>(vec![
513                SortKey {
514                    field: "number",
515                    direction: SortDirectionModel::Ascending,
516                },
517                SortKey {
518                    field: "value",
519                    direction: SortDirectionModel::Descending,
520                },
521            ]),
522            "ORDER BY number ASC, value DESC "
523        );
524    }
525
526    // Without sort keys there is no `ORDER BY` at all.
527    #[test]
528    fn order_by_is_omitted_without_sort_keys() {
529        assert_eq!(order_by_sql::<Postgres>(Vec::new()), "");
530    }
531
532    // A page needs both its number and its size.
533    #[rstest]
534    #[case(Some(1), Some(10), "LIMIT $1 OFFSET $2")]
535    #[case(None, Some(10), "")]
536    #[case(Some(1), None, "")]
537    #[case(None, None, "")]
538    fn a_page_needs_both_its_number_and_size(
539        #[case] page: Option<u32>,
540        #[case] page_size: Option<u32>,
541        #[case] expected_sql: &str,
542    ) {
543        assert_eq!(page_sql::<Postgres>(page, page_size), expected_sql);
544    }
545
546    // The offset is `page * page_size`, saturated rather than overflowing.
547    #[rstest]
548    #[case(0, 10, 0)]
549    #[case(3, 10, 30)]
550    #[case(u32::MAX, 1, 4_294_967_295)]
551    #[case(u32::MAX, u32::MAX, i64::MAX)]
552    fn the_offset_saturates(#[case] page: u32, #[case] page_size: u32, #[case] expected: i64) {
553        assert_eq!(offset(page, page_size), expected);
554    }
555}