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 35d187c36d4a12a0a52758e95fad069ac962242a Author: Walter Duque de Estrada <[email protected]> AuthorDate: Tue Feb 24 09:56:27 2026 -0600 test and cleanup HibernateHqlQuery --- .../orm/hibernate/HibernateGormStaticApi.groovy | 37 +- .../orm/hibernate/query/HibernateHqlQuery.java | 558 ++++----------------- .../orm/hibernate/query/HqlQueryContext.java | 242 +++++++++ .../hibernate/query/HibernateHqlQuerySpec.groovy | 226 +++++++++ 4 files changed, 593 insertions(+), 470 deletions(-) diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormStaticApi.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormStaticApi.groovy index ad81338df5..7cc6a103c2 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormStaticApi.groovy +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/HibernateGormStaticApi.groovy @@ -35,6 +35,7 @@ import org.grails.datastore.mapping.query.event.PreQueryEvent import org.grails.datastore.mapping.proxy.ProxyHandler import org.grails.orm.hibernate.query.GrailsHibernateQueryUtils import org.grails.orm.hibernate.query.HibernateHqlQuery +import org.grails.orm.hibernate.query.HqlQueryContext import org.grails.orm.hibernate.query.HibernateQuery import org.grails.orm.hibernate.query.PagedResultList import org.grails.orm.hibernate.support.HibernateRuntimeUtils @@ -329,17 +330,15 @@ class HibernateGormStaticApi<D> extends GormStaticApi<D> { Map args , boolean isNative) { boolean isUpdate = false + def ctx = HqlQueryContext.prepare(hql, isNative, isUpdate, namedParams, persistentEntity) def hqlQuery = HibernateHqlQuery.createHqlQuery( - (HibernateDatastore) datastore, + (HibernateDatastore) datastore, sessionFactory, persistentEntity, - hql, - isNative, - isUpdate, + ctx, args, - namedParams, - positionalParams - ,getHibernateTemplate() + positionalParams, + getHibernateTemplate() ) firePreQueryEvent() def ds = (List<D>) hqlQuery.list() @@ -354,17 +353,15 @@ class HibernateGormStaticApi<D> extends GormStaticApi<D> { Map args , boolean isNative) { boolean isUpdate = false + def ctx = HqlQueryContext.prepare(hql, isNative, isUpdate, namedParams, persistentEntity) def hqlQuery = HibernateHqlQuery.createHqlQuery( - (HibernateDatastore) datastore, + (HibernateDatastore) datastore, sessionFactory, persistentEntity, - hql, - isNative, - isUpdate, + ctx, args, - namedParams, - positionalParams - ,getHibernateTemplate() + positionalParams, + getHibernateTemplate() ) firePreQueryEvent() def sm = hqlQuery.singleResult() @@ -378,17 +375,15 @@ class HibernateGormStaticApi<D> extends GormStaticApi<D> { Map args) { boolean isNative = false boolean isUpdate = true + def ctx = HqlQueryContext.prepare(hql, isNative, isUpdate, namedParams, persistentEntity) def hqlQuery = HibernateHqlQuery.createHqlQuery( - (HibernateDatastore) datastore, + (HibernateDatastore) datastore, sessionFactory, persistentEntity, - hql, - isNative, - isUpdate, + ctx, args, - namedParams, - positionalParams - ,getHibernateTemplate() + positionalParams, + getHibernateTemplate() ) firePreQueryEvent() def execute = hqlQuery.executeUpdate() 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 79761e1164..eb4c7a5746 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 @@ -18,17 +18,12 @@ */ package org.grails.orm.hibernate.query; -import groovy.lang.GString; import jakarta.persistence.FlushModeType; -import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.IntStream; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; import org.apache.groovy.parser.antlr4.util.StringUtils; @@ -43,410 +38,99 @@ import org.grails.orm.hibernate.GrailsHibernateTemplate; import org.grails.orm.hibernate.HibernateDatastore; import org.grails.orm.hibernate.HibernateSession; import org.grails.orm.hibernate.exceptions.GrailsQueryException; + import org.hibernate.FlushMode; import org.hibernate.SessionFactory; import org.springframework.context.ApplicationEventPublisher; /** - * A query implementation for HQL queries + * A query implementation for HQL queries. * * @author Graeme Rocher * @since 6.0 */ public class HibernateHqlQuery extends Query { - private org.hibernate.query.Query query; + + private org.hibernate.query.Query<?> query; public HibernateHqlQuery( - Session session, PersistentEntity entity, org.hibernate.query.Query query) { + Session session, PersistentEntity entity, org.hibernate.query.Query<?> query) { super(session, entity); this.query = query; } @Override protected void flushBeforeQuery() { - // do nothing, hibernate handles this + // Hibernate handles flushing internally } @Override + @SuppressWarnings("rawtypes") protected List executeQuery(PersistentEntity entity, Junction criteria) { Datastore datastore = getSession().getDatastore(); - ApplicationEventPublisher applicationEventPublisher = datastore.getApplicationEventPublisher(); - PreQueryEvent preQueryEvent = new PreQueryEvent(datastore, this); - applicationEventPublisher.publishEvent(preQueryEvent); - - if (uniqueResult) { - query.setMaxResults(1); - List results = query.list(); - applicationEventPublisher.publishEvent(new PostQueryEvent(datastore, this, results)); - return results; - } else { - - List results = query.list(); - applicationEventPublisher.publishEvent(new PostQueryEvent(datastore, this, results)); - return results; - } - } - - private static String normalizeMultiLineQueryString(String query) { - if (query == null || query.indexOf('\n') == -1) { - return query; - } - return query.trim().replace("\n", " "); - } - - private static String buildNamedParameterQueryFromGString( - GString query, Map<String, Object> params) { - StringBuilder sqlString = new StringBuilder(); - Object[] values = query.getValues(); - String[] strings = query.getStrings(); - - for (int i = 0; i < strings.length; i++) { - sqlString.append(strings[i]); - if (i < values.length) { - String parameterName = "p" + i; - sqlString.append(':').append(parameterName); - params.put(parameterName, values[i]); - } - } - return sqlString.toString(); + ApplicationEventPublisher publisher = datastore.getApplicationEventPublisher(); + publisher.publishEvent(new PreQueryEvent(datastore, this)); + if (uniqueResult) query.setMaxResults(1); + List results = query.list(); + publisher.publishEvent(new PostQueryEvent(datastore, this, results)); + return results; } - public static HibernateHqlQuery createHqlQuery( - HibernateDatastore dataStore, - SessionFactory sessionFactory, - PersistentEntity persistentEntity, - CharSequence queryCharseq, - boolean isNative, - boolean isUpdate, - Map _args, - Map _namedParams, - Collection _positionalParams, - GrailsHibernateTemplate grailsHibernateTemplate) { - - Map namedParams = _namedParams != null ? new HashMap(_namedParams) : new HashMap(); - String queryString = - queryCharseq instanceof GString - ? buildNamedParameterQueryFromGString((GString) queryCharseq, namedParams) - : queryCharseq != null ? queryCharseq.toString() : ""; - List positionalParams = - CollectionUtils.isNotEmpty(_positionalParams) - ? new ArrayList(_positionalParams) - : new ArrayList(); - String sqlString = normalizeMultiLineQueryString(queryString); - Map args = MapUtils.isNotEmpty(_args) ? new HashMap(_args) : Collections.emptyMap(); - - HibernateHqlQuery hibernateHqlQuery = - grailsHibernateTemplate.execute( - session -> { - // Normalize only for HQL (not for native SQL) - String hqlToUse = isNative ? sqlString : normalizeNonAliasedSelect(sqlString); - var clazz = getTarget(hqlToUse, persistentEntity.getJavaClass()); - org.hibernate.query.Query q = null; - if (StringUtils.isEmpty(hqlToUse)) { - q = session.createQuery("from " + clazz.getName(), clazz); - } else if (isUpdate) { - q = session.createQuery(hqlToUse); - } else { - q = - isNative - ? session.createNativeQuery(hqlToUse, clazz) - : session.createQuery(hqlToUse, clazz); - } - var hibernateSession = new HibernateSession(dataStore, sessionFactory); - HibernateHqlQuery hibernateHqlQuery1 = - new HibernateHqlQuery(hibernateSession, persistentEntity, q); - hibernateHqlQuery1.setFlushMode(session.getHibernateFlushMode()); - return hibernateHqlQuery1; - }); - grailsHibernateTemplate.applySettings(hibernateHqlQuery.getQuery()); - // apply query settings (max, offset, cache, etc.) - hibernateHqlQuery.populateQuerySettings(args); - // apply parameters - if (MapUtils.isNotEmpty(namedParams)) { - Map namedCopy = new HashMap(namedParams); - hibernateHqlQuery.populateQueryWithNamedArguments(namedCopy); - } else if (CollectionUtils.isNotEmpty(positionalParams)) { - List positionalList = - (positionalParams instanceof List) - ? (List) positionalParams - : new ArrayList(positionalParams); - hibernateHqlQuery.populateQueryWithIndexedArguments(positionalList); - } - return hibernateHqlQuery; - } + // ─── Static factory API ────────────────────────────────────────────────── /** - * Determine the number of top-level projections in the HQL query. Returns 0 if there is no - * explicit SELECT clause (implicit entity projection), 1 if there is a single top-level - * projection expression (including constructs like DISTINCT x or NEW map(...)), and 2 if there - * are two or more top-level projection expressions (e.g. "select a, b from ..."). - * - * <p>Notes: - Commas within parentheses or string literals are ignored. - Constructor expressions - * like "new map(a as n, b as m)" count as a single projection. - Aggregate and function calls - * with commas in their argument lists are handled by parentheses tracking. + * Session-bound step — creates the {@link org.hibernate.query.Query} from an open + * {@link org.hibernate.Session} and wraps it in a {@link HibernateHqlQuery}. */ - static int countHqlProjections(CharSequence hql) { - if (hql == null) return 0; - String s = hql.toString().trim(); - if (s.isEmpty()) return 0; - // Find select and from in a case-insensitive way - String lower = s.toLowerCase(); - int selectIdx = lower.indexOf("select "); - if (selectIdx < 0) { - // no explicit select -> implicit single entity projection following "from" - return 0; - } - // Ensure this select occurs before the corresponding from - int fromIdx = lower.indexOf(" from ", selectIdx); - if (fromIdx < 0) { - // malformed or incomplete query; treat as one projection if select exists - fromIdx = s.length(); - } - int selectStart = selectIdx + "select".length(); - // Extract the select clause between 'select' and 'from' - String sel = s.substring(selectStart, fromIdx).trim(); - if (sel.isEmpty()) return 0; - // Strip leading DISTINCT/ALL keywords - String selLower = sel.toLowerCase(); - if (selLower.startsWith("distinct ")) { - sel = sel.substring("distinct ".length()).trim(); - selLower = sel.toLowerCase(); - } else if (selLower.startsWith("all ")) { - sel = sel.substring("all ".length()).trim(); - selLower = sel.toLowerCase(); - } - // Now count top-level commas ignoring those within parentheses and string literals - int depth = 0; - boolean inSingleQuote = false; - boolean inDoubleQuote = false; - int topLevelCommas = 0; - char singleQuote = '\''; - char doubleQuote = '"'; - char leftParen = '('; // Left parenthesis - char rightParen = ')'; // Right parenthesis - char comma = ','; // Comma - - for (int i = 0; i < sel.length(); i++) { - char c = sel.charAt(i); - // handle quotes (simple handling: toggle on quote not escaped by another same quote) - if (!inDoubleQuote && c == singleQuote) { - // handle doubled single quotes inside strings - if (inSingleQuote) { - if (i + 1 < sel.length() && sel.charAt(i + 1) == singleQuote) { - i++; // skip escaped quote - continue; - } else { - inSingleQuote = false; - continue; - } - } else { - inSingleQuote = true; - continue; - } - } - if (!inSingleQuote && c == doubleQuote) { - inDoubleQuote = !inDoubleQuote; - continue; - } - if (inSingleQuote || inDoubleQuote) continue; - if (c == leftParen) { - depth++; - continue; - } - if (c == rightParen && depth > 0) { - depth--; - continue; - } - if (c == comma && depth == 0) { - topLevelCommas++; - } - } - if (topLevelCommas == 0) return 1; - return 2; - } - - static Class getTarget(CharSequence hql, Class clazz) { - // Normalize non-aliased queries to an aliased form, then reuse the logic - String normalized = normalizeNonAliasedSelect(hql == null ? null : hql.toString()); - - int projections = countHqlProjections(normalized); - switch (projections) { - case 0: - return clazz; // No explicit SELECT - implicit entity projection - case 1: - // Single projection - property vs entity - if (isPropertyProjection(normalized)) { - return Object.class; // Scalar result - } else { - return clazz; // Entity result - } - default: - return Object[].class; // Multiple projections + @SuppressWarnings("unchecked") + public static HibernateHqlQuery buildQuery( + org.hibernate.Session session, + HibernateDatastore dataStore, + SessionFactory sessionFactory, + PersistentEntity entity, + HqlQueryContext ctx) { + org.hibernate.query.Query<?> q; + if (StringUtils.isEmpty(ctx.hql())) { + q = session.createQuery("from " + ctx.targetClass().getName(), ctx.targetClass()); + } else if (ctx.isUpdate()) { + q = session.createQuery(ctx.hql()); + } else { + q = ctx.isNative() + ? session.createNativeQuery(ctx.hql(), ctx.targetClass()) + : session.createQuery(ctx.hql(), ctx.targetClass()); } + HibernateHqlQuery result = new HibernateHqlQuery( + new HibernateSession(dataStore, sessionFactory), entity, q); + result.setFlushMode(session.getHibernateFlushMode()); + return result; } /** - * If the HQL query has no alias in the FROM clause, inject a synthetic alias ("e") and qualify - * the SELECT projection accordingly. Only SELECT is adjusted; the rest of the query is left - * intact (WHERE/JOIN conditions are valid as-is in HQL). - * - * <p>Examples: "select nameType from NameType where lower(nameType)=:nameType" -> "select - * e.nameType from NameType e where lower(nameType)=:nameType" "select NameType from NameType" -> - * "select e from NameType e" "select name from Person" -> "select e.name from Person e" + * Full factory — opens a session via the {@link GrailsHibernateTemplate}, builds the query + * from the prepared {@link HqlQueryContext}, then applies settings and parameters. */ - private static String normalizeNonAliasedSelect(String hql) { - if (hql == null) return null; - String s = hql.trim(); - if (s.isEmpty()) return s; - - String lower = s.toLowerCase(); - int selectIdx = lower.indexOf("select "); - if (selectIdx < 0) { - // No explicit select -> nothing to normalize for target detection - return s; - } - int fromIdx = lower.indexOf(" from ", selectIdx); - if (fromIdx < 0) { - // Malformed or incomplete; leave as-is - return s; - } - - // Extract SELECT clause original text - int selectStart = selectIdx + "select ".length(); - String selectClauseOrig = s.substring(selectStart, fromIdx).trim(); - String selectClauseLower = lower.substring(selectStart, fromIdx).trim(); - - // Extract FROM head to detect alias: "from Entity [as] alias ..." - int afterFrom = fromIdx + " from ".length(); - int endOfFromHead = afterFrom; - // read entity name token - while (endOfFromHead < s.length()) { - char ch = s.charAt(endOfFromHead); - if (Character.isWhitespace(ch)) break; - endOfFromHead++; - } - String entityName = s.substring(afterFrom, endOfFromHead).trim(); - if (entityName.isEmpty()) return s; - - // Skip spaces - int cur = endOfFromHead; - while (cur < s.length() && Character.isWhitespace(s.charAt(cur))) cur++; - - // Optional "as" - boolean hasAlias = false; - String alias = null; - int aliasStart = cur; - if (cur + 2 < s.length()) { - String nextWord = s.substring(cur, Math.min(cur + 2, s.length())).toLowerCase(); - } - if (cur + 2 < s.length() - && s.substring(cur, Math.min(cur + 2, s.length())).equalsIgnoreCase("as")) { - cur += 2; - while (cur < s.length() && Character.isWhitespace(s.charAt(cur))) cur++; - } - - // Try to read alias token unless we hit a clause keyword - int aliasEnd = cur; - while (aliasEnd < s.length()) { - char ch = s.charAt(aliasEnd); - if (Character.isWhitespace(ch)) break; - aliasEnd++; - } - String maybeAlias = (cur < aliasEnd) ? s.substring(cur, aliasEnd) : ""; - - // Keywords that indicate no alias present - String maybeAliasLower = maybeAlias.toLowerCase(); - boolean isClauseKeyword = - maybeAliasLower.isEmpty() - || maybeAliasLower.equals("where") - || maybeAliasLower.equals("join") - || maybeAliasLower.equals("left") - || maybeAliasLower.equals("right") - || maybeAliasLower.equals("inner") - || maybeAliasLower.equals("outer") - || maybeAliasLower.equals("group") - || maybeAliasLower.equals("order") - || maybeAliasLower.equals("having"); - - if (!isClauseKeyword) { - hasAlias = true; - alias = maybeAlias; - } - - if (hasAlias) { - // Already aliased; no normalization needed - return s; - } - - // Inject synthetic alias - String syntheticAlias = "e"; - - // Adjust SELECT clause: - // Preserve DISTINCT/ALL prefix - String prefix = ""; - String projOrig = selectClauseOrig; - String projLower = selectClauseLower; - if (projLower.startsWith("distinct ")) { - prefix = - selectClauseOrig.substring( - 0, selectClauseOrig.length() - projOrig.substring("distinct ".length()).length()); - projOrig = selectClauseOrig.substring("distinct ".length()).trim(); - projLower = projLower.substring("distinct ".length()).trim(); - prefix = "distinct "; - } else if (projLower.startsWith("all ")) { - prefix = "all "; - projOrig = selectClauseOrig.substring("all ".length()).trim(); - projLower = projLower.substring("all ".length()).trim(); - } - - String adjustedProjection = projOrig; - // If projection equals entity name -> "select e" - if (projLower.equals(entityName.toLowerCase())) { - adjustedProjection = syntheticAlias; - } - // If projection has no dot, treat as property -> qualify with alias - else if (!projLower.contains("(") - && !projLower.contains(".") - && !projLower.startsWith("new ")) { - adjustedProjection = syntheticAlias + "." + projOrig; + @SuppressWarnings({"unchecked", "rawtypes"}) + public static HibernateHqlQuery createHqlQuery( + HibernateDatastore dataStore, + SessionFactory sessionFactory, + PersistentEntity entity, + HqlQueryContext ctx, + Map args, + Collection positionalParams, + GrailsHibernateTemplate template) { + HibernateHqlQuery hqlQuery = template.execute( + session -> buildQuery(session, dataStore, sessionFactory, entity, ctx)); + template.applySettings(hqlQuery.getQuery()); + hqlQuery.populateQuerySettings(MapUtils.isNotEmpty(args) ? new HashMap<>(args) : Collections.emptyMap()); + if (MapUtils.isNotEmpty(ctx.namedParams())) { + hqlQuery.populateQueryWithNamedArguments(ctx.namedParams()); + } else if (CollectionUtils.isNotEmpty(positionalParams)) { + hqlQuery.populateQueryWithIndexedArguments(List.copyOf(positionalParams)); } - // else leave as-is (functions, constructor expr, already-qualified, etc.) - - // Build normalized SELECT ... FROM ... (inject alias right after entity name) - StringBuilder out = new StringBuilder(); - out.append("select ").append(prefix).append(adjustedProjection); - // Original FROM and the rest - String tail = s.substring(fromIdx); // starts with " from " - // Insert alias after entity name in tail - StringBuilder tailOut = new StringBuilder(); - tailOut.append(" from ").append(entityName).append(" ").append(syntheticAlias); - // Append the remainder after entity name in the FROM head - tailOut.append(s.substring(endOfFromHead)); - out.append(tailOut); - return out.toString(); + return hqlQuery; } - private static boolean isPropertyProjection(CharSequence hql) { - String s = hql.toString().toLowerCase().trim(); - int selectIdx = s.indexOf("select "); - if (selectIdx < 0) return false; - - int fromIdx = s.indexOf(" from ", selectIdx); - if (fromIdx < 0) fromIdx = s.length(); - - String selectClause = s.substring(selectIdx + "select ".length(), fromIdx).trim(); - - // Remove DISTINCT/ALL if present - if (selectClause.startsWith("distinct ")) { - selectClause = selectClause.substring("distinct ".length()).trim(); - } else if (selectClause.startsWith("all ")) { - selectClause = selectClause.substring("all ".length()).trim(); - } - - // Only return true for clear property projections (containing dots) - // This is the safest approach - only treat selections with dots as scalar projections - return selectClause.contains("."); - } + // ─── Query configuration ───────────────────────────────────────────────── public void setFlushMode(FlushMode flushMode) { session.setFlushMode( @@ -455,88 +139,64 @@ public class HibernateHqlQuery extends Query { : FlushModeType.COMMIT); } - public void populateQuerySettings(Map args) { - Optional.ofNullable(args.remove(DynamicFinder.ARGUMENT_MAX)) - .map(Object::toString) - .map(Integer::parseInt) - .ifPresent(query::setMaxResults); - Optional.ofNullable(args.remove(DynamicFinder.ARGUMENT_OFFSET)) - .map(Object::toString) - .map(Integer::parseInt) - .ifPresent(query::setFirstResult); - Optional.ofNullable(args.remove(DynamicFinder.ARGUMENT_CACHE)) - .map(Object::toString) - .map(Boolean::parseBoolean) - .ifPresent(query::setCacheable); - Optional.ofNullable(args.remove(DynamicFinder.ARGUMENT_FETCH_SIZE)) - .map(Object::toString) - .map(Integer::parseInt) - .ifPresent(query::setFetchSize); - Optional.ofNullable(args.remove(DynamicFinder.ARGUMENT_TIMEOUT)) - .map(Object::toString) - .map(Integer::parseInt) - .ifPresent(query::setTimeout); - Optional.ofNullable(args.remove(DynamicFinder.ARGUMENT_READ_ONLY)) - .map(Object::toString) - .map(Boolean::parseBoolean) - .ifPresent(query::setReadOnly); - Optional.ofNullable(args.remove(DynamicFinder.ARGUMENT_FLUSH_MODE)) - .filter(FlushMode.class::isInstance) - .map(FlushMode.class::cast) - .ifPresent(query::setHibernateFlushMode); + @SuppressWarnings({"unchecked", "rawtypes"}) + public void populateQuerySettings(Map<?, ?> args) { + ifPresent(args, DynamicFinder.ARGUMENT_MAX, v -> query.setMaxResults(toInt(v))); + ifPresent(args, DynamicFinder.ARGUMENT_OFFSET, v -> query.setFirstResult(toInt(v))); + ifPresent(args, DynamicFinder.ARGUMENT_CACHE, v -> query.setCacheable(toBool(v))); + ifPresent(args, DynamicFinder.ARGUMENT_FETCH_SIZE, v -> query.setFetchSize(toInt(v))); + ifPresent(args, DynamicFinder.ARGUMENT_TIMEOUT, v -> query.setTimeout(toInt(v))); + ifPresent(args, DynamicFinder.ARGUMENT_READ_ONLY, v -> query.setReadOnly(toBool(v))); + if (args.containsKey(DynamicFinder.ARGUMENT_FLUSH_MODE)) { + Object v = args.get(DynamicFinder.ARGUMENT_FLUSH_MODE); + if (v instanceof FlushMode fm) query.setHibernateFlushMode(fm); + } } - public void populateQueryWithNamedArguments(Map queryNamedArgs) { - Optional.ofNullable(queryNamedArgs) - .ifPresent( - map -> { - map.forEach( - (key, value) -> { - if (key instanceof CharSequence) { - String stringKey = key.toString(); - if (value == null) { - query.setParameter(stringKey, null); - } else if (value instanceof CharSequence) { - query.setParameter(stringKey, value.toString()); - } else if (List.class.isAssignableFrom(value.getClass())) { - query.setParameterList(stringKey, (List) value); - } else if (Set.class.isAssignableFrom(value.getClass())) { - query.setParameterList(stringKey, (Set) value); - } else if (value.getClass().isArray()) { - query.setParameterList(stringKey, (Object[]) value); - } else { - query.setParameter(stringKey, value); - } - } else { - throw new GrailsQueryException( - "Named parameter's name must be String: $queryNamedArgs"); - } - }); - }); + @SuppressWarnings({"unchecked", "rawtypes"}) + public void populateQueryWithNamedArguments(Map<?, ?> namedArgs) { + if (namedArgs == null) return; + namedArgs.forEach((key, value) -> { + if (!(key instanceof CharSequence)) { + throw new GrailsQueryException("Named parameter's name must be a String: " + namedArgs); + } + String name = key.toString(); + if (value == null) { + query.setParameter(name, null); + } else if (value instanceof Collection<?> col) { + query.setParameterList(name, col); + } else if (value.getClass().isArray()) { + query.setParameterList(name, (Object[]) value); + } else if (value instanceof CharSequence cs) { + query.setParameter(name, cs.toString(), String.class); + } else { + query.setParameter(name, value); + } + }); } - public void populateQueryWithIndexedArguments(List params) { - Optional.ofNullable(params) - .ifPresent( - collection -> { - IntStream.range(1, collection.size() + 1) - .forEach( - index -> { - var val = collection.get(index - 1); - if (val instanceof CharSequence) { - query.setParameter(index, val.toString()); - } else { - query.setParameter(index, val); - } - }); - }); + @SuppressWarnings({"unchecked", "rawtypes"}) + public void populateQueryWithIndexedArguments(List<?> params) { + if (params == null) return; + for (int i = 0; i < params.size(); i++) { + Object val = params.get(i); + if (val instanceof CharSequence cs) query.setParameter(i + 1, cs.toString(), String.class); + else if (val != null) query.setParameter(i + 1, val); + else query.setParameter(i + 1, null); + } } - public org.hibernate.query.Query getQuery() { - return query; - } + public org.hibernate.query.Query<?> getQuery() { return query; } + + public int executeUpdate() { return query.executeUpdate(); } + + // ─── Private utilities ──────────────────────────────────────────────────── + + private static int toInt(Object v) { return Integer.parseInt(v.toString()); } + private static boolean toBool(Object v) { return Boolean.parseBoolean(v.toString()); } - public int executeUpdate() { - return query.executeUpdate(); + private static void ifPresent(Map<?, ?> map, String key, java.util.function.Consumer<Object> action) { + Object v = map.get(key); + if (v != null) action.accept(v); } } 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 new file mode 100644 index 0000000000..bb3861abbc --- /dev/null +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlQueryContext.java @@ -0,0 +1,242 @@ +/* + * 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 groovy.lang.GString; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.grails.datastore.mapping.model.PersistentEntity; + +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Immutable value object that holds all resolved HQL query state which can be computed + * without a Hibernate {@code Session}: the final HQL string, the result target class, + * any named parameters (including those expanded from a {@link GString}), and flags + * for whether the query is an update or native SQL. + * + * <p>Use {@link #prepare} to build an instance from raw inputs. + */ +public record HqlQueryContext( + String hql, + Class<?> targetClass, + Map<String, Object> namedParams, + boolean isUpdate, + boolean isNative) { + + // ─── Factory ───────────────────────────────────────────────────────────── + + /** + * Resolves the final HQL string, the result target class, and expands any + * {@link GString} into named parameters. No {@code Session} is required. + */ + @SuppressWarnings("unchecked") + public static HqlQueryContext prepare( + CharSequence queryCharseq, + boolean isNative, + boolean isUpdate, + Map<?, ?> namedParams, + PersistentEntity entity) { + Map<String, Object> params = namedParams != null ? new HashMap<>((Map<String, Object>) namedParams) : new HashMap<>(); + String hql = resolveHql(queryCharseq, isNative, params); + return new HqlQueryContext(hql, getTarget(hql, entity.getJavaClass()), params, isUpdate, isNative); + } + + // ─── HQL resolution ────────────────────────────────────────────────────── + + public static @Nullable String resolveHql( + CharSequence queryCharseq, boolean isNative, Map<String, Object> namedParams) { + String raw = queryCharseq instanceof GString gstr + ? buildNamedParameterQueryFromGString(gstr, namedParams) + : queryCharseq != null ? queryCharseq.toString() : ""; + String normalized = normalizeMultiLineQueryString(raw); + return isNative ? normalized : normalizeNonAliasedSelect(normalized); + } + + // ─── Projection analysis ───────────────────────────────────────────────── + + /** + * Returns the result target class for a query: + * the entity class when there is no explicit SELECT or a single entity projection, + * {@code Object.class} for a single scalar projection, or {@code Object[].class} + * for multiple projections. + */ + public static Class<?> getTarget(CharSequence hql, Class<?> clazz) { + String normalized = normalizeNonAliasedSelect(hql == null ? null : hql.toString()); + return switch (countHqlProjections(normalized)) { + case 0 -> clazz; + case 1 -> isPropertyProjection(normalized) ? Object.class : clazz; + default -> Object[].class; + }; + } + + /** + * 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 top-level projections. + * + * <p>Commas inside parentheses or string literals are ignored. + */ + static int countHqlProjections(CharSequence hql) { + if (hql == null || hql.length() == 0) return 0; + String s = hql.toString().trim(); + String lower = s.toLowerCase(); + int selectIdx = lower.indexOf("select "); + if (selectIdx < 0) return 0; + + int fromIdx = lower.indexOf(" from ", selectIdx); + String sel = s.substring(selectIdx + "select".length(), fromIdx < 0 ? s.length() : fromIdx).trim(); + if (sel.isEmpty()) return 0; + + // Strip leading DISTINCT/ALL + String selLower = sel.toLowerCase(); + if (selLower.startsWith("distinct ")) sel = sel.substring("distinct ".length()).trim(); + else if (selLower.startsWith("all ")) sel = sel.substring("all ".length()).trim(); + + // Count top-level commas, ignoring those inside parens or string literals + int depth = 0, commas = 0; + boolean inSingle = false, inDouble = false; + for (int i = 0; i < sel.length(); i++) { + char c = sel.charAt(i); + if (!inDouble && c == '\'') { + if (inSingle && i + 1 < sel.length() && sel.charAt(i + 1) == '\'') { i++; continue; } // escaped '' + inSingle = !inSingle; + } else if (!inSingle && c == '"') { + inDouble = !inDouble; + } else if (!inSingle && !inDouble) { + if (c == '(') depth++; + else if (c == ')' && depth > 0) depth--; + else if (c == ',' && depth == 0) commas++; + } + } + return commas == 0 ? 1 : 2; + } + + // ─── HQL normalization ──────────────────────────────────────────────────── + + /** + * Injects a synthetic alias {@code "e"} into unaliased SELECT queries so that + * projection detection works uniformly. The FROM remainder is left intact. + * <p> + * Examples: + * {@code "select name from Person"} → {@code "select e.name from Person e"}<br> + * {@code "select Person from Person"} → {@code "select e from Person e"} + */ + static @Nullable String normalizeNonAliasedSelect(String hql) { + if (hql == null) return null; + String s = hql.trim(); + if (s.isEmpty()) return s; + + String lower = s.toLowerCase(); + int selectIdx = lower.indexOf("select "); + if (selectIdx < 0) return s; // no SELECT clause — nothing to normalize + + int fromIdx = lower.indexOf(" from ", selectIdx); + if (fromIdx < 0) return s; // malformed — leave as-is + + int selectStart = selectIdx + "select ".length(); + 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 entityEnd = afterFrom; + while (entityEnd < s.length() && !Character.isWhitespace(s.charAt(entityEnd))) entityEnd++; + String entityName = s.substring(afterFrom, entityEnd); + if (entityName.isEmpty()) return s; + + // 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; + while (cur < s.length() && Character.isWhitespace(s.charAt(cur))) cur++; + } + + // Read the next token; a clause keyword means no user-defined alias is present + int tokenEnd = cur; + while (tokenEnd < s.length() && !Character.isWhitespace(s.charAt(tokenEnd))) tokenEnd++; + String token = s.substring(cur, tokenEnd).toLowerCase(); + boolean hasAlias = !token.isEmpty() + && !Set.of("where", "join", "left", "right", "inner", "outer", "group", "order", "having") + .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(); + } + + // Qualify the projection with the synthetic alias + String adjusted; + if (projLower.equals(entityName.toLowerCase())) { + adjusted = "e"; // "select Person from Person" → "select e" + } else if (!projLower.contains("(") && !projLower.contains(".") && !projLower.startsWith("new ")) { + 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); + } + + // ─── 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("."); + } + + private static String normalizeMultiLineQueryString(String query) { + if (query == null || query.indexOf('\n') == -1) return query; + return query.trim().replace("\n", " "); + } + + @SuppressWarnings("unchecked") + private static String buildNamedParameterQueryFromGString( + GString query, Map<String, Object> params) { + StringBuilder sql = new StringBuilder(); + Object[] values = query.getValues(); + String[] strings = query.getStrings(); + for (int i = 0; i < strings.length; i++) { + sql.append(strings[i]); + if (i < values.length) { + String name = "p" + i; + sql.append(':').append(name); + params.put(name, values[i]); + } + } + return sql.toString(); + } +} diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HibernateHqlQuerySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HibernateHqlQuerySpec.groovy new file mode 100644 index 0000000000..6c8941381a --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HibernateHqlQuerySpec.groovy @@ -0,0 +1,226 @@ +package org.grails.orm.hibernate.query + +import grails.gorm.specs.HibernateGormDatastoreSpec +import grails.persistence.Entity +import org.hibernate.FlushMode +import spock.lang.Unroll + +class HibernateHqlQuerySpec extends HibernateGormDatastoreSpec { + + void setupSpec() { + manager.addAllDomainClasses([HibernateHqlQuerySpecBook, HibernateHqlQuerySpecAuthor]) + } + + def setup() { + def author = new HibernateHqlQuerySpecAuthor(name: "Tolkien").save(flush: true) + new HibernateHqlQuerySpecBook(title: "The Hobbit", pages: 310, author: author).save() + new HibernateHqlQuerySpecBook(title: "Fellowship", pages: 423, author: author).save() + new HibernateHqlQuerySpecBook(title: "The Two Towers", pages: 352, author: author).save(flush: true) + } + + private HibernateHqlQuery buildHqlQuery(String hql, Map namedParams = [:], List positionalParams = null, Map args = [:], boolean isUpdate = false) { + def entity = mappingContext.getPersistentEntity(HibernateHqlQuerySpecBook.name) + def ctx = HqlQueryContext.prepare(hql, false, isUpdate, namedParams, entity) + def session = sessionFactory.currentSession + def hqlQuery = HibernateHqlQuery.buildQuery(session, datastore, sessionFactory, entity, ctx) + if (args) hqlQuery.populateQuerySettings(new HashMap(args)) + if (ctx.namedParams()) hqlQuery.populateQueryWithNamedArguments(new HashMap(ctx.namedParams())) + else if (positionalParams) hqlQuery.populateQueryWithIndexedArguments(positionalParams) + hqlQuery + } + + // ─── countHqlProjections ──────────────────────────────────────────────── + + void "countHqlProjections returns 0 for null"() { + expect: HqlQueryContext.countHqlProjections(null) == 0 + } + + void "countHqlProjections returns 0 for empty string"() { + expect: HqlQueryContext.countHqlProjections("") == 0 + } + + void "countHqlProjections returns 0 when no SELECT clause"() { + expect: HqlQueryContext.countHqlProjections("from HibernateHqlQuerySpecBook") == 0 + } + + void "countHqlProjections returns 1 for single projection"() { + expect: HqlQueryContext.countHqlProjections("select b.title from HibernateHqlQuerySpecBook b") == 1 + } + + void "countHqlProjections returns 2 for multiple top-level projections"() { + expect: HqlQueryContext.countHqlProjections("select b.title, b.pages from HibernateHqlQuerySpecBook b") == 2 + } + + void "countHqlProjections ignores commas inside function calls"() { + expect: HqlQueryContext.countHqlProjections("select count(b.title) from HibernateHqlQuerySpecBook b") == 1 + } + + void "countHqlProjections handles DISTINCT single projection"() { + expect: HqlQueryContext.countHqlProjections("select distinct b.title from HibernateHqlQuerySpecBook b") == 1 + } + + void "countHqlProjections handles constructor expression as single projection"() { + expect: HqlQueryContext.countHqlProjections("select new map(b.title as t, b.pages as p) from HibernateHqlQuerySpecBook b") == 1 + } + + // ─── getTarget ────────────────────────────────────────────────────────── + + void "getTarget returns entity class when no SELECT clause"() { + expect: + HqlQueryContext.getTarget("from HibernateHqlQuerySpecBook", HibernateHqlQuerySpecBook) == HibernateHqlQuerySpecBook + } + + void "getTarget returns entity class for single entity projection"() { + expect: + HqlQueryContext.getTarget("select b from HibernateHqlQuerySpecBook b", HibernateHqlQuerySpecBook) == HibernateHqlQuerySpecBook + } + + void "getTarget returns Object for single scalar projection"() { + expect: + HqlQueryContext.getTarget("select b.title from HibernateHqlQuerySpecBook b", HibernateHqlQuerySpecBook) == Object + } + + void "getTarget returns Object array for multiple projections"() { + expect: + HqlQueryContext.getTarget("select b.title, b.pages from HibernateHqlQuerySpecBook b", HibernateHqlQuerySpecBook) == Object[].class + } + + // ─── createHqlQuery + executeQuery ────────────────────────────────────── + + void "createHqlQuery with plain HQL returns all results"() { + when: + def results = buildHqlQuery("from HibernateHqlQuerySpecBook").list() + then: + results.size() == 3 + } + + void "createHqlQuery with named parameters filters correctly"() { + when: + def results = buildHqlQuery("from HibernateHqlQuerySpecBook b where b.title = :title", [title: "The Hobbit"]).list() + then: + results.size() == 1 + results[0].title == "The Hobbit" + } + + void "createHqlQuery with positional parameters filters correctly"() { + when: + def results = buildHqlQuery("from HibernateHqlQuerySpecBook b where b.title = ?1", [:], ["Fellowship"]).list() + then: + results.size() == 1 + results[0].title == "Fellowship" + } + + void "createHqlQuery with max arg limits results"() { + when: + def results = buildHqlQuery("from HibernateHqlQuerySpecBook", [:], null, [max: 2]).list() + then: + results.size() == 2 + } + + void "createHqlQuery with offset arg skips results"() { + when: + def results = buildHqlQuery("from HibernateHqlQuerySpecBook order by title", [:], null, [offset: 2]).list() + then: + results.size() == 1 + } + + void "createHqlQuery with empty query string defaults to full entity query"() { + when: + def results = buildHqlQuery("").list() + then: + results.size() == 3 + } + + void "createHqlQuery executes update"() { + when: + int updated = buildHqlQuery("update HibernateHqlQuerySpecBook set pages = 999 where title = :t", + [t: "The Hobbit"], null, [:], true).executeUpdate() + then: + updated == 1 + } + + void "createHqlQuery with GString builds named parameters automatically"() { + given: + String titleVal = "The Two Towers" + GString gq = "from HibernateHqlQuerySpecBook b where b.title = ${titleVal}" + when: + def entity = mappingContext.getPersistentEntity(HibernateHqlQuerySpecBook.name) + def ctx = HqlQueryContext.prepare(gq, false, false, [:], entity) + def hqlQuery = HibernateHqlQuery.buildQuery(sessionFactory.currentSession, datastore, sessionFactory, entity, ctx) + if (ctx.namedParams()) hqlQuery.populateQueryWithNamedArguments(new HashMap(ctx.namedParams())) + def results = hqlQuery.list() + then: + results.size() == 1 + results[0].title == "The Two Towers" + } + + void "createHqlQuery with multiline query normalizes whitespace"() { + when: + def results = buildHqlQuery("from HibernateHqlQuerySpecBook b\nwhere b.pages > :p", [p: 350]).list() + then: + results.size() == 2 + } + + // ─── setFlushMode ─────────────────────────────────────────────────────── + + @Unroll + void "setFlushMode maps Hibernate #hibernateMode correctly"() { + when: + buildHqlQuery("from HibernateHqlQuerySpecBook").setFlushMode(hibernateMode) + then: + noExceptionThrown() + where: + hibernateMode << [FlushMode.AUTO, FlushMode.ALWAYS, FlushMode.COMMIT, FlushMode.MANUAL] + } + + // ─── named parameter edge cases ───────────────────────────────────────── + + void "populateQueryWithNamedArguments handles list parameter"() { + when: + def results = buildHqlQuery("from HibernateHqlQuerySpecBook b where b.title in (:titles)", + [titles: ["The Hobbit", "Fellowship"]]).list() + then: + results.size() == 2 + } + + void "populateQueryWithNamedArguments handles null value"() { + when: + def results = buildHqlQuery("from HibernateHqlQuerySpecBook b where b.title = :t", [t: null]).list() + then: + results.size() == 0 + } + + void "populateQueryWithNamedArguments throws for non-string key"() { + when: + buildHqlQuery("from HibernateHqlQuerySpecBook") + .populateQueryWithNamedArguments([(42): "value"]) + then: + thrown(Exception) + } +} + +@Entity +class HibernateHqlQuerySpecBook { + String title + Integer pages + HibernateHqlQuerySpecAuthor author + + static belongsTo = [author: HibernateHqlQuerySpecAuthor] + + static constraints = { + title nullable: false + pages nullable: false + author nullable: true + } +} + +@Entity +class HibernateHqlQuerySpecAuthor { + String name + + static hasMany = [books: HibernateHqlQuerySpecBook] + + static constraints = { + name nullable: false + } +}
