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() + } +}
