From b67e28e60a0b0361892b1265e5898bc20c70d6b4 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Wed, 15 Jul 2026 20:48:28 +1200 Subject: [PATCH 1/5] Bug: filterMany() expressions when additional nested fetch has predicates in wrong place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a filterMany() expression only references the many-root's own properties (e.g. .eq("status", ...)), but the query also has an additional nested fetch beneath that many-path (e.g. .fetch("orders.customer") or .fetch("contacts.group")), the filter predicate was incorrectly attached to the last/deepest join's ON clause instead of the many-root's own join — silently misapplying the filter. Fix: A hybrid approach: - If the filterMany expression references only root-level properties → attach the predicate directly on the many-root's own join (correct). - If it references deeper/nested properties requiring "extra joins" (a separate existing mechanism) → fall back to the original end-of-subtree behavior, since no SqlTreeNode exists to attach to. - includeFilterMany() made idempotent so both mechanisms coexist safely. --- .../server/deploy/DbSqlContext.java | 7 ++ .../server/query/CQueryPredicates.java | 32 ++++++++ .../server/query/DefaultDbSqlContext.java | 17 ++++- .../server/query/SqlTreeBuilder.java | 2 +- .../server/query/SqlTreeNodeBean.java | 7 ++ .../server/query/SqlTreeNodeManyRoot.java | 3 + .../org/tests/query/TestQueryFilterMany.java | 10 +-- .../query/TestQueryFilterManySimple.java | 2 +- ...tQueryFilterManyWithDeeperNestedFetch.java | 73 +++++++++++++++++++ 9 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 ebean-test/src/test/java/org/tests/query/TestQueryFilterManyWithDeeperNestedFetch.java diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/DbSqlContext.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/DbSqlContext.java index cd5843abb2..ddf64a0d73 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/DbSqlContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/DbSqlContext.java @@ -181,4 +181,11 @@ default void addFormula2Join(String joinLiteral, String table, String a2, String * Include the filter many predicates if specified into the JOIN clause. */ void includeFilterMany(); + + /** + * Return true if the given fetch path (relative to the query root) is the exact join clause + * that the pending filterMany predicate must be attached to - i.e. the deepest path the + * filterMany expression itself references. + */ + boolean isFilterManyAttachPoint(String prefix); } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java index cd5f17d70c..c5092b5b02 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java @@ -78,6 +78,12 @@ public final class CQueryPredicates { */ private Set predicateIncludes; private Set orderByIncludes; + /** + * The deepest fetch path (relative to the query root) that the filterMany expression itself + * references - e.g. for {@code filterMany("contacts").eq("group.name", ...)} this is + * {@code "contacts.group"} rather than just {@code "contacts"}. + */ + private String filterManyAttachPath; CQueryPredicates(Binder binder, OrmQueryRequest request) { this.binder = binder; @@ -221,7 +227,9 @@ public void prepare(boolean buildSql) { filterManyJoin = chunk.isFilterManyJoin(); filterMany = new DefaultExpressionRequest(request, deployParser, binder, filterManyExpr); if (buildSql) { + Set beforeIncludes = new HashSet<>(deployParser.includes()); dbFilterMany = filterMany.buildSql(); + filterManyAttachPath = deepestFilterManyPath(manyProperty.path(), beforeIncludes, deployParser.includes()); } } } @@ -242,6 +250,21 @@ public void prepare(boolean buildSql) { } } + /** + * Determine the specific fetch-path node (relative to the query root) that the filterMany + * predicate should be attached to, given the set of includes before and after building its SQL. + */ + private String deepestFilterManyPath(String manyPath, Set beforeIncludes, Set afterIncludes) { + for (String include : afterIncludes) { + if (!beforeIncludes.contains(include) && include.startsWith(manyPath) && include.length() > manyPath.length()) { + // the filterMany expression needed a deeper/nested include - not safe to attach at the + // many-root's own join, fall back to the default (end of subtree) behaviour. + return null; + } + } + return manyPath; + } + /** * Replace the table alias place-holders. */ @@ -399,6 +422,15 @@ String dbFilterManyJoin() { return filterManyJoin ? dbFilterMany : null; } + /** + * Return the deepest fetch path that the filterMany-in-JOIN predicate references - the path + * whose own join clause the predicate must be appended to (or null if there is no + * filterMany-in-JOIN predicate at all). + */ + String filterManyAttachPath() { + return filterManyJoin ? filterManyAttachPath : null; + } + /** * Return the db column version of the order by clause. */ diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultDbSqlContext.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultDbSqlContext.java index 67398de30b..86805e66c0 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultDbSqlContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultDbSqlContext.java @@ -23,6 +23,8 @@ final class DefaultDbSqlContext implements DbSqlContext { private final ArrayStack prefixStack = new ArrayStack<>(); private final String fromForUpdate; private final String dbFilterManyJoin; + private final String filterManyAttachPath; + private boolean filterManyIncluded; private boolean useColumnAlias; private int columnIndex; private int asOfTableCount; @@ -42,7 +44,8 @@ final class DefaultDbSqlContext implements DbSqlContext { private boolean joinSuppressed; DefaultDbSqlContext(SqlTreeAlias alias, String columnAliasPrefix, CQueryHistorySupport historySupport, - CQueryDraftSupport draftSupport, String fromForUpdate, String dbFilterManyJoin) { + CQueryDraftSupport draftSupport, String fromForUpdate, String dbFilterManyJoin, + String filterManyAttachPath) { this.alias = alias; this.columnAliasPrefix = columnAliasPrefix; this.useColumnAlias = columnAliasPrefix != null; @@ -51,11 +54,21 @@ final class DefaultDbSqlContext implements DbSqlContext { this.historyQuery = (historySupport != null); this.fromForUpdate = fromForUpdate; this.dbFilterManyJoin = dbFilterManyJoin; + this.filterManyAttachPath = filterManyAttachPath; + } + + @Override + public boolean isFilterManyAttachPoint(String prefix) { + return dbFilterManyJoin != null && filterManyAttachPath != null && filterManyAttachPath.equals(prefix); } @Override public void includeFilterMany() { - if (dbFilterManyJoin != null) { + if (dbFilterManyJoin != null && !filterManyIncluded) { + // idempotent - the predicate may be attached either at its designated node (generic path + // matching in SqlTreeNodeBean) or, as a fallback, at the end of the many-root's subtree + // (SqlTreeNodeManyRoot) - never both. + filterManyIncluded = true; sb.append(" and ").append(dbFilterManyJoin); } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeBuilder.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeBuilder.java index db6a44cfe9..e2990149cb 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeBuilder.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeBuilder.java @@ -108,7 +108,7 @@ public final class SqlTreeBuilder { CQueryHistorySupport historySupport = builder.historySupport(query); CQueryDraftSupport draftSupport = builder.draftSupport(query); String colAlias = subQuery || rootNode.isSingleProperty() ? null : columnAliasPrefix; - this.ctx = new DefaultDbSqlContext(alias, colAlias, historySupport, draftSupport, fromForUpdate, predicates.dbFilterManyJoin()); + this.ctx = new DefaultDbSqlContext(alias, colAlias, historySupport, draftSupport, fromForUpdate, predicates.dbFilterManyJoin(), predicates.filterManyAttachPath()); } /** diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java index e3f8d6327e..f9b275295a 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java @@ -342,6 +342,13 @@ SqlJoinType appendFromBaseTable(DbSqlContext ctx, SqlJoinType joinType) { if (desc.isSoftDelete() && temporalMode != SpiQuery.TemporalMode.SOFT_DELETED) { ctx.append(" and ").append(desc.softDeletePredicate(ctx.tableAlias(prefix))); } + if (prefix != null && ctx.isFilterManyAttachPoint(prefix)) { + // this node's own join is the deepest one the pending filterMany predicate references - + // include it now (right after this join, before any further nested/child joins beneath + // it are appended) so it correctly narrows this join's own rows rather than an unrelated + // deeper join's rows. + ctx.includeFilterMany(); + } return sqlJoinType; } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java index ec34d53953..e7726d9489 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java @@ -41,6 +41,9 @@ protected void appendExtraWhere(DbSqlContext ctx) { /** * Force outer join for everything after the many property. + *

+ * If the filterMany predicate only references the many-root's own direct properties, it is already + * included generically right after this node's own join clause (see SqlTreeNodeBean#appendFromBaseTable). */ @Override public void appendFrom(DbSqlContext ctx, SqlJoinType joinType) { diff --git a/ebean-test/src/test/java/org/tests/query/TestQueryFilterMany.java b/ebean-test/src/test/java/org/tests/query/TestQueryFilterMany.java index c0d75d099b..2f2bf3e178 100644 --- a/ebean-test/src/test/java/org/tests/query/TestQueryFilterMany.java +++ b/ebean-test/src/test/java/org/tests/query/TestQueryFilterMany.java @@ -174,7 +174,7 @@ public void filterManyRaw_singleQuery() { assertThat(customers).isNotEmpty(); List sqlList = LoggedSql.stop(); assertEquals(1, sqlList.size()); - assertThat(sqlList.get(0)).contains(" left join o_customer t2 on t2.id = t1.kcustomer_id and t1.status = ? where "); + assertThat(sqlList.get(0)).contains(" left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.status = ? left join o_customer t2 on t2.id = t1.kcustomer_id where "); assertThat(sqlList.get(0)).contains(" where lower(t0.name) = ? order by t0.id"); } @@ -211,7 +211,7 @@ public void test_with_findOne_rawSameQuery() { assertThat(result).isNotEmpty(); List sql = LoggedSql.stop(); assertThat(sql).hasSize(1); - assertThat(sql.get(0)).contains("from o_customer t0 left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null left join o_customer t2 on t2.id = t1.kcustomer_id and t1.order_date is not null order by t0.id"); + assertThat(sql.get(0)).contains("from o_customer t0 left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.order_date is not null left join o_customer t2 on t2.id = t1.kcustomer_id order by t0.id"); } @Test @@ -340,9 +340,9 @@ public void test_filterMany_copy_findList() { List sqlList = LoggedSql.stop(); assertEquals(1, sqlList.size()); if (isPostgresCompatible()) { - assertThat(sqlList.get(0)).contains("left join o_customer t2 on t2.id = t1.kcustomer_id and t1.status = any(?) order by t0.id"); + assertThat(sqlList.get(0)).contains("left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.status = any(?) left join o_customer t2 on t2.id = t1.kcustomer_id order by t0.id"); } else { - assertThat(sqlList.get(0)).contains("left join o_customer t2 on t2.id = t1.kcustomer_id and t1.status in (?) order by t0.id"); + assertThat(sqlList.get(0)).contains("left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.status in (?) left join o_customer t2 on t2.id = t1.kcustomer_id order by t0.id"); } } @@ -386,7 +386,7 @@ public void testDisjunction() { List sql = LoggedSql.stop(); assertEquals(1, sql.size()); - assertSql(sql.get(0)).contains(" left join o_customer t2 on t2.id = t1.kcustomer_id and (t1.status = ? or t1.order_date = ?) order by t0.id"); + assertSql(sql.get(0)).contains(" left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and (t1.status = ? or t1.order_date = ?) left join o_customer t2 on t2.id = t1.kcustomer_id order by t0.id"); } @Test diff --git a/ebean-test/src/test/java/org/tests/query/TestQueryFilterManySimple.java b/ebean-test/src/test/java/org/tests/query/TestQueryFilterManySimple.java index 47103dbcc1..81948026dc 100644 --- a/ebean-test/src/test/java/org/tests/query/TestQueryFilterManySimple.java +++ b/ebean-test/src/test/java/org/tests/query/TestQueryFilterManySimple.java @@ -35,7 +35,7 @@ public void test() { list.get(0).getOrders().size(); List sql = LoggedSql.stop(); assertThat(sql).hasSize(2); - assertThat(sql.get(0)).contains("left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null left join o_customer t2 on t2.id = t1.kcustomer_id and t1.status = ? and t1.order_date > ?"); + assertThat(sql.get(0)).contains("left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null and t1.status = ? and t1.order_date > ? left join o_customer t2 on t2.id = t1.kcustomer_id"); assertThat(sql.get(0)).contains("order by t0.id"); if (isPostgresCompatible()) { assertThat(sql.get(1)).contains("from contact t0 where (t0.customer_id) = any(?) and t0.first_name is not null;"); diff --git a/ebean-test/src/test/java/org/tests/query/TestQueryFilterManyWithDeeperNestedFetch.java b/ebean-test/src/test/java/org/tests/query/TestQueryFilterManyWithDeeperNestedFetch.java new file mode 100644 index 0000000000..875ec3263b --- /dev/null +++ b/ebean-test/src/test/java/org/tests/query/TestQueryFilterManyWithDeeperNestedFetch.java @@ -0,0 +1,73 @@ +package org.tests.query; + +import io.ebean.DB; +import io.ebean.test.LoggedSql; +import io.ebean.xtest.BaseTestCase; +import org.junit.jupiter.api.Test; +import org.tests.model.basic.Contact; +import org.tests.model.basic.Customer; +import org.tests.model.basic.ResetBasicData; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Reproduces a bug where filterMany() on a property of the many-root itself is misapplied + * to the ON clause of a deeper, unrelated nested fetch join instead of the many-root's own join + * clause - when an additional fetch path exists beneath the many property that the filterMany + * expression itself does not reference. + */ +public class TestQueryFilterManyWithDeeperNestedFetch extends BaseTestCase { + + @Test + void filterMany_onRootProperty_withUnrelatedDeeperNestedFetch_expectFilterOnOwnJoin() { + ResetBasicData.reset(); + + Customer customer = DB.find(Customer.class).where().ieq("name", "Rob").findOne(); + assertThat(customer).isNotNull(); + + List allContacts = DB.find(Contact.class).where().eq("customer", customer).findList(); + assertThat(allContacts).isNotEmpty(); + + // ensure at least one contact isMember=true and one isMember=false + Contact memberContact = allContacts.get(0); + memberContact.setMember(true); + DB.save(memberContact); + Contact nonMemberContact; + if (allContacts.size() > 1) { + nonMemberContact = allContacts.get(1); + } else { + nonMemberContact = new Contact(); + nonMemberContact.setFirstName("Extra"); + nonMemberContact.setLastName("NonMember"); + nonMemberContact.setCustomer(customer); + } + nonMemberContact.setMember(false); + DB.save(nonMemberContact); + + LoggedSql.start(); + // filterMany only references "isMember" (a property of Contact - the many-root itself) but + // the query ALSO fetches a further nested path beneath "contacts" (contacts.group) that the + // filterMany expression does NOT reference at all. + List found = DB.find(Customer.class) + .setBeanCacheMode(io.ebean.CacheMode.OFF) + .setPersistenceContextScope(io.ebean.PersistenceContextScope.QUERY) + .fetch("contacts", "id,firstName,lastName,isMember") + .fetch("contacts.group", "id,name") + .filterMany("contacts").eq("isMember", true) + .where().idEq(customer.getId()) + .findList(); + + List sql = LoggedSql.stop(); + assertThat(found).isNotEmpty(); + assertThat(sql).hasSize(1); + // the filterMany predicate must be on contact's own join (t1), not misapplied to the + // unrelated deeper contact_group join (t2) + assertThat(sql.get(0)).contains("left join contact t1 on t1.customer_id = t0.id and t1.is_member = ? left join contact_group t2 on t2.id = t1.group_id"); + + // every contact returned must be a member - the filter must actually exclude non-members + assertThat(found.get(0).getContacts()).isNotEmpty(); + assertThat(found.get(0).getContacts()).allMatch(Contact::isMember); + } +} From f6ff4850418430b11c90d7c8d103d2b1dff3f5ac Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Wed, 15 Jul 2026 22:42:25 +1200 Subject: [PATCH 2/5] Bug: filterMany() with nested-property predicates silently ineffective filterMany() expressions referencing a nested/dotted property (e.g. .eq("group.name", "x"), alone or mixed with root properties) were attached to a LEFT JOIN's ON clause via the "extra join" mechanism. That only nulls the extra join's own columns on non-match - it does not exclude the parent/many-side row, so the predicate had no actual filtering effect (confirmed by data, not just SQL shape). ## Fix: force any many-property fetch whose filterMany expression references a nested property onto a query-join (secondary select restoring filterMany's original always-fetchQuery behaviour for this with a genuine WHERE clause) instead of leaving it as an inline JOIN, case. - SpiExpressionValidation: track all visited property names (allProperties()), not just unknown ones. - OrmQueryProperties: filterManyHasNestedProperty() walks the filterMany expression for any dotted property reference. - OrmQueryDetail.markQueryJoins(): route nested-property filterMany chunks to markForQueryJoin() instead of the inline fetch-join slot, without consuming that slot for other many-paths. Also simplifies the earlier root-only-predicate join-placement fix (CQueryPredicates/DefaultDbSqlContext/SqlTreeNodeManyRoot) now that a nested-property filterMany can never reach the JOIN-based code path: removed the now-unreachable "deepest path" fallback branch, the includeFilterMany() idempotency guard, and the redundant fallback call in SqlTreeNodeManyRoot - isFilterManyAttachPoint() remains as the single, always-used attach mechanism. Updated TestQueryFilterMany/TestQueryFilterManySimple assertions to expect the corrected query-join (2 statement) behaviour. --- .../api/SpiExpressionValidation.java | 12 ++++++++++ .../server/query/CQueryPredicates.java | 24 ++++--------------- .../server/query/DefaultDbSqlContext.java | 7 +----- .../server/query/SqlTreeNodeManyRoot.java | 6 ++--- .../server/querydefn/OrmQueryDetail.java | 7 ++++-- .../server/querydefn/OrmQueryProperties.java | 19 +++++++++++++++ .../org/tests/query/TestQueryFilterMany.java | 7 ++++-- .../query/TestQueryFilterManySimple.java | 7 ++++-- 8 files changed, 54 insertions(+), 35 deletions(-) diff --git a/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java b/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java index 4f28591bdd..07570794da 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java +++ b/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java @@ -12,6 +12,7 @@ public final class SpiExpressionValidation { private final BeanType desc; private final LinkedHashSet unknown = new LinkedHashSet<>(); + private final LinkedHashSet all = new LinkedHashSet<>(); public SpiExpressionValidation(BeanType desc) { this.desc = desc; @@ -21,6 +22,7 @@ public SpiExpressionValidation(BeanType desc) { * Validate that the property expression (path) is valid. */ public void validate(String propertyName) { + all.add(propertyName); if (!desc.isValidExpression(propertyName)) { unknown.add(propertyName); } @@ -33,4 +35,14 @@ public Set unknownProperties() { return unknown; } + /** + * Return the set of all property names visited during this validation, regardless of + * whether they were considered valid against the bean type. Used to inspect the shape of + * an expression (for example, to check whether it references any nested/associated path) + * without needing a correctly-typed bean descriptor. + */ + public Set allProperties() { + return all; + } + } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java index c5092b5b02..0b3c785cd5 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java @@ -227,9 +227,9 @@ public void prepare(boolean buildSql) { filterManyJoin = chunk.isFilterManyJoin(); filterMany = new DefaultExpressionRequest(request, deployParser, binder, filterManyExpr); if (buildSql) { - Set beforeIncludes = new HashSet<>(deployParser.includes()); dbFilterMany = filterMany.buildSql(); - filterManyAttachPath = deepestFilterManyPath(manyProperty.path(), beforeIncludes, deployParser.includes()); + // safe as filterManyJoin only holds when the expression is root-property only - + filterManyAttachPath = manyProperty.path(); } } } @@ -250,21 +250,6 @@ public void prepare(boolean buildSql) { } } - /** - * Determine the specific fetch-path node (relative to the query root) that the filterMany - * predicate should be attached to, given the set of includes before and after building its SQL. - */ - private String deepestFilterManyPath(String manyPath, Set beforeIncludes, Set afterIncludes) { - for (String include : afterIncludes) { - if (!beforeIncludes.contains(include) && include.startsWith(manyPath) && include.length() > manyPath.length()) { - // the filterMany expression needed a deeper/nested include - not safe to attach at the - // many-root's own join, fall back to the default (end of subtree) behaviour. - return null; - } - } - return manyPath; - } - /** * Replace the table alias place-holders. */ @@ -423,9 +408,8 @@ String dbFilterManyJoin() { } /** - * Return the deepest fetch path that the filterMany-in-JOIN predicate references - the path - * whose own join clause the predicate must be appended to (or null if there is no - * filterMany-in-JOIN predicate at all). + * Return the fetch path of the filterMany-in-JOIN predicate - the path whose own join clause + * the predicate must be appended to (or null if there is no filterMany-in-JOIN predicate at all). */ String filterManyAttachPath() { return filterManyJoin ? filterManyAttachPath : null; diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultDbSqlContext.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultDbSqlContext.java index 86805e66c0..ba3f142a80 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultDbSqlContext.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/DefaultDbSqlContext.java @@ -24,7 +24,6 @@ final class DefaultDbSqlContext implements DbSqlContext { private final String fromForUpdate; private final String dbFilterManyJoin; private final String filterManyAttachPath; - private boolean filterManyIncluded; private boolean useColumnAlias; private int columnIndex; private int asOfTableCount; @@ -64,11 +63,7 @@ public boolean isFilterManyAttachPoint(String prefix) { @Override public void includeFilterMany() { - if (dbFilterManyJoin != null && !filterManyIncluded) { - // idempotent - the predicate may be attached either at its designated node (generic path - // matching in SqlTreeNodeBean) or, as a fallback, at the end of the many-root's subtree - // (SqlTreeNodeManyRoot) - never both. - filterManyIncluded = true; + if (dbFilterManyJoin != null) { sb.append(" and ").append(dbFilterManyJoin); } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java index e7726d9489..976b011d43 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java @@ -42,12 +42,12 @@ protected void appendExtraWhere(DbSqlContext ctx) { /** * Force outer join for everything after the many property. *

- * If the filterMany predicate only references the many-root's own direct properties, it is already - * included generically right after this node's own join clause (see SqlTreeNodeBean#appendFromBaseTable). + * The filterMany predicate (when it stays as a JOIN, i.e. references only the many-root's own + * direct properties) is included generically right after this node's own join clause - see + * {@link SqlTreeNodeBean#appendFromBaseTable}. */ @Override public void appendFrom(DbSqlContext ctx, SqlJoinType joinType) { super.appendFrom(ctx, joinType.autoToOuter()); - ctx.includeFilterMany(); } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryDetail.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryDetail.java index 7a46083190..a45023c27f 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryDetail.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryDetail.java @@ -4,6 +4,7 @@ import io.ebean.event.BeanQueryRequest; import io.ebean.util.SplitName; import io.ebeaninternal.api.SpiExpressionList; +import io.ebeaninternal.api.SpiExpressionValidation; import io.ebeaninternal.api.SpiQueryManyJoin; import io.ebeaninternal.server.deploy.BeanDescriptor; import io.ebeaninternal.server.deploy.BeanPropertyAssoc; @@ -384,14 +385,16 @@ SpiQueryManyJoin markQueryJoins(BeanDescriptor beanDescriptor, String lazyLoa OrmQueryProperties chunk = pair.getProperties(); if (isQueryJoinCandidate(lazyLoadManyPath, chunk)) { // this is a 'fetch join' (included in main query) - if (fetchJoinFirstMany) { + if (fetchJoinFirstMany && !chunk.filterManyHasNestedProperty(new SpiExpressionValidation(beanDescriptor))) { // letting the first one remain a 'fetch join' fetchJoinFirstMany = false; manyFetchProperty = pair.getPath(); chunk.filterManyInline(); many = elProp; } else { - // convert this one over to a 'query join' + // convert this one over to a 'query join' - either because another many has already claimed the + // 'fetch join' slot, or because its filterMany references a nested property that can't safely be + // included as a JOIN predicate (see OrmQueryProperties.filterManyHasNestedProperty) chunk.markForQueryJoin(); } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java index 1da618babd..280784ec65 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java @@ -9,6 +9,7 @@ import io.ebeaninternal.api.SpiExpression; import io.ebeaninternal.api.SpiExpressionFactory; import io.ebeaninternal.api.SpiExpressionList; +import io.ebeaninternal.api.SpiExpressionValidation; import io.ebeaninternal.api.SpiQuery; import io.ebeaninternal.server.expression.FilterExprPath; import io.ebeaninternal.server.expression.FilterExpressionList; @@ -234,6 +235,24 @@ public boolean isFilterManyJoin() { return filterMany != null && !markForQueryJoin; } + /** + * Return true if the filterMany expression (if any) references a nested/associated + * property - a dotted path such as {@code "group.name"} - rather than only direct + * properties of the many bean itself. + */ + boolean filterManyHasNestedProperty(SpiExpressionValidation validation) { + if (filterMany == null) { + return false; + } + filterMany.validate(validation); + for (String property : validation.allProperties()) { + if (property.indexOf('.') > -1) { + return true; + } + } + return false; + } + /** * Adjust filterMany expressions for inclusion in main query. */ diff --git a/ebean-test/src/test/java/org/tests/query/TestQueryFilterMany.java b/ebean-test/src/test/java/org/tests/query/TestQueryFilterMany.java index 2f2bf3e178..0b6667f6b7 100644 --- a/ebean-test/src/test/java/org/tests/query/TestQueryFilterMany.java +++ b/ebean-test/src/test/java/org/tests/query/TestQueryFilterMany.java @@ -459,7 +459,10 @@ void testFilterManyWithNestedPathExpression() { List sql = LoggedSql.stop(); - assertThat(sql).hasSize(1); - assertSql(sql.get(0)).contains(" from o_customer t0 left join contact t1 on t1.customer_id = t0.id left join contact_group t2 on t2.id = t1.group_id and t2.name = ? and t1.cretime is not null order by t0.id"); + // nested "group.name" reference forces this to a query join so the filter is applied + // as a genuine WHERE clause (not misapplied to a LEFT JOIN's ON clause) + assertThat(sql).hasSize(2); + assertSql(sql.get(1)).contains(" from contact t0 left join contact_group t1 on t1.id = t0.group_id where (t0.customer_id) in ("); + assertSql(sql.get(1)).contains(" and t1.name = ? and t0.cretime is not null"); } } diff --git a/ebean-test/src/test/java/org/tests/query/TestQueryFilterManySimple.java b/ebean-test/src/test/java/org/tests/query/TestQueryFilterManySimple.java index 81948026dc..e5be0a7f42 100644 --- a/ebean-test/src/test/java/org/tests/query/TestQueryFilterManySimple.java +++ b/ebean-test/src/test/java/org/tests/query/TestQueryFilterManySimple.java @@ -55,7 +55,10 @@ void testNestedFilterMany() { .findList(); List sql = LoggedSql.stop(); - assertThat(sql).hasSize(1); - assertThat(sql.get(0)).contains("from o_customer t0 left join o_order t1 on t1.kcustomer_id = t0.id and t1.order_date is not null left join o_customer t2 on t2.id = t1.kcustomer_id and t2.status = ? order by t0.id"); + // nested "customer.status" reference forces this to a query join so the filter is + // applied as a genuine WHERE clause (not misapplied to a LEFT JOIN's ON clause) + assertThat(sql).hasSize(2); + assertThat(sql.get(1)).contains("from o_order t0 join o_customer t1 on t1.id = t0.kcustomer_id where t0.order_date is not null and (t0.kcustomer_id) in ("); + assertThat(sql.get(1)).contains(" and t1.status = ?"); } } From 055a3bf63840217ffeba9e026d73e647e9faac95 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Wed, 15 Jul 2026 22:47:32 +1200 Subject: [PATCH 3/5] Simplify SpiExpressionValidation with boolean nestedProperty flag --- .../api/SpiExpressionValidation.java | 17 +++++++++-------- .../server/querydefn/OrmQueryProperties.java | 7 +------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java b/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java index 07570794da..e3df6a7172 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java +++ b/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java @@ -12,7 +12,7 @@ public final class SpiExpressionValidation { private final BeanType desc; private final LinkedHashSet unknown = new LinkedHashSet<>(); - private final LinkedHashSet all = new LinkedHashSet<>(); + private boolean nestedProperty; public SpiExpressionValidation(BeanType desc) { this.desc = desc; @@ -22,7 +22,9 @@ public SpiExpressionValidation(BeanType desc) { * Validate that the property expression (path) is valid. */ public void validate(String propertyName) { - all.add(propertyName); + if (!nestedProperty && propertyName.indexOf('.') > -1) { + nestedProperty = true; + } if (!desc.isValidExpression(propertyName)) { unknown.add(propertyName); } @@ -36,13 +38,12 @@ public Set unknownProperties() { } /** - * Return the set of all property names visited during this validation, regardless of - * whether they were considered valid against the bean type. Used to inspect the shape of - * an expression (for example, to check whether it references any nested/associated path) - * without needing a correctly-typed bean descriptor. + * Return true if any property visited during this validation referenced a nested/associated + * path (i.e. contained a '.'). Used to inspect the shape of an expression without needing a + * correctly-typed bean descriptor. */ - public Set allProperties() { - return all; + public boolean hasNestedProperty() { + return nestedProperty; } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java index 280784ec65..88dc02df4c 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java @@ -245,12 +245,7 @@ boolean filterManyHasNestedProperty(SpiExpressionValidation validation) { return false; } filterMany.validate(validation); - for (String property : validation.allProperties()) { - if (property.indexOf('.') > -1) { - return true; - } - } - return false; + return validation.hasNestedProperty(); } /** From 9947a41a9ba40f41988566d798324371bcdff397 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Wed, 15 Jul 2026 22:54:30 +1200 Subject: [PATCH 4/5] Tidy up comments --- .../java/io/ebeaninternal/server/query/CQueryPredicates.java | 5 ++--- .../java/io/ebeaninternal/server/query/SqlTreeNodeBean.java | 5 +---- .../io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java | 4 ---- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java index 0b3c785cd5..54769f9806 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryPredicates.java @@ -79,9 +79,8 @@ public final class CQueryPredicates { private Set predicateIncludes; private Set orderByIncludes; /** - * The deepest fetch path (relative to the query root) that the filterMany expression itself - * references - e.g. for {@code filterMany("contacts").eq("group.name", ...)} this is - * {@code "contacts.group"} rather than just {@code "contacts"}. + * The fetch path (relative to the query root) of the many-root whose own join clause the + * filterMany-in-JOIN predicate is attached to */ private String filterManyAttachPath; diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java index f9b275295a..fbecfcbe47 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java @@ -343,10 +343,7 @@ SqlJoinType appendFromBaseTable(DbSqlContext ctx, SqlJoinType joinType) { ctx.append(" and ").append(desc.softDeletePredicate(ctx.tableAlias(prefix))); } if (prefix != null && ctx.isFilterManyAttachPoint(prefix)) { - // this node's own join is the deepest one the pending filterMany predicate references - - // include it now (right after this join, before any further nested/child joins beneath - // it are appended) so it correctly narrows this join's own rows rather than an unrelated - // deeper join's rows. + // this node is where we inline the filterMany predicate ctx.includeFilterMany(); } return sqlJoinType; diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java index 976b011d43..ebfa9dd3f0 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyRoot.java @@ -41,10 +41,6 @@ protected void appendExtraWhere(DbSqlContext ctx) { /** * Force outer join for everything after the many property. - *

- * The filterMany predicate (when it stays as a JOIN, i.e. references only the many-root's own - * direct properties) is included generically right after this node's own join clause - see - * {@link SqlTreeNodeBean#appendFromBaseTable}. */ @Override public void appendFrom(DbSqlContext ctx, SqlJoinType joinType) { From 99c3d8b07b4bd3314cd7a18acaf77a41d8ea99c5 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Wed, 15 Jul 2026 23:08:51 +1200 Subject: [PATCH 5/5] Fix OrmQueryProperties.filterManyHasNestedProperty() with real nested property check --- .../api/SpiExpressionValidation.java | 17 ++++++++--------- .../server/querydefn/OrmQueryDetail.java | 9 +++++---- .../server/querydefn/OrmQueryProperties.java | 19 ++++++++++++++----- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java b/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java index e3df6a7172..2c62437bb3 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java +++ b/ebean-core/src/main/java/io/ebeaninternal/api/SpiExpressionValidation.java @@ -12,7 +12,7 @@ public final class SpiExpressionValidation { private final BeanType desc; private final LinkedHashSet unknown = new LinkedHashSet<>(); - private boolean nestedProperty; + private final LinkedHashSet all = new LinkedHashSet<>(); public SpiExpressionValidation(BeanType desc) { this.desc = desc; @@ -22,9 +22,7 @@ public SpiExpressionValidation(BeanType desc) { * Validate that the property expression (path) is valid. */ public void validate(String propertyName) { - if (!nestedProperty && propertyName.indexOf('.') > -1) { - nestedProperty = true; - } + all.add(propertyName); if (!desc.isValidExpression(propertyName)) { unknown.add(propertyName); } @@ -38,12 +36,13 @@ public Set unknownProperties() { } /** - * Return true if any property visited during this validation referenced a nested/associated - * path (i.e. contained a '.'). Used to inspect the shape of an expression without needing a - * correctly-typed bean descriptor. + * Return the set of all property names visited during this validation, regardless of + * whether they were considered valid against the bean type. Used to inspect the shape of + * an expression (for example, to check whether it references any associated/joined path) + * without needing a correctly-typed bean descriptor. */ - public boolean hasNestedProperty() { - return nestedProperty; + public Set allProperties() { + return all; } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryDetail.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryDetail.java index a45023c27f..9fa2411b0a 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryDetail.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryDetail.java @@ -4,7 +4,6 @@ import io.ebean.event.BeanQueryRequest; import io.ebean.util.SplitName; import io.ebeaninternal.api.SpiExpressionList; -import io.ebeaninternal.api.SpiExpressionValidation; import io.ebeaninternal.api.SpiQueryManyJoin; import io.ebeaninternal.server.deploy.BeanDescriptor; import io.ebeaninternal.server.deploy.BeanPropertyAssoc; @@ -385,7 +384,8 @@ SpiQueryManyJoin markQueryJoins(BeanDescriptor beanDescriptor, String lazyLoa OrmQueryProperties chunk = pair.getProperties(); if (isQueryJoinCandidate(lazyLoadManyPath, chunk)) { // this is a 'fetch join' (included in main query) - if (fetchJoinFirstMany && !chunk.filterManyHasNestedProperty(new SpiExpressionValidation(beanDescriptor))) { + BeanDescriptor targetDescriptor = ((BeanPropertyAssoc) elProp.beanProperty()).targetDescriptor(); + if (fetchJoinFirstMany && !chunk.filterManyHasNestedProperty(targetDescriptor)) { // letting the first one remain a 'fetch join' fetchJoinFirstMany = false; manyFetchProperty = pair.getPath(); @@ -393,8 +393,9 @@ SpiQueryManyJoin markQueryJoins(BeanDescriptor beanDescriptor, String lazyLoa many = elProp; } else { // convert this one over to a 'query join' - either because another many has already claimed the - // 'fetch join' slot, or because its filterMany references a nested property that can't safely be - // included as a JOIN predicate (see OrmQueryProperties.filterManyHasNestedProperty) + // 'fetch join' slot, or because its filterMany references a property that requires crossing into + // an associated bean and can't safely be included as a JOIN predicate (see + // OrmQueryProperties.filterManyHasNestedProperty) chunk.markForQueryJoin(); } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java index 88dc02df4c..97003ae38f 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/querydefn/OrmQueryProperties.java @@ -11,6 +11,8 @@ import io.ebeaninternal.api.SpiExpressionList; import io.ebeaninternal.api.SpiExpressionValidation; import io.ebeaninternal.api.SpiQuery; +import io.ebeaninternal.server.deploy.BeanDescriptor; +import io.ebeaninternal.server.el.ElPropertyValue; import io.ebeaninternal.server.expression.FilterExprPath; import io.ebeaninternal.server.expression.FilterExpressionList; @@ -236,16 +238,23 @@ public boolean isFilterManyJoin() { } /** - * Return true if the filterMany expression (if any) references a nested/associated - * property - a dotted path such as {@code "group.name"} - rather than only direct - * properties of the many bean itself. + * Return true if the filterMany expression (if any) references a property that requires + * crossing into an associated bean/join - e.g. {@code "group.name"} - rather than only + * plain/embedded properties resolving to columns on the many bean's own base table. */ - boolean filterManyHasNestedProperty(SpiExpressionValidation validation) { + boolean filterManyHasNestedProperty(BeanDescriptor targetDescriptor) { if (filterMany == null) { return false; } + SpiExpressionValidation validation = new SpiExpressionValidation(targetDescriptor); filterMany.validate(validation); - return validation.hasNestedProperty(); + for (String property : validation.allProperties()) { + ElPropertyValue elProp = targetDescriptor.elGetValue(property); + if (elProp != null && elProp.isAssocProperty()) { + return true; + } + } + return false; } /**