1use 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
19const LIKE_ESCAPE: char = '!';
25
26#[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#[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
46pub 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
64pub 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
79fn column<C: Columns + ?Sized>(field: &'static str) -> &'static str {
87 C::column(field).unwrap_or(field)
88}
89
90fn 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 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
128pub 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
142pub 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#[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
177fn offset(page: u32, page_size: u32) -> i64 {
181 i64::try_from(u64::from(page) * u64::from(page_size)).unwrap_or(i64::MAX)
182}
183
184pub 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
528 fn order_by_is_omitted_without_sort_keys() {
529 assert_eq!(order_by_sql::<Postgres>(Vec::new()), "");
530 }
531
532 #[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 #[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}