Ahmed Nobi95
09/13/2026, 10:24 PMquery { } block instead.*
val q = query {
from(UserEntityTable)
where {
UserEntityTable.age gte minAge // gone when minAge is null
UserEntityTable.status eq status // gone when status is null
}
orderBy(UserEntityTable.age, SortDirection.DESC)
}
dao.search(q.toQuery())
(18, "active") → SELECT * FROM users WHERE age >= ? AND status = ? ORDER BY age DESC
(18, null ) → SELECT * FROM users WHERE age >= ? ORDER BY age DESC
(null, null ) → SELECT * FROM users ORDER BY age DESC
A null filter doesn't match NULL — it never reaches the SQL. No if ladders. No (:x IS NULL OR col = :x). No 2ⁿ overloads.
UserEntityTable.age is generated by KSP from your @Entity, so renaming a column breaks the build, not production. Args bind as positional ?. Nothing reflects at runtime, so R8 needs no keep rules.
The catch, since you'd find it in five minutes anyway: it's @RawQuery underneath, so Room no longer verifies the whole statement at compile time. Column references are checked; query logic isn't. Static query? Keep using @Query — it gives you more. This is for the ones @Query can't express, like an ``ORDER BY`` column chosen at runtime.
I didn't want to just claim the alternatives are worse, so the demo app implements the same search four ways — RoomQL, IS NULL OR, 16 overloaded DAOs, raw string concat — and a test asserts all four return identical rows across all 16 filter combinations. A subtly wrong baseline would have made the whole comparison a lie.
Kotlin 2.0.x · Room 2.6–2.7 · minSdk 21 · Apache 2.0
github.com/ahmednobii/RoomQL
Tell me where it breaks — especially query shapes it can't build yet. Whole-row selects only today, so no projections or subqueries.CLOVIS
09/14/2026, 12:21 PMnull ignore the constraint" is too implicit and risks being surprising. I would prefer separate gte (doesn't compile if null) and gteNotNull operators (ignore if null). It's what I've done in KtMongo: ktmongo.opensavvy.dev/features/optional-filters.htmlAhmed Nobi95
09/14/2026, 8:48 PM