use crate::{BindValues, Columns};
use queryweave::{
Conditions, FilterCondition, FilterOperator, SortDirectionModel, SortKey, SortKeys, Value,
};
#[cfg(feature = "merge-patch")]
use queryweave::{FieldValue, FieldValues};
use sqlx::QueryBuilder;
#[cfg(feature = "merge-patch")]
use sqlx::query_builder::Separated;
const LIKE_ESCAPE: char = '!';
#[must_use]
pub const fn sql_operator(operator: FilterOperator) -> &'static str {
match operator {
FilterOperator::LessThan => "<",
FilterOperator::Equal => "=",
FilterOperator::GreaterThan => ">",
FilterOperator::StartsWith | FilterOperator::Contains | FilterOperator::EndsWith => "LIKE",
}
}
#[must_use]
pub const fn sql_direction(direction: SortDirectionModel) -> &'static str {
match direction {
SortDirectionModel::Ascending => "ASC",
SortDirectionModel::Descending => "DESC",
}
}
pub fn like_pattern(operator: FilterOperator, text: &str) -> Option<String> {
let text = escape_like(text);
match operator {
FilterOperator::StartsWith => Some(format!("{text}%")),
FilterOperator::Contains => Some(format!("%{text}%")),
FilterOperator::EndsWith => Some(format!("%{text}")),
FilterOperator::LessThan | FilterOperator::Equal | FilterOperator::GreaterThan => None,
}
}
pub fn escape_like(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
if matches!(character, '%' | '_') || character == LIKE_ESCAPE {
escaped.push(LIKE_ESCAPE);
}
escaped.push(character);
}
escaped
}
fn column<C: Columns + ?Sized>(field: &'static str) -> &'static str {
C::column(field).unwrap_or(field)
}
fn push_condition<C: Columns + ?Sized, DB: BindValues>(
query_builder: &mut QueryBuilder<'_, DB>,
condition: FilterCondition,
) {
let FilterCondition {
field,
operator,
value,
} = condition;
query_builder.push(format!(
"{} {} ",
column::<C>(field),
sql_operator(operator)
));
match value {
Value::Text(Some(text)) => match like_pattern(operator, &text) {
Some(pattern) => {
DB::push_bind_value(query_builder, Value::Text(Some(pattern)));
query_builder.push(format!(" ESCAPE '{LIKE_ESCAPE}'"));
},
None => DB::push_bind_value(query_builder, Value::Text(Some(text))),
},
value => DB::push_bind_value(query_builder, value),
}
query_builder.push(" ");
}
pub fn push_conditions<C: Columns + ?Sized, DB: BindValues, M>(
query_builder: &mut QueryBuilder<'_, DB>,
conditions: Conditions<M>,
separator: &str,
) {
for (index, condition) in conditions.into_iter().enumerate() {
if index > 0 {
query_builder.push(format!("{separator} "));
}
push_condition::<C, DB>(query_builder, condition);
}
}
pub fn push_order_by<C: Columns + ?Sized, DB: BindValues, M>(
query_builder: &mut QueryBuilder<'_, DB>,
sort_keys: SortKeys<M>,
) {
if sort_keys.is_empty() {
return;
}
let order_by = sort_keys
.into_iter()
.map(|SortKey { field, direction }| {
format!("{} {}", column::<C>(field), sql_direction(direction))
})
.collect::<Vec<_>>()
.join(", ");
query_builder.push(format!("ORDER BY {order_by} "));
}
#[cfg(feature = "merge-patch")]
pub fn push_assignments<'qb, 'args, C: Columns + ?Sized, DB: BindValues, M>(
query_builder: &'qb mut QueryBuilder<'args, DB>,
assignments: FieldValues<M>,
) -> Separated<'qb, 'args, DB, &'static str> {
let mut separated = query_builder.separated(", ");
for FieldValue { field, value } in assignments {
separated.push(format!("{} = ", column::<C>(field)));
DB::push_bind_value_unseparated(&mut separated, value);
}
separated
}
fn offset(page: u32, page_size: u32) -> i64 {
i64::try_from(u64::from(page) * u64::from(page_size)).unwrap_or(i64::MAX)
}
pub fn push_page<DB: BindValues>(
query_builder: &mut QueryBuilder<'_, DB>,
page: Option<u32>,
page_size: Option<u32>,
) {
let (Some(page), Some(page_size)) = (page, page_size) else {
return;
};
query_builder.push("LIMIT ");
DB::push_bind_value(query_builder, Value::I64(Some(i64::from(page_size))));
query_builder.push(" OFFSET ");
DB::push_bind_value(query_builder, Value::I64(Some(offset(page, page_size))));
}
#[cfg(test)]
mod tests {
use super::{escape_like, like_pattern, offset};
use crate::{BindValues, Columns, push_page};
use queryweave::{
FieldValue, FilterCondition, FilterOperator, PersistedFields, SortDirectionModel, SortKey,
Value,
};
use rstest::rstest;
use sqlx::{MySql, Postgres, QueryBuilder, Sqlite};
struct Table;
impl Columns for Table {
const NAME: &'static str = "Table";
const COLUMNS: &'static [(&'static str, &'static str)] = &[
("number", "number"),
("client", "client"),
("capacity", "capacity"),
("value", "value"),
("is_closed", "is_closed"),
("user_count", "user_count"),
("is_sweet", "is_sweet"),
("is_equal_to", "is_equal_to"),
("name_c", "name_c"),
("delivery_date", "delivered_at"),
];
}
struct Model;
impl PersistedFields for Model {
const FIELD_NAMES: &'static [&'static str] = &[
"number",
"client",
"capacity",
"value",
"is_closed",
"user_count",
"is_sweet",
"is_equal_to",
"name_c",
"delivery_date",
];
}
fn condition(field: &'static str, operator: FilterOperator) -> FilterCondition {
FilterCondition {
field,
operator,
value: Value::Text(Some(String::from("needle"))),
}
}
fn conditions_sql<DB: BindValues>(conditions: Vec<FilterCondition>) -> String {
let mut query_builder = QueryBuilder::<DB>::default();
Table::push_conditions(
&mut query_builder,
queryweave::__private::model_data::<Model, _>(conditions),
"AND",
);
query_builder.sql().to_string()
}
fn order_by_sql<DB: BindValues>(sort_keys: Vec<SortKey>) -> String {
let mut query_builder = QueryBuilder::<DB>::default();
Table::push_order_by(
&mut query_builder,
queryweave::__private::model_data::<Model, _>(sort_keys),
);
query_builder.sql().to_string()
}
fn page_sql<DB: BindValues>(page: Option<u32>, page_size: Option<u32>) -> String {
let mut query_builder = QueryBuilder::<DB>::default();
push_page(&mut query_builder, page, page_size);
query_builder.sql().to_string()
}
fn query_tail_sql<DB: BindValues>() -> String {
let mut query_builder = QueryBuilder::<DB>::default();
Table::push_conditions(
&mut query_builder,
queryweave::__private::model_data::<Model, _>(vec![
condition("number", FilterOperator::Equal),
condition("client", FilterOperator::StartsWith),
]),
"AND",
);
Table::push_order_by(
&mut query_builder,
queryweave::__private::model_data::<Model, _>(vec![SortKey {
field: "delivery_date",
direction: SortDirectionModel::Descending,
}]),
);
push_page(&mut query_builder, Some(2), Some(10));
query_builder.sql().to_string()
}
#[test]
fn every_database_gets_the_same_sql_with_its_own_placeholders() {
assert_eq!(
query_tail_sql::<Postgres>(),
"number = $1 AND client LIKE $2 ESCAPE '!' ORDER BY delivered_at DESC LIMIT $3 OFFSET $4"
);
let with_question_marks =
"number = ? AND client LIKE ? ESCAPE '!' ORDER BY delivered_at DESC LIMIT ? OFFSET ?";
assert_eq!(query_tail_sql::<MySql>(), with_question_marks);
assert_eq!(query_tail_sql::<Sqlite>(), with_question_marks);
}
#[test]
fn assignments_render_on_every_database() {
fn assignments_sql<DB: BindValues>() -> String {
let mut query_builder = QueryBuilder::<DB>::new("UPDATE t SET ");
let _ = Table::push_assignments(
&mut query_builder,
queryweave::__private::model_data::<Model, _>(vec![
FieldValue {
field: "number",
value: Value::Text(None),
},
FieldValue {
field: "delivery_date",
value: Value::DateTimeUtc(None),
},
]),
);
query_builder.sql().to_string()
}
assert_eq!(
assignments_sql::<Postgres>(),
"UPDATE t SET number = $1, delivered_at = $2"
);
assert_eq!(
assignments_sql::<MySql>(),
"UPDATE t SET number = ?, delivered_at = ?"
);
assert_eq!(
assignments_sql::<Sqlite>(),
"UPDATE t SET number = ?, delivered_at = ?"
);
}
#[test]
fn a_renamed_field_is_written_as_its_column() {
assert_eq!(
conditions_sql::<Postgres>(vec![condition(
"delivery_date",
FilterOperator::GreaterThan
)]),
"delivered_at > $1 "
);
assert_eq!(
order_by_sql::<Postgres>(vec![SortKey {
field: "delivery_date",
direction: SortDirectionModel::Descending,
}]),
"ORDER BY delivered_at DESC "
);
}
#[rstest]
#[case(FilterOperator::LessThan, "value < $1 ")]
#[case(FilterOperator::GreaterThan, "value > $1 ")]
#[case(FilterOperator::Equal, "value = $1 ")]
#[case(FilterOperator::StartsWith, "value LIKE $1 ESCAPE '!' ")]
#[case(FilterOperator::Contains, "value LIKE $1 ESCAPE '!' ")]
#[case(FilterOperator::EndsWith, "value LIKE $1 ESCAPE '!' ")]
fn the_operator_selects_the_sql_comparison(
#[case] operator: FilterOperator,
#[case] expected_sql: &str,
) {
assert_eq!(
conditions_sql::<Postgres>(vec![condition("value", operator)]),
expected_sql
);
}
#[rstest]
#[case("is_closed")]
#[case("user_count")]
#[case("is_sweet")]
#[case("is_equal_to")]
#[case("name_c")]
fn the_column_is_written_verbatim(#[case] column: &'static str) {
assert_eq!(
conditions_sql::<Postgres>(vec![condition(column, FilterOperator::Equal)]),
format!("{column} = $1 ")
);
}
#[test]
fn conditions_are_joined_by_the_separator() {
assert_eq!(
conditions_sql::<Postgres>(vec![
condition("number", FilterOperator::Equal),
condition("client", FilterOperator::Equal),
]),
"number = $1 AND client = $2 "
);
assert_eq!(conditions_sql::<Postgres>(Vec::new()), "");
}
#[test]
fn a_non_text_value_is_bound_with_its_own_type() {
let sql = conditions_sql::<Postgres>(vec![FilterCondition {
field: "value",
operator: FilterOperator::LessThan,
value: Value::F64(Some(9.5)),
}]);
assert_eq!(sql, "value < $1 ");
}
#[rstest]
#[case(FilterOperator::StartsWith, Some("needle%"))]
#[case(FilterOperator::Contains, Some("%needle%"))]
#[case(FilterOperator::EndsWith, Some("%needle"))]
#[case(FilterOperator::Equal, None)]
#[case(FilterOperator::LessThan, None)]
#[case(FilterOperator::GreaterThan, None)]
fn the_like_pattern_matches_the_operator(
#[case] operator: FilterOperator,
#[case] expected_pattern: Option<&str>,
) {
assert_eq!(
like_pattern(operator, "needle").as_deref(),
expected_pattern
);
}
#[rstest]
#[case("%", "!%")]
#[case("_", "!_")]
#[case("!", "!!")]
#[case(r"\", r"\")]
#[case(r"50%_off!\now", r"50!%!_off!!\now")]
#[case("plain", "plain")]
fn metacharacters_are_escaped_with_the_escape_character(
#[case] value: &str,
#[case] expected: &str,
) {
assert_eq!(escape_like(value), expected);
}
#[test]
fn the_pattern_wraps_the_escaped_text() {
assert_eq!(
like_pattern(FilterOperator::Contains, "50%").as_deref(),
Some("%50!%%")
);
}
#[test]
fn assignments_are_comma_separated_and_extendable() {
let mut query_builder = QueryBuilder::<Postgres>::new("UPDATE orders SET ");
let mut separated = Table::push_assignments(
&mut query_builder,
queryweave::__private::model_data::<Model, _>(vec![
FieldValue {
field: "number",
value: Value::Text(None),
},
FieldValue {
field: "capacity",
value: Value::I32(Some(3)),
},
]),
);
separated
.push("last_modified_at = ")
.push_bind_unseparated(1_i64);
assert_eq!(
query_builder.sql(),
"UPDATE orders SET number = $1, capacity = $2, last_modified_at = $3"
);
}
#[test]
fn order_by_lists_the_sort_keys_in_order() {
assert_eq!(
order_by_sql::<Postgres>(vec![
SortKey {
field: "number",
direction: SortDirectionModel::Ascending,
},
SortKey {
field: "value",
direction: SortDirectionModel::Descending,
},
]),
"ORDER BY number ASC, value DESC "
);
}
#[test]
fn order_by_is_omitted_without_sort_keys() {
assert_eq!(order_by_sql::<Postgres>(Vec::new()), "");
}
#[rstest]
#[case(Some(1), Some(10), "LIMIT $1 OFFSET $2")]
#[case(None, Some(10), "")]
#[case(Some(1), None, "")]
#[case(None, None, "")]
fn a_page_needs_both_its_number_and_size(
#[case] page: Option<u32>,
#[case] page_size: Option<u32>,
#[case] expected_sql: &str,
) {
assert_eq!(page_sql::<Postgres>(page, page_size), expected_sql);
}
#[rstest]
#[case(0, 10, 0)]
#[case(3, 10, 30)]
#[case(u32::MAX, 1, 4_294_967_295)]
#[case(u32::MAX, u32::MAX, i64::MAX)]
fn the_offset_saturates(#[case] page: u32, #[case] page_size: u32, #[case] expected: i64) {
assert_eq!(offset(page, page_size), expected);
}
}