From 4c2a3040fd95bb032378a5dccadbd1425536cfcf Mon Sep 17 00:00:00 2001 From: zio0911 Date: Thu, 16 Apr 2026 13:10:06 +0900 Subject: [PATCH] Deduplicate fetch join results at QueryDSL level Since Hibernate 6 removed automatic result deduplication for fetch joins, and Hibernate 7.3 fully removed the feature, queries with fetchJoin() can return duplicate parent entities from JOIN results. Add deduplication via LinkedHashSet in fetch(), fetchOne(), and fetchResults() when fetchJoin is detected, applied at the QueryDSL level so it works across all JPA providers. Closes #1596 --- .../jpa/hibernate/AbstractHibernateQuery.java | 33 +++++++++++++- .../querydsl/jpa/impl/AbstractJPAQuery.java | 44 ++++++++++++++++--- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/AbstractHibernateQuery.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/AbstractHibernateQuery.java index 20f7733ed4..3f53499042 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/AbstractHibernateQuery.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/AbstractHibernateQuery.java @@ -30,9 +30,12 @@ import com.querydsl.jpa.FactoryExpressionTransformer; import com.querydsl.jpa.HQLTemplates; import com.querydsl.jpa.JPAQueryBase; +import com.querydsl.jpa.JPAQueryMixin; import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.ScrollableResultsIterator; +import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.logging.Level; @@ -213,7 +216,11 @@ public Stream stream() { @SuppressWarnings("unchecked") public List fetch() { try { - return createQuery().list(); + List results = createQuery().list(); + if (hasFetchJoin()) { + results = new ArrayList<>(new LinkedHashSet<>(results)); + } + return results; } finally { reset(); } @@ -230,6 +237,9 @@ public QueryResults fetchResults() { var query = createQuery(modifiers, false); @SuppressWarnings("unchecked") List list = query.list(); + if (hasFetchJoin()) { + list = new ArrayList<>(new LinkedHashSet<>(list)); + } return new QueryResults<>(list, modifiers, total); } else { return QueryResults.emptyResults(); @@ -239,6 +249,16 @@ public QueryResults fetchResults() { } } + /** + * Check if any join in this query has a fetch join flag. + * + * @return true if at least one join uses fetchJoin + */ + private boolean hasFetchJoin() { + return getMetadata().getJoins().stream() + .anyMatch(join -> join.getFlags().contains(JPAQueryMixin.FETCH)); + } + protected void logQuery(String queryString) { if (logger.isLoggable(Level.FINE)) { var normalizedQuery = queryString.replace('\n', ' '); @@ -363,6 +383,17 @@ public T fetchOne() throws NonUniqueResultException { try { var modifiers = getMetadata().getModifiers(); var query = createQuery(modifiers, false); + if (hasFetchJoin()) { + List results = query.list(); + results = new ArrayList<>(new LinkedHashSet<>(results)); + if (results.isEmpty()) { + return null; + } else if (results.size() == 1) { + return results.get(0); + } else { + throw new NonUniqueResultException(); + } + } try { return (T) query.uniqueResult(); } catch (org.hibernate.NonUniqueResultException e) { diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/AbstractJPAQuery.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/AbstractJPAQuery.java index 438d858471..63a87ef18c 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/AbstractJPAQuery.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/AbstractJPAQuery.java @@ -22,6 +22,7 @@ import com.querydsl.core.types.Expression; import com.querydsl.core.types.FactoryExpression; import com.querydsl.jpa.JPAQueryBase; +import com.querydsl.jpa.JPAQueryMixin; import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.QueryHandler; @@ -179,10 +180,11 @@ protected Query createQuery(@Nullable QueryModifiers modifiers, boolean forCount */ private List getResultList(Query query) { // TODO : use lazy fetch here? + List results; if (projection != null) { - List results = query.getResultList(); - List rv = new ArrayList<>(results.size()); - for (Object o : results) { + List raw = query.getResultList(); + List rv = new ArrayList<>(raw.size()); + for (Object o : raw) { if (o != null) { if (!o.getClass().isArray()) { o = new Object[] {o}; @@ -192,10 +194,29 @@ private List getResultList(Query query) { rv.add(projection.newInstance(new Object[] {null})); } } - return rv; + results = rv; } else { - return query.getResultList(); + results = query.getResultList(); } + + // Deduplicate results when fetchJoin is used. + // Since Hibernate 6, automatic deduplication on fetch joins was removed, + // so we handle it at the QueryDSL level for all JPA providers. + if (hasFetchJoin()) { + results = new ArrayList<>(new LinkedHashSet<>(results)); + } + + return results; + } + + /** + * Check if any join in this query has a fetch join flag. + * + * @return true if at least one join uses fetchJoin + */ + private boolean hasFetchJoin() { + return getMetadata().getJoins().stream() + .anyMatch(join -> join.getFlags().contains(JPAQueryMixin.FETCH)); } /** @@ -336,6 +357,19 @@ protected void reset() {} @Override public T fetchOne() throws NonUniqueResultException { try { + if (hasFetchJoin()) { + // When fetchJoin is used, use getResultList with deduplication + // to avoid NonUniqueResultException caused by JOIN duplicates + var query = createQuery(getMetadata().getModifiers(), false); + var results = (List) getResultList(query); + if (results.isEmpty()) { + return null; + } else if (results.size() == 1) { + return results.get(0); + } else { + throw new NonUniqueResultException(); + } + } var query = createQuery(getMetadata().getModifiers(), false); return (T) getSingleResult(query); } catch (jakarta.persistence.NoResultException e) {