Important Notice
Is your feature request related to a problem? Please describe.
Building dynamic queries in Kotlin is more verbose than in Java. QueryDSL's where(...)
already ignores null predicates (DefaultQueryMetadata.addWhere returns early on null),
so in Java a null-yielding ternary is enough:
// Java: one line, where() skips the null
where(name != null ? member.name.eq(name) : null);
Kotlin has no ternary operator, so the same intent forces one of:
// 1) if-else expression (verbose, repeated per field)
where(if (name != null) member.name.eq(name) else null)
// 2) ?.let wrapping (repeated per field)
where(name?.let { member.name.eq(it) })
// 3) BooleanBuilder (imperative)
val b = BooleanBuilder()
if (name != null) b.and(member.name.eq(name))
if (age != null) b.and(member.age.gt(age))
// 4) hand-written helper per field
fun nameEq(name: String?) = name?.let { member.name.eq(it) }
The existing querydsl-kotlin module (ExpressionExtensions.kt) provides operator
overloads (and/or/xor, + - * /) but every signature is non-null, so it does not
help with the "skip this condition when the argument is null" case that dominates
dynamic-query code.
Describe the solution you'd like
Add null-safe overloads to querydsl-kotlin whose argument is nullable and which return
null (so where(...) transparently drops them). For example:
The overloads reuse the existing names (eq, gt, goe, lt, ne, in, like, ...) with a
nullable receiver and nullable argument, returning null to be dropped by where(...):
infix fun <T> SimpleExpression<T>?.eq(value: T?): BooleanExpression? =
if (this == null || value == null) null else this.eq(value)
infix fun <T : Comparable<T>> ComparableExpression<T>?.gt(value: T?): BooleanExpression? =
if (this == null || value == null) null else this.gt(value)
// ... ne, loe, goe, lt, in, like, contains, etc.
Usage becomes one line per condition, no if-else / BooleanBuilder / per-field helper:
where(
member.name eq name, // name: String? -> skipped when null
member.age gt age, // age: Int? -> skipped when null
)
Each comparison/equality helper comes in a value form and an expression form
(member.age gt age and member.age gt member.minAge), covering both parameter and
column-to-column comparisons.
No separate naming is needed. For eq/gt/between/in (backed by QueryDSL member
methods), a non-null call resolves to the member (member-over-extension), and the nullable
extension only kicks in when an argument is nullable, so existing code is unaffected.
The strongest case is a multi-argument condition such as a range filter, where each of
the two bounds is judged independently. A single-argument helper is "apply or skip"; a range
is "apply / degrade to one-sided / skip". Standard QueryDSL between(from, to) requires both
bounds non-null and has no partial-range behavior, so in Kotlin an optional range filter today
means a nested if-else:
// today
val b = BooleanBuilder()
if (from != null && to != null) b.and(entity.createdAt.between(from, to))
else if (from != null) b.and(entity.createdAt.goe(from))
else if (to != null) b.and(entity.createdAt.loe(to))
// (to is null && from is null) -> nothing
A null-safe range extension collapses all four branches into one call:
infix fun <T : Comparable<T>> ComparableExpression<T>?.between(range: Pair<T?, T?>): BooleanExpression? {
val (from, to) = range
return when {
this == null -> null
from != null && to != null -> this.between(from, to) // BETWEEN from AND to
from != null -> this.goe(from) // >= from (lower bound only)
to != null -> this.loe(to) // <= to (upper bound only)
else -> null // skip
}
}
// call site: from and to are both nullable, each judged independently
where(entity.createdAt between (from to to))
Resulting SQL by input:
-- from='2024-01-01', to='2024-12-31' -> created_at BETWEEN '2024-01-01' AND '2024-12-31'
-- from='2024-01-01', to=null -> created_at >= '2024-01-01'
-- from=null, to='2024-12-31' -> created_at <= '2024-12-31'
-- from=null, to=null -> (condition skipped)
notBetween mirrors this (both -> NOT BETWEEN, lower-only -> <, upper-only -> >, neither -> skip),
and a ClosedRange overload (age between (20..60)) covers the both-bounds-present case.
Notes on the change and how it splits by compatibility:
eq/ne/gt/goe/lt/loe/between/in/like/contains are backed by QueryDSL
member methods, so adding a nullable extension is purely additive — member-over-extension
keeps existing non-null calls intact.
and/or/xor/not already exist here as top-level (non-null) extensions. Folding in a
nullable version means the nullable one replaces/absorbs the non-null one (the non-null case
is a strict subset), which changes their return type to BooleanExpression?. That is a
source-breaking change, to be verified it compiles without ambiguity.
- So the surface splits cleanly into an additive group and a breaking group, placeable wherever
fits your release cadence (version placement is your call). The additive group can ship on its
own first; the breaking group would fit a major bump with a Migration Guide entry.
Describe alternatives you've considered
BooleanBuilder / if-else / ?.let — works but verbose and repeated at every call site.
- Per-field helper functions — what most Kotlin users end up writing; this issue is
essentially a request to standardize that pattern in the library.
- Third-party libraries (e.g. querydsl-ktx and similar) — several exist, which itself
signals demand, but fragments the ecosystem instead of covering it in the official Kotlin module.
Important Notice
Is your feature request related to a problem? Please describe.
Building dynamic queries in Kotlin is more verbose than in Java. QueryDSL's
where(...)already ignores
nullpredicates (DefaultQueryMetadata.addWherereturns early onnull),so in Java a
null-yielding ternary is enough:Kotlin has no ternary operator, so the same intent forces one of:
The existing
querydsl-kotlinmodule (ExpressionExtensions.kt) provides operatoroverloads (
and/or/xor,+ - * /) but every signature is non-null, so it does nothelp with the "skip this condition when the argument is null" case that dominates
dynamic-query code.
Describe the solution you'd like
Add null-safe overloads to
querydsl-kotlinwhose argument is nullable and which returnnull(sowhere(...)transparently drops them). For example:The overloads reuse the existing names (
eq,gt,goe,lt,ne,in,like, ...) with anullable receiver and nullable argument, returning
nullto be dropped bywhere(...):Usage becomes one line per condition, no if-else / BooleanBuilder / per-field helper:
where( member.name eq name, // name: String? -> skipped when null member.age gt age, // age: Int? -> skipped when null )Each comparison/equality helper comes in a value form and an expression form
(
member.age gt ageandmember.age gt member.minAge), covering both parameter andcolumn-to-column comparisons.
No separate naming is needed. For
eq/gt/between/in(backed by QueryDSL membermethods), a non-null call resolves to the member (member-over-extension), and the nullable
extension only kicks in when an argument is nullable, so existing code is unaffected.
The strongest case is a multi-argument condition such as a range filter, where each of
the two bounds is judged independently. A single-argument helper is "apply or skip"; a range
is "apply / degrade to one-sided / skip". Standard QueryDSL
between(from, to)requires bothbounds non-null and has no partial-range behavior, so in Kotlin an optional range filter today
means a nested if-else:
A null-safe range extension collapses all four branches into one call:
// call site: from and to are both nullable, each judged independently where(entity.createdAt between (from to to))Resulting SQL by input:
notBetweenmirrors this (both -> NOT BETWEEN, lower-only -><, upper-only ->>, neither -> skip),and a
ClosedRangeoverload (age between (20..60)) covers the both-bounds-present case.Notes on the change and how it splits by compatibility:
eq/ne/gt/goe/lt/loe/between/in/like/containsare backed by QueryDSLmember methods, so adding a nullable extension is purely additive — member-over-extension
keeps existing non-null calls intact.
and/or/xor/notalready exist here as top-level (non-null) extensions. Folding in anullable version means the nullable one replaces/absorbs the non-null one (the non-null case
is a strict subset), which changes their return type to
BooleanExpression?. That is asource-breaking change, to be verified it compiles without ambiguity.
fits your release cadence (version placement is your call). The additive group can ship on its
own first; the breaking group would fit a major bump with a Migration Guide entry.
Describe alternatives you've considered
BooleanBuilder/ if-else /?.let— works but verbose and repeated at every call site.essentially a request to standardize that pattern in the library.
signals demand, but fragments the ecosystem instead of covering it in the official Kotlin module.