use super::convert::{IntoExpr, IntoExprList, IntoIdent};
use super::node::Expr;
#[allow(clippy::wrong_self_convention)]
pub trait Chain: IntoExpr + IntoExprList + Sized {
fn from_expr(e: Expr) -> Self;
#[must_use]
fn step(self, f: impl FnOnce(Expr) -> Expr) -> Self {
Self::from_expr(f(self.into_expr()).grouped())
}
#[must_use]
fn op(self, op: &'static str, rhs: impl IntoExpr) -> Self {
self.step(move |lhs| Expr::binary(lhs, op, rhs))
}
#[must_use]
fn eq(self, rhs: impl IntoExpr) -> Self {
self.op("=", rhs)
}
#[must_use]
fn ne(self, rhs: impl IntoExpr) -> Self {
self.op("<>", rhs)
}
#[must_use]
fn lt(self, rhs: impl IntoExpr) -> Self {
self.op("<", rhs)
}
#[must_use]
fn lte(self, rhs: impl IntoExpr) -> Self {
self.op("<=", rhs)
}
#[must_use]
fn gt(self, rhs: impl IntoExpr) -> Self {
self.op(">", rhs)
}
#[must_use]
fn gte(self, rhs: impl IntoExpr) -> Self {
self.op(">=", rhs)
}
#[must_use]
fn in_(self, vals: impl IntoExprList) -> Self {
self.step(move |lhs| Expr::binary(lhs, "IN", Expr::group(vals)))
}
#[must_use]
fn not_in(self, vals: impl IntoExprList) -> Self {
self.step(move |lhs| Expr::binary(lhs, "NOT IN", Expr::group(vals)))
}
#[must_use]
fn is_null(self) -> Self {
self.step(|lhs| Expr::postfix(lhs, "IS NULL"))
}
#[must_use]
fn is_not_null(self) -> Self {
self.step(|lhs| Expr::postfix(lhs, "IS NOT NULL"))
}
#[must_use]
fn is_distinct_from(self, rhs: impl IntoExpr) -> Self {
self.op("IS DISTINCT FROM", rhs)
}
#[must_use]
fn is_not_distinct_from(self, rhs: impl IntoExpr) -> Self {
self.op("IS NOT DISTINCT FROM", rhs)
}
#[must_use]
fn between(self, a: impl IntoExpr, b: impl IntoExpr) -> Self {
self.step(move |lhs| Expr::join((lhs, Expr::raw("BETWEEN"), a, Expr::raw("AND"), b)))
}
#[must_use]
fn not_between(self, a: impl IntoExpr, b: impl IntoExpr) -> Self {
self.step(move |lhs| Expr::join((lhs, Expr::raw("NOT BETWEEN"), a, Expr::raw("AND"), b)))
}
#[must_use]
fn like(self, rhs: impl IntoExpr) -> Self {
self.op("LIKE", rhs)
}
#[must_use]
fn concat(self, others: impl IntoExprList) -> Self {
self.step(move |lhs| Expr::join_with(" || ", prepend(lhs, others)))
}
#[must_use]
fn and(self, others: impl IntoExprList) -> Self {
self.step(move |lhs| Expr::join_with(" AND ", prepend(lhs, others)))
}
#[must_use]
fn or(self, others: impl IntoExprList) -> Self {
self.step(move |lhs| Expr::join_with(" OR ", prepend(lhs, others)))
}
#[must_use]
fn plus(self, rhs: impl IntoExpr) -> Self {
self.op("+", rhs)
}
#[must_use]
fn minus(self, rhs: impl IntoExpr) -> Self {
self.op("-", rhs)
}
fn as_(self, alias: impl IntoIdent) -> Expr {
Expr::Binary {
lhs: Box::new(self.into_expr()),
op: "AS",
rhs: Box::new(Expr::ident(alias)),
}
}
}
fn prepend(first: Expr, rest: impl IntoExprList) -> Vec<Expr> {
let mut exprs = rest.into_expr_list();
exprs.insert(0, first);
exprs
}
impl Chain for Expr {
fn from_expr(e: Expr) -> Expr {
e
}
}
#[cfg(test)]
mod tests {
use keelson_sqlcheck::testing::assert_frag_sql;
use super::super::{arg, arg_group, literal, quote, raw};
use super::*;
use crate::dialect::testing::{Numbered, TestDialect};
use crate::value::Value;
use crate::writer::build;
const COND: &str = r#"SELECT "id" FROM users WHERE {}"#;
const VALUE: &str = r#"SELECT {} FROM users"#;
const POST_COND: &str = r#"SELECT "id" FROM posts WHERE {}"#;
fn sql(e: Expr) -> String {
build(&Numbered, &e).expect("render").0
}
#[test]
fn a_comparison_is_parenthesised_exactly_once() {
assert_frag_sql(COND, &sql(quote("age").gte(arg(21i32))), r#"("age" >= $1)"#);
}
#[test]
fn every_comparison_operator_uses_its_standard_spelling() {
let conditions = [
(quote("age").eq(raw("id")), r#"("age" = id)"#),
(quote("age").ne(raw("id")), r#"("age" <> id)"#),
(quote("age").lt(raw("id")), r#"("age" < id)"#),
(quote("age").lte(raw("id")), r#"("age" <= id)"#),
(quote("age").gt(raw("id")), r#"("age" > id)"#),
(quote("age").gte(raw("id")), r#"("age" >= id)"#),
(quote("name").like(literal("b%")), r#"("name" LIKE 'b%')"#),
];
for (e, expected) in conditions {
assert_frag_sql(COND, &sql(e), expected);
}
let values = [
(quote("age").plus(1i32), r#"("age" + 1)"#),
(quote("age").minus(quote("id")), r#"("age" - "id")"#),
];
for (e, expected) in values {
assert_frag_sql(VALUE, &sql(e), expected);
}
assert_frag_sql(
COND,
&sql(raw("ARRAY[1, 2]").op("@>", raw("ARRAY[1]"))),
"(ARRAY[1, 2] @> ARRAY[1])",
);
}
#[test]
fn null_tests_are_postfix() {
assert_frag_sql(COND, &sql(quote("age").is_null()), r#"("age" IS NULL)"#);
assert_frag_sql(
COND,
&sql(quote("age").is_not_null()),
r#"("age" IS NOT NULL)"#,
);
}
#[test]
fn distinct_from_is_an_infix_keyword_operator() {
assert_frag_sql(
COND,
&sql(quote("age").is_distinct_from(quote("id"))),
r#"("age" IS DISTINCT FROM "id")"#,
);
assert_frag_sql(
COND,
&sql(quote("age").is_not_distinct_from(quote("id"))),
r#"("age" IS NOT DISTINCT FROM "id")"#,
);
}
#[test]
fn between_keeps_its_three_part_shape() {
assert_frag_sql(
COND,
&sql(quote("age").between(arg(1i32), arg(2i32))),
r#"("age" BETWEEN $1 AND $2)"#,
);
assert_frag_sql(
COND,
&sql(quote("age").not_between(arg(1i32), arg(2i32))),
r#"("age" NOT BETWEEN $1 AND $2)"#,
);
}
#[test]
fn in_always_parenthesises_its_operands() {
assert_frag_sql(
POST_COND,
&sql(quote("status").in_((literal("A"), literal("B")))),
r#"("status" IN ('A', 'B'))"#,
);
assert_frag_sql(
COND,
&sql(quote("id").not_in(arg(1i32))),
r#"("id" NOT IN ($1))"#,
);
}
#[test]
fn a_row_constructor_in_a_list_of_row_constructors() {
let e = Expr::group((quote("id"), quote("user_id")))
.in_((arg_group([1i32, 2]), arg_group([3i32, 4])));
let (rendered, args) = build(&Numbered, &e).unwrap();
assert_frag_sql(
POST_COND,
&rendered,
r#"(("id", "user_id") IN (($1, $2), ($3, $4)))"#,
);
assert_eq!(args.len(), 4);
}
#[test]
fn boolean_chains_take_one_operand_or_several() {
assert_frag_sql(
COND,
&sql(quote("is_active").and(raw("age > 1"))),
r#"("is_active" AND age > 1)"#,
);
assert_frag_sql(
COND,
&sql(quote("is_active").or((raw("age > 1"), raw("age < 9")))),
r#"("is_active" OR age > 1 OR age < 9)"#,
);
}
#[test]
fn concat_joins_with_the_pipe_operator() {
let e = raw(r#"EXCLUDED."name""#).concat((
literal(" (formerly "),
quote(("tags", "name")),
literal(")"),
));
assert_frag_sql(
r#"INSERT INTO tags ("id", "name") VALUES (1, 'rust') ON CONFLICT ("id") DO UPDATE SET "name" = {}"#,
&sql(e),
r#"(EXCLUDED."name" || ' (formerly ' || "tags"."name" || ')')"#,
);
}
#[test]
fn nesting_a_chain_in_a_chain_adds_no_extra_parentheses() {
let e = quote("age").eq(arg(1i32)).and(quote("id").eq(arg(2i32)));
assert_frag_sql(COND, &sql(e), r#"(("age" = $1) AND ("id" = $2))"#);
}
#[test]
fn an_alias_ends_the_chain_and_is_not_parenthesised() {
let e = quote("age").minus(quote("id")).as_("difference");
assert_frag_sql(VALUE, &sql(e), r#"("age" - "id") AS "difference""#);
}
#[test]
fn arguments_are_numbered_left_to_right_across_a_whole_chain() {
let e = quote("age")
.between(arg(1i32), arg(2i32))
.and(quote("id").in_((arg(3i32), arg(4i32))));
let (rendered, args) = build(&Numbered, &e).unwrap();
assert_frag_sql(
COND,
&rendered,
r#"(("age" BETWEEN $1 AND $2) AND ("id" IN ($3, $4)))"#,
);
assert_eq!(
args,
vec![Value::I32(1), Value::I32(2), Value::I32(3), Value::I32(4)]
);
}
#[test]
fn a_dialect_can_add_an_operator_without_touching_core() {
trait PsqlOps: Chain {
fn contains(self, rhs: impl IntoExpr) -> Self {
self.op("@>", rhs)
}
fn between_symmetric(self, a: impl IntoExpr, b: impl IntoExpr) -> Self {
self.step(move |lhs| {
Expr::join((lhs, Expr::raw("BETWEEN SYMMETRIC"), a, Expr::raw("AND"), b))
})
}
}
impl<T: Chain> PsqlOps for T {}
assert_frag_sql(
COND,
&sql(raw("ARRAY[1, 2]").contains(raw("ARRAY[1]"))),
"(ARRAY[1, 2] @> ARRAY[1])",
);
assert_frag_sql(
COND,
&sql(quote("age").between_symmetric(arg(1i32), arg(2i32))),
r#"("age" BETWEEN SYMMETRIC $1 AND $2)"#,
);
}
#[test]
fn a_dialect_newtype_keeps_the_whole_chain_in_its_own_type() {
#[derive(Debug, Clone)]
struct SqliteExpr(Expr);
impl IntoExpr for SqliteExpr {
fn into_expr(self) -> Expr {
self.0
}
}
impl IntoExprList for SqliteExpr {
fn into_expr_list(self) -> Vec<Expr> {
vec![self.0]
}
}
impl Chain for SqliteExpr {
fn from_expr(e: Expr) -> Self {
SqliteExpr(e)
}
}
impl SqliteExpr {
fn glob(self, rhs: impl IntoExpr) -> Self {
self.op("GLOB", rhs)
}
}
let e = SqliteExpr::from_expr(Expr::ident("name"))
.glob(literal("a*"))
.and(SqliteExpr::from_expr(Expr::ident("b")).is_null());
let (s, _) = build(&TestDialect, &e.into_expr()).unwrap();
assert_eq!(s, r#"(("name" GLOB 'a*') AND ("b" IS NULL))"#);
}
}