From 77f4765cf9fbcd944ca914cb2f4fdbd23aaf3552 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Tue, 11 Aug 2026 10:32:56 +0200 Subject: [PATCH 1/4] fix: correct docs and error messages that name nonexistent methods - Point the collection-parameter error at whereId(ids)/where(path, IN, values) instead of the nonexistent whereAny/whereAll builder methods. - Replace the phantom whereAny convention in the WhereBuilder docs (Kotlin and Java) with the real cross-table composition via andAny/orAny or custom template strings. - Rewrite Kotlin KDoc examples to use whereBuilder { } and the resultList property instead of the Java-only where-lambda and getResultList(). - Fix copy-pasted javadoc on DbTable, DbColumn and the EQUALS/NOT_EQUALS operator labels. - Guard the column parameter in the built-in operators: a null column now throws instead of silently rendering "null = ?". The display-name null check it replaces was unreachable. Fixes #406 --- .../core/template/impl/TemplateProcessor.java | 4 +- .../src/main/java/st/orm/DbColumn.java | 2 +- .../src/main/java/st/orm/DbTable.java | 2 +- .../src/main/java/st/orm/Operator.java | 49 ++++++----- .../src/test/java/st/orm/OperatorTest.java | 84 ++++++++----------- .../java/st/orm/template/WhereBuilder.java | 6 +- .../st/orm/template/PredicateBuilder.kt | 11 ++- .../kotlin/st/orm/template/QueryBuilder.kt | 4 +- .../kotlin/st/orm/template/QueryTemplate.kt | 2 +- .../st/orm/template/TypedJoinBuilder.kt | 4 +- .../kotlin/st/orm/template/WhereBuilder.kt | 21 +++-- 11 files changed, 92 insertions(+), 97 deletions(-) diff --git a/storm-core/src/main/java/st/orm/core/template/impl/TemplateProcessor.java b/storm-core/src/main/java/st/orm/core/template/impl/TemplateProcessor.java index 80d4307a1..15f644839 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/TemplateProcessor.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/TemplateProcessor.java @@ -885,7 +885,7 @@ public String mapParameter(@Nullable Object value) { case Iterable it when template.expandCollection() -> mapArgs(it, template.inlineParameters()); case Object[] ignore -> throw new UncheckedSqlTemplateException(new SqlTemplateException("Array parameters are not supported in SQL templates. Use a List instead of an array to pass multiple values.")); case Iterable ignore -> - throw new UncheckedSqlTemplateException(new SqlTemplateException("Collection parameters are not supported at this position in the SQL template. Use individual parameters, or pass collections inside a WHERE ... IN clause using the whereAny/whereAll builder methods.")); + throw new UncheckedSqlTemplateException(new SqlTemplateException("Collection parameters are not supported at this position in the SQL template. Use individual parameters, or pass the collection to a query builder method such as whereId(ids) or where(path, IN, values), which renders a WHERE ... IN clause.")); case null, default -> { if (template.inlineParameters()) { yield toLiteral(value); @@ -1200,7 +1200,7 @@ public void bindParameter(@Nullable Object value) { case Iterable it when template.expandCollection() -> bindArgs(it, template.inlineParameters()); case Object[] ignore -> throw new UncheckedSqlTemplateException(new SqlTemplateException("Array parameters are not supported in SQL templates. Use a List instead of an array to pass multiple values.")); case Iterable ignore -> - throw new UncheckedSqlTemplateException(new SqlTemplateException("Collection parameters are not supported at this position in the SQL template. Use individual parameters, or pass collections inside a WHERE ... IN clause using the whereAny/whereAll builder methods.")); + throw new UncheckedSqlTemplateException(new SqlTemplateException("Collection parameters are not supported at this position in the SQL template. Use individual parameters, or pass the collection to a query builder method such as whereId(ids) or where(path, IN, values), which renders a WHERE ... IN clause.")); case null, default -> { if (!template.inlineParameters()) { parameters.add(new PositionalParameter(parameters.size() + 1, value)); diff --git a/storm-foundation/src/main/java/st/orm/DbColumn.java b/storm-foundation/src/main/java/st/orm/DbColumn.java index fddf83e3e..9cb5acb64 100644 --- a/storm-foundation/src/main/java/st/orm/DbColumn.java +++ b/storm-foundation/src/main/java/st/orm/DbColumn.java @@ -24,7 +24,7 @@ import java.lang.annotation.Target; /** - * Specifies the name for the column, table or view. + * Specifies the name of the database column. */ @Target({RECORD_COMPONENT, PARAMETER}) @Retention(RUNTIME) diff --git a/storm-foundation/src/main/java/st/orm/DbTable.java b/storm-foundation/src/main/java/st/orm/DbTable.java index 93ae0110c..f590d41fc 100644 --- a/storm-foundation/src/main/java/st/orm/DbTable.java +++ b/storm-foundation/src/main/java/st/orm/DbTable.java @@ -22,7 +22,7 @@ import java.lang.annotation.Target; /** - * Specifies the schema name for the table or view. + * Specifies the name of the database table or view, and optionally its schema. */ @Target(TYPE) @Retention(RUNTIME) diff --git a/storm-foundation/src/main/java/st/orm/Operator.java b/storm-foundation/src/main/java/st/orm/Operator.java index 719cecb5e..438d3837d 100644 --- a/storm-foundation/src/main/java/st/orm/Operator.java +++ b/storm-foundation/src/main/java/st/orm/Operator.java @@ -35,7 +35,7 @@ public interface Operator { */ Operator IN = (column, placeholders) -> switch (placeholders.length) { case 0 -> "1 <> 1"; - default -> "%s IN (%s)".formatted(column, String.join(", ", placeholders)); + default -> "%s IN (%s)".formatted(requireColumn(column), String.join(", ", placeholders)); }; /** @@ -43,73 +43,73 @@ public interface Operator { */ Operator NOT_IN = (column, placeholders) -> switch (placeholders.length) { case 0 -> "1 = 1"; - default -> "%s NOT IN (%s)".formatted(column, String.join(", ", placeholders)); + default -> "%s NOT IN (%s)".formatted(requireColumn(column), String.join(", ", placeholders)); }; /** - * The {@code EXISTS} operator. + * The {@code =} operator. */ - Operator EQUALS = (column, placeholders) -> format("Equals", 1, placeholders.length, "%s = %s".formatted(column, get(placeholders))); + Operator EQUALS = (column, placeholders) -> format("Equals", 1, placeholders.length, "%s = %s".formatted(requireColumn(column), get(placeholders))); /** - * The {@code NOT EXISTS} operator. + * The {@code <>} operator. */ - Operator NOT_EQUALS = (column, placeholders) -> format("Not equals", 1, placeholders.length, "%s <> %s".formatted(column, Operator.get(placeholders))); + Operator NOT_EQUALS = (column, placeholders) -> format("Not equals", 1, placeholders.length, "%s <> %s".formatted(requireColumn(column), Operator.get(placeholders))); /** * The {@code LIKE} operator. */ - Operator LIKE = (column, placeholders) -> format("Like", 1, placeholders.length, "%s LIKE %s".formatted(column, get(placeholders))); + Operator LIKE = (column, placeholders) -> format("Like", 1, placeholders.length, "%s LIKE %s".formatted(requireColumn(column), get(placeholders))); /** * The {@code NOT LIKE} operator. */ - Operator NOT_LIKE = (column, placeholders) -> format("Not like", 1, placeholders.length, "%s NOT LIKE %s".formatted(column, get(placeholders))); + Operator NOT_LIKE = (column, placeholders) -> format("Not like", 1, placeholders.length, "%s NOT LIKE %s".formatted(requireColumn(column), get(placeholders))); /** * The {@code >} operator. */ - Operator GREATER_THAN = (column, placeholders) -> format("Greater than", 1 , placeholders.length, "%s > %s".formatted(column, get(placeholders))); + Operator GREATER_THAN = (column, placeholders) -> format("Greater than", 1 , placeholders.length, "%s > %s".formatted(requireColumn(column), get(placeholders))); /** * The {@code >=} operator. */ - Operator GREATER_THAN_OR_EQUAL = (column, placeholders) -> format("Greater than or equal", 1, placeholders.length, "%s >= %s".formatted(column, get(placeholders))); + Operator GREATER_THAN_OR_EQUAL = (column, placeholders) -> format("Greater than or equal", 1, placeholders.length, "%s >= %s".formatted(requireColumn(column), get(placeholders))); /** * The {@code <} operator. */ - Operator LESS_THAN = (column, placeholders) -> format("Less than", 1, placeholders.length, "%s < %s".formatted(column, get(placeholders))); + Operator LESS_THAN = (column, placeholders) -> format("Less than", 1, placeholders.length, "%s < %s".formatted(requireColumn(column), get(placeholders))); /** * The {@code <=} operator. */ - Operator LESS_THAN_OR_EQUAL= (column, placeholders) -> format("Less than or equal", 1, placeholders.length, "%s <= %s".formatted(column, get(placeholders))); + Operator LESS_THAN_OR_EQUAL= (column, placeholders) -> format("Less than or equal", 1, placeholders.length, "%s <= %s".formatted(requireColumn(column), get(placeholders))); /** * The {@code BETWEEN} operator. */ - Operator BETWEEN = (column, placeholders) -> format("Between", 2, placeholders.length, "%s BETWEEN %s AND %s".formatted(column, get(placeholders), get(1, placeholders))); + Operator BETWEEN = (column, placeholders) -> format("Between", 2, placeholders.length, "%s BETWEEN %s AND %s".formatted(requireColumn(column), get(placeholders), get(1, placeholders))); /** * The {@code IS TRUE} operator. */ - Operator IS_TRUE = (column, placeholders) -> format("Is true", 0, placeholders.length, "%s IS TRUE".formatted(column)); + Operator IS_TRUE = (column, placeholders) -> format("Is true", 0, placeholders.length, "%s IS TRUE".formatted(requireColumn(column))); /** * The {@code IS FALSE} operator. */ - Operator IS_FALSE = (column, placeholders) -> format("Is false", 0, placeholders.length, "%s IS FALSE".formatted(column)); + Operator IS_FALSE = (column, placeholders) -> format("Is false", 0, placeholders.length, "%s IS FALSE".formatted(requireColumn(column))); /** * The {@code IS NULL} operator. */ - Operator IS_NULL = (column, placeholders) -> format("Is null", 0, placeholders.length, "%s IS NULL".formatted(column)); + Operator IS_NULL = (column, placeholders) -> format("Is null", 0, placeholders.length, "%s IS NULL".formatted(requireColumn(column))); /** * The {@code IS NOT NULL} operator. */ - Operator IS_NOT_NULL = (column, placeholders) -> format("Is not null", 0, placeholders.length, "%s IS NOT NULL".formatted(column)); + Operator IS_NOT_NULL = (column, placeholders) -> format("Is not null", 0, placeholders.length, "%s IS NOT NULL".formatted(requireColumn(column))); /** * Formats the operator with bind variables matching the specified size. @@ -117,20 +117,25 @@ public interface Operator { * @param column the column to compare. * @param placeholders the placeholders to use in the template. * @return the formatted operator. - * @throws IllegalArgumentException if the specified size is not supported by the operator. + * @throws IllegalArgumentException if the column is null but required by the operator, or if the number of + * placeholders is not supported by the operator. */ String format(@Nullable String column, String... placeholders); - private static String format(@Nullable String name, int requiredSize, int actualSize, @Nonnull String operator) { - if (name == null) { - throw new IllegalArgumentException("Column name cannot be null."); - } + private static String format(@Nonnull String name, int requiredSize, int actualSize, @Nonnull String operator) { if (requiredSize != actualSize) { throw new IllegalArgumentException("%s operator requires %s value(s). Found %s value(s).".formatted(name, requiredSize, actualSize)); } return operator; } + private static String requireColumn(@Nullable String column) { + if (column == null) { + throw new IllegalArgumentException("Column name cannot be null."); + } + return column; + } + private static String get(String... placeholders) { return get(0, placeholders); } diff --git a/storm-foundation/src/test/java/st/orm/OperatorTest.java b/storm-foundation/src/test/java/st/orm/OperatorTest.java index 12baa28c3..49ea61ca6 100644 --- a/storm-foundation/src/test/java/st/orm/OperatorTest.java +++ b/storm-foundation/src/test/java/st/orm/OperatorTest.java @@ -192,10 +192,8 @@ void isNotNullOperatorRequiresZeroPlaceholders() { } @Test - void equalsOperatorWithNullColumn() { - // When column is null, the operator format still produces SQL with "null" as string - String result = Operator.EQUALS.format(null, "?"); - assertEquals("null = ?", result); + void equalsOperatorWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.EQUALS.format(null, "?")); } @Test @@ -247,87 +245,79 @@ void isNotNullWithOnePlaceholderThrows() { } @Test - void notEqualsWithNullColumnFormatsWithNull() { - // When column is null, operators produce SQL with "null" string (no exception). - String result = Operator.NOT_EQUALS.format(null, "?"); - assertEquals("null <> ?", result); + void notEqualsWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.NOT_EQUALS.format(null, "?")); } @Test - void likeWithNullColumnFormatsWithNull() { - String result = Operator.LIKE.format(null, "?"); - assertEquals("null LIKE ?", result); + void likeWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.LIKE.format(null, "?")); } @Test - void notLikeWithNullColumnFormatsWithNull() { - String result = Operator.NOT_LIKE.format(null, "?"); - assertEquals("null NOT LIKE ?", result); + void notLikeWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.NOT_LIKE.format(null, "?")); } @Test - void greaterThanWithNullColumnFormatsWithNull() { - String result = Operator.GREATER_THAN.format(null, "?"); - assertEquals("null > ?", result); + void greaterThanWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.GREATER_THAN.format(null, "?")); } @Test - void greaterThanOrEqualWithNullColumnFormatsWithNull() { - String result = Operator.GREATER_THAN_OR_EQUAL.format(null, "?"); - assertEquals("null >= ?", result); + void greaterThanOrEqualWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.GREATER_THAN_OR_EQUAL.format(null, "?")); } @Test - void lessThanWithNullColumnFormatsWithNull() { - String result = Operator.LESS_THAN.format(null, "?"); - assertEquals("null < ?", result); + void lessThanWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.LESS_THAN.format(null, "?")); } @Test - void lessThanOrEqualWithNullColumnFormatsWithNull() { - String result = Operator.LESS_THAN_OR_EQUAL.format(null, "?"); - assertEquals("null <= ?", result); + void lessThanOrEqualWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.LESS_THAN_OR_EQUAL.format(null, "?")); } @Test - void betweenWithNullColumnFormatsWithNull() { - String result = Operator.BETWEEN.format(null, "?", "?"); - assertEquals("null BETWEEN ? AND ?", result); + void betweenWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.BETWEEN.format(null, "?", "?")); } @Test - void isTrueWithNullColumnFormatsWithNull() { - String result = Operator.IS_TRUE.format(null); - assertEquals("null IS TRUE", result); + void isTrueWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.IS_TRUE.format(null)); } @Test - void isFalseWithNullColumnFormatsWithNull() { - String result = Operator.IS_FALSE.format(null); - assertEquals("null IS FALSE", result); + void isFalseWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.IS_FALSE.format(null)); } @Test - void isNullWithNullColumnFormatsWithNull() { - String result = Operator.IS_NULL.format(null); - assertEquals("null IS NULL", result); + void isNullWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.IS_NULL.format(null)); } @Test - void isNotNullWithNullColumnFormatsWithNull() { - String result = Operator.IS_NOT_NULL.format(null); - assertEquals("null IS NOT NULL", result); + void isNotNullWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.IS_NOT_NULL.format(null)); } @Test - void inWithNullColumnFormatsWithNull() { - String result = Operator.IN.format(null, "?"); - assertEquals("null IN (?)", result); + void inWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.IN.format(null, "?")); } @Test - void notInWithNullColumnFormatsWithNull() { - String result = Operator.NOT_IN.format(null, "?"); - assertEquals("null NOT IN (?)", result); + void inWithNullColumnAndZeroPlaceholdersFormatsWithoutColumn() { + // The zero-placeholder form renders a constant expression and does not reference the column. + String result = Operator.IN.format(null); + assertEquals("1 <> 1", result); + } + + @Test + void notInWithNullColumnThrows() { + assertThrows(IllegalArgumentException.class, () -> Operator.NOT_IN.format(null, "?")); } } diff --git a/storm-java21/src/main/java/st/orm/template/WhereBuilder.java b/storm-java21/src/main/java/st/orm/template/WhereBuilder.java index 78aa3c689..d232aca82 100644 --- a/storm-java21/src/main/java/st/orm/template/WhereBuilder.java +++ b/storm-java21/src/main/java/st/orm/template/WhereBuilder.java @@ -33,8 +33,10 @@ * expressions. Each method returns a {@link PredicateBuilder} that can be further composed using * {@code and()} and {@code or()} combinators.

* - *

Methods named {@code where} are type-safe and restrict metamodel paths to the root table's entity graph. - * Methods named {@code whereAny} accept metamodel paths from any table, including manually added joins.

+ *

The {@code where} methods are type-safe and restrict metamodel paths to the root table's entity graph. + * Predicates for other tables, including manually added joins, can be combined using the + * {@link PredicateBuilder#andAny} and {@link PredicateBuilder#orAny} combinators or expressed as custom string + * template expressions.

* *

Example

*
{@code
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/PredicateBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/PredicateBuilder.kt
index d87864c86..35c55cff0 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/PredicateBuilder.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/PredicateBuilder.kt
@@ -31,13 +31,12 @@ import st.orm.Data
  * ```kotlin
  * val users = userRepository
  *     .select()
- *     .where { predicate ->
- *         predicate
- *             .where(User_.active eq true)
- *             .and(predicate.where(User_.email.isNotNull()))
- *             .or(predicate.where(User_.role eq "admin"))
+ *     .whereBuilder {
+ *         (User_.active eq true)
+ *             .and(User_.email.isNotNull())
+ *             .or(User_.role eq "admin")
  *     }
- *     .getResultList()
+ *     .resultList
  * ```
  *
  * @param T the type of the table being queried.
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt
index 9d3cdb0c2..523f7245e 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt
@@ -48,7 +48,7 @@ import kotlin.reflect.KClass
  *     .where(User_.address.city.name eq "Sunnyvale")
  *     .orderBy(User_.email)
  *     .limit(10)
- *     .getResultList()
+ *     .resultList
  * ```
  *
  * ## Example: Join with reified type arguments
@@ -56,7 +56,7 @@ import kotlin.reflect.KClass
  * val users = userRepository
  *     .select()
  *     .innerJoin().on()
- *     .getResultList()
+ *     .resultList
  * ```
  *
  * ## Example: Delete with WHERE clause
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/QueryTemplate.kt b/storm-kotlin/src/main/kotlin/st/orm/template/QueryTemplate.kt
index 7feab3a85..32224aecb 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/QueryTemplate.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/QueryTemplate.kt
@@ -38,7 +38,7 @@ import kotlin.reflect.KClass
  * ```kotlin
  * val users = orm.selectFrom(User::class)
  *     .where(User_.name eq "Alice")
- *     .getResultList()
+ *     .resultList
  * ```
  *
  * @see ORMTemplate
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/TypedJoinBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/TypedJoinBuilder.kt
index 7ec5f3a7c..700978e16 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/TypedJoinBuilder.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/TypedJoinBuilder.kt
@@ -31,7 +31,7 @@ import kotlin.reflect.KClass
  * val users = userRepository
  *     .select()
  *     .innerJoin(Order::class).on(User::class)
- *     .getResultList()
+ *     .resultList
  * ```
  *
  * The same join can be expressed with reified type arguments:
@@ -39,7 +39,7 @@ import kotlin.reflect.KClass
  * val users = userRepository
  *     .select()
  *     .innerJoin().on()
- *     .getResultList()
+ *     .resultList
  * ```
  *
  * @param T the type of the table being queried.
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt
index 0ff9972fc..ebba0f75a 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt
@@ -25,29 +25,28 @@ import st.orm.template.TemplateString.Companion.raw
 /**
  * A builder for constructing the WHERE clause of a query, providing type-safe predicate construction.
  *
- * The `WhereBuilder` is passed to the lambda argument of [QueryBuilder.where] and offers methods for matching by
- * primary key, record, ref, metamodel path, or custom template string expressions. Each method returns a
- * [PredicateBuilder] that can be further composed using `and()` and `or()` combinators.
+ * The `WhereBuilder` is the receiver of the lambda passed to [QueryBuilder.whereBuilder] and offers methods for
+ * matching by primary key, record, ref, metamodel path, or custom template string expressions. Each method returns
+ * a [PredicateBuilder] that can be further composed using `and()` and `or()` combinators.
  *
- * Methods named `where` are type-safe and restrict metamodel paths to the root table's entity graph.
- * Methods named `whereAny` accept metamodel paths from any table, including manually added joins.
+ * The `where` methods are type-safe and restrict metamodel paths to the root table's entity graph. Predicates for
+ * other tables, including manually added joins, can be combined using the [PredicateBuilder.andAny] and
+ * [PredicateBuilder.orAny] combinators or expressed as custom template strings.
  *
  * ## Example
  * ```kotlin
  * val users = userRepository
  *     .select()
- *     .where { predicate ->
- *         predicate
- *             .where(User_.active eq true)
- *             .and(predicate.where(User_.address.city.name eq "Sunnyvale"))
+ *     .whereBuilder {
+ *         (User_.active eq true) and (User_.address.city.name eq "Sunnyvale")
  *     }
- *     .getResultList()
+ *     .resultList
  * ```
  *
  * @param T the type of the table being queried.
  * @param R the type of the result.
  * @param ID the type of the primary key.
- * @see QueryBuilder.where
+ * @see QueryBuilder.whereBuilder
  * @see PredicateBuilder
  */
 @SqlDsl

From 54f6f27e72218b4bce2b082ac6e59f6a8be8d9c9 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort 
Date: Tue, 11 Aug 2026 13:10:42 +0200
Subject: [PATCH 2/4] feat!: predicate combinators inherit the query root,
 removing the Any twins

andAny/orAny were the predicate-level Any variants #372 left behind. The
combinators now follow the same model as the clauses: the query root decides
what combines.

- Java: every predicate carries the builder's root, so and/or absorb the Any
  twins with a ? extends T parameter; a narrow builder combines within the
  root graph and a join widens the root. The twins had no constructible
  argument with a foreign root left.
- Kotlin: and/or move into the whereBuilder { } scope as member extensions
  bound to the scope's root, so a narrow scope combines within the root graph
  and a widened scope combines predicates across joined entities with the
  same syntax. Top-level and/or extensions combine same-rooted predicates
  outside a scope.
- Core keeps a permissive ? extends Data parameter: it is the engine the
  Kotlin bridge feeds mixed-root predicates through, and query-time
  validation reports paths on entities outside the query.
---
 CHANGELOG.md                                  |  1 +
 .../orm/core/template/PredicateBuilder.java   | 32 ++--------
 .../core/template/impl/QueryBuilderImpl.java  | 18 +-----
 .../QueryBuilderPredicateIntegrationTest.java | 12 ++--
 .../st/orm/template/PredicateBuilder.java     | 38 +++---------
 .../java/st/orm/template/WhereBuilder.java    |  5 +-
 .../orm/template/impl/QueryBuilderImpl.java   | 18 ++----
 .../st/orm/template/QueryBuilderTest.java     | 10 ++--
 .../st/orm/template/PredicateBuilder.kt       | 59 +++----------------
 .../kotlin/st/orm/template/QueryBuilder.kt    | 24 ++++++++
 .../kotlin/st/orm/template/WhereBuilder.kt    | 30 +++++++++-
 .../template/impl/PredicateBuilderFactory.kt  | 24 ++++++++
 .../st/orm/template/impl/QueryBuilderImpl.kt  | 12 ++--
 .../st/orm/template/EntityRepositoryTest.kt   | 12 ++--
 .../kotlin/st/orm/template/ORMTemplateTest.kt | 10 ++--
 .../st/orm/template/QueryBuilderTest.kt       | 22 +++++--
 16 files changed, 149 insertions(+), 178 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 355684b29..8409dd25d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@ A join widens the query: from the join onward, every clause accepts paths from a
   ```
 
   In Java the lambda escalation goes with it: `.where(it -> it.whereAny(UserRole_.user, EQUALS, user))` becomes the typed overload `.where(UserRole_.user, EQUALS, user)`. A path on an entity that is not part of the query fails when the query is built, with the error naming the entity, the query root, and — when the table appears more than once — the paths that pin it.
+- The predicate combinators follow the same model: `andAny` and `orAny` are removed from every API, and `and`/`or` inherit the query root. In Java every predicate carries the builder's root, so the plain calls absorb the `Any` twins as a rename per call site. In Kotlin, `and`/`or` live in the `whereBuilder { }` scope: a narrow scope combines predicates within the root graph, and a join widens the root so the same expression combines predicates across joined entities — `(Pet_.name eq "Leo") and (Owner_.lastName eq "Davis")` after joining `Owner`. Outside a builder scope, the top-level `and`/`or` extensions combine predicates that share a root.
 - Added `widen()` and `narrow(rootType)`, the two directions of the model made explicit: `widen()` widens without a join, admitting short-form references to entities of the query's graph on a query that joins nothing, and `narrow` restores the root after a join for the operations defined relative to it, verified against the query's FROM table. Those are `resultGroupedBy`, which types the map key, and `scroll`, whose key has to identify one row of the root — a unique key on a joined table does not, so scrolling a joined query narrows first.
 - Renamed `typed(pkType)` to `typedId(pkType)` — it types the erased primary-key parameter, while `narrow` types the root — and added its missing null check.
 - `fetch(...)` comes right after `select()`, before any join, enforced at compile time: resolving references is defined relative to the root, and a join widens the builder past it.
diff --git a/storm-core/src/main/java/st/orm/core/template/PredicateBuilder.java b/storm-core/src/main/java/st/orm/core/template/PredicateBuilder.java
index 05b17d050..c92b88a8a 100644
--- a/storm-core/src/main/java/st/orm/core/template/PredicateBuilder.java
+++ b/storm-core/src/main/java/st/orm/core/template/PredicateBuilder.java
@@ -31,23 +31,13 @@ public interface PredicateBuilder {
      * Adds a predicate to the WHERE clause using an AND condition.
      *
      * 

This method combines the specified predicate with existing predicates using an AND operation, ensuring - * that all added conditions must be true.

- * - * @param predicate the predicate to add. - * @return the predicate builder. - */ - PredicateBuilder and(@Nonnull PredicateBuilder predicate); - - /** - * Adds a predicate to the WHERE clause using an AND condition. - * - *

This method combines the specified predicate with existing predicates using an AND operation, ensuring - * that all added conditions must be true.

+ * that all added conditions must be true. The predicate may be rooted at any entity in the query; a path on an + * entity outside the query fails when the query is built.

* * @param predicate the predicate to add. * @return the predicate builder. */ - PredicateBuilder andAny(@Nonnull PredicateBuilder predicate); + PredicateBuilder and(@Nonnull PredicateBuilder predicate); /** * Adds a predicate to the WHERE clause using an AND condition. @@ -64,23 +54,13 @@ public interface PredicateBuilder { * Adds a predicate to the WHERE clause using an OR condition. * *

This method combines the specified predicate with existing predicates using an OR operation, allowing any - * of the added conditions to be true.

- * - * @param predicate the predicate to add. - * @return the predicate builder. - */ - PredicateBuilder or(@Nonnull PredicateBuilder predicate); - - /** - * Adds a predicate to the WHERE clause using an OR condition. - * - *

This method combines the specified predicate with existing predicates using an OR operation, allowing any - * of the added conditions to be true.

+ * of the added conditions to be true. The predicate may be rooted at any entity in the query; a path on an + * entity outside the query fails when the query is built.

* * @param predicate the predicate to add. * @return the predicate builder. */ - PredicateBuilder orAny(@Nonnull PredicateBuilder predicate); + PredicateBuilder or(@Nonnull PredicateBuilder predicate); /** * Adds a predicate to the WHERE clause using an OR condition. diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java index 60804c933..77b221b0f 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java @@ -446,18 +446,11 @@ static class PredicateBuilderImpl implements Predicate } @Override - public PredicateBuilder and(@Nonnull PredicateBuilder predicate) { + public PredicateBuilder and(@Nonnull PredicateBuilder predicate) { add(RAW_AND, predicate); return this; } - @Override - public PredicateBuilder andAny(@Nonnull PredicateBuilder predicate) { - add(RAW_AND, predicate); - //noinspection unchecked - return (PredicateBuilder) this; - } - @Override public PredicateBuilder and(@Nonnull TemplateString template) { add(RAW_AND, combine(RAW_OPEN, template, RAW_CLOSE)); // Always wrap a template in parentheses as we don't know if it's a single expression or a complex one. @@ -465,18 +458,11 @@ public PredicateBuilder and(@Nonnull TemplateString template) { } @Override - public PredicateBuilder or(@Nonnull PredicateBuilder predicate) { + public PredicateBuilder or(@Nonnull PredicateBuilder predicate) { add(RAW_OR, predicate); return this; } - @Override - public PredicateBuilder orAny(@Nonnull PredicateBuilder predicate) { - add(RAW_OR, predicate); - //noinspection unchecked - return (PredicateBuilder) this; - } - @Override public PredicateBuilder or(@Nonnull TemplateString template) { add(RAW_OR, combine(RAW_OPEN, template, RAW_CLOSE)); diff --git a/storm-core/src/test/java/st/orm/core/QueryBuilderPredicateIntegrationTest.java b/storm-core/src/test/java/st/orm/core/QueryBuilderPredicateIntegrationTest.java index e7f1d6bc9..9c24981f4 100644 --- a/storm-core/src/test/java/st/orm/core/QueryBuilderPredicateIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/QueryBuilderPredicateIntegrationTest.java @@ -238,27 +238,27 @@ public void testPredicateBuilderOrTemplate() { assertTrue(cities.size() >= 1); } - // PredicateBuilder.andAny - AND with cross-type predicate + // PredicateBuilder.and - AND with a second predicate @Test - public void testPredicateBuilderAndAny() { + public void testPredicateBuilderAndPredicate() { var orm = ORMTemplate.of(dataSource); List visits = orm.selectFrom(Visit.class) .where(predicate -> predicate.where(Visit_.id, GREATER_THAN, 0) - .andAny(predicate.where(Visit_.id, IN, List.of(1, 2, 3)))) + .and(predicate.where(Visit_.id, IN, List.of(1, 2, 3)))) .getResultList(); assertEquals(3, visits.size()); } - // PredicateBuilder.orAny - OR with cross-type predicate + // PredicateBuilder.or - OR with a second predicate @Test - public void testPredicateBuilderOrAny() { + public void testPredicateBuilderOrPredicate() { var orm = ORMTemplate.of(dataSource); List visits = orm.selectFrom(Visit.class) .typedId(Integer.class) .where(predicate -> predicate.whereId(1) - .orAny(predicate.whereId(2))) + .or(predicate.whereId(2))) .getResultList(); assertEquals(2, visits.size()); } diff --git a/storm-java21/src/main/java/st/orm/template/PredicateBuilder.java b/storm-java21/src/main/java/st/orm/template/PredicateBuilder.java index 224f6572e..1777f6953 100644 --- a/storm-java21/src/main/java/st/orm/template/PredicateBuilder.java +++ b/storm-java21/src/main/java/st/orm/template/PredicateBuilder.java @@ -25,8 +25,9 @@ * using {@link #and(PredicateBuilder)} and {@link #or(PredicateBuilder)} to build compound conditions. Each * combinator returns a new {@code PredicateBuilder} that represents the combined expression.

* - *

Methods named {@code and}/{@code or} are type-safe and restrict predicates to the root table's entity graph. - * Methods named {@code andAny}/{@code orAny} accept predicates from any table, including manually added joins.

+ *

Predicates combine within the query root. A join widens the root, after which the {@link WhereBuilder} + * produces predicates that may reference any entity in the query; a path on an entity outside the query fails + * when the query is built.

* *

Example

*
{@code
@@ -51,24 +52,13 @@ public interface PredicateBuilder {
      * Adds a predicate to the WHERE clause using an AND condition.
      *
      * 

This method combines the specified predicate with existing predicates using an AND operation, ensuring - * that all added conditions must be true.

+ * that all added conditions must be true. The predicate inherits the query root: a join widens the root, + * admitting predicates that reference any entity in the query.

* * @param predicate the predicate to add. * @return the predicate builder. */ - PredicateBuilder and(@Nonnull PredicateBuilder predicate); - - /** - * Adds a predicate to the WHERE clause using an AND condition. - * - *

This method combines the specified predicate with existing predicates using an AND operation, ensuring - * that all added conditions must be true.

- * - * @param predicate the predicate to add. - * @return the predicate builder. - */ - PredicateBuilder andAny(@Nonnull PredicateBuilder predicate); - + PredicateBuilder and(@Nonnull PredicateBuilder predicate); /** * Adds a predicate to the WHERE clause using an AND condition. @@ -85,23 +75,13 @@ public interface PredicateBuilder { * Adds a predicate to the WHERE clause using an OR condition. * *

This method combines the specified predicate with existing predicates using an OR operation, allowing any - * of the added conditions to be true.

- * - * @param predicate the predicate to add. - * @return the predicate builder. - */ - PredicateBuilder or(@Nonnull PredicateBuilder predicate); - - /** - * Adds a predicate to the WHERE clause using an OR condition. - * - *

This method combines the specified predicate with existing predicates using an OR operation, allowing any - * of the added conditions to be true.

+ * of the added conditions to be true. The predicate inherits the query root: a join widens the root, + * admitting predicates that reference any entity in the query.

* * @param predicate the predicate to add. * @return the predicate builder. */ - PredicateBuilder orAny(@Nonnull PredicateBuilder predicate); + PredicateBuilder or(@Nonnull PredicateBuilder predicate); /** * Adds a predicate to the WHERE clause using an OR condition. diff --git a/storm-java21/src/main/java/st/orm/template/WhereBuilder.java b/storm-java21/src/main/java/st/orm/template/WhereBuilder.java index d232aca82..2d242da44 100644 --- a/storm-java21/src/main/java/st/orm/template/WhereBuilder.java +++ b/storm-java21/src/main/java/st/orm/template/WhereBuilder.java @@ -34,9 +34,8 @@ * {@code and()} and {@code or()} combinators.

* *

The {@code where} methods are type-safe and restrict metamodel paths to the root table's entity graph. - * Predicates for other tables, including manually added joins, can be combined using the - * {@link PredicateBuilder#andAny} and {@link PredicateBuilder#orAny} combinators or expressed as custom string - * template expressions.

+ * A join widens the query root, after which the {@code where} methods accept paths from any entity in the query; + * a path on an entity outside the query fails when the query is built.

* *

Example

*
{@code
diff --git a/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java b/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java
index 0d8aa90e8..97accdfdb 100644
--- a/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java
+++ b/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java
@@ -461,13 +461,8 @@ static class PredicateBuilderImpl implements Predicate
         }
 
         @Override
-        public PredicateBuilder and(@Nonnull PredicateBuilder predicate) {
-            return new PredicateBuilderImpl<>(core.and(((PredicateBuilderImpl) predicate).core));
-        }
-
-        @Override
-        public  PredicateBuilder andAny(@Nonnull PredicateBuilder predicate) {
-            return new PredicateBuilderImpl<>(core.andAny(((PredicateBuilderImpl) predicate).core));
+        public PredicateBuilder and(@Nonnull PredicateBuilder predicate) {
+            return new PredicateBuilderImpl<>(core.and(((PredicateBuilderImpl) predicate).core));
         }
 
         @Override
@@ -476,13 +471,8 @@ public PredicateBuilder and(@Nonnull StringTemplate template) {
         }
 
         @Override
-        public PredicateBuilder or(@Nonnull PredicateBuilder predicate) {
-            return new PredicateBuilderImpl<>(core.or(((PredicateBuilderImpl) predicate).core));
-        }
-
-        @Override
-        public  PredicateBuilder orAny(@Nonnull PredicateBuilder predicate) {
-            return new PredicateBuilderImpl<>(core.orAny(((PredicateBuilderImpl) predicate).core));
+        public PredicateBuilder or(@Nonnull PredicateBuilder predicate) {
+            return new PredicateBuilderImpl<>(core.or(((PredicateBuilderImpl) predicate).core));
         }
 
         @Override
diff --git a/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java b/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java
index 2e500c4bb..36550d01c 100644
--- a/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java
+++ b/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java
@@ -690,22 +690,22 @@ public void testHavingWithOrTemplate() {
         assertEquals(3L, result.stream().mapToLong(Long::longValue).sum());
     }
 
-    // PredicateBuilder - andAny / orAny
+    // PredicateBuilder - and / or with a second predicate
 
     @Test
-    public void testPredicateAndAny() {
+    public void testPredicateAndPredicate() {
         List cities = orm.entity(City.class).select()
                 .where(wb -> wb.where(RAW."\{City.class}.id = \{1}")
-                        .andAny(wb.where(RAW."\{City.class}.name = \{"Sun Paririe"}")))
+                        .and(wb.where(RAW."\{City.class}.name = \{"Sun Paririe"}")))
                 .getResultList();
         assertEquals(1, cities.size());
     }
 
     @Test
-    public void testPredicateOrAny() {
+    public void testPredicateOrPredicate() {
         List cities = orm.entity(City.class).select()
                 .where(wb -> wb.where(RAW."\{City.class}.id = \{999}")
-                        .orAny(wb.where(RAW."\{City.class}.name = \{"Madison"}")))
+                        .or(wb.where(RAW."\{City.class}.name = \{"Madison"}")))
                 .getResultList();
         assertEquals(1, cities.size());
     }
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/PredicateBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/PredicateBuilder.kt
index 35c55cff0..1da620c22 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/PredicateBuilder.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/PredicateBuilder.kt
@@ -20,12 +20,14 @@ import st.orm.Data
 /**
  * Represents a composable predicate for the WHERE clause of a query, supporting `AND` and `OR` composition.
  *
- * `PredicateBuilder` instances are returned by the methods on [WhereBuilder] and can be combined using [and]
- * and [or] to build compound conditions. Each combinator returns a new `PredicateBuilder` that represents the
- * combined expression.
+ * `PredicateBuilder` instances are returned by the methods on [WhereBuilder] and by the infix operators such as
+ * `eq` and `like`. They combine with `and` and `or`; each combinator returns a new `PredicateBuilder` that
+ * represents the combined expression.
  *
- * Methods named `and`/`or` are type-safe and restrict predicates to the root table's entity graph.
- * Methods named `andAny`/`orAny` accept predicates from any table, including manually added joins.
+ * Inside a [WhereBuilder] scope, the scope's `and`/`or` combinators inherit the query root: a narrow scope
+ * combines predicates within the root table's entity graph, and a join widens the root so the same syntax
+ * combines predicates across all entities in the query. Outside a scope, the top-level `and`/`or` extensions
+ * combine predicates that share the same root.
  *
  * ## Example
  * ```kotlin
@@ -46,29 +48,6 @@ import st.orm.Data
  * @see QueryBuilder
  */
 public interface PredicateBuilder {
-    /**
-     * Adds a predicate to the WHERE clause using an AND condition.
-     *
-     * This method combines the specified predicate with existing predicates using an AND operation, ensuring
-     * that all added conditions must be true.
-     *
-     * @param predicate the predicate to add.
-     * @return the predicate builder.
-     */
-    public infix fun and(predicate: PredicateBuilder): PredicateBuilder
-
-    /**
-     * Adds a predicate to the WHERE clause using an AND condition.
-     *
-     *
-     * This method combines the specified predicate with existing predicates using an AND operation, ensuring
-     * that all added conditions must be true.
-     *
-     * @param predicate the predicate to add.
-     * @return the predicate builder.
-     */
-    public infix fun  andAny(predicate: PredicateBuilder): PredicateBuilder
-
     /**
      * Adds a predicate to the WHERE clause using an AND condition.
      *
@@ -91,30 +70,6 @@ public interface PredicateBuilder {
      */
     public infix fun and(template: TemplateString): PredicateBuilder
 
-    /**
-     * Adds a predicate to the WHERE clause using an OR condition.
-     *
-     *
-     * This method combines the specified predicate with existing predicates using an OR operation, allowing any
-     * of the added conditions to be true.
-     *
-     * @param predicate the predicate to add.
-     * @return the predicate builder.
-     */
-    public infix fun or(predicate: PredicateBuilder): PredicateBuilder
-
-    /**
-     * Adds a predicate to the WHERE clause using an OR condition.
-     *
-     *
-     * This method combines the specified predicate with existing predicates using an OR operation, allowing any
-     * of the added conditions to be true.
-     *
-     * @param predicate the predicate to add.
-     * @return the predicate builder.
-     */
-    public infix fun  orAny(predicate: PredicateBuilder): PredicateBuilder
-
     /**
      * Adds a predicate to the WHERE clause using an OR condition.
      *
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt
index 523f7245e..21a95489d 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt
@@ -25,6 +25,8 @@ import st.orm.core.template.impl.Elements.ObjectExpression
 import st.orm.template.TemplateString.Companion.combine
 import st.orm.template.TemplateString.Companion.raw
 import st.orm.template.TemplateString.Companion.wrap
+import st.orm.template.impl.combineAnd
+import st.orm.template.impl.combineOr
 import st.orm.template.impl.create
 import st.orm.template.impl.createRef
 import java.util.stream.Stream
@@ -1369,6 +1371,28 @@ public fun  Navigable.isNull(): PredicateBuilder = c
  */
 public fun  Navigable.isNotNull(): PredicateBuilder = create(this.asMetamodel(), IS_NOT_NULL, emptyList())
 
+/**
+ * Combines two predicates that share the same root using an AND condition.
+ *
+ * Inside a [WhereBuilder] scope, the scope's own `and` takes precedence and inherits the query root, so a widened
+ * query combines predicates across joined entities with the same syntax.
+ *
+ * @param predicate the predicate to add.
+ * @return the combined predicate builder.
+ */
+public infix fun  PredicateBuilder.and(predicate: PredicateBuilder): PredicateBuilder = combineAnd(this, predicate)
+
+/**
+ * Combines two predicates that share the same root using an OR condition.
+ *
+ * Inside a [WhereBuilder] scope, the scope's own `or` takes precedence and inherits the query root, so a widened
+ * query combines predicates across joined entities with the same syntax.
+ *
+ * @param predicate the predicate to add.
+ * @return the combined predicate builder.
+ */
+public infix fun  PredicateBuilder.or(predicate: PredicateBuilder): PredicateBuilder = combineOr(this, predicate)
+
 // Block-based query DSL
 
 /**
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt
index ebba0f75a..e131bccbe 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt
@@ -29,9 +29,9 @@ import st.orm.template.TemplateString.Companion.raw
  * matching by primary key, record, ref, metamodel path, or custom template string expressions. Each method returns
  * a [PredicateBuilder] that can be further composed using `and()` and `or()` combinators.
  *
- * The `where` methods are type-safe and restrict metamodel paths to the root table's entity graph. Predicates for
- * other tables, including manually added joins, can be combined using the [PredicateBuilder.andAny] and
- * [PredicateBuilder.orAny] combinators or expressed as custom template strings.
+ * The `where` methods are type-safe and restrict metamodel paths to the root table's entity graph. A join widens
+ * the query root, and the scope's `and`/`or` combinators inherit it: a narrow scope combines predicates within
+ * the root graph, a widened scope combines predicates across all entities in the query.
  *
  * ## Example
  * ```kotlin
@@ -62,6 +62,30 @@ public interface WhereBuilder : SubqueryTemplate {
      */
     public fun FALSE(): PredicateBuilder = where(raw("FALSE"))
 
+    /**
+     * Combines this predicate with another using an AND condition.
+     *
+     * The combinator inherits the query root of this `WhereBuilder`: a narrow scope combines predicates within
+     * the root table's entity graph, and a join widens the root so predicates rooted at any entity in the query
+     * combine with the same syntax.
+     *
+     * @param predicate the predicate to add.
+     * @return the combined predicate builder.
+     */
+    public infix fun PredicateBuilder.and(predicate: PredicateBuilder): PredicateBuilder
+
+    /**
+     * Combines this predicate with another using an OR condition.
+     *
+     * The combinator inherits the query root of this `WhereBuilder`: a narrow scope combines predicates within
+     * the root table's entity graph, and a join widens the root so predicates rooted at any entity in the query
+     * combine with the same syntax.
+     *
+     * @param predicate the predicate to add.
+     * @return the combined predicate builder.
+     */
+    public infix fun PredicateBuilder.or(predicate: PredicateBuilder): PredicateBuilder
+
     /**
      * Adds an `EXISTS` condition to the WHERE clause using the specified subquery.
      *
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt
index cdc11bd8a..f8f56178e 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt
@@ -92,3 +92,27 @@ internal fun  createRefWithId(
     operator: Operator,
     o: Iterable>,
 ): PredicateBuilder = PredicateBuilderImpl(PredicateBuilderFactory.createRefWithId(path, operator, o))
+
+/**
+ * Combines two predicates using an AND condition, keeping the left-hand predicate's root.
+ *
+ * @param left the predicate to combine into
+ * @param right the predicate to add
+ * @return the combined [PredicateBuilder]
+ */
+internal fun  combineAnd(
+    left: PredicateBuilder,
+    right: PredicateBuilder<*, *, *>,
+): PredicateBuilder = PredicateBuilderImpl((left as PredicateBuilderImpl).core.and((right as PredicateBuilderImpl<*, *, *>).core))
+
+/**
+ * Combines two predicates using an OR condition, keeping the left-hand predicate's root.
+ *
+ * @param left the predicate to combine into
+ * @param right the predicate to add
+ * @return the combined [PredicateBuilder]
+ */
+internal fun  combineOr(
+    left: PredicateBuilder,
+    right: PredicateBuilder<*, *, *>,
+): PredicateBuilder = PredicateBuilderImpl((left as PredicateBuilderImpl).core.or((right as PredicateBuilderImpl<*, *, *>).core))
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/QueryBuilderImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/QueryBuilderImpl.kt
index b276afe40..73eea250e 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/QueryBuilderImpl.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/QueryBuilderImpl.kt
@@ -324,16 +324,8 @@ internal class QueryBuilderImpl(
     internal class PredicateBuilderImpl(
         val core: st.orm.core.template.PredicateBuilder,
     ) : PredicateBuilder {
-        override infix fun and(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl(core.and((predicate as PredicateBuilderImpl).core))
-
-        override fun  andAny(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl(core.andAny((predicate as PredicateBuilderImpl).core))
-
         override fun and(template: TemplateString): PredicateBuilder = PredicateBuilderImpl(core.and(template.unwrap))
 
-        override infix fun or(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl(core.or((predicate as PredicateBuilderImpl).core))
-
-        override fun  orAny(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl(core.orAny((predicate as PredicateBuilderImpl).core))
-
         override fun or(template: TemplateString): PredicateBuilder = PredicateBuilderImpl(core.or(template.unwrap))
     }
 
@@ -375,6 +367,10 @@ internal class QueryBuilderImpl(
         ): PredicateBuilder = PredicateBuilderImpl(core.where(path.asMetamodel(), operator, it))
 
         override fun where(template: TemplateString): PredicateBuilder = PredicateBuilderImpl(core.where((template as TemplateStringHolder).templateString))
+
+        override infix fun PredicateBuilder.and(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl((this as PredicateBuilderImpl).core.and((predicate as PredicateBuilderImpl<*, *, *>).core))
+
+        override infix fun PredicateBuilder.or(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl((this as PredicateBuilderImpl).core.or((predicate as PredicateBuilderImpl<*, *, *>).core))
     }
 
     /**
diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/EntityRepositoryTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/EntityRepositoryTest.kt
index 5d8e01bf1..bbd7753f0 100644
--- a/storm-kotlin/src/test/kotlin/st/orm/template/EntityRepositoryTest.kt
+++ b/storm-kotlin/src/test/kotlin/st/orm/template/EntityRepositoryTest.kt
@@ -1033,10 +1033,10 @@ open class EntityRepositoryTest(
         preparedQuery.close()
     }
 
-    // QueryBuilder: whereAny with multiple predicates
+    // QueryBuilder: whereBuilder with multiple predicates
 
     @Test
-    fun `whereAny with combined predicates should match any`() {
+    fun `whereBuilder with combined predicates should match any`() {
         val repo = orm.entity(City::class)
         val namePath = metamodel(repo.model, "name")
         val cities = repo.select().whereBuilder {
@@ -1045,15 +1045,15 @@ open class EntityRepositoryTest(
         cities shouldHaveSize 2
     }
 
-    // QueryBuilder: andAny / orAny predicate builders
+    // QueryBuilder: and / or predicate combinators
 
     @Test
-    fun `andAny should combine predicates with AND-OR logic`() {
+    fun `and should combine predicates with AND-OR logic`() {
         val repo = orm.entity(Owner::class)
         val firstNamePath = metamodel(repo.model, "first_name")
         val lastNamePath = metamodel(repo.model, "last_name")
         val result = repo.select().whereBuilder {
-            (lastNamePath eq "Davis") andAny (
+            (lastNamePath eq "Davis") and (
                 (firstNamePath eq "Betty") or (firstNamePath eq "Harold")
                 )
         }.resultList
@@ -1061,7 +1061,7 @@ open class EntityRepositoryTest(
     }
 
     @Test
-    fun `orAny should combine predicates with OR logic`() {
+    fun `or should combine predicates with OR logic`() {
         val repo = orm.entity(City::class)
         val namePath = metamodel(repo.model, "name")
         val result = repo.select().whereBuilder {
diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt
index 0d63463e4..ec9be0096 100644
--- a/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt
+++ b/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt
@@ -1540,26 +1540,26 @@ open class ORMTemplateTest(
         cities shouldHaveSize 0
     }
 
-    // PredicateBuilder: andAny/orAny
+    // PredicateBuilder: and/or
 
     @Test
-    fun `predicateBuilder andAny should combine predicates from different entities`() {
+    fun `predicateBuilder and should combine predicates`() {
         val repo = orm.entity(City::class)
         val namePath = metamodel(repo.model, "name")
         val idPath = metamodel(repo.model, "id")
         val cities = repo.select().whereBuilder {
-            (namePath eq "Madison") andAny (idPath eq 2)
+            (namePath eq "Madison") and (idPath eq 2)
         }.resultList
         cities shouldHaveSize 1
     }
 
     @Test
-    fun `predicateBuilder orAny should combine predicates from different entities`() {
+    fun `predicateBuilder or should combine predicates`() {
         val repo = orm.entity(City::class)
         val namePath = metamodel(repo.model, "name")
         val idPath = metamodel(repo.model, "id")
         val cities = repo.select().whereBuilder {
-            (namePath eq "Madison") orAny (idPath eq 1)
+            (namePath eq "Madison") or (idPath eq 1)
         }.resultList
         cities shouldHaveSize 2
     }
diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt
index 7863750f5..5a36a34b5 100644
--- a/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt
+++ b/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt
@@ -1243,28 +1243,40 @@ open class QueryBuilderTest(
     }
 
     @Test
-    fun `andAny should combine predicates across different types`() {
+    fun `and should combine two predicates`() {
         val repo = orm.entity(Owner::class)
         val firstNamePath = metamodel(repo.model, "first_name")
         val lastNamePath = metamodel(repo.model, "last_name")
-        // Use andAny to combine a predicate with another typed predicate.
         val result = repo.select().whereBuilder {
-            (firstNamePath eq "Betty") andAny (lastNamePath eq "Davis")
+            (firstNamePath eq "Betty") and (lastNamePath eq "Davis")
         }.resultList
         result shouldHaveSize 1
         result[0].firstName shouldBe "Betty"
     }
 
     @Test
-    fun `orAny should combine predicates across different types`() {
+    fun `or should combine two predicates`() {
         val repo = orm.entity(City::class)
         val namePath = metamodel(repo.model, "name")
         val result = repo.select().whereBuilder {
-            (namePath eq "Madison") orAny (namePath eq "Windsor")
+            (namePath eq "Madison") or (namePath eq "Windsor")
         }.resultList
         result shouldHaveSize 2
     }
 
+    @Test
+    fun `and should combine predicates across entities after a join`() {
+        val petNamePath = metamodel(orm.entity(Pet::class).model, "name")
+        val ownerLastNamePath = metamodel(orm.entity(Owner::class).model, "last_name")
+        val pets = orm.entity(Pet::class).select()
+            .innerJoin(Owner::class).on(Pet::class)
+            .whereBuilder {
+                (petNamePath eq "Leo") and (ownerLastNamePath eq "Davis")
+            }.resultList
+        pets shouldHaveSize 1
+        pets[0].name shouldBe "Leo"
+    }
+
     @Test
     fun `predicate and with TemplateBuilder should combine conditions`() {
         val repo = orm.entity(City::class)

From 0836eebd994195a6b2f1dd95e59348902b667ec8 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort 
Date: Tue, 11 Aug 2026 13:29:28 +0200
Subject: [PATCH 3/4] refactor: state the root-relative contract on the core
 combinators too

The core and/or parameters tighten from ? extends Data to ? extends T,
the same level where operates at. The bridges' arguments are within the
receiver's root in every sound path: narrow scopes guarantee same-root by
their public signatures, and a widened scope's root is Data itself, so the
permissive parameter stated less than what every caller already proves.
---
 .../java/st/orm/core/template/PredicateBuilder.java  | 12 ++++++------
 .../st/orm/core/template/impl/QueryBuilderImpl.java  |  4 ++--
 .../java/st/orm/template/impl/QueryBuilderImpl.java  |  4 ++--
 .../st/orm/template/impl/PredicateBuilderFactory.kt  |  8 ++++----
 .../kotlin/st/orm/template/impl/QueryBuilderImpl.kt  |  4 ++--
 5 files changed, 16 insertions(+), 16 deletions(-)

diff --git a/storm-core/src/main/java/st/orm/core/template/PredicateBuilder.java b/storm-core/src/main/java/st/orm/core/template/PredicateBuilder.java
index c92b88a8a..5b3b0e122 100644
--- a/storm-core/src/main/java/st/orm/core/template/PredicateBuilder.java
+++ b/storm-core/src/main/java/st/orm/core/template/PredicateBuilder.java
@@ -31,13 +31,13 @@ public interface PredicateBuilder {
      * Adds a predicate to the WHERE clause using an AND condition.
      *
      * 

This method combines the specified predicate with existing predicates using an AND operation, ensuring - * that all added conditions must be true. The predicate may be rooted at any entity in the query; a path on an - * entity outside the query fails when the query is built.

+ * that all added conditions must be true. The predicate inherits the query root: a join widens the root, + * admitting predicates that reference any entity in the query.

* * @param predicate the predicate to add. * @return the predicate builder. */ - PredicateBuilder and(@Nonnull PredicateBuilder predicate); + PredicateBuilder and(@Nonnull PredicateBuilder predicate); /** * Adds a predicate to the WHERE clause using an AND condition. @@ -54,13 +54,13 @@ public interface PredicateBuilder { * Adds a predicate to the WHERE clause using an OR condition. * *

This method combines the specified predicate with existing predicates using an OR operation, allowing any - * of the added conditions to be true. The predicate may be rooted at any entity in the query; a path on an - * entity outside the query fails when the query is built.

+ * of the added conditions to be true. The predicate inherits the query root: a join widens the root, + * admitting predicates that reference any entity in the query.

* * @param predicate the predicate to add. * @return the predicate builder. */ - PredicateBuilder or(@Nonnull PredicateBuilder predicate); + PredicateBuilder or(@Nonnull PredicateBuilder predicate); /** * Adds a predicate to the WHERE clause using an OR condition. diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java index 77b221b0f..e0e29512a 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryBuilderImpl.java @@ -446,7 +446,7 @@ static class PredicateBuilderImpl implements Predicate } @Override - public PredicateBuilder and(@Nonnull PredicateBuilder predicate) { + public PredicateBuilder and(@Nonnull PredicateBuilder predicate) { add(RAW_AND, predicate); return this; } @@ -458,7 +458,7 @@ public PredicateBuilder and(@Nonnull TemplateString template) { } @Override - public PredicateBuilder or(@Nonnull PredicateBuilder predicate) { + public PredicateBuilder or(@Nonnull PredicateBuilder predicate) { add(RAW_OR, predicate); return this; } diff --git a/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java b/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java index 97accdfdb..f34f9d648 100644 --- a/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java +++ b/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java @@ -462,7 +462,7 @@ static class PredicateBuilderImpl implements Predicate @Override public PredicateBuilder and(@Nonnull PredicateBuilder predicate) { - return new PredicateBuilderImpl<>(core.and(((PredicateBuilderImpl) predicate).core)); + return new PredicateBuilderImpl<>(core.and(((PredicateBuilderImpl) predicate).core)); } @Override @@ -472,7 +472,7 @@ public PredicateBuilder and(@Nonnull StringTemplate template) { @Override public PredicateBuilder or(@Nonnull PredicateBuilder predicate) { - return new PredicateBuilderImpl<>(core.or(((PredicateBuilderImpl) predicate).core)); + return new PredicateBuilderImpl<>(core.or(((PredicateBuilderImpl) predicate).core)); } @Override diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt index f8f56178e..7893b251a 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/PredicateBuilderFactory.kt @@ -102,8 +102,8 @@ internal fun createRefWithId( */ internal fun combineAnd( left: PredicateBuilder, - right: PredicateBuilder<*, *, *>, -): PredicateBuilder = PredicateBuilderImpl((left as PredicateBuilderImpl).core.and((right as PredicateBuilderImpl<*, *, *>).core)) + right: PredicateBuilder, +): PredicateBuilder = PredicateBuilderImpl((left as PredicateBuilderImpl).core.and((right as PredicateBuilderImpl).core)) /** * Combines two predicates using an OR condition, keeping the left-hand predicate's root. @@ -114,5 +114,5 @@ internal fun combineAnd( */ internal fun combineOr( left: PredicateBuilder, - right: PredicateBuilder<*, *, *>, -): PredicateBuilder = PredicateBuilderImpl((left as PredicateBuilderImpl).core.or((right as PredicateBuilderImpl<*, *, *>).core)) + right: PredicateBuilder, +): PredicateBuilder = PredicateBuilderImpl((left as PredicateBuilderImpl).core.or((right as PredicateBuilderImpl).core)) diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/QueryBuilderImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/QueryBuilderImpl.kt index 73eea250e..a287bc119 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/QueryBuilderImpl.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/QueryBuilderImpl.kt @@ -368,9 +368,9 @@ internal class QueryBuilderImpl( override fun where(template: TemplateString): PredicateBuilder = PredicateBuilderImpl(core.where((template as TemplateStringHolder).templateString)) - override infix fun PredicateBuilder.and(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl((this as PredicateBuilderImpl).core.and((predicate as PredicateBuilderImpl<*, *, *>).core)) + override infix fun PredicateBuilder.and(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl((this as PredicateBuilderImpl).core.and((predicate as PredicateBuilderImpl).core)) - override infix fun PredicateBuilder.or(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl((this as PredicateBuilderImpl).core.or((predicate as PredicateBuilderImpl<*, *, *>).core)) + override infix fun PredicateBuilder.or(predicate: PredicateBuilder): PredicateBuilder = PredicateBuilderImpl((this as PredicateBuilderImpl).core.or((predicate as PredicateBuilderImpl).core)) } /** From 360367831ae0c34358ece329b10962ee9178b918 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Tue, 11 Aug 2026 13:36:55 +0200 Subject: [PATCH 4/4] test: cover and/or across entities on a widened builder on every surface The Kotlin surface had the cross-entity and case; core and storm-java21 predicates on a widened builder were untested, and no surface covered or. Pets joined with owners: name = 'Leo' AND owner 'Davis' finds Betty's Leo; OR also admits Harold Davis's Iggy. --- .../QueryBuilderPredicateIntegrationTest.java | 25 +++++++++++++++++++ .../st/orm/template/QueryBuilderTest.java | 23 +++++++++++++++++ .../st/orm/template/QueryBuilderTest.kt | 13 ++++++++++ 3 files changed, 61 insertions(+) diff --git a/storm-core/src/test/java/st/orm/core/QueryBuilderPredicateIntegrationTest.java b/storm-core/src/test/java/st/orm/core/QueryBuilderPredicateIntegrationTest.java index 9c24981f4..f1d7f1eb0 100644 --- a/storm-core/src/test/java/st/orm/core/QueryBuilderPredicateIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/QueryBuilderPredicateIntegrationTest.java @@ -263,6 +263,31 @@ public void testPredicateBuilderOrPredicate() { assertEquals(2, visits.size()); } + // PredicateBuilder.and / or - predicates across entities on a widened builder + + @Test + public void testPredicateBuilderAndAcrossEntitiesAfterJoin() { + var orm = ORMTemplate.of(dataSource); + List pets = orm.selectFrom(Pet.class) + .innerJoin(Owner.class).on(Pet.class) + .where(predicate -> predicate.where(Pet_.name, EQUALS, "Leo") + .and(predicate.where(Owner_.lastName, EQUALS, "Davis"))) + .getResultList(); + assertEquals(1, pets.size()); + } + + @Test + public void testPredicateBuilderOrAcrossEntitiesAfterJoin() { + // Pets named Leo (Betty Davis's pet) or owned by a Davis: Leo and Harold Davis's Iggy. + var orm = ORMTemplate.of(dataSource); + List pets = orm.selectFrom(Pet.class) + .innerJoin(Owner.class).on(Pet.class) + .where(predicate -> predicate.where(Pet_.name, EQUALS, "Leo") + .or(predicate.where(Owner_.lastName, EQUALS, "Davis"))) + .getResultList(); + assertEquals(2, pets.size()); + } + // QueryBuilder.having with raw template @Test diff --git a/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java b/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java index 36550d01c..4ad2c82d2 100644 --- a/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java +++ b/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java @@ -710,6 +710,29 @@ public void testPredicateOrPredicate() { assertEquals(1, cities.size()); } + // PredicateBuilder - and / or across entities on a widened builder + + @Test + public void testPredicateAndAcrossEntitiesAfterJoin() { + List pets = orm.entity(Pet.class).select() + .innerJoin(Owner.class).on(Pet.class) + .where(wb -> wb.where(Pet_.name, EQUALS, "Leo") + .and(wb.where(Owner_.lastName, EQUALS, "Davis"))) + .getResultList(); + assertEquals(1, pets.size()); + } + + @Test + public void testPredicateOrAcrossEntitiesAfterJoin() { + // Pets named Leo (Betty Davis's pet) or owned by a Davis: Leo and Harold Davis's Iggy. + List pets = orm.entity(Pet.class).select() + .innerJoin(Owner.class).on(Pet.class) + .where(wb -> wb.where(Pet_.name, EQUALS, "Leo") + .or(wb.where(Owner_.lastName, EQUALS, "Davis"))) + .getResultList(); + assertEquals(2, pets.size()); + } + @Test public void testPredicateAndTemplate() { List cities = orm.entity(City.class).select() diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt index 5a36a34b5..24121c053 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt @@ -1277,6 +1277,19 @@ open class QueryBuilderTest( pets[0].name shouldBe "Leo" } + @Test + fun `or should combine predicates across entities after a join`() { + // Pets named Leo (Betty Davis's pet) or owned by a Davis: Leo and Harold Davis's Iggy. + val petNamePath = metamodel(orm.entity(Pet::class).model, "name") + val ownerLastNamePath = metamodel(orm.entity(Owner::class).model, "last_name") + val pets = orm.entity(Pet::class).select() + .innerJoin(Owner::class).on(Pet::class) + .whereBuilder { + (petNamePath eq "Leo") or (ownerLastNamePath eq "Davis") + }.resultList + pets shouldHaveSize 2 + } + @Test fun `predicate and with TemplateBuilder should combine conditions`() { val repo = orm.entity(City::class)