Skip to content

A join widens the query: replace the Any variants with root widening #371

Description

@zantvoort

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, typedtypedId — 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    corestorm-core and foundation workenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions