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"() {

Reply via email to