From 4df12c6eb27dd3f9bfa63bb9bdfd48b162437625 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Mon, 3 Aug 2026 12:14:54 +0200 Subject: [PATCH] docs: teach the WHERE form ladder in the skills and fix the Java joined-entity predicates The query skills now state a strict preference order for building WHERE clauses: where() first, whereAny() only for fields of joined (non-root) entities, and the builder form last, reserved for conditions a plain predicate cannot express. The Kotlin skill previously presented whereBuilder { } as the way to group AND/OR conditions, which infix and/or inside where() already covers. Verifying the guidance against the API surfaced that the Java QueryBuilder has no chained whereAny(path, operator, value) overload; joined-entity predicates go through the where-lambda as where(it -> it.whereAny(...)). The Java query skill and the Java tabs of first-query, queries, and relationships used the non-compiling chained form; all are corrected to the lambda form. --- docs/first-query.md | 2 +- docs/queries.md | 4 +- docs/relationships.md | 2 +- website/static/skills/storm-query-java.md | 36 ++++++++++++-- website/static/skills/storm-query-kotlin.md | 53 +++++++++++++++++---- website/static/skills/storm-rules.md | 1 + 6 files changed, 81 insertions(+), 17 deletions(-) diff --git a/docs/first-query.md b/docs/first-query.md index 792aca258..047e7b8ae 100644 --- a/docs/first-query.md +++ b/docs/first-query.md @@ -180,7 +180,7 @@ List page = users.select() List roles = orm.entity(Role.class) .select() .innerJoin(UserRole.class).on(Role.class) - .where(UserRole_.user, EQUALS, user) + .where(it -> it.whereAny(UserRole_.user, EQUALS, user)) .getResultList(); // Aggregation diff --git a/docs/queries.md b/docs/queries.md index 80a703f3e..13c31c652 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -685,10 +685,12 @@ Storm automatically joins entities referenced by `@FK` fields. For entities not List roles = orm.entity(Role.class) .select() .innerJoin(UserRole.class).on(Role.class) - .where(UserRole_.user, EQUALS, user) + .where(it -> it.whereAny(UserRole_.user, EQUALS, user)) .getResultList(); ``` +The typed `where(path, ...)` overloads take paths rooted at the query's root entity. `UserRole_.user` is rooted at the joined `UserRole`, so the condition goes through the where-lambda's `whereAny(...)`, which accepts a path rooted at any joined table. + ### Joins (SQL Templates) SQL Templates let you write JOIN clauses directly, which is useful when the join condition is not a simple foreign key match or when you need to join on computed expressions. diff --git a/docs/relationships.md b/docs/relationships.md index 70eab33e3..b503a4b82 100644 --- a/docs/relationships.md +++ b/docs/relationships.md @@ -286,7 +286,7 @@ For more control, use explicit join queries: List roles = orm.entity(Role.class) .select() .innerJoin(UserRole.class).on(Role.class) - .where(UserRole_.user, EQUALS, user) + .where(it -> it.whereAny(UserRole_.user, EQUALS, user)) .getResultList(); ``` diff --git a/website/static/skills/storm-query-java.md b/website/static/skills/storm-query-java.md index 1a024a719..a45b710d4 100644 --- a/website/static/skills/storm-query-java.md +++ b/website/static/skills/storm-query-java.md @@ -352,9 +352,19 @@ List citiesWithoutUsers = orm.entity(City.class) ## Compound Predicates (where with WhereBuilder) -For complex WHERE clauses with AND/OR grouping: +Two preferences govern every WHERE clause, and both say: **use the weakest form that compiles**. + +- **Typed overloads over the lambda.** `where(User_.active, EQUALS, true)` beats `where(it -> it.where(User_.active, EQUALS, true))`. The chained typed `where(path, ...)` overloads take root-typed paths only — and a nested path from the root (`User_.city.country.code`) is root-typed, so navigating through a foreign key never forces the lambda. Reach for the `where(it -> ...)` lambda only for what the typed overloads cannot express: AND/OR grouping, EXISTS/NOT EXISTS, and joined-entity paths. +- **`it.where(...)` over `it.whereAny(...)` inside the lambda.** `it.where(path, ...)` is typed to the root entity; `it.whereAny(path, ...)` accepts a path rooted at any entity and exists only for fields of explicitly joined (non-root) entities. Escalating without need gives up the compile-time root check for nothing. (The outer `whereAny(it -> ...)` form is the same escalation one level up: needed only when the lambda's resulting predicate is typed to another entity, e.g. built with `andAny`/`orAny`.) ```java +// ✅ Single condition — typed overload, no lambda +.where(User_.active, EQUALS, true) + +// ❌ Lambda adds nothing for a single condition +.where(it -> it.where(User_.active, EQUALS, true)) + +// ✅ Lambda earns its place for AND/OR grouping List users = orm.entity(User.class) .select() .where(it -> it.where(User_.active, EQUALS, true) @@ -363,15 +373,31 @@ List users = orm.entity(User.class) .getResultList(); ``` +Consecutive `where()` calls AND together (each clause parenthesized), so an AND of a root predicate and a joined-entity predicate is two calls, each in its weakest form — no single big lambda needed: + +```java +users.select() + .innerJoin(UserRole.class).on(User.class) + .where(User_.active, EQUALS, true) // root field — typed overload + .where(it -> it.whereAny(UserRole_.role, EQUALS, role)) // joined entity — lambda + whereAny + .getResultList(); +``` + ## Joined-Entity Predicates, Ordering, and Grouping -The `where()`, `orderBy()`, and `groupBy()` methods are typed to the root entity. To filter, order, or group by a joined entity's field, use the `Any` variants: `.whereAny(...)`, `.orderByAny(...)`, `.orderByDescendingAny(...)`, `.groupByAny(...)`. The `Any` variants (`whereAny`, `orderByAny`, `orderByDescendingAny`, `groupByAny`) are needed when referencing fields from joined (non-root) entities. +The typed `where(path, ...)` overloads, `orderBy()`, and `groupBy()` are typed to the root entity. For a joined entity's field: + +- **Filtering**: there is no chained `whereAny(path, ...)` overload — use the lambda with `it.whereAny(...)`: `.where(it -> it.whereAny(UserRole_.role, EQUALS, role))`. +- **Ordering/grouping**: use the chained `Any` variants `.orderByAny(...)`, `.orderByDescendingAny(...)`, `.groupByAny(...)`. + +The `Any` forms are needed **only** for fields of joined (non-root) entities — never for root paths, however deep: `User_.city.country.name` starts at the root, so it stays with the typed `where(...)` overloads and `orderBy()`. ```java users.select() .innerJoin(UserRole.class).on(User.class) - .whereAny(UserRole_.role, EQUALS, role) - .orderByAny(UserRole_.assignedAt) + .where(it -> it.whereAny(UserRole_.role, EQUALS, role)) // joined entity + .orderBy(User_.name) // root path — plain orderBy + .orderByAny(UserRole_.assignedAt) // joined entity .getResultList(); ``` @@ -387,7 +413,7 @@ userRoles.scroll(Scrollable.of(UserRole_.id, 20)); // fails — UserRole has co // ✅ Scroll User (simple PK) with a JOIN through UserRole for filtering users.select() .innerJoin(UserRole.class).on(User.class) - .whereAny(UserRole_.role, EQUALS, role) + .where(it -> it.whereAny(UserRole_.role, EQUALS, role)) .scroll(Scrollable.of(User_.id, 20)); ``` diff --git a/website/static/skills/storm-query-kotlin.md b/website/static/skills/storm-query-kotlin.md index 1f34ba75f..70d726eea 100644 --- a/website/static/skills/storm-query-kotlin.md +++ b/website/static/skills/storm-query-kotlin.md @@ -76,10 +76,12 @@ User_.email.isNotNull() // IS_NOT_NULL Combine with `and`/`or`: ```kotlin -(User_.active eq true) and (User_.email isNotNull()) +(User_.active eq true) and User_.email.isNotNull() (User_.role eq "admin") or (User_.role eq "superadmin") ``` +Compound predicates composed this way stay inside plain `where(...)` — never reach for `whereBuilder { }` to group AND/OR (see **Choosing the WHERE Form**). + The `eq` operator accepts both entities and `Ref`. When you have an entity, use it directly — no need to extract the ID or convert to a `Ref`: ```kotlin User_.city eq city // ✅ entity directly — compares by FK @@ -223,7 +225,7 @@ Pagination: `.page(0, 20)` or `.page(Pageable.ofSize(20).sortBy(User_.name))`. P Scrolling (keyset, better for large tables): `.scroll(Scrollable.of(User_.id, 20))` — do NOT combine with `orderBy()` (Scrollable manages ORDER BY internally, see Keyset Scrolling section) Explicit joins — two syntax forms depending on context: - **Block DSL** (inside `select { }`): `innerJoin()` — reified two-type-arg form, no `.on()` -- **Chained API**: `.innerJoin().on()` — returns builder, chain `.whereAny()` etc. +- **Chained API**: `.innerJoin().on()` — returns builder, chain `.where()` for root fields, `.whereAny()` for joined fields Select result type: `.select(ResultType::class)` to return a different type than the root entity **Always prefer entity/metamodel-based QueryBuilder methods over SQL template strings.** SQL templates are an escape hatch for things the QueryBuilder cannot express. @@ -424,11 +426,22 @@ val citiesWithoutUsers = orm.entity() The `whereExists { }` / `whereNotExists { }` lambdas receive a `SubqueryTemplate` that provides the `subquery()` method. The subquery is automatically correlated with the outer query. -## Compound Predicates (whereBuilder) +## Choosing the WHERE Form: where() → whereAny() → whereBuilder { } + +Three forms build a WHERE clause. They are a strict ladder: **always use the first form that can express the condition**, and escalate only when it cannot. -For complex WHERE clauses that need AND/OR grouping beyond what infix operators provide: +1. **`where(predicate)`** — the default. Typed to the root entity: a predicate on another entity does not compile. Compound conditions stay at this level — compose them with infix `and`/`or`. A nested path that starts at the root (`User_.city.country.code eq "US"`) is root-typed too, so navigating through a foreign key never forces an escalation. +2. **`whereAny(predicate)`** — only when the predicate references a **joined (non-root) entity's** field, which `where()` rejects at compile time. `whereAny` accepts a predicate on any entity, so it gives up that compile-time check — escalating without need trades safety for nothing. +3. **`whereBuilder { }`** — only when the condition needs the builder scope itself: `exists()`/`notExists()` composed into compound logic, or id/ref/record/template matching (`whereId`, `whereRef`, `where(record)`, `where { template }`) inside a compound expression. Never for plain field predicates, and never for AND/OR grouping — infix operators inside `where()` already do that. (`whereAnyBuilder { }` is the same escalation applied to rung 2.) ```kotlin +// ✅ Compound predicates belong in where() — infix and/or, parenthesized per group +val users = orm.entity() + .select() + .where(((User_.active eq true) and User_.email.isNotNull()) or (User_.role eq "admin")) + .resultList + +// ❌ whereBuilder adds nothing here — same query, more machinery val users = orm.entity() .select() .whereBuilder { @@ -439,7 +452,27 @@ val users = orm.entity() .resultList ``` -The `whereBuilder { }` lambda receives a `WhereBuilder` that provides `where()`, `exists()`, `notExists()` methods returning `PredicateBuilder` instances composable with `.and()` / `.or()`. +Consecutive `where()`/`whereAny()` calls AND together (each clause parenthesized), so an AND of a root predicate and a joined-entity predicate needs no `whereBuilder` either — each clause sits on its own rung: + +```kotlin +users.select() + .innerJoin().on() + .where(User_.active eq true) // root field — rung 1 + .whereAny(UserRole_.role eq role) // joined entity — rung 2, for this clause only + .resultList +``` + +A genuine `whereBuilder { }` case — a subquery composed into compound logic (a bare EXISTS needs no builder either: `whereExists { }` covers it): + +```kotlin +users.select() + .whereBuilder { + (User_.active eq true) and exists(subquery(UserRole::class).where(UserRole_.role eq role)) + } + .resultList +``` + +The `whereBuilder { }` lambda receives a `WhereBuilder` that provides `where()`, `exists()`, `notExists()` methods returning `PredicateBuilder` instances composable with `.and()` / `.or()` — and infix predicates work inside it, so even there prefer `(A eq x) and exists(...)` over the operator-argument style. ## Joined-Entity Predicates and Ordering @@ -450,13 +483,15 @@ The `where()` and `orderBy()` methods in the block DSL are typed to the root ent - `orderByDescendingAny(path)` — same, descending - `groupByAny(path)` — accepts `Metamodel<*, *>` (any entity type; chained API only, not in the block DSL) -The `Any` variants (`whereAny`, `orderByAny`, `orderByDescendingAny`, `groupByAny`) are needed when referencing fields from joined (non-root) entities. +The `Any` variants (`whereAny`, `orderByAny`, `orderByDescendingAny`, `groupByAny`) are needed **only** when referencing fields from joined (non-root) entities — never for root paths, however deep: `User_.city.country.name` starts at the root, so it stays with `where()`/`orderBy()`. Escalate per clause, not per query: a root-field condition keeps `where()` even when the query also has a `whereAny()` for a joined field (see **Choosing the WHERE Form**). ```kotlin +// Root is User: the joined UserRole field escalates, the root field does not select { innerJoin() - whereAny(UserRole_.role eq role) - orderByAny(User_.name) + whereAny(UserRole_.role eq role) // joined entity — Any variant required + orderBy(User_.name) // root path — plain orderBy + orderByAny(UserRole_.assignedAt) // joined entity — Any variant required }.resultList ``` @@ -601,7 +636,7 @@ select { }.page(page, size) ``` -Available in the block: `where`, `whereAny`, `whereBuilder`, `whereExists`, `whereNotExists`, `orderBy`, `orderByAny`, `orderByDescending`, `orderByDescendingAny`, `groupBy`, `having`, `limit`, `offset`, `distinct`, `unsafe`, `forUpdate`, `forShare`, `innerJoin`, `leftJoin`, `rightJoin`, `crossJoin`, `append`. Note: `groupByAny` is NOT available in the block — it exists only on the chained QueryBuilder API. +Available in the block: `where`, `whereAny`, `whereBuilder`, `whereExists`, `whereNotExists`, `orderBy`, `orderByAny`, `orderByDescending`, `orderByDescendingAny`, `groupBy`, `having`, `limit`, `offset`, `distinct`, `unsafe`, `forUpdate`, `forShare`, `innerJoin`, `leftJoin`, `rightJoin`, `crossJoin`, `append`. Note: `groupByAny` is NOT available in the block — it exists only on the chained QueryBuilder API. The WHERE ladder applies in the block exactly as it does on the chained API: `where` first, `whereAny` only for joined-entity fields, `whereBuilder` last (see **Choosing the WHERE Form**). **Note:** The block DSL has `orderBy { template }` but NOT `orderByDescending { template }`. For template-based descending order, use the chained API: `.orderByDescending { template }` or escape to raw SQL. diff --git a/website/static/skills/storm-rules.md b/website/static/skills/storm-rules.md index 9875d04c5..27223f6e9 100644 --- a/website/static/skills/storm-rules.md +++ b/website/static/skills/storm-rules.md @@ -31,6 +31,7 @@ The setup and repository skills listed below carry the per-framework entry point ### Query and Template Rules - **Prefer the QueryBuilder and metamodel-based methods** for joins, where clauses, ordering, and pagination. Fall back to SQL templates only when the QueryBuilder cannot express the query. +- **WHERE clauses escalate; always use the weakest form that compiles.** Prefer root-typed `where(...)` — in Kotlin, compound AND/OR conditions stay there via infix `and`/`or`. Use `whereAny(...)` only for fields of joined (non-root) entities; root paths are root-typed however deep. Use the builder form (Kotlin `whereBuilder { }`, Java `where(it -> ...)`) only for what a plain predicate cannot express: AND/OR grouping in Java, or EXISTS/NOT EXISTS and id/ref/record matching inside compound logic. The same ladder applies to `orderBy`/`groupBy` and their `Any` variants. - **Write template expressions as lambdas** (`{ "..." }`) in Kotlin, or `RAW."""..."""` in Java. Never construct `TemplateString.raw()`. - **Reference columns through the metamodel** (`User_.email`), including inside templates, rather than hardcoding column names. - **Keep one API style per snippet.** In Kotlin, prefer the reified forms (`orm.entity()`, `.innerJoin().on()`, `resultList()`), and never mix reified and `::class` styles within one query or code block.