Every query clause exists twice: a root-typed form (where, orderBy, groupBy, having) and an Any twin that accepts paths from other entities. The split is not a design choice — Java erasure forbids overloading where(Navigable<T, V>) with where(Navigable<?, V>), so the second form needs a second name. That decision then echoes across four surfaces: ~100 Any members over storm-core, storm-java21, the Kotlin chained builder, its WhereBuilders and the select { } block DSL, plus a three-rung "WHERE form ladder" in the docs and skills teaching users which name to pick per clause.
Usage data says the ladder solves a problem nobody has. In a production codebase built on Storm: every Any call site co-occurs with an explicit join (123 of 123 function bodies); not one of 82 bodies using whereAny also uses a typed where in the same query; four of the seven Any families are never used at all.
Design: a join widens the query
The root type tracks what a clause may reference, instead of asking the user to pick a method name:
- Joins return
QueryBuilder<Data, R, ID> (TypedJoinBuilder.on, JoinBuilder.on, crossJoin). Clause parameters widen to Navigable<? extends T, V> (Kotlin: out T), so after a join every clause accepts paths from any entity in the query — and every existing root-typed call site keeps compiling, because Navigable<User, V> satisfies Navigable<? extends Data, V>.
- The
Any variants are deleted from all surfaces. Their bodies were the plain bodies; core's own comment said the split existed "to provide (more) type safety" over identical logic.
narrow(rootType) narrows a widened builder back for the operations that are defined relative to the root — getResultGroupedBy after a join in particular. The root is verified against the query's FROM table at runtime, mirroring typedId (renamed from typed; the pair now names the type parameter each narrows). The rename also fixed a latent bug: typed(null) threw NPE while building its own error message.
fetch(...) stays root-typed, which makes the types enforce the intended order: right after select(), before any join.
- The
select { } block is widened from the start — a var cannot change type mid-block, so per-join widening cannot be expressed there. The scope's own type parameters keep where(record), where(id) and where(ref) typed to the entity. The block returns the widened builder: narrowing on return would reject chained clauses on the block's own joins.
- A path on an entity outside the query fails at query time with
Cannot find alias for column x: X is not part of this query rooted at Y. Join the table or correct the metamodel path. The compile-time root check moves to this error only from the join onward; before the join it still does not compile.
What is deliberately given up
- Short form through the entity graph without a join (
whereAny(Country_.name eq …) on an unjoined query) has no typed route anymore. The nested path (User_.city.country.name), the block DSL, or a join covers it; zero real-world call sites used it.
- The pathless cross-entity record match on an unjoined query (
where(it -> it.whereAny(owner))) is likewise inexpressible pre-join. The path form (where(Visit_.pet.owner, EQUALS, owner)) states the same match explicitly; the runtime graph resolution — including its ambiguity error — remains live for widened builders and stays tested.
- The compile-time root check for clauses after a join. The ambiguity and wrong-entity errors that guarded the
Any forms at runtime guard the same territory now, and the error names the entity and root.
Considered and rejected
untyped() as the mechanism. Replacing the join-flip with an explicit opt-in makes the user declare what the query already knows; a join is the event that makes other entities referable, so the type flips there. As a supplement the idea returns as widen(): the explicit counterpart of what a join does implicitly, admitting short-form references to the query's graph on a query that joins nothing — Java's only route to graph short form, since it has no widened select { } block. Widening is always safe, so unlike narrow(rootType) it verifies nothing. narrow/widen name the two directions of the documented model ("a join widens the query"); typedId keeps its own name, since recovering an erased ID type is a different axis.
- A
JoinedQueryBuilder subtype adding relaxed overloads. Erasure: where(Navigable<T,V>) and where(Navigable<?,V>) clash on the same name. This is also why the Any names existed at all.
- A fourth type parameter separating "root" from "referable scope". Keeps every check but taxes every signature; not worth it.
- Widening
getResultGroupedBy. Its TypedMetamodel<T, V, V> is what guarantees the group key is extractable from the hydrated record — the docs promise that misuse "does not compile". narrow restores it instead.
Compatibility
Breaking, targeted at 1.14.0 (1.x policy, no shims): the Any variants and whereAny(Function) are gone; typed is typedId; join builders return QueryBuilder<Data, R, ID>; select { } returns the widened builder; abstract members changed on the builders for subclassers. Migration is mechanical — Any name → plain name, typed → typedId — plus narrow(...) where a grouped terminal follows a join. All 7,331 reactor tests, the docs, skills and website are migrated in the implementing PR; in the reference codebase the entire migration is renames.
Every query clause exists twice: a root-typed form (
where,orderBy,groupBy,having) and anAnytwin that accepts paths from other entities. The split is not a design choice — Java erasure forbids overloadingwhere(Navigable<T, V>)withwhere(Navigable<?, V>), so the second form needs a second name. That decision then echoes across four surfaces: ~100Anymembers overstorm-core,storm-java21, the Kotlin chained builder, itsWhereBuilders and theselect { }block DSL, plus a three-rung "WHERE form ladder" in the docs and skills teaching users which name to pick per clause.Usage data says the ladder solves a problem nobody has. In a production codebase built on Storm: every
Anycall site co-occurs with an explicit join (123 of 123 function bodies); not one of 82 bodies usingwhereAnyalso uses a typedwherein the same query; four of the sevenAnyfamilies are never used at all.Design: a join widens the query
The root type tracks what a clause may reference, instead of asking the user to pick a method name:
QueryBuilder<Data, R, ID>(TypedJoinBuilder.on,JoinBuilder.on,crossJoin). Clause parameters widen toNavigable<? extends T, V>(Kotlin:out T), so after a join every clause accepts paths from any entity in the query — and every existing root-typed call site keeps compiling, becauseNavigable<User, V>satisfiesNavigable<? extends Data, V>.Anyvariants are deleted from all surfaces. Their bodies were the plain bodies; core's own comment said the split existed "to provide (more) type safety" over identical logic.narrow(rootType)narrows a widened builder back for the operations that are defined relative to the root —getResultGroupedByafter a join in particular. The root is verified against the query's FROM table at runtime, mirroringtypedId(renamed fromtyped; the pair now names the type parameter each narrows). The rename also fixed a latent bug:typed(null)threw NPE while building its own error message.fetch(...)stays root-typed, which makes the types enforce the intended order: right afterselect(), before any join.select { }block is widened from the start — avarcannot change type mid-block, so per-join widening cannot be expressed there. The scope's own type parameters keepwhere(record),where(id)andwhere(ref)typed to the entity. The block returns the widened builder: narrowing on return would reject chained clauses on the block's own joins.Cannot find alias for column x: X is not part of this query rooted at Y. Join the table or correct the metamodel path.The compile-time root check moves to this error only from the join onward; before the join it still does not compile.What is deliberately given up
whereAny(Country_.name eq …)on an unjoined query) has no typed route anymore. The nested path (User_.city.country.name), the block DSL, or a join covers it; zero real-world call sites used it.where(it -> it.whereAny(owner))) is likewise inexpressible pre-join. The path form (where(Visit_.pet.owner, EQUALS, owner)) states the same match explicitly; the runtime graph resolution — including its ambiguity error — remains live for widened builders and stays tested.Anyforms at runtime guard the same territory now, and the error names the entity and root.Considered and rejected
untyped()as the mechanism. Replacing the join-flip with an explicit opt-in makes the user declare what the query already knows; a join is the event that makes other entities referable, so the type flips there. As a supplement the idea returns aswiden(): the explicit counterpart of what a join does implicitly, admitting short-form references to the query's graph on a query that joins nothing — Java's only route to graph short form, since it has no widenedselect { }block. Widening is always safe, so unlikenarrow(rootType)it verifies nothing.narrow/widenname the two directions of the documented model ("a join widens the query");typedIdkeeps its own name, since recovering an erased ID type is a different axis.JoinedQueryBuildersubtype adding relaxed overloads. Erasure:where(Navigable<T,V>)andwhere(Navigable<?,V>)clash on the same name. This is also why theAnynames existed at all.getResultGroupedBy. ItsTypedMetamodel<T, V, V>is what guarantees the group key is extractable from the hydrated record — the docs promise that misuse "does not compile".narrowrestores it instead.Compatibility
Breaking, targeted at 1.14.0 (1.x policy, no shims): the
Anyvariants andwhereAny(Function)are gone;typedistypedId; join builders returnQueryBuilder<Data, R, ID>;select { }returns the widened builder; abstract members changed on the builders for subclassers. Migration is mechanical —Anyname → plain name,typed→typedId— plusnarrow(...)where a grouped terminal follows a join. All 7,331 reactor tests, the docs, skills and website are migrated in the implementing PR; in the reference codebase the entire migration is renames.