Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/first-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ List<User> page = users.select()
List<Role> 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
Expand Down
4 changes: 3 additions & 1 deletion docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -685,10 +685,12 @@ Storm automatically joins entities referenced by `@FK` fields. For entities not
List<Role> 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ For more control, use explicit join queries:
List<Role> 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();
```

Expand Down
36 changes: 31 additions & 5 deletions website/static/skills/storm-query-java.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,19 @@ List<City> 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<User> users = orm.entity(User.class)
.select()
.where(it -> it.where(User_.active, EQUALS, true)
Expand All @@ -363,15 +373,31 @@ List<User> 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();
```

Expand All @@ -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));
```

Expand Down
53 changes: 44 additions & 9 deletions website/static/skills/storm-query-kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`. 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
Expand Down Expand Up @@ -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<UserRole, Role>()` — reified two-type-arg form, no `.on()`
- **Chained API**: `.innerJoin<UserRole>().on<Role>()` — returns builder, chain `.whereAny()` etc.
- **Chained API**: `.innerJoin<UserRole>().on<Role>()` — 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.
Expand Down Expand Up @@ -424,11 +426,22 @@ val citiesWithoutUsers = orm.entity<City>()

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<User>()
.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<User>()
.select()
.whereBuilder {
Expand All @@ -439,7 +452,27 @@ val users = orm.entity<User>()
.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<UserRole>().on<User>()
.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

Expand All @@ -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<UserRole, User>()
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
```

Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions website/static/skills/storm-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>()`, `.innerJoin<X>().on<Y>()`, `resultList<T>()`), and never mix reified and `::class` styles within one query or code block.
Expand Down
Loading