You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/10/22 19:43:00 UTC
(grails-core) 02/02: Hibernate 7: read an HQL query once
This is an automated email from the ASF dual-hosted git repository.
borinquenkid pushed a commit to branch feat/sql-text-8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 7c045693041daa35c11030f6b0576d4ca459d6a3
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 22 14:40:50 2026 -0500
Hibernate 7: read an HQL query once
HqlQueryContext.prepare read the same query four times through separate
static helpers: normalise it, then inside getTarget normalise the normalised
text again, count its projections, take its single projection clause and
find its root alias, each one lowercasing and scanning the text on its own.
HqlText is one reading: it lowercases once, scans once with SqlText, finds
the select and the from once, and derives the projection count, the select
clause, the root alias, the normalized form and the result type of that
normalized form from those positions, without reading the normalized text.
prepare creates one HqlText and takes the query text and the target class
from it. The static helpers the spec calls remain as thin entry points, so
HqlQueryContextSpec keeps its 64 cases as the safety net; HqlTextSpec shows
the one-reading contract on a table of queries. rootAlias() also reads a
query that starts with from, which the old search for " from " never did.
---
.../orm/hibernate/query/HqlQueryContext.java | 229 +------------------
.../org/grails/orm/hibernate/query/HqlText.java | 242 +++++++++++++++++++++
.../grails/orm/hibernate/query/HqlTextSpec.groovy | 70 ++++++
3 files changed, 322 insertions(+), 219 deletions(-)
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 4f40f3f5a4..564a789014 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
@@ -23,15 +23,12 @@ import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
-import java.util.Locale;
import java.util.Map;
import java.util.Optional;
-import java.util.Set;
import java.util.stream.Collectors;
import groovy.lang.GString;
-import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import org.hibernate.jpa.AvailableHints;
@@ -118,8 +115,10 @@ public record HqlQueryContext(
String resolved = positionalParamsCopy.isEmpty() ?
resolveHql(queryCharseq, namedParamsCopy) :
resolveHql(queryCharseq, isNative, positionalParamsCopy);
+ // the query is read once: its normalized form and its result type come from the same reading
+ HqlText reading = HqlText.of(resolved);
// the result type of a native query or an update is not read from its select, so it is run as written
- var hql = Optional.ofNullable(isNative || isUpdate ? resolved : normalizeNonAliasedSelect(resolved))
+ var hql = Optional.of(isNative || isUpdate ? reading.text() : reading.normalized())
.filter(s -> !s.trim().isEmpty())
.orElseGet(() -> "from %s".formatted(entity.getName()));
@@ -128,7 +127,7 @@ public record HqlQueryContext(
// a native query always returns entities, as Hibernate 5 did by adding the entity to every native query
Class<?> targetClass = targetClassOverride != null ? targetClassOverride :
- isNative ? entity.getJavaClass() : getTarget(hql, entity.getJavaClass());
+ isNative ? entity.getJavaClass() : reading.target(entity.getJavaClass());
return new HqlQueryContext(
hql,
@@ -159,234 +158,26 @@ public record HqlQueryContext(
}
// ─── Projection analysis ─────────────────────────────────────────────────
+ // The query is read once, by HqlText; these entry points read a text of their own for the callers that have one.
- /**
- * Returns the result target class for a query: the entity class when there is no explicit SELECT
- * or the SELECT is the root alias, {@code Object.class} for any other single projection, such as a
- * property, a joined alias or a function, or {@code Object[].class} for multiple projections.
- */
static Class<?> getTarget(CharSequence hql, Class<?> clazz) {
- String normalized = normalizeNonAliasedSelect(hql == null ? null : hql.toString());
- return switch (countHqlProjections(normalized)) {
- case 0 -> clazz;
- case 1 -> {
- String clause = getSingleProjectionClause(normalized);
- if (clause != null) {
- if (clause.startsWith("count(") || clause.startsWith("sum(") ||
- clause.startsWith("avg(") || clause.startsWith("min(") ||
- clause.startsWith("max(")) {
- yield null; // Let Hibernate determine the result type for aggregates
- }
- }
- yield isRootAliasProjection(normalized, clause) ? clazz : Object.class;
- }
- default -> Object[].class;
- };
- }
-
- static @Nullable String getSingleProjectionClause(CharSequence hql) {
- if (hql == null) return null;
- String s = hql.toString().toLowerCase(Locale.ROOT).trim();
- SqlText lower = SqlText.hql(s);
- int selectIdx = indexOfSelect(lower);
- if (selectIdx < 0) return null;
- int fromIdx = indexOfFrom(lower, selectIdx);
- return extractSelectClause(s, selectIdx, fromIdx);
+ return HqlText.of(hql).target(clazz);
}
- 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
- * top-level projections.
- *
- * <p>Commas inside parentheses, string literals, quoted identifiers and comments are ignored.
- */
static int countHqlProjections(CharSequence hql) {
- if (hql == null || hql.isEmpty()) return 0;
- String s = hql.toString().trim();
- SqlText lower = SqlText.hql(s.toLowerCase(Locale.ROOT));
- int selectIdx = indexOfSelect(lower);
- if (selectIdx < 0) return 0;
-
- int fromIdx = indexOfFrom(lower, selectIdx);
- int selStart = selectIdx + HibernateQueryArgument.HQL_SELECT.value().length();
- int selEnd = fromIdx < 0 ? s.length() : fromIdx;
- if (s.substring(selStart, selEnd).isBlank()) return 0;
-
- // Count the top-level commas of the select clause in place: a leading DISTINCT or ALL holds none
- int commas = lower.countTopLevel(',', selStart, selEnd);
- return commas == 0 ? 1 : 2;
+ return HqlText.of(hql).projectionCount();
}
static int getCommas(String sel) {
return SqlText.hql(sel).countTopLevel(',');
}
- /** Returns the index of the {@code select} of the query itself, not of a subquery, or -1 if it has none. */
- private static int indexOfSelect(SqlText lowerHql) {
- return lowerHql.indexOfTopLevel(HibernateQueryArgument.HQL_SELECT.value() + " ", 0);
- }
-
- /**
- * Returns the index of the space before the {@code from} of the query itself, not of a subquery, at or after
- * {@code fromIndex}, or -1 if it has none.
- */
- private static int indexOfFrom(SqlText lowerHql, int fromIndex) {
- return lowerHql.indexOfTopLevel(" " + HibernateQueryArgument.HQL_FROM.value() + " ", fromIndex);
- }
-
- // ─── HQL normalization ────────────────────────────────────────────────────
-
- /**
- * Injects a synthetic alias {@code "e"} into unaliased SELECT queries so that projection
- * detection works uniformly. The FROM remainder is left intact. Only the SELECT and FROM of the
- * query itself are read, not those of a subquery, and what precedes the SELECT, such as a comment
- * or the {@code with} clause of a common table expression, is kept.
- *
- * <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();
- SqlText lowerText = SqlText.hql(lower);
- int selectIdx = indexOfSelect(lowerText);
- if (selectIdx < 0) return s; // no SELECT clause — nothing to normalize
-
- int fromIdx = indexOfFrom(lowerText, selectIdx);
- if (fromIdx < 0) return s; // malformed — leave as-is
-
- 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 + 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);
- 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(HibernateQueryArgument.HQL_AS.value())) {
- cur += HibernateQueryArgument.HQL_AS.value().length();
- 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++;
- boolean hasAlias = isHasAlias(s, cur, tokenEnd);
- if (hasAlias) return s;
-
- // Strip DISTINCT/ALL prefix before adjusting the projection
- String prefix = "";
- String projOrig = selectClauseOrig;
- String projLower = selectClauseLower;
- if (projLower.startsWith(HibernateQueryArgument.HQL_DISTINCT.value() + " ")) {
- prefix = HibernateQueryArgument.HQL_DISTINCT.value() + " ";
- projOrig = selectClauseOrig.substring(prefix.length()).trim();
- projLower = projLower.substring(prefix.length()).trim();
- } else if (projLower.startsWith(HibernateQueryArgument.HQL_ALL.value() + " ")) {
- prefix = HibernateQueryArgument.HQL_ALL.value() + " ";
- projOrig = selectClauseOrig.substring(prefix.length()).trim();
- projLower = projLower.substring(prefix.length()).trim();
- }
-
- // Qualify the projection with the synthetic alias
- String adjusted = getAdjusted(projLower, entityName, projOrig);
-
- return s.substring(0, selectIdx) + HibernateQueryArgument.HQL_SELECT.value() + " " + prefix + adjusted + " " +
- HibernateQueryArgument.HQL_FROM.value() + " " + entityName + " e" + s.substring(entityEnd);
- }
-
- private static String getAdjusted(String projLower, String entityName, String projOrig) {
- String adjusted;
- if (projLower.equalsIgnoreCase(entityName)) {
- adjusted = "e"; // "select Person from Person" → "select e"
- } 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 adjusted;
- }
-
- private static boolean isHasAlias(String s, int cur, int tokenEnd) {
- String token = s.substring(cur, tokenEnd).toLowerCase(Locale.ROOT);
- return !token.isEmpty() &&
- !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);
+ return hql == null ? null : HqlText.of(hql).normalized();
}
- // ─── Private helpers ─────────────────────────────────────────────────────
-
- /**
- * Whether the single projection is the alias of the queried entity. An unaliased query has been given the
- * alias {@code e} by {@link #normalizeNonAliasedSelect(String)}, which also turns a projection of the entity
- * name into {@code e}.
- */
- private static boolean isRootAliasProjection(String normalizedHql, @Nullable String clause) {
- if (clause == null) {
- return true;
- }
- String rootAlias = getRootAlias(normalizedHql);
- return rootAlias != null && clause.equals(rootAlias.toLowerCase(Locale.ROOT));
- }
-
- /** The alias of the entity named first after FROM, or {@code null} if it has none. */
- static @Nullable String getRootAlias(@Nullable String hql) {
- if (hql == null) return null;
- String s = hql.trim();
- SqlText lower = SqlText.hql(s.toLowerCase(Locale.ROOT));
- int fromIdx = indexOfFrom(lower, Math.max(indexOfSelect(lower), 0));
- if (fromIdx < 0) return null;
- int cur = fromIdx + HibernateQueryArgument.HQL_FROM.value().length() + 2;
- while (cur < s.length() && Character.isWhitespace(s.charAt(cur))) cur++;
- while (cur < s.length() && !Character.isWhitespace(s.charAt(cur))) cur++;
- while (cur < s.length() && Character.isWhitespace(s.charAt(cur))) cur++;
- if (cur + 2 < s.length() &&
- s.substring(cur, cur + 2).equalsIgnoreCase(HibernateQueryArgument.HQL_AS.value()) &&
- Character.isWhitespace(s.charAt(cur + 2))) {
- cur += HibernateQueryArgument.HQL_AS.value().length();
- while (cur < s.length() && Character.isWhitespace(s.charAt(cur))) cur++;
- }
- int tokenEnd = cur;
- while (tokenEnd < s.length() && !Character.isWhitespace(s.charAt(tokenEnd)) && s.charAt(tokenEnd) != ',') tokenEnd++;
- return isHasAlias(s, cur, tokenEnd) ? s.substring(cur, tokenEnd) : null;
+ static boolean isHasAlias(String s, int cur, int tokenEnd) {
+ return HqlText.isClauseAlias(s, cur, tokenEnd);
}
private static String normalizeMultiLineQueryString(String query) {
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlText.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlText.java
new file mode 100644
index 0000000000..178f15e2f5
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlText.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 java.util.Locale;
+import java.util.Set;
+
+import jakarta.annotation.Nullable;
+
+/**
+ * One reading of one HQL string: the positions of the {@code select} and {@code from} of the query itself, its
+ * select clause, how many projections it has, the entity it queries and that entity's alias. The text is lowercased
+ * once and scanned once, by {@link SqlText}; everything here is derived from those positions, including the
+ * normalized form that gives an unaliased query the alias {@code e} and the result type the normalized query has.
+ * Nothing is read twice.
+ *
+ * @since 8.0.1
+ */
+final class HqlText {
+
+ private static final String SELECT = HibernateQueryArgument.HQL_SELECT.value();
+ private static final String FROM = HibernateQueryArgument.HQL_FROM.value();
+ private static final String DISTINCT = HibernateQueryArgument.HQL_DISTINCT.value();
+ private static final String ALL = HibernateQueryArgument.HQL_ALL.value();
+ private static final String AS = HibernateQueryArgument.HQL_AS.value();
+ private static final String NEW = HibernateQueryArgument.HQL_NEW.value();
+ private static final String SYNTHETIC_ALIAS = "e";
+ private static final Set<String> CLAUSE_KEYWORDS = 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());
+
+ private final String text;
+ private final int selectIndex;
+ private final int fromIndex;
+ private final int projectionCount;
+ /** The select clause in lower case without a leading {@code distinct} or {@code all}, or {@code null} without a select. */
+ private final @Nullable String selectClauseLower;
+ private final @Nullable String rootAlias;
+ private final String normalized;
+ /** The select clause the normalized query has, in lower case, and the alias of its root. */
+ private final @Nullable String normalizedClause;
+ private final @Nullable String normalizedRootAlias;
+
+ private HqlText(String hql) {
+ text = hql.trim();
+ String lower = text.toLowerCase(Locale.ROOT);
+ SqlText scan = SqlText.hql(lower);
+ selectIndex = scan.indexOfTopLevel(SELECT + " ", 0);
+ fromIndex = scan.indexOfTopLevel(" " + FROM + " ", Math.max(selectIndex, 0));
+
+ // the projections: none without a select or with an empty clause, one, or two or more
+ String prefix = "";
+ String clauseOriginal = null;
+ String clauseLower = null;
+ int count = 0;
+ if (selectIndex >= 0) {
+ int selectStart = selectIndex + SELECT.length();
+ int selectEnd = fromIndex < 0 ? text.length() : fromIndex;
+ clauseOriginal = text.substring(selectStart, selectEnd).trim();
+ clauseLower = lower.substring(selectStart, selectEnd).trim();
+ if (!clauseOriginal.isEmpty()) {
+ count = scan.countTopLevel(',', selectStart, selectEnd) == 0 ? 1 : 2;
+ }
+ if (clauseLower.startsWith(DISTINCT + " ")) {
+ prefix = DISTINCT + " ";
+ } else if (clauseLower.startsWith(ALL + " ")) {
+ prefix = ALL + " ";
+ }
+ clauseOriginal = clauseOriginal.substring(prefix.length()).trim();
+ clauseLower = clauseLower.substring(prefix.length()).trim();
+ }
+ projectionCount = count;
+ selectClauseLower = clauseLower;
+ rootAlias = readRootAlias(lower);
+
+ // the normalized form: an unaliased root gets the alias e, and the projection is qualified with it
+ String rewritten = text;
+ String rewrittenClause = clauseLower;
+ String rewrittenRootAlias = rootAlias;
+ if (selectIndex >= 0 && fromIndex >= 0) {
+ int afterFrom = fromIndex + FROM.length() + 2;
+ int entityEnd = afterFrom;
+ while (entityEnd < text.length() && !Character.isWhitespace(text.charAt(entityEnd))) entityEnd++;
+ String entityName = text.substring(afterFrom, entityEnd);
+ if (!entityName.isEmpty() && !hasUserAlias(entityEnd)) {
+ String adjusted = qualify(clauseLower, entityName, clauseOriginal);
+ rewritten = text.substring(0, selectIndex) + SELECT + " " + prefix + adjusted + " " +
+ FROM + " " + entityName + " " + SYNTHETIC_ALIAS + text.substring(entityEnd);
+ rewrittenClause = adjusted.toLowerCase(Locale.ROOT);
+ rewrittenRootAlias = SYNTHETIC_ALIAS;
+ }
+ }
+ normalized = rewritten;
+ normalizedClause = rewrittenClause;
+ normalizedRootAlias = rewrittenRootAlias;
+ }
+
+ /** Reads the HQL once; {@code null} reads as an empty query. */
+ static HqlText of(@Nullable CharSequence hql) {
+ return new HqlText(hql == null ? "" : hql.toString());
+ }
+
+ /** The HQL as given, trimmed. */
+ String text() {
+ return text;
+ }
+
+ /**
+ * The number of top-level projections of the select clause: 0 without an explicit select or with an empty one,
+ * 1 for a single projection (a {@code distinct x} or a {@code new map(...)} included), 2 for two or more.
+ * Commas inside parentheses, string literals, quoted identifiers and comments do not separate projections.
+ */
+ int projectionCount() {
+ return projectionCount;
+ }
+
+ /** The select clause in lower case, without a leading {@code distinct} or {@code all}; {@code null} without a select. */
+ @Nullable String singleProjectionClause() {
+ return selectClauseLower;
+ }
+
+ /** The alias of the entity named first after {@code from}, or {@code null} when it has none. */
+ @Nullable String rootAlias() {
+ return rootAlias;
+ }
+
+ /**
+ * The query with the synthetic alias {@code e} given to an unaliased root, so that projection detection works
+ * uniformly: {@code select name from Person} becomes {@code select e.name from Person e}, and
+ * {@code select Person from Person} becomes {@code select e from Person e}. Only the select and from of the
+ * query itself are read, not those of a subquery, and what precedes the select, such as a comment or the
+ * {@code with} clause of a common table expression, is kept. A query that already aliases its root, or has no
+ * select or from, is returned as it is.
+ */
+ String normalized() {
+ return normalized;
+ }
+
+ /**
+ * The result type of the normalized query: the entity for a query without a select or one that selects the
+ * root alias, {@code null} for a single aggregate so that Hibernate decides, {@code Object} for one other
+ * projection and {@code Object[]} for several.
+ */
+ @Nullable Class<?> target(Class<?> entityClass) {
+ return switch (projectionCount) {
+ case 0 -> entityClass;
+ case 1 -> {
+ String clause = normalizedClause;
+ if (clause != null && (clause.startsWith("count(") || clause.startsWith("sum(") ||
+ clause.startsWith("avg(") || clause.startsWith("min(") || clause.startsWith("max("))) {
+ yield null;
+ }
+ boolean root = clause == null ||
+ (normalizedRootAlias != null && clause.equals(normalizedRootAlias.toLowerCase(Locale.ROOT)));
+ yield root ? entityClass : Object.class;
+ }
+ default -> Object[].class;
+ };
+ }
+
+ /**
+ * Whether the token between {@code from} and {@code to} is an alias and not the keyword of the clause that
+ * follows the entity.
+ */
+ static boolean isClauseAlias(String hql, int from, int to) {
+ String token = hql.substring(from, to).toLowerCase(Locale.ROOT);
+ return !token.isEmpty() && !CLAUSE_KEYWORDS.contains(token);
+ }
+
+ /** Whether the root entity ending at {@code entityEnd} is followed by an alias of the user's, with or without {@code as}. */
+ private boolean hasUserAlias(int entityEnd) {
+ int cur = entityEnd;
+ while (cur < text.length() && Character.isWhitespace(text.charAt(cur))) cur++;
+ if (cur + AS.length() <= text.length() && text.substring(cur, cur + AS.length()).equalsIgnoreCase(AS)) {
+ cur += AS.length();
+ while (cur < text.length() && Character.isWhitespace(text.charAt(cur))) cur++;
+ }
+ int tokenEnd = cur;
+ while (tokenEnd < text.length() && !Character.isWhitespace(text.charAt(tokenEnd))) tokenEnd++;
+ return isClauseAlias(text, cur, tokenEnd);
+ }
+
+ /** The alias of the first entity after {@code from}, read the way GORM reads it for a root alias. */
+ private @Nullable String readRootAlias(String lower) {
+ int cur;
+ if (fromIndex >= 0) {
+ cur = fromIndex + FROM.length() + 2;
+ } else if (lower.startsWith(FROM + " ")) {
+ // a query that starts with from has no space before it for the top-level search to find
+ cur = FROM.length() + 1;
+ } else {
+ return null;
+ }
+ while (cur < text.length() && Character.isWhitespace(text.charAt(cur))) cur++;
+ while (cur < text.length() && !Character.isWhitespace(text.charAt(cur))) cur++;
+ while (cur < text.length() && Character.isWhitespace(text.charAt(cur))) cur++;
+ if (cur + AS.length() < text.length() &&
+ text.substring(cur, cur + AS.length()).equalsIgnoreCase(AS) &&
+ Character.isWhitespace(text.charAt(cur + AS.length()))) {
+ cur += AS.length();
+ while (cur < text.length() && Character.isWhitespace(text.charAt(cur))) cur++;
+ }
+ int tokenEnd = cur;
+ while (tokenEnd < text.length() && !Character.isWhitespace(text.charAt(tokenEnd)) && text.charAt(tokenEnd) != ',') tokenEnd++;
+ return isClauseAlias(text, cur, tokenEnd) ? text.substring(cur, tokenEnd) : null;
+ }
+
+ /** Qualifies a projection with the synthetic alias: the entity itself becomes {@code e}, a bare property {@code e.property}. */
+ private static String qualify(String clauseLower, String entityName, String clauseOriginal) {
+ if (clauseLower.equalsIgnoreCase(entityName)) {
+ return SYNTHETIC_ALIAS;
+ }
+ if (!clauseLower.contains("(") && !clauseLower.contains(".") && !clauseLower.startsWith(NEW + " ")) {
+ return SYNTHETIC_ALIAS + "." + clauseOriginal;
+ }
+ return clauseOriginal;
+ }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlTextSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlTextSpec.groovy
new file mode 100644
index 0000000000..05a96c5a96
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlTextSpec.groovy
@@ -0,0 +1,70 @@
+/*
+ * 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 spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * One reading of an HQL string answers every question about it: the projection count, the select clause, the root
+ * alias, the normalized form and the result type come from the same positions, found once.
+ */
+class HqlTextSpec extends Specification {
+
+ @Unroll
+ void "one reading of '#hql' gives #count projection(s), clause '#clause', root alias #alias, normalized '#normalized' and target #target"() {
+ given:
+ HqlText reading = HqlText.of(hql)
+
+ expect:
+ reading.projectionCount() == count
+ reading.singleProjectionClause() == clause
+ reading.rootAlias() == alias
+ reading.normalized() == normalized
+ reading.target(Book) == target
+
+ where:
+ hql | count | clause | alias | normalized | target
+ 'from Book' | 0 | null | null | 'from Book' | Book
+ 'from Book b' | 0 | null | 'b' | 'from Book b' | Book
+ 'select b from Book b' | 1 | 'b' | 'b' | 'select b from Book b' | Book
+ 'select Book from Book' | 1 | 'book' | null | 'select e from Book e' | Book
+ 'select name from Book' | 1 | 'name' | null | 'select e.name from Book e' | Object
+ 'select distinct name from Book' | 1 | 'name' | null | 'select distinct e.name from Book e' | Object
+ 'select b.name, b.author from Book b' | 2 | 'b.name, b.author' | 'b' | 'select b.name, b.author from Book b' | Object[]
+ 'select count(b) from Book b' | 1 | 'count(b)' | 'b' | 'select count(b) from Book b' | null
+ 'select new map(b.name as n) from Book b' | 1 | 'new map(b.name as n)' | 'b' | 'select new map(b.name as n) from Book b' | Object
+ 'select b from Book as b where b.name = \'x, y\'' | 1 | 'b' | 'b' | 'select b from Book as b where b.name = \'x, y\'' | Book
+ 'from Book b where b.author in (select a from Author a)' | 0 | null | 'b' | 'from Book b where b.author in (select a from Author a)' | Book
+ 'select b from Book b /* , not a projection */' | 1 | 'b' | 'b' | 'select b from Book b /* , not a projection */' | Book
+ 'select b from Book b -- select ignored\n' | 1 | 'b' | 'b' | 'select b from Book b -- select ignored' | Book
+ '' | 0 | null | null | '' | Book
+ }
+
+ void "a null query reads as an empty one"() {
+ expect:
+ HqlText.of(null).text() == ''
+ HqlText.of(null).projectionCount() == 0
+ HqlText.of(null).target(Book) == Book
+ }
+
+ static class Book {
+ }
+
+}