This is an automated email from the ASF dual-hosted git repository. borinquenkid pushed a commit to branch 8.0.x-hibernate7-dev in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 95835198eb445675fe57a012ef466526f44d3e1f Author: Walter Duque de Estrada <[email protected]> AuthorDate: Sun Mar 22 18:44:29 2026 -0500 hibernate 7: * Query Engine Precision: * Replaced inefficient IN subqueries with true SQL JOINs for association criteria. * Enabled full support for user-specified JoinType (INNER, LEFT, etc.) across all criteria APIs. * Resolved ClassCastException and IllegalArgumentException issues when navigating deeply nested association paths or querying into @Embedded components. * Fixed correlated subquery support (exists) to correctly handle its own independent join providers. --- grails-data-hibernate7/core/ISSUES.md | 5 + .../groovy/grails/orm/CriteriaMethodInvoker.java | 34 +++++- .../grails/orm/HibernateCriteriaBuilder.java | 17 ++- .../grails/orm/hibernate/GrailsSessionContext.java | 9 +- .../generator/GrailsIncrementGenerator.java | 76 +++++++----- .../query/DetachedAssociationFunction.java | 6 + .../grails/orm/hibernate/query/HibernateAlias.java | 8 +- .../orm/hibernate/query/JpaFromProvider.java | 135 +++++++++++++++++---- .../orm/hibernate/query/PredicateGenerator.java | 53 ++++++-- .../grails/orm/CriteriaMethodInvokerSpec.groovy | 23 ++-- 10 files changed, 276 insertions(+), 90 deletions(-) diff --git a/grails-data-hibernate7/core/ISSUES.md b/grails-data-hibernate7/core/ISSUES.md index e9194b2bc3..8874cbb009 100644 --- a/grails-data-hibernate7/core/ISSUES.md +++ b/grails-data-hibernate7/core/ISSUES.md @@ -15,6 +15,11 @@ In Grails 7: - `HibernateDatastore.getDatastoreForConnection` supports `"dataSource"`, `ConnectionSource.DEFAULT` ("default"), and `ConnectionSource.OLD_DEFAULT` ("DEFAULT"). - Raw `"default"` and `"DEFAULT"` strings in H7 production code and key tests have been replaced with `ConnectionSource.DEFAULT`. - `HibernateMappingContextConfiguration` and `HibernateDatastore` correctly use `ConnectionSource.DEFAULT` for the primary datasource name. +- **Fixed Issues in GORM Querying (Hibernate 7):** + - Refactored `JpaFromProvider` to correctly handle root aliases and hierarchical joins for dot-notated projection/criteria paths. + - Fixed `ClassCastException` in `PredicateGenerator` by ensuring all association paths are properly pre-joined in the JPA metamodel. + - Enhanced `PredicateGenerator.handleExists` to properly support correlated subqueries with their own join providers. + - Ensured basic collection joins (e.g., `nicknames`) are automatically handled during query construction. **Risk & Potential Propagation:** - This change might affect other GORM modules (Neo4j, MongoDB, etc.) if they rely on the uppercase `"DEFAULT"` string literal and don't yet support the lowercase `"default"`. diff --git a/grails-data-hibernate7/core/src/main/groovy/grails/orm/CriteriaMethodInvoker.java b/grails-data-hibernate7/core/src/main/groovy/grails/orm/CriteriaMethodInvoker.java index 1eb07c07bd..68ba31de38 100644 --- a/grails-data-hibernate7/core/src/main/groovy/grails/orm/CriteriaMethodInvoker.java +++ b/grails-data-hibernate7/core/src/main/groovy/grails/orm/CriteriaMethodInvoker.java @@ -34,7 +34,12 @@ import org.springframework.beans.BeanUtils; import grails.gorm.DetachedCriteria; import grails.gorm.PagedResultList; +import org.grails.datastore.gorm.query.criteria.DetachedAssociationCriteria; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.model.PersistentProperty; +import org.grails.datastore.mapping.model.types.Association; import org.grails.datastore.mapping.query.Query; +import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentEntity; import org.grails.orm.hibernate.query.HibernateQuery; import org.grails.orm.hibernate.query.HibernateQueryArgument; @@ -188,18 +193,38 @@ public class CriteriaMethodInvoker { if (attribute.isAssociation()) { Class<?> oldTargetClass = builder.getTargetClass(); - builder.setTargetClass(builder.getClassForAssociationType(attribute)); + Class<?> associationClass = builder.getClassForAssociationType(attribute); + builder.setTargetClass(associationClass); JoinType joinType; if (hasMoreThanOneArg) { joinType = builder.convertFromInt((Integer) args[0]); - } else if (builder.getTargetClass().equals(oldTargetClass)) { + } else if (associationClass.equals(oldTargetClass)) { joinType = JoinType.LEFT; // default to left join if joining on the same table } else { joinType = builder.convertFromInt(0); } hibernateQuery.join(name, joinType); - hibernateQuery.in(name, new DetachedCriteria<>(builder.getTargetClass()).build(callable)); + + PersistentEntity parentEntity = + hibernateQuery.getSession().getMappingContext().getPersistentEntity(oldTargetClass.getName()); + PersistentProperty property = parentEntity.getPropertyByName(name); + if (property instanceof Association association) { + DetachedAssociationCriteria associationCriteria = + new DetachedAssociationCriteria(associationClass, association); + DetachedCriteria oldDetachedCriteria = hibernateQuery.getDetachedCriteria(); + hibernateQuery.setDetachedCriteria(associationCriteria); + try { + invokeClosureNode(callable); + } finally { + hibernateQuery.setDetachedCriteria(oldDetachedCriteria); + } + hibernateQuery.add((Query.Criterion) associationCriteria); + } else { + // Fallback for non-GORM associations if any + hibernateQuery.in(name, new DetachedCriteria<>(associationClass).build(callable)); + } + builder.setTargetClass(oldTargetClass); return name; @@ -238,7 +263,8 @@ public class CriteriaMethodInvoker { && args[0] instanceof String s && args[1] instanceof String a && args[2] instanceof Number jt) { - return builder.createAlias(s, a, jt.intValue()); + builder.createAlias(s, a, jt.intValue()); + return builder; } return name; case IS_NULL, IS_NOT_NULL, IS_EMPTY, IS_NOT_EMPTY: diff --git a/grails-data-hibernate7/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java b/grails-data-hibernate7/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java index ceba4af686..fb8d7eccd4 100644 --- a/grails-data-hibernate7/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java +++ b/grails-data-hibernate7/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java @@ -168,13 +168,26 @@ public class HibernateCriteriaBuilder extends GroovyObjectSupport implements Bui } } - public Criteria createAlias(String associationPath, String alias) { + public org.grails.datastore.mapping.query.api.Criteria createAlias(String associationPath, String alias) { var prop = hibernateQuery.getEntity().getPropertyByName(associationPath); if (prop instanceof org.grails.datastore.mapping.model.types.Basic) { - hibernateQuery.addAlias(new org.grails.orm.hibernate.query.HibernateAlias(associationPath, alias)); + hibernateQuery.addAlias(new org.grails.orm.hibernate.query.HibernateAlias(associationPath, alias, JoinType.INNER)); return this; } hibernateQuery.getDetachedCriteria().createAlias(associationPath, alias); + hibernateQuery.getDetachedCriteria().join(associationPath, JoinType.INNER); + return this; + } + + public org.grails.datastore.mapping.query.api.Criteria createAlias(String associationPath, String alias, int joinType) { + var prop = hibernateQuery.getEntity().getPropertyByName(associationPath); + JoinType convertedJoinType = convertFromInt(joinType); + if (prop instanceof org.grails.datastore.mapping.model.types.Basic) { + hibernateQuery.addAlias(new org.grails.orm.hibernate.query.HibernateAlias(associationPath, alias, convertedJoinType)); + return this; + } + hibernateQuery.getDetachedCriteria().createAlias(associationPath, alias); + hibernateQuery.getDetachedCriteria().join(associationPath, convertedJoinType); return this; } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsSessionContext.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsSessionContext.java index 82b18747ff..f292dd0108 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsSessionContext.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsSessionContext.java @@ -79,6 +79,7 @@ public class GrailsSessionContext implements CurrentSessionContext { } /** Retrieve the Spring-managed Session for the current thread, if any. */ + @Override public Session currentSession() throws HibernateException { Object value = TransactionSynchronizationManager.getResource(sessionFactory); if (value instanceof Session) { @@ -87,16 +88,16 @@ public class GrailsSessionContext implements CurrentSessionContext { if (value instanceof SessionHolder sessionHolder) { Session session = sessionHolder.getSession(); - if (TransactionSynchronizationManager.isSynchronizationActive() && - !sessionHolder.isSynchronizedWithTransaction()) { + if (TransactionSynchronizationManager.isSynchronizationActive() + && !sessionHolder.isSynchronizedWithTransaction()) { TransactionSynchronizationManager.registerSynchronization( createSpringSessionSynchronization(sessionHolder)); sessionHolder.setSynchronizedWithTransaction(true); // Switch to FlushMode.AUTO, as we have to assume a thread-bound Session // with FlushMode.MANUAL, which needs to allow flushing within the transaction. FlushMode flushMode = session.getHibernateFlushMode(); - if (flushMode.equals(FlushMode.MANUAL) && - !TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { + if (flushMode.equals(FlushMode.MANUAL) + && !TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { session.setHibernateFlushMode(FlushMode.AUTO); sessionHolder.setPreviousFlushMode(flushMode); } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/generator/GrailsIncrementGenerator.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/generator/GrailsIncrementGenerator.java index bc11819d80..172247aa5d 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/generator/GrailsIncrementGenerator.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/generator/GrailsIncrementGenerator.java @@ -19,8 +19,10 @@ package org.grails.orm.hibernate.cfg.domainbinding.generator; import java.io.Serial; +import java.util.Optional; import java.util.Properties; +import org.hibernate.boot.model.naming.Identifier; import org.hibernate.boot.model.relational.SqlStringGenerationContext; import org.hibernate.boot.model.relational.internal.SqlStringGenerationContextImpl; import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment; @@ -49,44 +51,52 @@ public class GrailsIncrementGenerator extends IncrementGenerator { GrailsHibernatePersistentEntity domainClass, PersistentEntityNamingStrategy namingStrategy) { + configure(context, buildParams(context, mappedId, domainClass, namingStrategy)); + initialize(buildSqlContext(context)); + } + + private Properties buildParams( + GeneratorCreationContext context, + Identity mappedId, + GrailsHibernatePersistentEntity domainClass, + PersistentEntityNamingStrategy namingStrategy) { + Properties params = new Properties(); - if (mappedId != null && mappedId.getProperties() != null) { - params.putAll(mappedId.getProperties()); - } + Optional.ofNullable(mappedId).map(Identity::getProperties).ifPresent(params::putAll); - // Use the entity's naming-strategy-aware table resolution: - // handles explicit mapping, table-per-hierarchy root, and PhysicalNamingStrategy fallback. params.put(TABLES, domainClass.getTableName(namingStrategy)); + params.put(COLUMN, resolveColumnName(context, mappedId)); - org.grails.orm.hibernate.cfg.Mapping mapping = domainClass.getHibernateMappedForm(); - if (mapping != null && mapping.getTable() != null) { - if (mapping.getTable().getCatalog() != null) - params.put(CATALOG, mapping.getTable().getCatalog()); - if (mapping.getTable().getSchema() != null) - params.put(SCHEMA, mapping.getTable().getSchema()); - } + Optional.ofNullable(domainClass.getHibernateMappedForm()) + .map(org.grails.orm.hibernate.cfg.Mapping::getTable) + .ifPresent(table -> { + if (table.getCatalog() != null) params.put(CATALOG, table.getCatalog()); + if (table.getSchema() != null) params.put(SCHEMA, table.getSchema()); + }); + + return params; + } - // Resolve column name — fall back to "id" if the property path is dotted (composite) - String columnName = context.getProperty().getName(); - if (columnName == null || columnName.contains(".")) { - columnName = (mappedId != null && - mappedId.getName() != null && - !mappedId.getName().contains(".")) ? - mappedId.getName() : - "id"; + private String resolveColumnName(GeneratorCreationContext context, Identity mappedId) { + String propertyName = context.getProperty().getName(); + if (propertyName != null && !propertyName.contains(".")) { + return propertyName; } - params.put(COLUMN, columnName); - - // Delegate to the standard configure() — sets returnClass, column, physicalTableNames - configure(context, params); - - // Build SqlStringGenerationContext and initialize the SQL query - JdbcEnvironment jdbcEnvironment = context.getDatabase().getJdbcEnvironment(); - var physicalName = context.getDatabase().getDefaultNamespace().getPhysicalName(); - String catalog = physicalName.catalog() != null ? physicalName.catalog().getCanonicalName() : null; - String schema = physicalName.schema() != null ? physicalName.schema().getCanonicalName() : null; - SqlStringGenerationContext sqlContext = - SqlStringGenerationContextImpl.fromExplicit(jdbcEnvironment, context.getDatabase(), catalog, schema); - initialize(sqlContext); + return Optional.ofNullable(mappedId) + .map(Identity::getName) + .filter(name -> !name.contains(".")) + .orElse("id"); + } + + private SqlStringGenerationContext buildSqlContext(GeneratorCreationContext context) { + var database = context.getDatabase(); + var physicalName = database.getDefaultNamespace().getPhysicalName(); + + return SqlStringGenerationContextImpl.fromExplicit( + database.getJdbcEnvironment(), + database, + Optional.ofNullable(physicalName.catalog()).map(Identifier::getCanonicalName).orElse(null), + Optional.ofNullable(physicalName.schema()).map(Identifier::getCanonicalName).orElse(null) + ); } } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/DetachedAssociationFunction.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/DetachedAssociationFunction.java index fe43d6819f..e8fd19a73a 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/DetachedAssociationFunction.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/DetachedAssociationFunction.java @@ -30,6 +30,12 @@ public class DetachedAssociationFunction implements Function<Query.Criterion, Li public List<DetachedAssociationCriteria<?>> apply(Query.Criterion o) { if (o instanceof DetachedAssociationCriteria) { return List.of((DetachedAssociationCriteria<?>) o); + } else if (o instanceof Query.Junction junction) { + java.util.List<DetachedAssociationCriteria<?>> result = new java.util.ArrayList<>(); + for (Query.Criterion criterion : junction.getCriteria()) { + result.addAll(apply(criterion)); + } + return result; } return List.of(); } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateAlias.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateAlias.java index bd3fac0f6d..a1c9c10263 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateAlias.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateAlias.java @@ -18,6 +18,8 @@ */ package org.grails.orm.hibernate.query; +import jakarta.persistence.criteria.JoinType; + import org.grails.datastore.mapping.query.Query; /** @@ -25,6 +27,10 @@ import org.grails.datastore.mapping.query.Query; * * @author walterduquedeestrada */ -public record HibernateAlias(String path, String alias) implements Query.Criterion, Query.QueryElement { +public record HibernateAlias(String path, String alias, JoinType joinType) implements Query.Criterion, Query.QueryElement { + + public HibernateAlias(String path, String alias) { + this(path, alias, JoinType.INNER); + } } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaFromProvider.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaFromProvider.java index 7bc699eae9..2ae820e7f8 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaFromProvider.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaFromProvider.java @@ -31,6 +31,7 @@ import jakarta.persistence.criteria.Path; import grails.gorm.DetachedCriteria; import org.grails.datastore.gorm.query.criteria.DetachedAssociationCriteria; +import org.grails.datastore.mapping.model.PersistentEntity; import org.grails.datastore.mapping.query.Query; @SuppressWarnings({ @@ -72,6 +73,19 @@ public class JpaFromProvider implements Cloneable { fromMap.putAll(getFromsByName(detachedCriteria, projections, List.of(), root)); } + @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + public JpaFromProvider( + JpaFromProvider parent, + PersistentEntity entity, + List<Query.Criterion> criteria, + List<Query.Projection> projections, + Map<String, jakarta.persistence.FetchType> fetchStrategies, + Map<String, jakarta.persistence.criteria.JoinType> joinTypes, + From<?, ?> root) { + fromMap = new HashMap<>(parent.fromMap); + fromMap.putAll(getFromsByName(entity, criteria, projections, List.of(), fetchStrategies, joinTypes, root)); + } + public Map<String, From<?, ?>> getFromsByName() { return fromMap; } @@ -85,7 +99,33 @@ public class JpaFromProvider implements Cloneable { List<Query.Projection> projections, List<HibernateAlias> aliases, From<?, ?> root) { - var detachedAssociationCriteriaList = detachedCriteria.getCriteria().stream() + Map<String, From<?, ?>> froms = getFromsByName( + detachedCriteria.getPersistentEntity(), + detachedCriteria.getCriteria(), + projections, + aliases, + detachedCriteria.getFetchStrategies(), + detachedCriteria.getJoinTypes(), + root); + if (detachedCriteria.getAlias() != null) { + froms.put(detachedCriteria.getAlias(), root); + } + return froms; + } + + protected Map<String, From<?, ?>> getFromsByName( + PersistentEntity entity, + List<Query.Criterion> criteria, + List<Query.Projection> projections, + List<HibernateAlias> aliases, + Map<String, FetchType> fetchStrategies, + Map<String, JoinType> joinTypes, + From<?, ?> root) { + var allCriteriaPaths = criteria.stream() + .flatMap(c -> findPaths(c).stream()) + .toList(); + + var detachedAssociationCriteriaList = criteria.stream() .map(new DetachedAssociationFunction()) .flatMap(List::stream) .toList(); @@ -94,8 +134,10 @@ public class JpaFromProvider implements Cloneable { // Also scan for HibernateAlias (basic collections) Map<String, String> basicAliasMap = new HashMap<>(); + Map<String, JoinType> basicJoinTypeMap = new HashMap<>(); for (HibernateAlias ha : aliases) { basicAliasMap.put(ha.path(), ha.alias()); + basicJoinTypeMap.put(ha.path(), ha.joinType()); } var definedAliases = detachedAssociationCriteriaList.stream() @@ -104,32 +146,39 @@ public class JpaFromProvider implements Cloneable { .collect(Collectors.toSet()); definedAliases.addAll(basicAliasMap.values()); - var directProjectedPaths = projections.stream() + var associationProjectedPaths = projections.stream() .filter(Query.PropertyProjection.class::isInstance) .map(p -> ((Query.PropertyProjection) p).getPropertyName()) .filter(name -> name.contains(".")) .map(name -> name.substring(0, name.lastIndexOf('.'))) .collect(Collectors.toSet()); - var eagerPaths = detachedCriteria.getFetchStrategies().entrySet().stream() + var criteriaPaths = allCriteriaPaths.stream() + .filter(p -> p.contains(".")) + .map(p -> p.substring(0, p.lastIndexOf('.'))) + .collect(Collectors.toSet()); + + var eagerPaths = fetchStrategies.entrySet().stream() .filter(entry -> entry.getValue().equals(FetchType.EAGER)) .map(Map.Entry::getKey) .collect(Collectors.toSet()); - var collectionPaths = detachedCriteria.getPersistentEntity().getPersistentProperties().stream() + var collectionPaths = entity != null ? entity.getPersistentProperties().stream() .filter(p -> p instanceof org.grails.datastore.mapping.model.types.Basic) .map(org.grails.datastore.mapping.model.PersistentProperty::getName) - .collect(Collectors.toSet()); + .collect(Collectors.toSet()) : java.util.Collections.<String>emptySet(); java.util.Set<String> allPaths = new java.util.HashSet<>(); allPaths.addAll(aliasMap.keySet()); allPaths.addAll(basicAliasMap.keySet()); - allPaths.addAll(directProjectedPaths.stream() - .filter(p -> !definedAliases.contains(p)) - .toList()); + allPaths.addAll(associationProjectedPaths); + allPaths.addAll(criteriaPaths); allPaths.addAll(eagerPaths); allPaths.addAll(collectionPaths); + // Don't try to join segments that are already defined aliases + allPaths.removeAll(definedAliases); + // Expand paths to include all parents (e.g., "a.b.c" -> "a", "a.b", "a.b.c") java.util.Set<String> expandedPaths = new java.util.HashSet<>(); for (String path : allPaths) { @@ -146,7 +195,7 @@ public class JpaFromProvider implements Cloneable { // Re-calculate projected paths to include expanded segments for LEFT join logic var finalProjectedPaths = expandedPaths.stream() - .filter(p -> directProjectedPaths.stream().anyMatch(dp -> dp.equals(p) || dp.startsWith(p + "."))) + .filter(p -> associationProjectedPaths.stream().anyMatch(dp -> dp.equals(p) || dp.startsWith(p + "."))) .collect(Collectors.toSet()); Map<String, From<?, ?>> fromsByPath = new HashMap<>(); @@ -164,10 +213,15 @@ public class JpaFromProvider implements Cloneable { String leaf = path.contains(".") ? path.substring(path.lastIndexOf('.') + 1) : path; From<?, ?> base = fromsByPath.get(parentPath); + if (base == null) { + continue; + } JoinType joinType = JoinType.INNER; - if (detachedCriteria.getJoinTypes().containsKey(path)) { - joinType = detachedCriteria.getJoinTypes().get(path); + if (joinTypes.containsKey(path)) { + joinType = joinTypes.get(path); + } else if (basicJoinTypeMap.containsKey(path)) { + joinType = basicJoinTypeMap.get(path); } else if (finalProjectedPaths.contains(path) || eagerPaths.contains(path) || collectionPaths.contains(path)) { @@ -176,28 +230,43 @@ public class JpaFromProvider implements Cloneable { var table = base.join(leaf, joinType); + boolean aliasApplied = false; // If there's an alias for this path, map it to the alias too var dac = aliasMap.get(path); if (dac != null && dac.getAlias() != null) { + table.alias(dac.getAlias()); fromsByPath.put(dac.getAlias(), table); + aliasApplied = true; } String basicAlias = basicAliasMap.get(path); if (basicAlias != null) { + table.alias(basicAlias); fromsByPath.put(basicAlias, table); + aliasApplied = true; } - table.alias(path); + if (!aliasApplied) { + table.alias(path); + } fromsByPath.put(path, table); } - String rootAlias = detachedCriteria.getAlias(); - if (rootAlias != null && !rootAlias.isEmpty()) { - fromsByPath.put(rootAlias, root); - } return fromsByPath; } + private java.util.Set<String> findPaths(Query.Criterion criterion) { + java.util.Set<String> paths = new java.util.HashSet<>(); + if (criterion instanceof Query.PropertyNameCriterion pnc) { + paths.add(pnc.getProperty()); + } else if (criterion instanceof Query.Junction junction) { + for (Query.Criterion c : junction.getCriteria()) { + paths.addAll(findPaths(c)); + } + } + return paths; + } + private Map<String, DetachedAssociationCriteria<?>> createAliasMap( List<DetachedAssociationCriteria<?>> detachedAssociationCriteriaList) { // Use a merge function and a stable map type to avoid DuplicateKey exceptions when the same @@ -224,29 +293,43 @@ public class JpaFromProvider implements Cloneable { String[] parsed = propertyName.split("\\."); if (parsed.length == SINGLE_PROPERTY) { From<?, ?> root = fromMap.get("root"); - if (propertyName.equals(root.getJavaType().getSimpleName()) - || propertyName.equals(root.getJavaType().getName())) { - return root; + if (root != null) { + if (propertyName.equals(root.getJavaType().getSimpleName()) + || propertyName.equals(root.getJavaType().getName())) { + return root; + } + return root.get(propertyName); } - return root.get(propertyName); } // Try to find the longest matching prefix in fromMap - for (int i = parsed.length - 1; i >= 1; i--) { + for (int i = parsed.length; i >= 1; i--) { String prefix = java.util.Arrays.stream(parsed, 0, i).collect(Collectors.joining(".")); if (fromMap.containsKey(prefix)) { Path<?> path = fromMap.get(prefix); - for (int j = i; j < parsed.length; j++) { - path = path.get(parsed[j]); + if (path != null) { + for (int j = i; j < parsed.length; j++) { + path = path.get(parsed[j]); + if (path == null) { + break; + } + } + if (path != null) { + return path; + } } - return path; } } // Fallback to root Path<?> path = fromMap.get("root"); - for (String segment : parsed) { - path = path.get(segment); + if (path != null) { + for (String segment : parsed) { + path = path.get(segment); + if (path == null) { + break; + } + } } return path; } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java index e9a20cbd27..07985dee5b 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java @@ -136,12 +136,18 @@ public class PredicateGenerator { JpaFromProvider fromsByProvider, DetachedAssociationCriteria<?> c) { - var child = root.join(c.getAssociationPath(), JoinType.LEFT); - JpaFromProvider childTablesByName = (JpaFromProvider) fromsByProvider.clone(); - childTablesByName.put("root", child); - + From<?, ?> child = (From<?, ?>) fromsByProvider.getFullyQualifiedPath(c.getAssociationPath()); PersistentEntity associatedEntity = c.getAssociation().getAssociatedEntity(); + JpaFromProvider childTablesByName = new JpaFromProvider( + fromsByProvider, + associatedEntity, + c.getCriteria(), + java.util.Collections.<Query.Projection>emptyList(), + c.getFetchStrategies(), + c.getJoinTypes(), + child); + return cb.and(getPredicates(cb, criteriaQuery, child, c.getCriteria(), childTablesByName, associatedEntity)); } @@ -151,9 +157,15 @@ public class PredicateGenerator { From<?, ?> root, JpaFromProvider fromsByProvider, HibernateAssociationQuery haq) { - var child = root.join(haq.associationPath, JoinType.LEFT); - JpaFromProvider childFroms = (JpaFromProvider) fromsByProvider.clone(); - childFroms.put("root", child); + From<?, ?> child = (From<?, ?>) fromsByProvider.getFullyQualifiedPath(haq.associationPath); + JpaFromProvider childFroms = new JpaFromProvider( + fromsByProvider, + haq.getEntity(), + haq.getAssociationCriteria(), + java.util.Collections.<Query.Projection>emptyList(), + java.util.Collections.<String, jakarta.persistence.FetchType>emptyMap(), + java.util.Collections.<String, jakarta.persistence.criteria.JoinType>emptyMap(), + child); return cb.and( getPredicates(cb, criteriaQuery, child, haq.getAssociationCriteria(), childFroms, haq.getEntity())); } @@ -389,12 +401,27 @@ public class PredicateGenerator { JpaFromProvider fromsByProvider, PersistentEntity entity, Query.Exists c) { - Subquery subquery = criteriaQuery.subquery(Integer.class); - Root subRoot = subquery.from(entity.getJavaClass()); - JpaFromProvider newMap = (JpaFromProvider) fromsByProvider.clone(); - newMap.put("root", subRoot); - var predicates = - getPredicates(cb, criteriaQuery, subRoot, c.getSubquery().getCriteria(), newMap, entity); + QueryableCriteria<?> subqueryable = c.getSubquery(); + PersistentEntity subEntity = subqueryable.getPersistentEntity(); + Subquery<Integer> subquery = criteriaQuery.subquery(Integer.class); + Root<?> subRoot = subquery.from(subEntity.getJavaClass()); + + JpaFromProvider subFromsProvider = new JpaFromProvider( + fromsByProvider, + subEntity, + subqueryable.getCriteria(), + java.util.Collections.<Query.Projection>emptyList(), + java.util.Collections.<String, jakarta.persistence.FetchType>emptyMap(), + java.util.Collections.<String, jakarta.persistence.criteria.JoinType>emptyMap(), + subRoot); + + var predicates = getPredicates( + cb, + criteriaQuery, + subRoot, + subqueryable.getCriteria(), + subFromsProvider, + subEntity); var existsPredicate = getExistsPredicate(cb, root_, entity, subRoot); Predicate[] allPredicates = existsPredicate != null ? Stream.concat(Arrays.stream(predicates), Stream.of(existsPredicate)) diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/orm/CriteriaMethodInvokerSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/orm/CriteriaMethodInvokerSpec.groovy index b21b1050e4..d35c864e61 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/orm/CriteriaMethodInvokerSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/orm/CriteriaMethodInvokerSpec.groovy @@ -19,14 +19,12 @@ package grails.orm -import groovy.lang.Closure -import groovy.lang.MissingMethodException + +import grails.gorm.DetachedCriteria +import org.grails.datastore.gorm.query.criteria.DetachedAssociationCriteria import org.grails.orm.hibernate.query.HibernateQuery -import org.grails.orm.hibernate.HibernateSession -import org.hibernate.Session + import org.hibernate.SessionFactory -import org.springframework.orm.hibernate5.SessionHolder -import org.springframework.transaction.support.TransactionSynchronizationManager import spock.lang.Specification class CriteriaMethodInvokerSpec extends Specification { @@ -34,10 +32,14 @@ class CriteriaMethodInvokerSpec extends Specification { HibernateCriteriaBuilder builder = Mock(HibernateCriteriaBuilder) HibernateQuery query = Mock(HibernateQuery) SessionFactory sessionFactory = Mock(SessionFactory) + org.grails.orm.hibernate.HibernateSession session = Mock(org.grails.orm.hibernate.HibernateSession) + org.grails.datastore.mapping.model.MappingContext mappingContext = Mock(org.grails.datastore.mapping.model.MappingContext) CriteriaMethodInvoker invoker = new CriteriaMethodInvoker(builder) def setup() { builder.getHibernateQuery() >> query + query.getSession() >> session + session.getMappingContext() >> mappingContext _ * builder.isPaginationEnabledList() >> false } @@ -146,6 +148,8 @@ class CriteriaMethodInvokerSpec extends Specification { void "test invokeMethod handles association query"() { given: def closure = { eq("amount", 10) } + def association = Mock(org.grails.datastore.mapping.model.types.Association) + def persistentEntity = Mock(org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentEntity) when: invoker.invokeMethod("transactions", [closure] as Object[]) @@ -164,7 +168,12 @@ class CriteriaMethodInvokerSpec extends Specification { } 1 * builder.getClassForAssociationType(_) >> InvokerTransaction 1 * query.join("transactions", _) - 1 * query.in("transactions", _) + 1 * mappingContext.getPersistentEntity(InvokerAccount.name) >> persistentEntity + 1 * persistentEntity.getPropertyByName("transactions") >> association + 1 * query.getDetachedCriteria() >> Mock(DetachedCriteria) + 1 * query.setDetachedCriteria(_ as DetachedAssociationCriteria) + 1 * query.setDetachedCriteria(_ as DetachedCriteria) + 1 * query.add(_ as DetachedAssociationCriteria) } void "test invokeMethod handles and/or/not junctions"() {
