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 9620b0b50101011fed4ba1f70712cee40892d5b0 Author: Walter Duque de Estrada <[email protected]> AuthorDate: Sun Mar 22 20:52:51 2026 -0500 hibernate 7: * Fix HQL Queries --- .../orm/hibernate/query/HibernateHqlQuery.java | 52 +++--- .../hibernate/query/HibernateQueryArgument.java | 19 +++ .../orm/hibernate/query/HqlListQueryBuilder.java | 26 +-- .../orm/hibernate/query/HqlQueryContext.java | 105 +++++++----- .../hibernate/query/HqlListQueryBuilderSpec.groovy | 177 +++++++++++++++++++++ .../orm/hibernate/query/HqlQueryContextSpec.groovy | 20 ++- 6 files changed, 321 insertions(+), 78 deletions(-) diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateHqlQuery.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateHqlQuery.java index ce121fb447..3683801770 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateHqlQuery.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateHqlQuery.java @@ -101,6 +101,7 @@ public class HibernateHqlQuery extends Query { /** * Session-bound step — creates the appropriate Hibernate query from an open {@link * org.hibernate.Session} and wraps it in a {@link HibernateHqlQuery}. + * Note: The HqlQueryContext has a sanitized sql string */ protected static HibernateHqlQuery buildQuery( org.hibernate.Session session, @@ -109,31 +110,26 @@ public class HibernateHqlQuery extends Query { PersistentEntity entity, HqlQueryContext ctx) { HibernateSession hibernateSession = new HibernateSession(dataStore, sessionFactory); - if (StringUtils.isEmpty(ctx.hql())) { + String hql = ctx.hql(); + if (StringUtils.isEmpty(hql)) { var q = session.createQuery("from " + ctx.targetClass().getName(), ctx.targetClass()); return new HibernateHqlQuery(hibernateSession, entity, q); } else if (ctx.isUpdate()) { - var mq = session.createMutationQuery(ctx.hql()); + var mq = session.createMutationQuery(hql); var result = new HibernateHqlQuery(hibernateSession, entity, mq); result.setFlushMode(session.getHibernateFlushMode()); return result; } else { - var q = ctx.isNative() ? - session.createNativeQuery(ctx.hql(), ctx.targetClass()) : - isScalarSelect(ctx.hql()) ? - session.createQuery(ctx.hql()) : - session.createQuery(ctx.hql(), ctx.targetClass()); + var q = ctx.isNative() + ? session.createNativeQuery(hql, ctx.targetClass()) + : session.createQuery(hql, ctx.targetClass()); var result = new HibernateHqlQuery(hibernateSession, entity, q); result.setFlushMode(session.getHibernateFlushMode()); return result; } } - /** Returns true if the HQL starts with a {@code select} clause that doesn't project the entity. */ - private static boolean isScalarSelect(String hql) { - String trimmed = hql.stripLeading().toLowerCase(); - return trimmed.startsWith("select") && !trimmed.startsWith("select e ") && !trimmed.startsWith("select e,"); - } + /** * Full factory — opens a session via the {@link GrailsHibernateTemplate}, builds the query from @@ -184,9 +180,9 @@ public class HibernateHqlQuery extends Query { protected void setFlushMode(FlushMode flushMode) { session.setFlushMode( - flushMode == FlushMode.AUTO || flushMode == FlushMode.ALWAYS ? - FlushModeType.AUTO : - FlushModeType.COMMIT); + flushMode == FlushMode.AUTO || flushMode == FlushMode.ALWAYS + ? FlushModeType.AUTO + : FlushModeType.COMMIT); } protected void populateQuerySettings(Map<?, ?> args, ConversionService conversionService) { @@ -209,7 +205,8 @@ public class HibernateHqlQuery extends Query { delegate.setCacheable(false); } else { if (!args.containsKey(HibernateQueryArgument.CACHE.value())) { - org.grails.orm.hibernate.cfg.Mapping m = ((org.grails.orm.hibernate.cfg.HibernateMappingContext) getEntity().getMappingContext()) + org.grails.orm.hibernate.cfg.Mapping m = ((org.grails.orm.hibernate.cfg.HibernateMappingContext) + getEntity().getMappingContext()) .getMappingCacheHolder() .getMapping(getEntity().getJavaClass()); if (m != null && m.getCache() != null && m.getCache().getEnabled()) { @@ -246,14 +243,14 @@ public class HibernateHqlQuery extends Query { throw new GrailsQueryException("Named parameter's name must be a String: " + namedArgs); } String name = key.toString(); - if (HibernateQueryArgument.MAX.value().equals(name) || - HibernateQueryArgument.OFFSET.value().equals(name) || - HibernateQueryArgument.CACHE.value().equals(name) || - HibernateQueryArgument.FETCH_SIZE.value().equals(name) || - HibernateQueryArgument.TIMEOUT.value().equals(name) || - HibernateQueryArgument.READ_ONLY.value().equals(name) || - HibernateQueryArgument.FLUSH_MODE.value().equals(name) || - HibernateQueryArgument.LOCK.value().equals(name)) { + if (HibernateQueryArgument.MAX.value().equals(name) + || HibernateQueryArgument.OFFSET.value().equals(name) + || HibernateQueryArgument.CACHE.value().equals(name) + || HibernateQueryArgument.FETCH_SIZE.value().equals(name) + || HibernateQueryArgument.TIMEOUT.value().equals(name) + || HibernateQueryArgument.READ_ONLY.value().equals(name) + || HibernateQueryArgument.FLUSH_MODE.value().equals(name) + || HibernateQueryArgument.LOCK.value().equals(name)) { return; } if (value == null) { @@ -302,8 +299,11 @@ public class HibernateHqlQuery extends Query { // ─── Private utilities ──────────────────────────────────────────────────── private static int toInt(Object v, ConversionService cs) { - if (v instanceof Integer i) return i; - return cs.convert(v, Integer.class); + if (v instanceof Integer i) { + return i; + } + Integer i = cs.convert(v, Integer.class); + return i != null ? i : 0; } private static boolean toBool(Object v) { diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQueryArgument.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQueryArgument.java index 1e22059ea2..eab49d4ba2 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQueryArgument.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HibernateQueryArgument.java @@ -52,6 +52,25 @@ public enum HibernateQueryArgument { IGNORE_CASE(DynamicFinder.ARGUMENT_IGNORE_CASE), ORDER_DESC(DynamicFinder.ORDER_DESC), ORDER_ASC(DynamicFinder.ORDER_ASC), + EAGER("eager"), + JOIN("join"), + + // ── HQL keywords ────────────────────────────────────────────────────────── + HQL_SELECT("select"), + HQL_FROM("from"), + HQL_WHERE("where"), + HQL_JOIN("join"), + HQL_LEFT("left"), + HQL_RIGHT("right"), + HQL_INNER("inner"), + HQL_OUTER("outer"), + HQL_GROUP("group"), + HQL_ORDER("order"), + HQL_HAVING("having"), + HQL_DISTINCT("distinct"), + HQL_ALL("all"), + HQL_AS("as"), + HQL_NEW("new"), // ── Hibernate config properties ─────────────────────────────────────────── CONFIG_CACHE_QUERIES("grails.hibernate.cache.queries"), diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java index 8bd46c7a3c..cf0b133926 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java @@ -22,7 +22,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import org.grails.datastore.gorm.finders.DynamicFinder; import org.grails.datastore.mapping.model.PersistentEntity; import org.grails.datastore.mapping.model.PersistentProperty; import org.grails.datastore.mapping.model.types.Association; @@ -81,7 +80,8 @@ public class HqlListQueryBuilder { private static boolean isJoinFetch(Object mode) { if (mode == null) return false; String s = mode.toString(); - return s.equalsIgnoreCase("join") || s.equalsIgnoreCase("eager"); + return s.equalsIgnoreCase(HibernateQueryArgument.JOIN.value()) + || s.equalsIgnoreCase(HibernateQueryArgument.EAGER.value()); } // ─── ORDER BY ──────────────────────────────────────────────────────────── @@ -102,13 +102,13 @@ public class HqlListQueryBuilder { clauses.add(orderClause(alias, sort, dir, ignoreCase && isStringProp(sort))); } else { // fall back to default mapping sort - MappingCacheHolder cacheHolder = ((HibernateMappingContext) entity.getMappingContext()).getMappingCacheHolder(); - Mapping m = cacheHolder.getMapping(entity.getJavaClass()); - if (m != null) { - m.getSort() - .getNamesAndDirections() - .forEach((prop, dir) -> clauses.add(orderClause( - alias, (String) prop, direction((String) dir), isStringProp((String) prop)))); + if (entity.getMappingContext() instanceof HibernateMappingContext hmc) { + Mapping m = hmc.getMappingCacheHolder().getMapping(entity.getJavaClass()); + if (m != null) { + ((Map<?, ?>) m.getSort().getNamesAndDirections()) + .forEach((prop, dir) -> clauses.add(orderClause( + alias, (String) prop, direction((String) dir), isStringProp((String) prop)))); + } } } @@ -123,9 +123,9 @@ public class HqlListQueryBuilder { } private static String direction(String raw) { - return HibernateQueryArgument.ORDER_DESC.value().equalsIgnoreCase(raw) ? - HibernateQueryArgument.ORDER_DESC.value() : - HibernateQueryArgument.ORDER_ASC.value(); + return HibernateQueryArgument.ORDER_DESC.value().equalsIgnoreCase(raw) + ? HibernateQueryArgument.ORDER_DESC.value() + : HibernateQueryArgument.ORDER_ASC.value(); } private boolean isIgnoreCase() { @@ -155,6 +155,6 @@ public class HqlListQueryBuilder { /** Returns true when the params indicate a paged query (i.e. {@code max} is set). */ @SuppressWarnings("rawtypes") static boolean isPaged(Map params) { - return params.containsKey(DynamicFinder.ARGUMENT_MAX); + return params.containsKey(HibernateQueryArgument.MAX.value()); } } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlQueryContext.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlQueryContext.java index e50a5c35f7..5fbf29c98f 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlQueryContext.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlQueryContext.java @@ -27,6 +27,7 @@ import java.util.Set; import groovy.lang.GString; +import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.grails.datastore.mapping.model.PersistentEntity; @@ -37,6 +38,10 @@ import org.grails.datastore.mapping.model.PersistentEntity; * (including those expanded from a {@link GString}), and flags for whether the query is an update * or native SQL. * + * <p><strong>Security Note:</strong> The {@code hql} string must be trust-verified or + * properly parameterized (e.g. via {@link GString} expansion in {@link #prepare}) before + * being passed to execution engines to prevent injection vulnerabilities. + * * <p>Use {@link #prepare} to build an instance from raw inputs. */ @SuppressWarnings({ @@ -120,11 +125,38 @@ public record HqlQueryContext( String normalized = normalizeNonAliasedSelect(hql == null ? null : hql.toString()); return switch (countHqlProjections(normalized)) { case 0 -> clazz; - case 1 -> isPropertyProjection(normalized) ? Object.class : clazz; + case 1 -> isAggregateProjection(normalized) ? Long.class : (isPropertyProjection(normalized) ? Object.class : clazz); default -> Object[].class; }; } + private static boolean isAggregateProjection(CharSequence hql) { + String clause = getSingleProjectionClause(hql); + if (clause == null) return false; + + return clause.startsWith("count(") || clause.startsWith("sum(") || clause.startsWith("avg(") || clause.startsWith("min(") || clause.startsWith("max("); + } + + private static @Nullable String getSingleProjectionClause(CharSequence hql) { + if (hql == null) return null; + String s = hql.toString().toLowerCase(Locale.ROOT).trim(); + int selectIdx = s.indexOf(HibernateQueryArgument.HQL_SELECT.value() + " "); + if (selectIdx < 0) return null; + int fromIdx = s.indexOf(" " + HibernateQueryArgument.HQL_FROM.value() + " ", selectIdx); + return extractSelectClause(s, selectIdx, fromIdx); + } + + private static @NonNull String extractSelectClause(String s, int selectIdx, int fromIdx) { + String clause = s.substring(selectIdx + HibernateQueryArgument.HQL_SELECT.value().length(), fromIdx < 0 ? s.length() : fromIdx) + .trim(); + if (clause.startsWith(HibernateQueryArgument.HQL_DISTINCT.value() + " ")) { + clause = clause.substring(HibernateQueryArgument.HQL_DISTINCT.value().length() + 1).trim(); + } else if (clause.startsWith(HibernateQueryArgument.HQL_ALL.value() + " ")) { + clause = clause.substring(HibernateQueryArgument.HQL_ALL.value().length() + 1).trim(); + } + return clause; + } + /** * Returns the number of top-level projections in the SELECT clause: 0 if no explicit SELECT, 1 * for a single projection (including DISTINCT x or NEW map(…)), 2 for two or more comma-separated @@ -136,20 +168,20 @@ public record HqlQueryContext( if (hql == null || hql.isEmpty()) return 0; String s = hql.toString().trim(); String lower = s.toLowerCase(Locale.ROOT); - int selectIdx = lower.indexOf("select "); + int selectIdx = lower.indexOf(HibernateQueryArgument.HQL_SELECT.value() + " "); if (selectIdx < 0) return 0; - int fromIdx = lower.indexOf(" from ", selectIdx); - String sel = s.substring(selectIdx + "select".length(), fromIdx < 0 ? s.length() : fromIdx) + int fromIdx = lower.indexOf(" " + HibernateQueryArgument.HQL_FROM.value() + " ", selectIdx); + String sel = s.substring(selectIdx + HibernateQueryArgument.HQL_SELECT.value().length(), fromIdx < 0 ? s.length() : fromIdx) .trim(); if (sel.isEmpty()) return 0; // Strip leading DISTINCT/ALL String selLower = sel.toLowerCase(Locale.ROOT); - if (selLower.startsWith("distinct ")) - sel = sel.substring("distinct ".length()).trim(); - else if (selLower.startsWith("all ")) - sel = sel.substring("all ".length()).trim(); + if (selLower.startsWith(HibernateQueryArgument.HQL_DISTINCT.value() + " ")) + sel = sel.substring(HibernateQueryArgument.HQL_DISTINCT.value().length() + 1).trim(); + else if (selLower.startsWith(HibernateQueryArgument.HQL_ALL.value() + " ")) + sel = sel.substring(HibernateQueryArgument.HQL_ALL.value().length() + 1).trim(); // Count top-level commas, ignoring those inside parens or string literals int commas = getCommas(sel); @@ -193,18 +225,18 @@ public record HqlQueryContext( if (s.isEmpty()) return s; String lower = s.toLowerCase(); - int selectIdx = lower.indexOf("select "); + int selectIdx = lower.indexOf(HibernateQueryArgument.HQL_SELECT.value() + " "); if (selectIdx < 0) return s; // no SELECT clause — nothing to normalize - int fromIdx = lower.indexOf(" from ", selectIdx); + int fromIdx = lower.indexOf(" " + HibernateQueryArgument.HQL_FROM.value() + " ", selectIdx); if (fromIdx < 0) return s; // malformed — leave as-is - int selectStart = selectIdx + "select ".length(); + int selectStart = selectIdx + HibernateQueryArgument.HQL_SELECT.value().length() + 1; String selectClauseOrig = s.substring(selectStart, fromIdx).trim(); String selectClauseLower = lower.substring(selectStart, fromIdx).trim(); // Parse entity name from the FROM head - int afterFrom = fromIdx + " from ".length(); + int afterFrom = fromIdx + HibernateQueryArgument.HQL_FROM.value().length() + 2; int entityEnd = afterFrom; while (entityEnd < s.length() && !Character.isWhitespace(s.charAt(entityEnd))) entityEnd++; String entityName = s.substring(afterFrom, entityEnd); @@ -213,8 +245,8 @@ public record HqlQueryContext( // Skip whitespace, then optional "as" keyword int cur = entityEnd; while (cur < s.length() && Character.isWhitespace(s.charAt(cur))) cur++; - if (cur + 2 <= s.length() && s.substring(cur, cur + 2).equalsIgnoreCase("as")) { - cur += 2; + if (cur + 2 <= s.length() && s.substring(cur, cur + 2).equalsIgnoreCase(HibernateQueryArgument.HQL_AS.value())) { + cur += HibernateQueryArgument.HQL_AS.value().length(); while (cur < s.length() && Character.isWhitespace(s.charAt(cur))) cur++; } @@ -223,50 +255,49 @@ public record HqlQueryContext( while (tokenEnd < s.length() && !Character.isWhitespace(s.charAt(tokenEnd))) tokenEnd++; String token = s.substring(cur, tokenEnd).toLowerCase(Locale.ROOT); boolean hasAlias = !token.isEmpty() - && !Set.of("where", "join", "left", "right", "inner", "outer", "group", "order", "having") + && !Set.of( + HibernateQueryArgument.HQL_WHERE.value(), + HibernateQueryArgument.HQL_JOIN.value(), + HibernateQueryArgument.HQL_LEFT.value(), + HibernateQueryArgument.HQL_RIGHT.value(), + HibernateQueryArgument.HQL_INNER.value(), + HibernateQueryArgument.HQL_OUTER.value(), + HibernateQueryArgument.HQL_GROUP.value(), + HibernateQueryArgument.HQL_ORDER.value(), + HibernateQueryArgument.HQL_HAVING.value()) .contains(token); if (hasAlias) return s; // Strip DISTINCT/ALL prefix before adjusting the projection String prefix = "", projOrig = selectClauseOrig, projLower = selectClauseLower; - if (projLower.startsWith("distinct ")) { - prefix = "distinct "; - projOrig = selectClauseOrig.substring("distinct ".length()).trim(); - projLower = projLower.substring("distinct ".length()).trim(); - } else if (projLower.startsWith("all ")) { - prefix = "all "; - projOrig = selectClauseOrig.substring("all ".length()).trim(); - projLower = projLower.substring("all ".length()).trim(); + if (projLower.startsWith(HibernateQueryArgument.HQL_DISTINCT.value() + " ")) { + prefix = HibernateQueryArgument.HQL_DISTINCT.value() + " "; + projOrig = selectClauseOrig.substring(HibernateQueryArgument.HQL_DISTINCT.value().length() + 1).trim(); + projLower = projLower.substring(HibernateQueryArgument.HQL_DISTINCT.value().length() + 1).trim(); + } else if (projLower.startsWith(HibernateQueryArgument.HQL_ALL.value() + " ")) { + prefix = HibernateQueryArgument.HQL_ALL.value() + " "; + projOrig = selectClauseOrig.substring(HibernateQueryArgument.HQL_ALL.value().length() + 1).trim(); + projLower = projLower.substring(HibernateQueryArgument.HQL_ALL.value().length() + 1).trim(); } // Qualify the projection with the synthetic alias String adjusted; if (projLower.equalsIgnoreCase(entityName)) { adjusted = "e"; // "select Person from Person" → "select e" - } else if (!projLower.contains("(") && !projLower.contains(".") && !projLower.startsWith("new ")) { + } else if (!projLower.contains("(") && !projLower.contains(".") && !projLower.startsWith(HibernateQueryArgument.HQL_NEW.value() + " ")) { adjusted = "e." + projOrig; // "select name from Person" → "select e.name" } else { adjusted = projOrig; // functions / constructor expr / already qualified } - return "select " + prefix + adjusted + " from " + entityName + " e" + s.substring(entityEnd); + return HibernateQueryArgument.HQL_SELECT.value() + " " + prefix + adjusted + " " + HibernateQueryArgument.HQL_FROM.value() + " " + entityName + " e" + s.substring(entityEnd); } // ─── Private helpers ───────────────────────────────────────────────────── private static boolean isPropertyProjection(CharSequence hql) { - if (hql == null) return false; - String s = hql.toString().toLowerCase().trim(); - int selectIdx = s.indexOf("select "); - if (selectIdx < 0) return false; - int fromIdx = s.indexOf(" from ", selectIdx); - String clause = s.substring(selectIdx + "select ".length(), fromIdx < 0 ? s.length() : fromIdx) - .trim(); - if (clause.startsWith("distinct ")) - clause = clause.substring("distinct ".length()).trim(); - else if (clause.startsWith("all ")) - clause = clause.substring("all ".length()).trim(); - return clause.contains("."); + String clause = getSingleProjectionClause(hql); + return clause != null && clause.contains("."); } private static String normalizeMultiLineQueryString(String query) { diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilderSpec.groovy new file mode 100644 index 0000000000..dab8c09c79 --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilderSpec.groovy @@ -0,0 +1,177 @@ +/* + * 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.query + +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.orm.hibernate.cfg.HibernateMappingContext +import org.grails.orm.hibernate.cfg.Mapping +import org.grails.orm.hibernate.cfg.MappingCacheHolder +import org.grails.orm.hibernate.cfg.SortConfig +import spock.lang.Specification +import spock.lang.Unroll + +class HqlListQueryBuilderSpec extends Specification { + + PersistentEntity entity = Mock(PersistentEntity) + HibernateMappingContext mappingContext = Mock(HibernateMappingContext) + MappingCacheHolder cacheHolder = Mock(MappingCacheHolder) + + def setup() { + entity.getName() >> "Person" + entity.getJavaClass() >> Object + entity.getMappingContext() >> mappingContext + mappingContext.getMappingCacheHolder() >> cacheHolder + } + + void "test buildCountHql"() { + given: + def builder = new HqlListQueryBuilder(entity, [:]) + + expect: + builder.buildCountHql() == "select count(distinct e) from Person e" + } + + void "test buildListHql with no arguments"() { + given: + def builder = new HqlListQueryBuilder(entity, [:]) + + expect: + builder.buildListHql() == "from Person e" + } + + void "test buildListHql with simple sort"() { + given: + entity.getPropertyByName("name") >> Mock(PersistentProperty) { + getType() >> String + } + def builder = new HqlListQueryBuilder(entity, [ + (HibernateQueryArgument.SORT.value()) : "name", + (HibernateQueryArgument.ORDER.value()): HibernateQueryArgument.ORDER_DESC.value() + ]) + + expect: + builder.buildListHql() == "from Person e order by upper(e.name) desc" + } + + void "test buildListHql with numeric sort"() { + given: + entity.getPropertyByName("age") >> Mock(PersistentProperty) { + getType() >> Integer + } + def builder = new HqlListQueryBuilder(entity, [ + (HibernateQueryArgument.SORT.value()) : "age", + (HibernateQueryArgument.ORDER.value()): HibernateQueryArgument.ORDER_ASC.value() + ]) + + expect: + builder.buildListHql() == "from Person e order by e.age asc" + } + + void "test buildListHql with ignoreCase false"() { + given: + entity.getPropertyByName("name") >> Mock(PersistentProperty) { + getType() >> String + } + def builder = new HqlListQueryBuilder(entity, [ + (HibernateQueryArgument.SORT.value()) : "name", + (HibernateQueryArgument.IGNORE_CASE.value()): false + ]) + + expect: + builder.buildListHql() == "from Person e order by e.name asc" + } + + void "test buildListHql with multiple sorts"() { + given: + entity.getPropertyByName("name") >> Mock(PersistentProperty) { getType() >> String } + entity.getPropertyByName("age") >> Mock(PersistentProperty) { getType() >> Integer } + // Use LinkedHashMap to ensure deterministic order in HQL generation + def builder = new HqlListQueryBuilder(entity, [ + (HibernateQueryArgument.SORT.value()): [ + name: HibernateQueryArgument.ORDER_ASC.value(), + age : HibernateQueryArgument.ORDER_DESC.value() + ] + ]) + + expect: + builder.buildListHql() == "from Person e order by upper(e.name) asc, e.age desc" + } + + void "test buildListHql with nested property sort"() { + given: + def authorAssociation = Mock(Association) + def authorEntity = Mock(PersistentEntity) + entity.getPropertyByName("author") >> authorAssociation + authorAssociation.getAssociatedEntity() >> authorEntity + authorEntity.getPropertyByName("name") >> Mock(PersistentProperty) { getType() >> String } + + def builder = new HqlListQueryBuilder(entity, [(HibernateQueryArgument.SORT.value()): "author.name"]) + + expect: + builder.buildListHql() == "from Person e order by upper(e.author.name) asc" + } + + void "test buildListHql with join fetch"() { + given: + def builder = new HqlListQueryBuilder(entity, [ + (HibernateQueryArgument.FETCH.value()): [ + books: HibernateQueryArgument.JOIN.value(), + profile: HibernateQueryArgument.EAGER.value() + ] + ]) + + when: + String hql = builder.buildListHql() + + then: + hql.startsWith("from Person e") + hql.contains(" join fetch e.books") + hql.contains(" join fetch e.profile") + } + + void "test buildListHql with default sort from mapping"() { + given: + def mapping = Mock(Mapping) + def sortConfig = new SortConfig(name: "lastName", direction: "asc") + + cacheHolder.getMapping(_) >> mapping + mapping.getSort() >> sortConfig + entity.getPropertyByName("lastName") >> Mock(PersistentProperty) { getType() >> String } + + def builder = new HqlListQueryBuilder(entity, [:]) + + expect: + builder.buildListHql() == "from Person e order by upper(e.lastName) asc" + } + + @Unroll + void "test isPaged for params: #params"() { + expect: + HqlListQueryBuilder.isPaged(params) == expected + + where: + params | expected + [:] | false + [max: 10] | true + [offset: 5] | false + [max: 10, offset: 5] | true + } +} diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy index 5c13d2334a..3a13b21148 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlQueryContextSpec.groovy @@ -176,7 +176,7 @@ class HqlQueryContextSpec extends Specification { def params = [:] when: - String result = HqlQueryContext.resolveHql(gq, false, params) + String result = HqlQueryContext.resolveHql(gq, false, params as Map<String, Object>) then: result == "from Foo where x = :p0" @@ -191,7 +191,7 @@ class HqlQueryContextSpec extends Specification { def params = [:] when: - String result = HqlQueryContext.resolveHql(gq, false, params) + String result = HqlQueryContext.resolveHql(gq, false, params as Map<String, Object>) then: result == "from Foo where x = :p0 and y = :p1" @@ -238,6 +238,22 @@ class HqlQueryContextSpec extends Specification { HqlQueryContext.getTarget("select p.name, p.age from Person p", String) == Object[].class } + @Unroll + void "getTarget returns Long for aggregate projection: #hql"() { + expect: + HqlQueryContext.getTarget(hql, String) == Long + where: + hql << [ + "select count(p) from Person p", + "select sum(p.age) from Person p", + "select avg(p.age) from Person p", + "select min(p.age) from Person p", + "select max(p.age) from Person p", + "select count(*) from Person", + "select distinct count(p.id) from Person p" + ] + } + // ─── countHqlProjections ───────────────────────────────────────────────── void "countHqlProjections returns 0 for null"() {
