Skip to content

Provide null-safe (nullable-argument) extensions for querydsl-kotlin dynamic queries #1845

Description

@HarryJhin

Important Notice

  • I am willing to put in the work and submit a PR to resolve this issue.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions