This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 8.0.x-hibernate7
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 90c5e4787bcfdfc01e7066ad28a0257fd35f7815
Author: Walter Duque de Estrada <[email protected]>
AuthorDate: Tue Feb 24 03:40:02 2026 -0600

    HibernateAssociationQuery
---
 .../HIBERNATE7-GRAILS8-UPGRADE.md                  |  22 -
 .../HibernateMappingContextSessionFactoryBean.java | 587 ---------------------
 .../hibernate/query/HibernateAssociationQuery.java | 147 ++----
 .../grails/orm/hibernate/query/HibernateQuery.java |  18 +-
 .../orm/hibernate/query/PredicateGenerator.java    |  16 +
 .../HibernateAssociationQuerySpec.groovy           | 160 ++++++
 6 files changed, 222 insertions(+), 728 deletions(-)

diff --git a/grails-data-hibernate7/HIBERNATE7-GRAILS8-UPGRADE.md 
b/grails-data-hibernate7/HIBERNATE7-GRAILS8-UPGRADE.md
index 851e975b26..6bf5db52e4 100644
--- a/grails-data-hibernate7/HIBERNATE7-GRAILS8-UPGRADE.md
+++ b/grails-data-hibernate7/HIBERNATE7-GRAILS8-UPGRADE.md
@@ -151,32 +151,10 @@ Do **not** drop Hibernate 5.6 support in 8.0.0. Ship both:
 **Effort**: Very High (binder refactoring must be applied to both versions; 
module consolidation required)
 
 
-### 2.6 GORM Query Safety Audit
-
-**Current state**: GORM's HQL/`@Query` support accepts string interpolation in 
queries. Dynamic finders are safe by design (parameterized). But 
`executeQuery()`, `executeUpdate()`, and `@Query` with GString interpolation 
can produce SQL injection.
-
-**Potentially for Grails 8**:
-- Add compile-time warning for GString-interpolated HQL queries (AST transform 
or type checking extension)
-- Document safe query patterns prominently
-- Consider deprecating `executeQuery(String)` in favor of parameterized-only 
API
-- Audit all internal GORM query construction for injection vectors
-
-**Priority**: **P2** - SQL injection is OWASP #3. Framework should make the 
safe path the easy path.
-**Effort**: Medium
 
 
 ### 2.6 GORM Query Safety Audit
 
-**Current state**: GORM's HQL/`@Query` support accepts string interpolation in 
queries. Dynamic finders are safe by design (parameterized). But 
`executeQuery()`, `executeUpdate()`, and `@Query` with GString interpolation 
can produce SQL injection.
-
-**Potentially for Grails 8**:
-- Add compile-time warning for GString-interpolated HQL queries (AST transform 
or type checking extension)
-- Document safe query patterns prominently
-- Consider deprecating `executeQuery(String)` in favor of parameterized-only 
API
-- Audit all internal GORM query construction for injection vectors
-
-**Priority**: **P2** - SQL injection is OWASP #3. Framework should make the 
safe path the easy path.
-**Effort**: Medium
 
 **8.1+ scope** (requires Groovy 5 features, deeper changes):
 - Replace GORM dynamic finders with AST-generated static methods
diff --git 
a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateMappingContextSessionFactoryBean.java
 
b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateMappingContextSessionFactoryBean.java
deleted file mode 100644
index 3c5ce4a04b..0000000000
--- 
a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateMappingContextSessionFactoryBean.java
+++ /dev/null
@@ -1,587 +0,0 @@
-/*
- *  Licensed to the Apache Software Foundation (ASF) under one
- *  or more contributor license agreements.  See the NOTICE file
- *  distributed with this work for additional information
- *  regarding copyright ownership.  The ASF licenses this file
- *  to you under the Apache License, Version 2.0 (the
- *  "License"); you may not use this file except in compliance
- *  with the License.  You may obtain a copy of the License at
- *
- *    https://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing,
- *  software distributed under the License is distributed on an
- *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- *  KIND, either express or implied.  See the License for the
- *  specific language governing permissions and limitations
- *  under the License.
- */
-package org.grails.orm.hibernate;
-
-import java.io.File;
-import java.util.Map;
-import java.util.Properties;
-import javax.naming.NameNotFoundException;
-import javax.sql.DataSource;
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-import org.grails.datastore.mapping.core.connections.ConnectionSource;
-import org.grails.orm.hibernate.cfg.HibernateMappingContext;
-import org.grails.orm.hibernate.cfg.HibernateMappingContextConfiguration;
-import org.hibernate.HibernateException;
-import org.hibernate.Interceptor;
-import org.hibernate.SessionFactory;
-import org.hibernate.boot.model.naming.PhysicalNamingStrategy;
-import org.hibernate.cfg.Configuration;
-import org.hibernate.cfg.Environment;
-import org.springframework.beans.BeanUtils;
-import org.springframework.beans.BeansException;
-import org.springframework.beans.factory.BeanClassLoaderAware;
-import org.springframework.beans.factory.DisposableBean;
-import org.springframework.beans.factory.FactoryBean;
-import org.springframework.beans.factory.InitializingBean;
-import org.springframework.context.ApplicationContext;
-import org.springframework.context.ApplicationContextAware;
-import org.springframework.context.ResourceLoaderAware;
-import org.springframework.core.io.ClassPathResource;
-import org.springframework.core.io.Resource;
-import org.springframework.core.io.ResourceLoader;
-import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
-import org.springframework.core.io.support.ResourcePatternResolver;
-import org.springframework.core.io.support.ResourcePatternUtils;
-import org.springframework.orm.hibernate5.HibernateExceptionTranslator;
-import org.springframework.transaction.PlatformTransactionManager;
-import org.springframework.util.Assert;
-
-/**
- * Configures a SessionFactory using a {@link 
org.grails.orm.hibernate.cfg.HibernateMappingContext}
- * and a {@link 
org.grails.orm.hibernate.cfg.HibernateMappingContextConfiguration}
- *
- * @author Graeme Rocher
- * @since 5.0
- */
-public class HibernateMappingContextSessionFactoryBean extends 
HibernateExceptionTranslator
-    implements FactoryBean<SessionFactory>,
-        ResourceLoaderAware,
-        DisposableBean,
-        ApplicationContextAware,
-        InitializingBean,
-        BeanClassLoaderAware {
-  protected Class<? extends HibernateMappingContextConfiguration> configClass =
-      HibernateMappingContextConfiguration.class;
-  protected HibernateMappingContext hibernateMappingContext;
-  protected PlatformTransactionManager transactionManager;
-
-  private DataSource dataSource;
-  private Resource[] configLocations;
-  private String[] mappingResources;
-  private Resource[] mappingLocations;
-  private Resource[] cacheableMappingLocations;
-  private Resource[] mappingJarLocations;
-  private Resource[] mappingDirectoryLocations;
-  private Interceptor entityInterceptor;
-  private PhysicalNamingStrategy namingStrategy;
-  private Properties hibernateProperties;
-  private Class<?>[] annotatedClasses;
-  private String[] annotatedPackages;
-  private String[] packagesToScan;
-  private ResourcePatternResolver resourcePatternResolver =
-      new PathMatchingResourcePatternResolver();
-  private HibernateMappingContextConfiguration configuration;
-  private SessionFactory sessionFactory;
-
-  private static final Log LOG = 
LogFactory.getLog(HibernateMappingContextSessionFactoryBean.class);
-  protected Class<?> currentSessionContextClass;
-  protected Map<String, Object> eventListeners;
-  protected HibernateEventListeners hibernateEventListeners;
-  protected ApplicationContext applicationContext;
-  protected boolean proxyIfReloadEnabled = false;
-  protected String sessionFactoryBeanName = "sessionFactory";
-  protected String dataSourceName = ConnectionSource.DEFAULT;
-  protected ClassLoader classLoader;
-
-  @Override
-  public void setBeanClassLoader(ClassLoader classLoader) {
-    this.classLoader = classLoader;
-  }
-
-  public void afterPropertiesSet() throws Exception {
-    Thread thread = Thread.currentThread();
-    ClassLoader cl = thread.getContextClassLoader();
-    try {
-      thread.setContextClassLoader(classLoader);
-      buildSessionFactory();
-    } finally {
-      thread.setContextClassLoader(cl);
-    }
-  }
-
-  public PlatformTransactionManager getTransactionManager() {
-    return transactionManager;
-  }
-
-  public void setTransactionManager(PlatformTransactionManager 
transactionManager) {
-    this.transactionManager = transactionManager;
-  }
-
-  public void setHibernateMappingContext(HibernateMappingContext 
hibernateMappingContext) {
-    this.hibernateMappingContext = hibernateMappingContext;
-  }
-
-  /**
-   * Sets the class to be used for Hibernate Configuration.
-   *
-   * @param configClass A subclass of the Hibernate Configuration class
-   */
-  public void setConfigClass(Class<? extends 
HibernateMappingContextConfiguration> configClass) {
-    this.configClass = configClass;
-  }
-
-  /**
-   * Set the DataSource to be used by the SessionFactory. If set, this will 
override corresponding
-   * settings in Hibernate properties.
-   *
-   * <p>If this is set, the Hibernate settings should not define a connection 
provider to avoid
-   * meaningless double configuration.
-   */
-  public void setDataSource(DataSource dataSource) {
-    this.dataSource = dataSource;
-  }
-
-  public DataSource getDataSource() {
-    return dataSource;
-  }
-
-  /**
-   * Set the location of a single Hibernate XML config file, for example as 
classpath resource
-   * "classpath:hibernate.cfg.xml".
-   *
-   * <p>Note: Can be omitted when all necessary properties and mapping 
resources are specified
-   * locally via this bean.
-   *
-   * @see org.hibernate.cfg.Configuration#configure(java.net.URL)
-   */
-  public void setConfigLocation(Resource configLocation) {
-    configLocations = new Resource[] {configLocation};
-  }
-
-  /**
-   * Set the locations of multiple Hibernate XML config files, for example as 
classpath resources
-   * "classpath:hibernate.cfg.xml,classpath:extension.cfg.xml".
-   *
-   * <p>Note: Can be omitted when all necessary properties and mapping 
resources are specified
-   * locally via this bean.
-   *
-   * @see org.hibernate.cfg.Configuration#configure(java.net.URL)
-   */
-  public void setConfigLocations(Resource[] configLocations) {
-    this.configLocations = configLocations;
-  }
-
-  public Resource[] getConfigLocations() {
-    return configLocations;
-  }
-
-  /**
-   * Set Hibernate mapping resources to be found in the class path, like 
"example.hbm.xml" or
-   * "mypackage/example.hbm.xml". Analogous to mapping entries in a Hibernate 
XML config file.
-   * Alternative to the more generic setMappingLocations method.
-   *
-   * <p>Can be used to add to mappings from a Hibernate XML config file, or to 
specify all mappings
-   * locally.
-   *
-   * @see #setMappingLocations
-   * @see org.hibernate.cfg.Configuration#addResource
-   */
-  public void setMappingResources(String[] mappingResources) {
-    this.mappingResources = mappingResources;
-  }
-
-  public String[] getMappingResources() {
-    return mappingResources;
-  }
-
-  /**
-   * Set locations of Hibernate mapping files, for example as classpath 
resource
-   * "classpath:example.hbm.xml". Supports any resource location via Spring's 
resource abstraction,
-   * for example relative paths like "WEB-INF/mappings/example.hbm.xml" when 
running in an
-   * application context.
-   *
-   * <p>Can be used to add to mappings from a Hibernate XML config file, or to 
specify all mappings
-   * locally.
-   *
-   * @see org.hibernate.cfg.Configuration#addInputStream
-   */
-  public void setMappingLocations(Resource[] mappingLocations) {
-    this.mappingLocations = mappingLocations;
-  }
-
-  public Resource[] getMappingLocations() {
-    return mappingLocations;
-  }
-
-  /**
-   * Set locations of cacheable Hibernate mapping files, for example as web 
app resource
-   * "/WEB-INF/mapping/example.hbm.xml". Supports any resource location via 
Spring's resource
-   * abstraction, as long as the resource can be resolved in the file system.
-   *
-   * <p>Can be used to add to mappings from a Hibernate XML config file, or to 
specify all mappings
-   * locally.
-   *
-   * @see org.hibernate.cfg.Configuration#addCacheableFile(java.io.File)
-   */
-  public void setCacheableMappingLocations(Resource[] 
cacheableMappingLocations) {
-    this.cacheableMappingLocations = cacheableMappingLocations;
-  }
-
-  public Resource[] getCacheableMappingLocations() {
-    return cacheableMappingLocations;
-  }
-
-  /**
-   * Set locations of jar files that contain Hibernate mapping resources, like
-   * "WEB-INF/lib/example.hbm.jar".
-   *
-   * <p>Can be used to add to mappings from a Hibernate XML config file, or to 
specify all mappings
-   * locally.
-   *
-   * @see org.hibernate.cfg.Configuration#addJar(java.io.File)
-   */
-  public void setMappingJarLocations(Resource[] mappingJarLocations) {
-    this.mappingJarLocations = mappingJarLocations;
-  }
-
-  public Resource[] getMappingJarLocations() {
-    return mappingJarLocations;
-  }
-
-  /**
-   * Set locations of directories that contain Hibernate mapping resources, 
like "WEB-INF/mappings".
-   *
-   * <p>Can be used to add to mappings from a Hibernate XML config file, or to 
specify all mappings
-   * locally.
-   *
-   * @see org.hibernate.cfg.Configuration#addDirectory(java.io.File)
-   */
-  public void setMappingDirectoryLocations(Resource[] 
mappingDirectoryLocations) {
-    this.mappingDirectoryLocations = mappingDirectoryLocations;
-  }
-
-  public Resource[] getMappingDirectoryLocations() {
-    return mappingDirectoryLocations;
-  }
-
-  /**
-   * Set a Hibernate entity interceptor that allows to inspect and change 
property values before
-   * writing to and reading from the database. Will get applied to any new 
Session created by this
-   * factory.
-   *
-   * @see org.hibernate.cfg.Configuration#setInterceptor
-   */
-  public void setEntityInterceptor(Interceptor entityInterceptor) {
-    this.entityInterceptor = entityInterceptor;
-  }
-
-  public Interceptor getEntityInterceptor() {
-    return entityInterceptor;
-  }
-
-  /**
-   * Set a Hibernate NamingStrategy for the SessionFactory, determining the 
physical column and
-   * table names given the info in the mapping document.
-   */
-  public void setNamingStrategy(PhysicalNamingStrategy namingStrategy) {
-    this.namingStrategy = namingStrategy;
-  }
-
-  public PhysicalNamingStrategy getNamingStrategy() {
-    return namingStrategy;
-  }
-
-  /**
-   * Set Hibernate properties, such as "hibernate.dialect".
-   *
-   * <p>Note: Do not specify a transaction provider here when using 
Spring-driven transactions. It
-   * is also advisable to omit connection provider settings and use a 
Spring-set DataSource instead.
-   *
-   * @see #setDataSource
-   */
-  public void setHibernateProperties(Properties hibernateProperties) {
-    this.hibernateProperties = hibernateProperties;
-  }
-
-  /**
-   * Return the Hibernate properties, if any. Mainly available for 
configuration through property
-   * paths that specify individual keys.
-   */
-  public Properties getHibernateProperties() {
-    if (hibernateProperties == null) {
-      hibernateProperties = new Properties();
-    }
-    return hibernateProperties;
-  }
-
-  /**
-   * Specify annotated entity classes to register with this Hibernate 
SessionFactory.
-   *
-   * @see org.hibernate.cfg.Configuration#addAnnotatedClass(Class)
-   */
-  public void setAnnotatedClasses(Class<?>[] annotatedClasses) {
-    this.annotatedClasses = annotatedClasses;
-  }
-
-  public Class<?>[] getAnnotatedClasses() {
-    return annotatedClasses;
-  }
-
-  /**
-   * Specify the names of annotated packages, for which package-level 
annotation metadata will be
-   * read.
-   *
-   * @see org.hibernate.cfg.Configuration#addPackage(String)
-   */
-  public void setAnnotatedPackages(String[] annotatedPackages) {
-    this.annotatedPackages = annotatedPackages;
-  }
-
-  public String[] getAnnotatedPackages() {
-    return annotatedPackages;
-  }
-
-  /**
-   * Specify packages to search for autodetection of your entity classes in 
the classpath. This is
-   * analogous to Spring's component-scan feature ({@link
-   * org.springframework.context.annotation.ClassPathBeanDefinitionScanner}).
-   */
-  public void setPackagesToScan(String... packagesToScan) {
-    this.packagesToScan = packagesToScan;
-  }
-
-  public String[] getPackagesToScan() {
-    return packagesToScan;
-  }
-
-  public void setResourceLoader(ResourceLoader resourceLoader) {
-    resourcePatternResolver = 
ResourcePatternUtils.getResourcePatternResolver(resourceLoader);
-  }
-
-  /**
-   * @param proxyIfReloadEnabled Sets whether a proxy should be created if 
reload is enabled
-   */
-  public void setProxyIfReloadEnabled(boolean proxyIfReloadEnabled) {
-    this.proxyIfReloadEnabled = proxyIfReloadEnabled;
-  }
-
-  public boolean isProxyIfReloadEnabled() {
-    return proxyIfReloadEnabled;
-  }
-
-  /**
-   * Sets class to be used for the Hibernate CurrentSessionContext.
-   *
-   * @param currentSessionContextClass An implementation of the 
CurrentSessionContext interface
-   */
-  public void setCurrentSessionContextClass(Class<?> 
currentSessionContextClass) {
-    this.currentSessionContextClass = currentSessionContextClass;
-  }
-
-  public Class<?> getCurrentSessionContextClass() {
-    return currentSessionContextClass;
-  }
-
-  public Class<? extends HibernateMappingContextConfiguration> 
getConfigClass() {
-    return configClass;
-  }
-
-  public void setHibernateEventListeners(final HibernateEventListeners 
listeners) {
-    hibernateEventListeners = listeners;
-  }
-
-  public HibernateEventListeners getHibernateEventListeners() {
-    return hibernateEventListeners;
-  }
-
-  public void setSessionFactoryBeanName(String name) {
-    sessionFactoryBeanName = name;
-  }
-
-  public String getSessionFactoryBeanName() {
-    return sessionFactoryBeanName;
-  }
-
-  public void setDataSourceName(String name) {
-    dataSourceName = name;
-  }
-
-  public String getDataSourceName() {
-    return dataSourceName;
-  }
-
-  /**
-   * Specify the Hibernate event listeners to register, with listener types as 
keys and listener
-   * objects as values. Instead of a single listener object, you can also pass 
in a list or set of
-   * listeners objects as value.
-   *
-   * <p>See the Hibernate documentation for further details on listener types 
and associated
-   * listener interfaces.
-   *
-   * @param eventListeners Map with listener type Strings as keys and listener 
objects as values
-   */
-  public void setEventListeners(Map<String, Object> eventListeners) {
-    this.eventListeners = eventListeners;
-  }
-
-  public Map<String, Object> getEventListeners() {
-    return eventListeners;
-  }
-
-  protected void buildSessionFactory() throws Exception {
-
-    configuration = newConfiguration();
-
-    if (hibernateMappingContext == null) {
-
-      throw new IllegalArgumentException("HibernateMappingContext is 
required.");
-    }
-
-    configuration.setHibernateMappingContext(hibernateMappingContext);
-
-    if (configLocations != null) {
-      for (Resource resource : configLocations) {
-        // Load Hibernate configuration from given location.
-        configuration.configure(resource.getURL());
-      }
-    }
-
-    if (mappingResources != null) {
-      // Register given Hibernate mapping definitions, contained in resource 
files.
-      for (String mapping : mappingResources) {
-        Resource mr =
-            new ClassPathResource(mapping.trim(), 
resourcePatternResolver.getClassLoader());
-        configuration.addInputStream(mr.getInputStream());
-      }
-    }
-
-    if (mappingLocations != null) {
-      // Register given Hibernate mapping definitions, contained in resource 
files.
-      for (Resource resource : mappingLocations) {
-        configuration.addInputStream(resource.getInputStream());
-      }
-    }
-
-    if (cacheableMappingLocations != null) {
-      // Register given cacheable Hibernate mapping definitions, read from the 
file system.
-      for (Resource resource : cacheableMappingLocations) {
-        configuration.addCacheableFile(resource.getFile());
-      }
-    }
-
-    if (mappingJarLocations != null) {
-      // Register given Hibernate mapping definitions, contained in jar files.
-      for (Resource resource : mappingJarLocations) {
-        configuration.addJar(resource.getFile());
-      }
-    }
-
-    if (mappingDirectoryLocations != null) {
-      // Register all Hibernate mapping definitions in the given directories.
-      for (Resource resource : mappingDirectoryLocations) {
-        File file = resource.getFile();
-        if (!file.isDirectory()) {
-          throw new IllegalArgumentException(
-              "Mapping directory location [" + resource + "] does not denote a 
directory");
-        }
-        configuration.addDirectory(file);
-      }
-    }
-
-    if (entityInterceptor != null) {
-      configuration.setInterceptor(entityInterceptor);
-    }
-
-    if (namingStrategy != null) {
-      //            configuration.setNamingStrategy(namingStrategy);
-    }
-
-    if (hibernateProperties != null) {
-      configuration.addProperties(hibernateProperties);
-    }
-
-    if (annotatedClasses != null) {
-      configuration.addAnnotatedClasses(annotatedClasses);
-    }
-
-    if (annotatedPackages != null) {
-      configuration.addPackages(annotatedPackages);
-    }
-
-    if (packagesToScan != null) {
-      configuration.scanPackages(packagesToScan);
-    }
-
-    if (eventListeners != null) {
-      configuration.setEventListeners(eventListeners);
-    }
-
-    sessionFactory = doBuildSessionFactory();
-  }
-
-  protected SessionFactory doBuildSessionFactory() {
-    return configuration.buildSessionFactory();
-  }
-
-  /**
-   * Return the Hibernate Configuration object used to build the 
SessionFactory. Allows for access
-   * to configuration metadata stored there (rarely needed).
-   *
-   * @throws IllegalStateException if the Configuration object has not been 
initialized yet
-   */
-  public final Configuration getConfiguration() {
-    Assert.state(configuration != null, "Configuration not initialized yet");
-    return configuration;
-  }
-
-  public SessionFactory getObject() {
-    return sessionFactory;
-  }
-
-  public Class<?> getObjectType() {
-    return sessionFactory == null ? SessionFactory.class : 
sessionFactory.getClass();
-  }
-
-  public boolean isSingleton() {
-    return true;
-  }
-
-  public void destroy() {
-    try {
-      sessionFactory.close();
-    } catch (HibernateException e) {
-      if (e.getCause() instanceof NameNotFoundException) {
-        LOG.debug(e.getCause().getMessage(), e);
-      } else {
-        throw e;
-      }
-    }
-  }
-
-  protected HibernateMappingContextConfiguration newConfiguration() throws 
Exception {
-    if (configClass == null) {
-      configClass = HibernateMappingContextConfiguration.class;
-    }
-    HibernateMappingContextConfiguration config = 
BeanUtils.instantiateClass(configClass);
-    config.setDataSourceName(dataSourceName);
-    config.setApplicationContext(applicationContext);
-    config.setSessionFactoryBeanName(sessionFactoryBeanName);
-    config.setHibernateEventListeners(hibernateEventListeners);
-    if (currentSessionContextClass != null) {
-      config.setProperty(
-          Environment.CURRENT_SESSION_CONTEXT_CLASS, 
currentSessionContextClass.getName());
-    }
-    return config;
-  }
-
-  public void setApplicationContext(ApplicationContext applicationContext) 
throws BeansException {
-    this.applicationContext = applicationContext;
-  }
-}
diff --git 
a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateAssociationQuery.java
 
b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateAssociationQuery.java
index fdf6284baa..16c7f3eaf9 100644
--- 
a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateAssociationQuery.java
+++ 
b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateAssociationQuery.java
@@ -18,146 +18,71 @@
  */
 package org.grails.orm.hibernate.query;
 
-import jakarta.persistence.criteria.CriteriaQuery;
 import java.util.List;
-import java.util.Map;
 import org.grails.datastore.mapping.model.PersistentEntity;
 import org.grails.datastore.mapping.model.types.Association;
 import org.grails.datastore.mapping.query.AssociationQuery;
 import org.grails.datastore.mapping.query.Query;
 import org.grails.orm.hibernate.AbstractHibernateSession;
 
-class HibernateAssociationQuery extends AssociationQuery {
+/**
+ * A thin wrapper over {@link HibernateQuery} that collects criteria for a 
single association scope.
+ *
+ * <p>When {@link HibernateQuery#createQuery(String)} is called (e.g. via
+ * {@code Person.withCriteria { pets { eq 'name', 'Lucky' } }}), the
+ * {@code AbstractCriteriaBuilder} sets the current query to this instance and 
routes all
+ * criteria added inside the closure through {@link #add(Query.Criterion)}.
+ * Those criteria are held by an inner {@link HibernateQuery} scoped to the 
associated entity.
+ *
+ * <p>At query-execution time, {@link PredicateGenerator} dispatches on this 
type and performs
+ * a {@code LEFT JOIN} on {@link #associationPath}, then applies the collected 
predicates.
+ *
+ * @see PredicateGenerator
+ * @see HibernateQuery#createQuery(String)
+ */
+public class HibernateAssociationQuery extends AssociationQuery {
+  final String alias;
 
-  protected String alias;
-  protected CriteriaQuery assocationCriteria;
+  /** Dotted property path used for the JPA join (e.g. {@code "pets"} or 
{@code "owner.address"}) */
+  final String associationPath;
+
+  /** Criteria collector — a real HibernateQuery scoped to the associated 
entity */
+  private final HibernateQuery innerQuery;
 
   public HibernateAssociationQuery(
-      CriteriaQuery criteria,
       AbstractHibernateSession session,
       PersistentEntity associatedEntity,
       Association association,
+      String associationPath,
       String alias) {
     super(session, associatedEntity, association);
     this.alias = alias;
-    assocationCriteria = criteria;
-  }
-
-  @Override
-  public Query order(Order order) {
-    return this;
-  }
-
-  @Override
-  public Query isEmpty(String property) {
-    return this;
-  }
-
-  @Override
-  public Query isNotEmpty(String property) {
-    return this;
-  }
-
-  @Override
-  public Query isNull(String property) {
-    return this;
-  }
-
-  @Override
-  public Query isNotNull(String property) {
-    return this;
-  }
-
-  @Override
-  public void add(Criterion criterion) {}
-
-  @Override
-  public Junction disjunction() {
-    return null;
-  }
-
-  @Override
-  public Junction negation() {
-    return null;
-  }
-
-  @Override
-  public Query eq(String property, Object value) {
-    return this;
+    this.associationPath = associationPath;
+    this.innerQuery = new HibernateQuery(session, associatedEntity);
   }
 
-  @Override
-  public Query idEq(Object value) {
-    return this;
-  }
-
-  @Override
-  public Query gt(String property, Object value) {
-    return this;
-  }
-
-  @Override
-  public Query and(Criterion a, Criterion b) {
-    return this;
-  }
-
-  @Override
-  public Query or(Criterion a, Criterion b) {
-    return this;
-  }
-
-  @Override
-  public Query allEq(Map<String, Object> values) {
-    return this;
-  }
-
-  @Override
-  public Query ge(String property, Object value) {
-    return this;
-  }
-
-  @Override
-  public Query le(String property, Object value) {
-    return this;
-  }
-
-  @Override
-  public Query gte(String property, Object value) {
-    return this;
-  }
-
-  @Override
-  public Query lte(String property, Object value) {
-    return this;
-  }
-
-  @Override
-  public Query lt(String property, Object value) {
-    return this;
-  }
-
-  @Override
-  public Query in(String property, List values) {
-    return this;
+  /** Returns the criteria collected inside the association closure. */
+  public List<Query.Criterion> getAssociationCriteria() {
+    return innerQuery.getAllCriteria();
   }
 
   @Override
-  public Query between(String property, Object start, Object end) {
-    return this;
+  public void add(Query.Criterion criterion) {
+    innerQuery.add(criterion);
   }
 
   @Override
-  public Query like(String property, String expr) {
-    return this;
+  public void add(Query.Junction currentJunction, Query.Criterion criterion) {
+    innerQuery.add(currentJunction, criterion);
   }
 
   @Override
-  public Query ilike(String property, String expr) {
-    return this;
+  public Query.Junction disjunction() {
+    return innerQuery.disjunction();
   }
 
   @Override
-  public Query rlike(String property, String expr) {
-    return this;
+  public Query.Junction negation() {
+    return innerQuery.negation();
   }
 }
diff --git 
a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQuery.java
 
b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQuery.java
index 3a52eb46b2..b8224157c5 100644
--- 
a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQuery.java
+++ 
b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQuery.java
@@ -140,6 +140,10 @@ public class HibernateQuery extends Query {
     }
   }
 
+  public List<Criterion> getAllCriteria() {
+    return  detachedCriteria.getCriteria();
+  }
+
   public void add(Criterion criterion) {
     detachedCriteria.add(criterion);
   }
@@ -313,14 +317,12 @@ public class HibernateQuery extends Query {
     if ((property instanceof Association association)) {
       String alias = generateAlias(associationName);
       CriteriaAndAlias subCriteria = getOrCreateAlias(associationName, alias);
-      if (subCriteria.criteria != null) {
-        return new HibernateAssociationQuery(
-            subCriteria.criteria,
-            (AbstractHibernateSession) getSession(),
-            association.getAssociatedEntity(),
-            association,
-            alias);
-      }
+      return new HibernateAssociationQuery(
+          (AbstractHibernateSession) getSession(),
+          association.getAssociatedEntity(),
+          association,
+          subCriteria.associationPath,
+          alias);
     }
     throw new InvalidDataAccessApiUsageException(
         "Cannot query association ["
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 18c92ef77b..87d476ce4c 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
@@ -88,6 +88,8 @@ public class PredicateGenerator {
       return cb.conjunction();
     } else if (criterion instanceof DetachedAssociationCriteria<?> c) {
       return handleAssociationCriteria(cb, criteriaQuery, root, 
fromsByProvider, entity, c);
+    } else if (criterion instanceof HibernateAssociationQuery haq) {
+      return handleHibernateAssociationQuery(cb, criteriaQuery, root, 
fromsByProvider, entity, haq);
     } else if (criterion instanceof Query.PropertyCriterion pc) {
       return handlePropertyCriterion(cb, criteriaQuery, root, fromsByProvider, 
entity, pc);
     } else if (criterion instanceof Query.PropertyComparisonCriterion c) {
@@ -141,6 +143,20 @@ public class PredicateGenerator {
         getPredicates(cb, criteriaQuery, child, c.getCriteria(), 
childTablesByName, entity));
   }
 
+  private Predicate handleHibernateAssociationQuery(
+      HibernateCriteriaBuilder cb,
+      CriteriaQuery<?> criteriaQuery,
+      From<?, ?> root,
+      JpaFromProvider fromsByProvider,
+      PersistentEntity entity,
+      HibernateAssociationQuery haq) {
+    var child = root.join(haq.associationPath, JoinType.LEFT);
+    JpaFromProvider childFroms = (JpaFromProvider) fromsByProvider.clone();
+    childFroms.put("root", child);
+    return cb.and(
+        getPredicates(cb, criteriaQuery, child, haq.getAssociationCriteria(), 
childFroms, haq.getEntity()));
+  }
+
   private Predicate handlePropertyCriterion(
       HibernateCriteriaBuilder cb,
       CriteriaQuery<?> criteriaQuery,
diff --git 
a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/HibernateAssociationQuerySpec.groovy
 
b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/HibernateAssociationQuerySpec.groovy
new file mode 100644
index 0000000000..f01f20eb85
--- /dev/null
+++ 
b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/specs/hibernatequery/HibernateAssociationQuerySpec.groovy
@@ -0,0 +1,160 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.specs.hibernatequery
+
+import grails.gorm.specs.HibernateGormDatastoreSpec
+import org.apache.grails.data.testing.tck.domains.Person
+import org.apache.grails.data.testing.tck.domains.Pet
+import org.grails.datastore.mapping.query.AssociationQuery
+import org.grails.datastore.mapping.query.Query
+import org.grails.orm.hibernate.AbstractHibernateSession
+import org.grails.orm.hibernate.HibernateDatastore
+import org.grails.orm.hibernate.query.HibernateAssociationQuery
+import org.grails.orm.hibernate.query.HibernateQuery
+
+class HibernateAssociationQuerySpec extends HibernateGormDatastoreSpec {
+
+    HibernateQuery personQuery
+    Person bob
+
+    def setupSpec() {
+        manager.addAllDomainClasses([Person, Pet])
+    }
+
+    def setup() {
+        HibernateDatastore hibernateDatastore = manager.hibernateDatastore
+        AbstractHibernateSession session = hibernateDatastore.connect() as 
AbstractHibernateSession
+        personQuery = new HibernateQuery(session, 
hibernateDatastore.getMappingContext().getPersistentEntity(Person.typeName))
+        bob = new Person(firstName: "Bob", lastName: "Builder", age: 
50).save(flush: true)
+        new Pet(name: "Lucky", age: 3, owner: bob).save(flush: true)
+        new Pet(name: "Rex", age: 7, owner: bob).save(flush: true)
+        def alice = new Person(firstName: "Alice", lastName: "Smith", age: 
30).save(flush: true)
+        new Pet(name: "Whiskers", age: 2, owner: alice).save(flush: true)
+        session.flush()
+    }
+
+    def "createQuery returns a HibernateAssociationQuery for an association 
property"() {
+        when:
+        def assocQuery = personQuery.createQuery("pets")
+
+        then:
+        assocQuery instanceof HibernateAssociationQuery
+        assocQuery instanceof AssociationQuery
+        assocQuery.getEntity() != null
+        assocQuery.getAssociation() != null
+        assocQuery.getAssociation().getName() == "pets"
+    }
+
+    def "HibernateAssociationQuery collects eq criteria added via add()"() {
+        given:
+        def assocQuery = personQuery.createQuery("pets") as 
HibernateAssociationQuery
+
+        when:
+        assocQuery.add(new Query.Equals("name", "Lucky"))
+
+        then:
+        assocQuery.getAssociationCriteria().size() == 1
+        assocQuery.getAssociationCriteria()[0] instanceof Query.Equals
+    }
+
+    def "HibernateAssociationQuery collects multiple criteria"() {
+        given:
+        def assocQuery = personQuery.createQuery("pets") as 
HibernateAssociationQuery
+
+        when:
+        assocQuery.add(new Query.Equals("name", "Lucky"))
+        assocQuery.add(new Query.GreaterThan("age", 1))
+
+        then:
+        assocQuery.getAssociationCriteria().size() == 2
+    }
+
+    def "HibernateAssociationQuery supports disjunction"() {
+        given:
+        def assocQuery = personQuery.createQuery("pets") as 
HibernateAssociationQuery
+
+        when:
+        def disj = assocQuery.disjunction()
+        assocQuery.add(disj, new Query.Equals("name", "Lucky"))
+        assocQuery.add(disj, new Query.Equals("name", "Rex"))
+
+        then: "criteria list contains a Disjunction with both inner criteria"
+        def allCriteria = assocQuery.getAssociationCriteria()
+        def found = allCriteria.find { it instanceof Query.Disjunction } as 
Query.Disjunction
+        found != null
+        found.criteria.size() == 2
+    }
+
+    // --- DSL integration tests via withCriteria ---
+
+    def "withCriteria on association with eq filter returns correct results"() 
{
+        when:
+        def results = Person.withCriteria {
+            pets {
+                eq 'name', 'Lucky'
+            }
+        }
+
+        then:
+        results.size() == 1
+        results[0].firstName == "Bob"
+    }
+
+    def "withCriteria on association with no matching criteria returns 
empty"() {
+        when:
+        def results = Person.withCriteria {
+            pets {
+                eq 'name', 'NoSuchPet'
+            }
+        }
+
+        then:
+        results.isEmpty()
+    }
+
+    def "withCriteria on association filters by multiple criteria"() {
+        when:
+        def results = Person.withCriteria {
+            pets {
+                eq 'name', 'Rex'
+                gt 'age', 5
+            }
+        }
+
+        then:
+        results.size() == 1
+        results[0].firstName == "Bob"
+    }
+
+    def "withCriteria on association with disjunction returns both matching 
owners"() {
+        when:
+        def results = Person.withCriteria {
+            pets {
+                or {
+                    eq 'name', 'Lucky'
+                    eq 'name', 'Whiskers'
+                }
+            }
+        } as List<Person>
+
+        then:
+        results.size() == 2
+        results*.firstName.toSet() == ["Bob", "Alice"].toSet()
+    }
+}


Reply via email to