Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Order by expression missing in select list after update to Spring Boot 3.0.1 with DISTINCT queries #2756

Closed
william00179 opened this issue Jan 6, 2023 · 28 comments
Assignees
Labels
status: invalid An issue that we don't feel is valid

Comments

@william00179
Copy link

william00179 commented Jan 6, 2023

Hi there,

Spring Boot: 3.0.1
Database: postgres 14

I am seeing an issue after updating from Spring Boot 2.7x to Spring Boot 3.0.1. I am raising this issue here as I think the issue lies within Spring Data JPA after some debugging, specifically in QueryUtils in converting a Sort to an Expression.

I provide the following Sort as a default for a query

Sort.by(Sort.Order.desc(Study_.DATE), Sort.Order.desc(Study_.TIME), Sort.Order.asc("patient.lastName"), Sort.Order.asc("patient.firstName"))

I pass this through to findAll(Specification, Sort) and the following query is generated

select 
  distinct s1_0.id, 
  s1_0.date, 
  s1_0.description, 
  s1_0.patient_id, 
  s1_0.size, 
  s1_0.study_instance_uid, 
  s1_0.time
from 
  patient.studies s1_0 
  join patient.patients p1_0 on p1_0.id = s1_0.patient_id 
where 
  s1_0.customer_id = ?
order by 
  s1_0.date desc, 
  s1_0.time desc, 
  p1_0.last_name asc, 
  p1_0.first_name asc offset ? rows fetch first ? rows only

Note that this query is DISTINCT. This query will fail with PSQLException: ERROR: for SELECT DISTINCT, ORDER BY expressions must appear in select list. If I remove the distinct and run this the query will execute successfully but will give the wrong results.

If I try to work around this by using an entity graph to fetch the patient record eagerly, Spring Data will not detect the join in QueryUtils and join the record again and then use that alias in the order by, once again running into the same issue.

select 
  distinct s1_0.id, 
  s1_0.date,  
  s1_0.description, 
  p2_0.id, 
  p2_0.attributes_id, 
  p2_0.created_at, 
  p2_0.customer_id, 
  p2_0.deceased, 
  p2_0.deceased_at, 
  p2_0.deleted_at, 
  p2_0.dob, 
  p2_0.first_name, 
  p2_0.last_name, 
  p2_0.middle_name, 
  p2_0.prefix, 
  p2_0.sex, 
  p2_0.suffix, 
  p2_0.updated_at, 
  s1_0.size, 
  s1_0.study_instance_uid, 
  s1_0.time
from 
  patient.studies s1_0 
  join patient.patients p1_0 on p1_0.id = s1_0.patient_id 
  left join patient.patients p2_0 on p2_0.id = s1_0.patient_id 
where 
  s1_0.customer_id = ? 
order by 
  s1_0.date desc, 
  s1_0.time desc, 
  p1_0.last_name asc, 
  p1_0.first_name asc offset ? rows fetch first ? rows only

I think this issue is likely caused by a combination of the new aliasing in Hibernate and the related changes made to QueryUtils about 8 months ago.

From searching through Hibernate bug tracker, there are a few reports of this issue going way back to early 5.x releases of Hibernate. eg https://hibernate.atlassian.net/browse/HHH-13434. The feedback from the maintainers is a fetch() should be used rather than a join in these cases.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Jan 6, 2023
@gregturn
Copy link
Contributor

gregturn commented Jan 6, 2023

Could you include a link to a reproducer, staged on github, ideally using https://start.spring.io, H2 (or Testcontainers for PostGreSQL, etc.), and the version of Spring Boot aligned with your issue?

@gregturn gregturn added status: waiting-for-feedback We need additional information before we can continue and removed status: waiting-for-triage An issue we've not yet triaged labels Jan 6, 2023
@william00179
Copy link
Author

Hi @gregturn

Please find a simple reproducer here https://github.com/william00179/spring-data-reproducer-2756

I think the issue may be specific to Postgres, other databases may be more lenient.

The problem can be fixed by making the following change to QueryUtils. This change requires passing a distinct flag into the utils from the query. Currently this method also doesn't consider fetches, it only checks joins. This cast appears to be safe. There may be a more graceful way to do it that doesn't add the unnecessary select for other databases that don't require it.

private static Join<?, ?> getOrCreateJoin(From<?, ?> from, String attribute, JoinType joinType, boolean distinct) {

        for (Fetch<?, ?> fetch : from.getFetches()) {
            if (fetch.getAttribute().getName().equals(attribute))
                return (Join<?, ?>) fetch;
        }

        if(!distinct)
        {
            for (Join<?, ?> join : from.getJoins()) {
                if (join.getAttribute().getName().equals(attribute)) {
                    return join;
                }
            }
        }

        return distinct ?
                (Join<?, ?>) from.fetch(attribute, joinType) : from.join(attribute, joinType);
    }

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Jan 8, 2023
@reda-alaoui
Copy link
Contributor

reda-alaoui commented Feb 6, 2023

Hello,

We have also encoutered this issue. Should this be fixed on Hibernate or Spring Data side?

@william00179
Copy link
Author

I would think this would be a Hibernate issue but they have previously rejected it being fixed within Hibernate.

Its easily fixed within spring-data by using fetch rather than join conditionally.

@reda-alaoui
Copy link
Contributor

reda-alaoui commented Feb 6, 2023

@william00179 , according to #2756 (comment) , the fix you suggest is not a solution for those who use EntityGraph, is it?

@william00179
Copy link
Author

Yes it will still work with an entity graph. We are currently using a forked version of the lib with this fix made.

I havent raised this as a PR as my fix isn't very efficient for other dialects that don't require this change and I don't know enough about the internals of Spring Data to acomplish that currently. That being said, with this change everything now works as expected as it did before the upgrade to Spring Boot 3 and Hibernate 6

Here are the changes that need to be made

diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java
index 16074ed2..fe335e8b 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java
@@ -191,7 +191,7 @@ public class JpaQueryCreator extends AbstractQueryCreator<CriteriaQuery<? extend
 			query = query.select((Root) root);
 		}
 
-		CriteriaQuery<? extends Object> select = query.orderBy(QueryUtils.toOrders(sort, root, builder));
+		CriteriaQuery<? extends Object> select = query.orderBy(QueryUtils.toOrders(sort, root, builder, query.isDistinct()));
 		return predicate == null ? select : select.where(predicate);
 	}
 
@@ -380,7 +380,7 @@ public class JpaQueryCreator extends AbstractQueryCreator<CriteriaQuery<? extend
 		}
 
 		private <T> Expression<T> getTypedPath(Root<?> root, Part part) {
-			return toExpressionRecursively(root, part.getProperty());
+			return toExpressionRecursively(root, part.getProperty(), false);
 		}
 
 		private <T> Expression<T> traversePath(Path<?> root, PropertyPath path) {
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
index cdd49c62..bd27cd84 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
@@ -70,856 +70,865 @@ import org.springframework.util.StringUtils;
  */
 public abstract class QueryUtils {
 
-	public static final String COUNT_QUERY_STRING = "select count(%s) from %s x";
-	public static final String DELETE_ALL_QUERY_STRING = "delete from %s x";
-	public static final String DELETE_ALL_QUERY_BY_ID_STRING = "delete from %s x where %s in :ids";
-
-	// Used Regex/Unicode categories (see https://www.unicode.org/reports/tr18/#General_Category_Property):
-	// Z Separator
-	// Cc Control
-	// Cf Format
-	// Punct Punctuation
-	private static final String IDENTIFIER = "[._$[\\P{Z}&&\\P{Cc}&&\\P{Cf}&&\\P{Punct}]]+";
-	static final String COLON_NO_DOUBLE_COLON = "(?<![:\\\\]):";
-	static final String IDENTIFIER_GROUP = String.format("(%s)", IDENTIFIER);
-
-	private static final String COUNT_REPLACEMENT_TEMPLATE = "select count(%s) $5$6$7";
-	private static final String SIMPLE_COUNT_VALUE = "$2";
-	private static final String COMPLEX_COUNT_VALUE = "$3 $6";
-	private static final String COMPLEX_COUNT_LAST_VALUE = "$6";
-	private static final String ORDER_BY_PART = "(?iu)\\s+order\\s+by\\s+.*";
-
-	private static final Pattern ALIAS_MATCH;
-	private static final Pattern COUNT_MATCH;
-	private static final Pattern STARTS_WITH_PAREN = Pattern.compile("^\\s*\\(");
-	private static final Pattern PARENS_TO_REMOVE = Pattern.compile("(\\(.*\\bfrom\\b[^)]+\\))",
-			CASE_INSENSITIVE | DOTALL | MULTILINE);
-	private static final Pattern PROJECTION_CLAUSE = Pattern.compile("select\\s+(?:distinct\\s+)?(.+)\\s+from",
-			Pattern.CASE_INSENSITIVE);
-
-	private static final Pattern NO_DIGITS = Pattern.compile("\\D+");
-
-	private static final String JOIN = "join\\s+(fetch\\s+)?" + IDENTIFIER + "\\s+(as\\s+)?" + IDENTIFIER_GROUP;
-	private static final Pattern JOIN_PATTERN = Pattern.compile(JOIN, Pattern.CASE_INSENSITIVE);
-
-	private static final String EQUALS_CONDITION_STRING = "%s.%s = :%s";
-	private static final Pattern ORDER_BY = Pattern.compile("(order\\s+by\\s+)", CASE_INSENSITIVE);
-	private static final Pattern ORDER_BY_IN_WINDOW_OR_SUBSELECT = Pattern
-			.compile("\\([\\s\\S]*order\\s+by\\s[\\s\\S]*\\)", CASE_INSENSITIVE);
-
-	private static final Pattern NAMED_PARAMETER = Pattern.compile(COLON_NO_DOUBLE_COLON + IDENTIFIER + "|#" + IDENTIFIER,
-			CASE_INSENSITIVE);
-
-	private static final Pattern CONSTRUCTOR_EXPRESSION;
-
-	private static final Map<PersistentAttributeType, Class<? extends Annotation>> ASSOCIATION_TYPES;
-
-	private static final int QUERY_JOIN_ALIAS_GROUP_INDEX = 3;
-	private static final int VARIABLE_NAME_GROUP_INDEX = 4;
-	private static final int COMPLEX_COUNT_FIRST_INDEX = 3;
-
-	private static final Pattern PUNCTATION_PATTERN = Pattern.compile(".*((?![._])[\\p{Punct}|\\s])");
-	private static final Pattern FUNCTION_PATTERN;
-	private static final Pattern FIELD_ALIAS_PATTERN;
-
-	private static final String UNSAFE_PROPERTY_REFERENCE = "Sort expression '%s' must only contain property references or "
-			+ "aliases used in the select clause; If you really want to use something other than that for sorting, please use "
-			+ "JpaSort.unsafe(…)";
-
-	static {
-
-		StringBuilder builder = new StringBuilder();
-		builder.append("(?<=\\bfrom)"); // from as starting delimiter
-		builder.append("(?:\\s)+"); // at least one space separating
-		builder.append(IDENTIFIER_GROUP); // Entity name, can be qualified (any
-		builder.append("(?:\\sas)*"); // exclude possible "as" keyword
-		builder.append("(?:\\s)+"); // at least one space separating
-		builder.append("(?!(?:where|group\\s*by|order\\s*by))(\\w+)"); // the actual alias
-
-		ALIAS_MATCH = compile(builder.toString(), CASE_INSENSITIVE);
-
-		builder = new StringBuilder();
-		builder.append("\\s*");
-		builder.append("(select\\s+((distinct)?((?s).+?)?)\\s+)?(from\\s+");
-		builder.append(IDENTIFIER);
-		builder.append("(?:\\s+as)?\\s+)");
-		builder.append(IDENTIFIER_GROUP);
-		builder.append("(.*)");
-
-		COUNT_MATCH = compile(builder.toString(), CASE_INSENSITIVE | DOTALL);
-
-		Map<PersistentAttributeType, Class<? extends Annotation>> persistentAttributeTypes = new HashMap<>();
-		persistentAttributeTypes.put(ONE_TO_ONE, OneToOne.class);
-		persistentAttributeTypes.put(ONE_TO_MANY, null);
-		persistentAttributeTypes.put(MANY_TO_ONE, ManyToOne.class);
-		persistentAttributeTypes.put(MANY_TO_MANY, null);
-		persistentAttributeTypes.put(ELEMENT_COLLECTION, null);
-
-		ASSOCIATION_TYPES = Collections.unmodifiableMap(persistentAttributeTypes);
-
-		builder = new StringBuilder();
-		builder.append("select");
-		builder.append("\\s+"); // at least one space separating
-		builder.append("(.*\\s+)?"); // anything in between (e.g. distinct) at least one space separating
-		builder.append("new");
-		builder.append("\\s+"); // at least one space separating
-		builder.append(IDENTIFIER);
-		builder.append("\\s*"); // zero to unlimited space separating
-		builder.append("\\(");
-		builder.append(".*");
-		builder.append("\\)");
-
-		CONSTRUCTOR_EXPRESSION = compile(builder.toString(), CASE_INSENSITIVE + DOTALL);
-
-		builder = new StringBuilder();
-		// any function call including parameters within the brackets
-		builder.append("\\w+\\s*\\([\\w\\.,\\s'=:\\\\?]+\\)");
-		// the potential alias
-		builder.append("\\s+[as|AS]+\\s+(([\\w\\.]+))");
-
-		FUNCTION_PATTERN = compile(builder.toString());
-
-		builder = new StringBuilder();
-		builder.append("\\s+"); // at least one space
-		builder.append("[^\\s\\(\\)]+"); // No white char no bracket
-		builder.append("\\s+[as|AS]+\\s+(([\\w\\.]+))"); // the potential alias
-
-		FIELD_ALIAS_PATTERN = compile(builder.toString());
-
-	}
-
-	/**
-	 * Private constructor to prevent instantiation.
-	 */
-	private QueryUtils() {
-
-	}
-
-	/**
-	 * Returns the query string to execute an exists query for the given id attributes.
-	 *
-	 * @param entityName the name of the entity to create the query for, must not be {@literal null}.
-	 * @param countQueryPlaceHolder the placeholder for the count clause, must not be {@literal null}.
-	 * @param idAttributes the id attributes for the entity, must not be {@literal null}.
-	 */
-	public static String getExistsQueryString(String entityName, String countQueryPlaceHolder,
-			Iterable<String> idAttributes) {
-
-		String whereClause = Streamable.of(idAttributes).stream() //
-				.map(idAttribute -> String.format(EQUALS_CONDITION_STRING, "x", idAttribute, idAttribute)) //
-				.collect(Collectors.joining(" AND ", " WHERE ", ""));
-
-		return String.format(COUNT_QUERY_STRING, countQueryPlaceHolder, entityName) + whereClause;
-	}
-
-	/**
-	 * Returns the query string for the given class name.
-	 *
-	 * @param template must not be {@literal null}.
-	 * @param entityName must not be {@literal null}.
-	 * @return the template with placeholders replaced by the {@literal entityName}. Guaranteed to be not {@literal null}.
-	 */
-	public static String getQueryString(String template, String entityName) {
-
-		Assert.hasText(entityName, "Entity name must not be null or empty");
-
-		return String.format(template, entityName);
-	}
-
-	/**
-	 * Adds {@literal order by} clause to the JPQL query. Uses the first alias to bind the sorting property to.
-	 *
-	 * @param query the query string to which sorting is applied
-	 * @param sort the sort specification to apply.
-	 * @return the modified query string.
-	 */
-	public static String applySorting(String query, Sort sort) {
-		return applySorting(query, sort, detectAlias(query));
-	}
-
-	/**
-	 * Adds {@literal order by} clause to the JPQL query.
-	 *
-	 * @param query the query string to which sorting is applied. Must not be {@literal null} or empty.
-	 * @param sort the sort specification to apply.
-	 * @param alias the alias to be used in the order by clause. May be {@literal null} or empty.
-	 * @return the modified query string.
-	 */
-	public static String applySorting(String query, Sort sort, @Nullable String alias) {
-
-		Assert.hasText(query, "Query must not be null or empty");
-
-		if (sort.isUnsorted()) {
-			return query;
-		}
-
-		StringBuilder builder = new StringBuilder(query);
-
-		if (hasOrderByClause(query)) {
-			builder.append(", ");
-		} else {
-			builder.append(" order by ");
-		}
-
-		Set<String> joinAliases = getOuterJoinAliases(query);
-		Set<String> selectionAliases = getFunctionAliases(query);
-		selectionAliases.addAll(getFieldAliases(query));
-
-		for (Order order : sort) {
-			builder.append(getOrderClause(joinAliases, selectionAliases, alias, order)).append(", ");
-		}
-
-		builder.delete(builder.length() - 2, builder.length());
-
-		return builder.toString();
-	}
-
-	/**
-	 * Returns {@code true} if the query has {@code order by} clause. The query has {@code order by} clause if there is an
-	 * {@code order by} which is not part of window clause.
-	 *
-	 * @param query the analysed query string
-	 * @return {@code true} if the query has {@code order by} clause, {@code false} otherwise
-	 */
-	private static boolean hasOrderByClause(String query) {
-		return countOccurrences(ORDER_BY, query) > countOccurrences(ORDER_BY_IN_WINDOW_OR_SUBSELECT, query);
-	}
-
-	/**
-	 * Counts the number of occurrences of the pattern in the string
-	 *
-	 * @param pattern regex with a group to match
-	 * @param string analysed string
-	 * @return the number of occurrences of the pattern in the string
-	 */
-	private static int countOccurrences(Pattern pattern, String string) {
-
-		Matcher matcher = pattern.matcher(string);
-
-		int occurrences = 0;
-		while (matcher.find()) {
-			occurrences++;
-		}
-		return occurrences;
-	}
-
-	/**
-	 * Returns the order clause for the given {@link Order}. Will prefix the clause with the given alias if the referenced
-	 * property refers to a join alias, i.e. starts with {@code $alias.}.
-	 *
-	 * @param joinAliases the join aliases of the original query. Must not be {@literal null}.
-	 * @param alias the alias for the root entity. May be {@literal null}.
-	 * @param order the order object to build the clause for. Must not be {@literal null}.
-	 * @return a String containing an order clause. Guaranteed to be not {@literal null}.
-	 */
-	private static String getOrderClause(Set<String> joinAliases, Set<String> selectionAlias, @Nullable String alias,
-			Order order) {
-
-		String property = order.getProperty();
-
-		checkSortExpression(order);
-
-		if (selectionAlias.contains(property)) {
-
-			return String.format("%s %s", //
-					order.isIgnoreCase() ? String.format("lower(%s)", property) : property, //
-					toJpaDirection(order));
-		}
-
-		boolean qualifyReference = !property.contains("("); // ( indicates a function
-
-		for (String joinAlias : joinAliases) {
-
-			if (property.startsWith(joinAlias.concat("."))) {
-
-				qualifyReference = false;
-				break;
-			}
-		}
-
-		String reference = qualifyReference && StringUtils.hasText(alias) ? String.format("%s.%s", alias, property)
-				: property;
-		String wrapped = order.isIgnoreCase() ? String.format("lower(%s)", reference) : reference;
-
-		return String.format("%s %s", wrapped, toJpaDirection(order));
-	}
-
-	/**
-	 * Returns the aliases used for {@code left (outer) join}s.
-	 *
-	 * @param query a query string to extract the aliases of joins from. Must not be {@literal null}.
-	 * @return a {@literal Set} of aliases used in the query. Guaranteed to be not {@literal null}.
-	 */
-	static Set<String> getOuterJoinAliases(String query) {
-
-		Set<String> result = new HashSet<>();
-		Matcher matcher = JOIN_PATTERN.matcher(query);
-
-		while (matcher.find()) {
-
-			String alias = matcher.group(QUERY_JOIN_ALIAS_GROUP_INDEX);
-			if (StringUtils.hasText(alias)) {
-				result.add(alias);
-			}
-		}
-
-		return result;
-	}
-
-	/**
-	 * Returns the aliases used for fields in the query.
-	 *
-	 * @param query a {@literal String} containing a query. Must not be {@literal null}.
-	 * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}.
-	 */
-	private static Set<String> getFieldAliases(String query) {
-
-		Set<String> result = new HashSet<>();
-		Matcher matcher = FIELD_ALIAS_PATTERN.matcher(query);
-
-		while (matcher.find()) {
-			String alias = matcher.group(1);
-
-			if (StringUtils.hasText(alias)) {
-				result.add(alias);
-			}
-		}
-		return result;
-	}
-
-	/**
-	 * Returns the aliases used for aggregate functions like {@code SUM, COUNT, ...}.
-	 *
-	 * @param query a {@literal String} containing a query. Must not be {@literal null}.
-	 * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}.
-	 */
-	static Set<String> getFunctionAliases(String query) {
-
-		Set<String> result = new HashSet<>();
-		Matcher matcher = FUNCTION_PATTERN.matcher(query);
-
-		while (matcher.find()) {
-
-			String alias = matcher.group(1);
-
-			if (StringUtils.hasText(alias)) {
-				result.add(alias);
-			}
-		}
-
-		return result;
-	}
-
-	private static String toJpaDirection(Order order) {
-		return order.getDirection().name().toLowerCase(Locale.US);
-	}
-
-	/**
-	 * Resolves the alias for the entity to be retrieved from the given JPA query.
-	 *
-	 * @param query must not be {@literal null}.
-	 * @return Might return {@literal null}.
-	 * @deprecated use {@link DeclaredQuery#getAlias()} instead.
-	 */
-	@Nullable
-	@Deprecated
-	public static String detectAlias(String query) {
-
-		String alias = null;
-		Matcher matcher = ALIAS_MATCH.matcher(removeSubqueries(query));
-		while (matcher.find()) {
-			alias = matcher.group(2);
-		}
-		return alias;
-	}
-
-	/**
-	 * Remove subqueries from the query, in order to identify the correct alias in order by clauses. If the entire query
-	 * is surrounded by parenthesis, the outermost parenthesis are not removed.
-	 *
-	 * @param query
-	 * @return query with all subqueries removed.
-	 */
-	static String removeSubqueries(String query) {
-
-		if (!StringUtils.hasText(query)) {
-			return query;
-		}
-
-		List<Integer> opens = new ArrayList<>();
-		List<Integer> closes = new ArrayList<>();
-		List<Boolean> closeMatches = new ArrayList<>();
-
-		for (int i = 0; i < query.length(); i++) {
-
-			char c = query.charAt(i);
-			if (c == '(') {
-				opens.add(i);
-			} else if (c == ')') {
-				closes.add(i);
-				closeMatches.add(Boolean.FALSE);
-			}
-		}
-
-		StringBuilder sb = new StringBuilder(query);
-		boolean startsWithParen = STARTS_WITH_PAREN.matcher(query).find();
-		for (int i = opens.size() - 1; i >= (startsWithParen ? 1 : 0); i--) {
-
-			Integer open = opens.get(i);
-			Integer close = findClose(open, closes, closeMatches) + 1;
-
-			if (close > open) {
-
-				String subquery = sb.substring(open, close);
-				Matcher matcher = PARENS_TO_REMOVE.matcher(subquery);
-				if (matcher.find()) {
-					sb.replace(open, close, new String(new char[close - open]).replace('\0', ' '));
-				}
-			}
-		}
-
-		return sb.toString();
-	}
-
-	private static Integer findClose(final Integer open, final List<Integer> closes, final List<Boolean> closeMatches) {
-
-		for (int i = 0; i < closes.size(); i++) {
-
-			int close = closes.get(i);
-			if (close > open && !closeMatches.get(i)) {
-				closeMatches.set(i, Boolean.TRUE);
-				return close;
-			}
-		}
-
-		return -1;
-	}
-
-	/**
-	 * Creates a where-clause referencing the given entities and appends it to the given query string. Binds the given
-	 * entities to the query.
-	 *
-	 * @param <T> type of the entities.
-	 * @param queryString must not be {@literal null}.
-	 * @param entities must not be {@literal null}.
-	 * @param entityManager must not be {@literal null}.
-	 * @return Guaranteed to be not {@literal null}.
-	 */
-
-	public static <T> Query applyAndBind(String queryString, Iterable<T> entities, EntityManager entityManager) {
-
-		Assert.notNull(queryString, "Querystring must not be null");
-		Assert.notNull(entities, "Iterable of entities must not be null");
-		Assert.notNull(entityManager, "EntityManager must not be null");
-
-		Iterator<T> iterator = entities.iterator();
-
-		if (!iterator.hasNext()) {
-			return entityManager.createQuery(queryString);
-		}
-
-		String alias = detectAlias(queryString);
-		StringBuilder builder = new StringBuilder(queryString);
-		builder.append(" where");
-
-		int i = 0;
-
-		while (iterator.hasNext()) {
-
-			iterator.next();
-
-			builder.append(String.format(" %s = ?%d", alias, ++i));
-
-			if (iterator.hasNext()) {
-				builder.append(" or");
-			}
-		}
-
-		Query query = entityManager.createQuery(builder.toString());
-
-		iterator = entities.iterator();
-		i = 0;
-
-		while (iterator.hasNext()) {
-			query.setParameter(++i, iterator.next());
-		}
-
-		return query;
-	}
-
-	/**
-	 * Creates a count projected query from the given original query.
-	 *
-	 * @param originalQuery must not be {@literal null} or empty.
-	 * @return Guaranteed to be not {@literal null}.
-	 * @deprecated use {@link DeclaredQuery#deriveCountQuery(String, String)} instead.
-	 */
-	@Deprecated
-	public static String createCountQueryFor(String originalQuery) {
-		return createCountQueryFor(originalQuery, null);
-	}
-
-	/**
-	 * Creates a count projected query from the given original query.
-	 *
-	 * @param originalQuery must not be {@literal null}.
-	 * @param countProjection may be {@literal null}.
-	 * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}.
-	 * @since 1.6
-	 * @deprecated use {@link DeclaredQuery#deriveCountQuery(String, String)} instead.
-	 */
-	@Deprecated
-	public static String createCountQueryFor(String originalQuery, @Nullable String countProjection) {
-
-		Assert.hasText(originalQuery, "OriginalQuery must not be null or empty");
-
-		Matcher matcher = COUNT_MATCH.matcher(originalQuery);
-		String countQuery;
-
-		if (countProjection == null) {
-
-			String variable = matcher.matches() ? matcher.group(VARIABLE_NAME_GROUP_INDEX) : null;
-			boolean useVariable = StringUtils.hasText(variable) //
-					&& !variable.startsWith("new") // select [new com.example.User...
-					&& !variable.startsWith(" new") // select distinct[ new com.example.User...
-					&& !variable.startsWith("count(") // select [count(...
-					&& !variable.contains(",");
-
-			String complexCountValue = matcher.matches() && StringUtils.hasText(matcher.group(COMPLEX_COUNT_FIRST_INDEX))
-					? COMPLEX_COUNT_VALUE
-					: COMPLEX_COUNT_LAST_VALUE;
-
-			String replacement = useVariable ? SIMPLE_COUNT_VALUE : complexCountValue;
-
-			String alias = QueryUtils.detectAlias(originalQuery);
-			if ("*".equals(variable) && alias != null) {
-				replacement = alias;
-			}
-
-			countQuery = matcher.replaceFirst(String.format(COUNT_REPLACEMENT_TEMPLATE, replacement));
-		} else {
-			countQuery = matcher.replaceFirst(String.format(COUNT_REPLACEMENT_TEMPLATE, countProjection));
-		}
-
-		return countQuery.replaceFirst(ORDER_BY_PART, "");
-	}
-
-	/**
-	 * Returns whether the given {@link Query} contains named parameters.
-	 *
-	 * @param query Must not be {@literal null}.
-	 * @return whether the given {@link Query} contains named parameters.
-	 */
-	public static boolean hasNamedParameter(Query query) {
-
-		Assert.notNull(query, "Query must not be null");
-
-		for (Parameter<?> parameter : query.getParameters()) {
-
-			String name = parameter.getName();
-
-			// Hibernate 3 specific hack as it returns the index as String for the name.
-			if (name != null && NO_DIGITS.matcher(name).find()) {
-				return true;
-			}
-		}
-
-		return false;
-	}
-
-	/**
-	 * Returns whether the given query contains named parameters.
-	 *
-	 * @param query can be {@literal null} or empty.
-	 * @return whether the given query contains named parameters.
-	 */
-	@Deprecated
-	static boolean hasNamedParameter(@Nullable String query) {
-		return StringUtils.hasText(query) && NAMED_PARAMETER.matcher(query).find();
-	}
-
-	/**
-	 * Turns the given {@link Sort} into {@link jakarta.persistence.criteria.Order}s.
-	 *
-	 * @param sort the {@link Sort} instance to be transformed into JPA {@link jakarta.persistence.criteria.Order}s.
-	 * @param from must not be {@literal null}.
-	 * @param cb must not be {@literal null}.
-	 * @return a {@link List} of {@link jakarta.persistence.criteria.Order}s.
-	 */
-	public static List<jakarta.persistence.criteria.Order> toOrders(Sort sort, From<?, ?> from, CriteriaBuilder cb) {
-
-		if (sort.isUnsorted()) {
-			return Collections.emptyList();
-		}
-
-		Assert.notNull(from, "From must not be null");
-		Assert.notNull(cb, "CriteriaBuilder must not be null");
-
-		List<jakarta.persistence.criteria.Order> orders = new ArrayList<>();
-
-		for (org.springframework.data.domain.Sort.Order order : sort) {
-			orders.add(toJpaOrder(order, from, cb));
-		}
-
-		return orders;
-	}
-
-	/**
-	 * Returns whether the given JPQL query contains a constructor expression.
-	 *
-	 * @param query must not be {@literal null} or empty.
-	 * @return whether the given JPQL query contains a constructor expression.
-	 * @since 1.10
-	 */
-	public static boolean hasConstructorExpression(String query) {
-
-		Assert.hasText(query, "Query must not be null or empty");
-
-		return CONSTRUCTOR_EXPRESSION.matcher(query).find();
-	}
-
-	/**
-	 * Returns the projection part of the query, i.e. everything between {@code select} and {@code from}.
-	 *
-	 * @param query must not be {@literal null} or empty.
-	 * @return the projection part of the query.
-	 * @since 1.10.2
-	 */
-	public static String getProjection(String query) {
-
-		Assert.hasText(query, "Query must not be null or empty");
-
-		Matcher matcher = PROJECTION_CLAUSE.matcher(query);
-		String projection = matcher.find() ? matcher.group(1) : "";
-		return projection.trim();
-	}
-
-	/**
-	 * Creates a criteria API {@link jakarta.persistence.criteria.Order} from the given {@link Order}.
-	 *
-	 * @param order the order to transform into a JPA {@link jakarta.persistence.criteria.Order}
-	 * @param from the {@link From} the {@link Order} expression is based on
-	 * @param cb the {@link CriteriaBuilder} to build the {@link jakarta.persistence.criteria.Order} with
-	 * @return Guaranteed to be not {@literal null}.
-	 */
-	@SuppressWarnings("unchecked")
-	private static jakarta.persistence.criteria.Order toJpaOrder(Order order, From<?, ?> from, CriteriaBuilder cb) {
-
-		PropertyPath property = PropertyPath.from(order.getProperty(), from.getJavaType());
-		Expression<?> expression = toExpressionRecursively(from, property);
-
-		if (order.isIgnoreCase() && String.class.equals(expression.getJavaType())) {
-			Expression<String> upper = cb.lower((Expression<String>) expression);
-			return order.isAscending() ? cb.asc(upper) : cb.desc(upper);
-		} else {
-			return order.isAscending() ? cb.asc(expression) : cb.desc(expression);
-		}
-	}
-
-	static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property) {
-		return toExpressionRecursively(from, property, false);
-	}
-
-	static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property, boolean isForSelection) {
-		return toExpressionRecursively(from, property, isForSelection, false);
-	}
-
-	/**
-	 * Creates an expression with proper inner and left joins by recursively navigating the path
-	 *
-	 * @param from the {@link From}
-	 * @param property the property path
-	 * @param isForSelection is the property navigated for the selection or ordering part of the query?
-	 * @param hasRequiredOuterJoin has a parent already required an outer join?
-	 * @param <T> the type of the expression
-	 * @return the expression
-	 */
-	@SuppressWarnings("unchecked")
-	static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property, boolean isForSelection,
-			boolean hasRequiredOuterJoin) {
-
-		String segment = property.getSegment();
-
-		boolean isLeafProperty = !property.hasNext();
-
-		boolean requiresOuterJoin = requiresOuterJoin(from, property, isForSelection, hasRequiredOuterJoin);
-
-		// if it does not require an outer join and is a leaf, simply get the segment
-		if (!requiresOuterJoin && isLeafProperty) {
-			return from.get(segment);
-		}
-
-		// get or create the join
-		JoinType joinType = requiresOuterJoin ? JoinType.LEFT : JoinType.INNER;
-		Join<?, ?> join = getOrCreateJoin(from, segment, joinType);
-
-		// if it's a leaf, return the join
-		if (isLeafProperty) {
-			return (Expression<T>) join;
-		}
-
-		PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null");
-
-		// recurse with the next property
-		return toExpressionRecursively(join, nextProperty, isForSelection, requiresOuterJoin);
-	}
-
-	/**
-	 * Checks if this attribute requires an outer join. This is the case e.g. if it hadn't already been fetched with an
-	 * inner join and if it's an optional association, and if previous paths has already required outer joins. It also
-	 * ensures outer joins are used even when Hibernate defaults to inner joins (HHH-12712 and HHH-12999).
-	 *
-	 * @param from the {@link From} to check for fetches.
-	 * @param property the property path
-	 * @param isForSelection is the property navigated for the selection or ordering part of the query? if true, we need
-	 *          to generate an explicit outer join in order to prevent Hibernate to use an inner join instead. see
-	 *          https://hibernate.atlassian.net/browse/HHH-12999
-	 * @param hasRequiredOuterJoin has a parent already required an outer join?
-	 * @return whether an outer join is to be used for integrating this attribute in a query.
-	 */
-	private static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property, boolean isForSelection,
-			boolean hasRequiredOuterJoin) {
-
-		String segment = property.getSegment();
-
-		// already inner joined so outer join is useless
-		if (isAlreadyInnerJoined(from, segment))
-			return false;
-
-		Bindable<?> propertyPathModel;
-		Bindable<?> model = from.getModel();
-
-		// required for EclipseLink: we try to avoid using from.get as EclipseLink produces an inner join
-		// regardless of which join operation is specified next
-		// see: https://bugs.eclipse.org/bugs/show_bug.cgi?id=413892
-		// still occurs as of 2.7
-		ManagedType<?> managedType = null;
-		if (model instanceof ManagedType) {
-			managedType = (ManagedType<?>) model;
-		} else if (model instanceof SingularAttribute
-				&& ((SingularAttribute<?, ?>) model).getType() instanceof ManagedType) {
-			managedType = (ManagedType<?>) ((SingularAttribute<?, ?>) model).getType();
-		}
-		if (managedType != null) {
-			propertyPathModel = (Bindable<?>) managedType.getAttribute(segment);
-		} else {
-			propertyPathModel = from.get(segment).getModel();
-		}
-
-		// is the attribute of Collection type?
-		boolean isPluralAttribute = model instanceof PluralAttribute;
-
-		boolean isLeafProperty = !property.hasNext();
-
-		if (propertyPathModel == null && isPluralAttribute) {
-			return true;
-		}
-
-		if (!(propertyPathModel instanceof Attribute)) {
-			return false;
-		}
-
-		Attribute<?, ?> attribute = (Attribute<?, ?>) propertyPathModel;
-
-		// not a persistent attribute type association (@OneToOne, @ManyToOne)
-		if (!ASSOCIATION_TYPES.containsKey(attribute.getPersistentAttributeType())) {
-			return false;
-		}
-
-		boolean isCollection = attribute.isCollection();
-		// if this path is an optional one to one attribute navigated from the not owning side we also need an
-		// explicit outer join to avoid https://hibernate.atlassian.net/browse/HHH-12712
-		// and https://github.com/eclipse-ee4j/jpa-api/issues/170
-		boolean isInverseOptionalOneToOne = PersistentAttributeType.ONE_TO_ONE == attribute.getPersistentAttributeType()
-				&& StringUtils.hasText(getAnnotationProperty(attribute, "mappedBy", ""));
-
-		if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) {
-			return false;
-		}
-
-		return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true);
-	}
-
-	@Nullable
-	private static <T> T getAnnotationProperty(Attribute<?, ?> attribute, String propertyName, T defaultValue) {
-
-		Class<? extends Annotation> associationAnnotation = ASSOCIATION_TYPES.get(attribute.getPersistentAttributeType());
-
-		if (associationAnnotation == null) {
-			return defaultValue;
-		}
-
-		Member member = attribute.getJavaMember();
-
-		if (!(member instanceof AnnotatedElement)) {
-			return defaultValue;
-		}
-
-		Annotation annotation = AnnotationUtils.getAnnotation((AnnotatedElement) member, associationAnnotation);
-		return annotation == null ? defaultValue : (T) AnnotationUtils.getValue(annotation, propertyName);
-	}
-
-	/**
-	 * Returns an existing join for the given attribute if one already exists or creates a new one if not.
-	 *
-	 * @param from the {@link From} to get the current joins from.
-	 * @param attribute the {@link Attribute} to look for in the current joins.
-	 * @param joinType the join type to create if none was found
-	 * @return will never be {@literal null}.
-	 */
-	private static Join<?, ?> getOrCreateJoin(From<?, ?> from, String attribute, JoinType joinType) {
-
-		for (Join<?, ?> join : from.getJoins()) {
-
-			if (join.getAttribute().getName().equals(attribute)) {
-				return join;
-			}
-		}
-		return from.join(attribute, joinType);
-	}
-
-	/**
-	 * Return whether the given {@link From} contains an inner join for the attribute with the given name.
-	 *
-	 * @param from the {@link From} to check for joins.
-	 * @param attribute the attribute name to check.
-	 * @return true if the attribute has already been inner joined
-	 */
-	private static boolean isAlreadyInnerJoined(From<?, ?> from, String attribute) {
-
-		for (Fetch<?, ?> fetch : from.getFetches()) {
-
-			if (fetch.getAttribute().getName().equals(attribute) //
-					&& fetch.getJoinType().equals(JoinType.INNER)) {
-				return true;
-			}
-		}
-
-		for (Join<?, ?> join : from.getJoins()) {
-
-			if (join.getAttribute().getName().equals(attribute) //
-					&& join.getJoinType().equals(JoinType.INNER)) {
-				return true;
-			}
-		}
-
-		return false;
-	}
-
-	/**
-	 * Check any given {@link JpaOrder#isUnsafe()} order for presence of at least one property offending the
-	 * {@link #PUNCTATION_PATTERN} and throw an {@link Exception} indicating potential unsafe order by expression.
-	 *
-	 * @param order
-	 */
-	static void checkSortExpression(Order order) {
-
-		if (order instanceof JpaOrder && ((JpaOrder) order).isUnsafe()) {
-			return;
-		}
-
-		if (PUNCTATION_PATTERN.matcher(order.getProperty()).find()) {
-			throw new InvalidDataAccessApiUsageException(String.format(UNSAFE_PROPERTY_REFERENCE, order));
-		}
-	}
+    public static final String COUNT_QUERY_STRING = "select count(%s) from %s x";
+    public static final String DELETE_ALL_QUERY_STRING = "delete from %s x";
+    public static final String DELETE_ALL_QUERY_BY_ID_STRING = "delete from %s x where %s in :ids";
+
+    // Used Regex/Unicode categories (see https://www.unicode.org/reports/tr18/#General_Category_Property):
+    // Z Separator
+    // Cc Control
+    // Cf Format
+    // Punct Punctuation
+    private static final String IDENTIFIER = "[._$[\\P{Z}&&\\P{Cc}&&\\P{Cf}&&\\P{Punct}]]+";
+    static final String COLON_NO_DOUBLE_COLON = "(?<![:\\\\]):";
+    static final String IDENTIFIER_GROUP = String.format("(%s)", IDENTIFIER);
+
+    private static final String COUNT_REPLACEMENT_TEMPLATE = "select count(%s) $5$6$7";
+    private static final String SIMPLE_COUNT_VALUE = "$2";
+    private static final String COMPLEX_COUNT_VALUE = "$3 $6";
+    private static final String COMPLEX_COUNT_LAST_VALUE = "$6";
+    private static final String ORDER_BY_PART = "(?iu)\\s+order\\s+by\\s+.*";
+
+    private static final Pattern ALIAS_MATCH;
+    private static final Pattern COUNT_MATCH;
+    private static final Pattern STARTS_WITH_PAREN = Pattern.compile("^\\s*\\(");
+    private static final Pattern PARENS_TO_REMOVE = Pattern.compile("(\\(.*\\bfrom\\b[^)]+\\))",
+            CASE_INSENSITIVE | DOTALL | MULTILINE);
+    private static final Pattern PROJECTION_CLAUSE = Pattern.compile("select\\s+(?:distinct\\s+)?(.+)\\s+from",
+            Pattern.CASE_INSENSITIVE);
+
+    private static final Pattern NO_DIGITS = Pattern.compile("\\D+");
+
+    private static final String JOIN = "join\\s+(fetch\\s+)?" + IDENTIFIER + "\\s+(as\\s+)?" + IDENTIFIER_GROUP;
+    private static final Pattern JOIN_PATTERN = Pattern.compile(JOIN, Pattern.CASE_INSENSITIVE);
+
+    private static final String EQUALS_CONDITION_STRING = "%s.%s = :%s";
+    private static final Pattern ORDER_BY = Pattern.compile("(order\\s+by\\s+)", CASE_INSENSITIVE);
+    private static final Pattern ORDER_BY_IN_WINDOW_OR_SUBSELECT = Pattern
+            .compile("\\([\\s\\S]*order\\s+by\\s[\\s\\S]*\\)", CASE_INSENSITIVE);
+
+    private static final Pattern NAMED_PARAMETER = Pattern.compile(COLON_NO_DOUBLE_COLON + IDENTIFIER + "|#" + IDENTIFIER,
+            CASE_INSENSITIVE);
+
+    private static final Pattern CONSTRUCTOR_EXPRESSION;
+
+    private static final Map<PersistentAttributeType, Class<? extends Annotation>> ASSOCIATION_TYPES;
+
+    private static final int QUERY_JOIN_ALIAS_GROUP_INDEX = 3;
+    private static final int VARIABLE_NAME_GROUP_INDEX = 4;
+    private static final int COMPLEX_COUNT_FIRST_INDEX = 3;
+
+    private static final Pattern PUNCTATION_PATTERN = Pattern.compile(".*((?![._])[\\p{Punct}|\\s])");
+    private static final Pattern FUNCTION_PATTERN;
+    private static final Pattern FIELD_ALIAS_PATTERN;
+
+    private static final String UNSAFE_PROPERTY_REFERENCE = "Sort expression '%s' must only contain property references or "
+            + "aliases used in the select clause; If you really want to use something other than that for sorting, please use "
+            + "JpaSort.unsafe(…)";
+
+    static {
+
+        StringBuilder builder = new StringBuilder();
+        builder.append("(?<=\\bfrom)"); // from as starting delimiter
+        builder.append("(?:\\s)+"); // at least one space separating
+        builder.append(IDENTIFIER_GROUP); // Entity name, can be qualified (any
+        builder.append("(?:\\sas)*"); // exclude possible "as" keyword
+        builder.append("(?:\\s)+"); // at least one space separating
+        builder.append("(?!(?:where|group\\s*by|order\\s*by))(\\w+)"); // the actual alias
+
+        ALIAS_MATCH = compile(builder.toString(), CASE_INSENSITIVE);
+
+        builder = new StringBuilder();
+        builder.append("\\s*");
+        builder.append("(select\\s+((distinct)?((?s).+?)?)\\s+)?(from\\s+");
+        builder.append(IDENTIFIER);
+        builder.append("(?:\\s+as)?\\s+)");
+        builder.append(IDENTIFIER_GROUP);
+        builder.append("(.*)");
+
+        COUNT_MATCH = compile(builder.toString(), CASE_INSENSITIVE | DOTALL);
+
+        Map<PersistentAttributeType, Class<? extends Annotation>> persistentAttributeTypes = new HashMap<>();
+        persistentAttributeTypes.put(ONE_TO_ONE, OneToOne.class);
+        persistentAttributeTypes.put(ONE_TO_MANY, null);
+        persistentAttributeTypes.put(MANY_TO_ONE, ManyToOne.class);
+        persistentAttributeTypes.put(MANY_TO_MANY, null);
+        persistentAttributeTypes.put(ELEMENT_COLLECTION, null);
+
+        ASSOCIATION_TYPES = Collections.unmodifiableMap(persistentAttributeTypes);
+
+        builder = new StringBuilder();
+        builder.append("select");
+        builder.append("\\s+"); // at least one space separating
+        builder.append("(.*\\s+)?"); // anything in between (e.g. distinct) at least one space separating
+        builder.append("new");
+        builder.append("\\s+"); // at least one space separating
+        builder.append(IDENTIFIER);
+        builder.append("\\s*"); // zero to unlimited space separating
+        builder.append("\\(");
+        builder.append(".*");
+        builder.append("\\)");
+
+        CONSTRUCTOR_EXPRESSION = compile(builder.toString(), CASE_INSENSITIVE + DOTALL);
+
+        builder = new StringBuilder();
+        // any function call including parameters within the brackets
+        builder.append("\\w+\\s*\\([\\w\\.,\\s'=:\\\\?]+\\)");
+        // the potential alias
+        builder.append("\\s+[as|AS]+\\s+(([\\w\\.]+))");
+
+        FUNCTION_PATTERN = compile(builder.toString());
+
+        builder = new StringBuilder();
+        builder.append("\\s+"); // at least one space
+        builder.append("[^\\s\\(\\)]+"); // No white char no bracket
+        builder.append("\\s+[as|AS]+\\s+(([\\w\\.]+))"); // the potential alias
+
+        FIELD_ALIAS_PATTERN = compile(builder.toString());
+
+    }
+
+    /**
+     * Private constructor to prevent instantiation.
+     */
+    private QueryUtils() {
+
+    }
+
+    /**
+     * Returns the query string to execute an exists query for the given id attributes.
+     *
+     * @param entityName            the name of the entity to create the query for, must not be {@literal null}.
+     * @param countQueryPlaceHolder the placeholder for the count clause, must not be {@literal null}.
+     * @param idAttributes          the id attributes for the entity, must not be {@literal null}.
+     */
+    public static String getExistsQueryString(String entityName, String countQueryPlaceHolder,
+                                              Iterable<String> idAttributes) {
+
+        String whereClause = Streamable.of(idAttributes).stream() //
+                .map(idAttribute -> String.format(EQUALS_CONDITION_STRING, "x", idAttribute, idAttribute)) //
+                .collect(Collectors.joining(" AND ", " WHERE ", ""));
+
+        return String.format(COUNT_QUERY_STRING, countQueryPlaceHolder, entityName) + whereClause;
+    }
+
+    /**
+     * Returns the query string for the given class name.
+     *
+     * @param template   must not be {@literal null}.
+     * @param entityName must not be {@literal null}.
+     * @return the template with placeholders replaced by the {@literal entityName}. Guaranteed to be not {@literal null}.
+     */
+    public static String getQueryString(String template, String entityName) {
+
+        Assert.hasText(entityName, "Entity name must not be null or empty");
+
+        return String.format(template, entityName);
+    }
+
+    /**
+     * Adds {@literal order by} clause to the JPQL query. Uses the first alias to bind the sorting property to.
+     *
+     * @param query the query string to which sorting is applied
+     * @param sort  the sort specification to apply.
+     * @return the modified query string.
+     */
+    public static String applySorting(String query, Sort sort) {
+        return applySorting(query, sort, detectAlias(query));
+    }
+
+    /**
+     * Adds {@literal order by} clause to the JPQL query.
+     *
+     * @param query the query string to which sorting is applied. Must not be {@literal null} or empty.
+     * @param sort  the sort specification to apply.
+     * @param alias the alias to be used in the order by clause. May be {@literal null} or empty.
+     * @return the modified query string.
+     */
+    public static String applySorting(String query, Sort sort, @Nullable String alias) {
+
+        Assert.hasText(query, "Query must not be null or empty");
+
+        if (sort.isUnsorted()) {
+            return query;
+        }
+
+        StringBuilder builder = new StringBuilder(query);
+
+        if (hasOrderByClause(query)) {
+            builder.append(", ");
+        } else {
+            builder.append(" order by ");
+        }
+
+        Set<String> joinAliases = getOuterJoinAliases(query);
+        Set<String> selectionAliases = getFunctionAliases(query);
+        selectionAliases.addAll(getFieldAliases(query));
+
+        for (Order order : sort) {
+            builder.append(getOrderClause(joinAliases, selectionAliases, alias, order)).append(", ");
+        }
+
+        builder.delete(builder.length() - 2, builder.length());
+
+        return builder.toString();
+    }
+
+    /**
+     * Returns {@code true} if the query has {@code order by} clause. The query has {@code order by} clause if there is an
+     * {@code order by} which is not part of window clause.
+     *
+     * @param query the analysed query string
+     * @return {@code true} if the query has {@code order by} clause, {@code false} otherwise
+     */
+    private static boolean hasOrderByClause(String query) {
+        return countOccurrences(ORDER_BY, query) > countOccurrences(ORDER_BY_IN_WINDOW_OR_SUBSELECT, query);
+    }
+
+    /**
+     * Counts the number of occurrences of the pattern in the string
+     *
+     * @param pattern regex with a group to match
+     * @param string  analysed string
+     * @return the number of occurrences of the pattern in the string
+     */
+    private static int countOccurrences(Pattern pattern, String string) {
+
+        Matcher matcher = pattern.matcher(string);
+
+        int occurrences = 0;
+        while (matcher.find()) {
+            occurrences++;
+        }
+        return occurrences;
+    }
+
+    /**
+     * Returns the order clause for the given {@link Order}. Will prefix the clause with the given alias if the referenced
+     * property refers to a join alias, i.e. starts with {@code $alias.}.
+     *
+     * @param joinAliases the join aliases of the original query. Must not be {@literal null}.
+     * @param alias       the alias for the root entity. May be {@literal null}.
+     * @param order       the order object to build the clause for. Must not be {@literal null}.
+     * @return a String containing an order clause. Guaranteed to be not {@literal null}.
+     */
+    private static String getOrderClause(Set<String> joinAliases, Set<String> selectionAlias, @Nullable String alias,
+                                         Order order) {
+
+        String property = order.getProperty();
+
+        checkSortExpression(order);
+
+        if (selectionAlias.contains(property)) {
+
+            return String.format("%s %s", //
+                    order.isIgnoreCase() ? String.format("lower(%s)", property) : property, //
+                    toJpaDirection(order));
+        }
+
+        boolean qualifyReference = !property.contains("("); // ( indicates a function
+
+        for (String joinAlias : joinAliases) {
+
+            if (property.startsWith(joinAlias.concat("."))) {
+
+                qualifyReference = false;
+                break;
+            }
+        }
+
+        String reference = qualifyReference && StringUtils.hasText(alias) ? String.format("%s.%s", alias, property)
+                : property;
+        String wrapped = order.isIgnoreCase() ? String.format("lower(%s)", reference) : reference;
+
+        return String.format("%s %s", wrapped, toJpaDirection(order));
+    }
+
+    /**
+     * Returns the aliases used for {@code left (outer) join}s.
+     *
+     * @param query a query string to extract the aliases of joins from. Must not be {@literal null}.
+     * @return a {@literal Set} of aliases used in the query. Guaranteed to be not {@literal null}.
+     */
+    static Set<String> getOuterJoinAliases(String query) {
+
+        Set<String> result = new HashSet<>();
+        Matcher matcher = JOIN_PATTERN.matcher(query);
+
+        while (matcher.find()) {
+
+            String alias = matcher.group(QUERY_JOIN_ALIAS_GROUP_INDEX);
+            if (StringUtils.hasText(alias)) {
+                result.add(alias);
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * Returns the aliases used for fields in the query.
+     *
+     * @param query a {@literal String} containing a query. Must not be {@literal null}.
+     * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}.
+     */
+    private static Set<String> getFieldAliases(String query) {
+
+        Set<String> result = new HashSet<>();
+        Matcher matcher = FIELD_ALIAS_PATTERN.matcher(query);
+
+        while (matcher.find()) {
+            String alias = matcher.group(1);
+
+            if (StringUtils.hasText(alias)) {
+                result.add(alias);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Returns the aliases used for aggregate functions like {@code SUM, COUNT, ...}.
+     *
+     * @param query a {@literal String} containing a query. Must not be {@literal null}.
+     * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}.
+     */
+    static Set<String> getFunctionAliases(String query) {
+
+        Set<String> result = new HashSet<>();
+        Matcher matcher = FUNCTION_PATTERN.matcher(query);
+
+        while (matcher.find()) {
+
+            String alias = matcher.group(1);
+
+            if (StringUtils.hasText(alias)) {
+                result.add(alias);
+            }
+        }
+
+        return result;
+    }
+
+    private static String toJpaDirection(Order order) {
+        return order.getDirection().name().toLowerCase(Locale.US);
+    }
+
+    /**
+     * Resolves the alias for the entity to be retrieved from the given JPA query.
+     *
+     * @param query must not be {@literal null}.
+     * @return Might return {@literal null}.
+     * @deprecated use {@link DeclaredQuery#getAlias()} instead.
+     */
+    @Nullable
+    @Deprecated
+    public static String detectAlias(String query) {
+
+        String alias = null;
+        Matcher matcher = ALIAS_MATCH.matcher(removeSubqueries(query));
+        while (matcher.find()) {
+            alias = matcher.group(2);
+        }
+        return alias;
+    }
+
+    /**
+     * Remove subqueries from the query, in order to identify the correct alias in order by clauses. If the entire query
+     * is surrounded by parenthesis, the outermost parenthesis are not removed.
+     *
+     * @param query
+     * @return query with all subqueries removed.
+     */
+    static String removeSubqueries(String query) {
+
+        if (!StringUtils.hasText(query)) {
+            return query;
+        }
+
+        List<Integer> opens = new ArrayList<>();
+        List<Integer> closes = new ArrayList<>();
+        List<Boolean> closeMatches = new ArrayList<>();
+
+        for (int i = 0; i < query.length(); i++) {
+
+            char c = query.charAt(i);
+            if (c == '(') {
+                opens.add(i);
+            } else if (c == ')') {
+                closes.add(i);
+                closeMatches.add(Boolean.FALSE);
+            }
+        }
+
+        StringBuilder sb = new StringBuilder(query);
+        boolean startsWithParen = STARTS_WITH_PAREN.matcher(query).find();
+        for (int i = opens.size() - 1; i >= (startsWithParen ? 1 : 0); i--) {
+
+            Integer open = opens.get(i);
+            Integer close = findClose(open, closes, closeMatches) + 1;
+
+            if (close > open) {
+
+                String subquery = sb.substring(open, close);
+                Matcher matcher = PARENS_TO_REMOVE.matcher(subquery);
+                if (matcher.find()) {
+                    sb.replace(open, close, new String(new char[close - open]).replace('\0', ' '));
+                }
+            }
+        }
+
+        return sb.toString();
+    }
+
+    private static Integer findClose(final Integer open, final List<Integer> closes, final List<Boolean> closeMatches) {
+
+        for (int i = 0; i < closes.size(); i++) {
+
+            int close = closes.get(i);
+            if (close > open && !closeMatches.get(i)) {
+                closeMatches.set(i, Boolean.TRUE);
+                return close;
+            }
+        }
+
+        return -1;
+    }
+
+    /**
+     * Creates a where-clause referencing the given entities and appends it to the given query string. Binds the given
+     * entities to the query.
+     *
+     * @param <T>           type of the entities.
+     * @param queryString   must not be {@literal null}.
+     * @param entities      must not be {@literal null}.
+     * @param entityManager must not be {@literal null}.
+     * @return Guaranteed to be not {@literal null}.
+     */
+
+    public static <T> Query applyAndBind(String queryString, Iterable<T> entities, EntityManager entityManager) {
+
+        Assert.notNull(queryString, "Querystring must not be null");
+        Assert.notNull(entities, "Iterable of entities must not be null");
+        Assert.notNull(entityManager, "EntityManager must not be null");
+
+        Iterator<T> iterator = entities.iterator();
+
+        if (!iterator.hasNext()) {
+            return entityManager.createQuery(queryString);
+        }
+
+        String alias = detectAlias(queryString);
+        StringBuilder builder = new StringBuilder(queryString);
+        builder.append(" where");
+
+        int i = 0;
+
+        while (iterator.hasNext()) {
+
+            iterator.next();
+
+            builder.append(String.format(" %s = ?%d", alias, ++i));
+
+            if (iterator.hasNext()) {
+                builder.append(" or");
+            }
+        }
+
+        Query query = entityManager.createQuery(builder.toString());
+
+        iterator = entities.iterator();
+        i = 0;
+
+        while (iterator.hasNext()) {
+            query.setParameter(++i, iterator.next());
+        }
+
+        return query;
+    }
+
+    /**
+     * Creates a count projected query from the given original query.
+     *
+     * @param originalQuery must not be {@literal null} or empty.
+     * @return Guaranteed to be not {@literal null}.
+     * @deprecated use {@link DeclaredQuery#deriveCountQuery(String, String)} instead.
+     */
+    @Deprecated
+    public static String createCountQueryFor(String originalQuery) {
+        return createCountQueryFor(originalQuery, null);
+    }
+
+    /**
+     * Creates a count projected query from the given original query.
+     *
+     * @param originalQuery   must not be {@literal null}.
+     * @param countProjection may be {@literal null}.
+     * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}.
+     * @since 1.6
+     * @deprecated use {@link DeclaredQuery#deriveCountQuery(String, String)} instead.
+     */
+    @Deprecated
+    public static String createCountQueryFor(String originalQuery, @Nullable String countProjection) {
+
+        Assert.hasText(originalQuery, "OriginalQuery must not be null or empty");
+
+        Matcher matcher = COUNT_MATCH.matcher(originalQuery);
+        String countQuery;
+
+        if (countProjection == null) {
+
+            String variable = matcher.matches() ? matcher.group(VARIABLE_NAME_GROUP_INDEX) : null;
+            boolean useVariable = StringUtils.hasText(variable) //
+                    && !variable.startsWith("new") // select [new com.example.User...
+                    && !variable.startsWith(" new") // select distinct[ new com.example.User...
+                    && !variable.startsWith("count(") // select [count(...
+                    && !variable.contains(",");
+
+            String complexCountValue = matcher.matches() && StringUtils.hasText(matcher.group(COMPLEX_COUNT_FIRST_INDEX))
+                    ? COMPLEX_COUNT_VALUE
+                    : COMPLEX_COUNT_LAST_VALUE;
+
+            String replacement = useVariable ? SIMPLE_COUNT_VALUE : complexCountValue;
+
+            String alias = QueryUtils.detectAlias(originalQuery);
+            if ("*".equals(variable) && alias != null) {
+                replacement = alias;
+            }
+
+            countQuery = matcher.replaceFirst(String.format(COUNT_REPLACEMENT_TEMPLATE, replacement));
+        } else {
+            countQuery = matcher.replaceFirst(String.format(COUNT_REPLACEMENT_TEMPLATE, countProjection));
+        }
+
+        return countQuery.replaceFirst(ORDER_BY_PART, "");
+    }
+
+    /**
+     * Returns whether the given {@link Query} contains named parameters.
+     *
+     * @param query Must not be {@literal null}.
+     * @return whether the given {@link Query} contains named parameters.
+     */
+    public static boolean hasNamedParameter(Query query) {
+
+        Assert.notNull(query, "Query must not be null");
+
+        for (Parameter<?> parameter : query.getParameters()) {
+
+            String name = parameter.getName();
+
+            // Hibernate 3 specific hack as it returns the index as String for the name.
+            if (name != null && NO_DIGITS.matcher(name).find()) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Returns whether the given query contains named parameters.
+     *
+     * @param query can be {@literal null} or empty.
+     * @return whether the given query contains named parameters.
+     */
+    @Deprecated
+    static boolean hasNamedParameter(@Nullable String query) {
+        return StringUtils.hasText(query) && NAMED_PARAMETER.matcher(query).find();
+    }
+
+    /**
+     * Turns the given {@link Sort} into {@link jakarta.persistence.criteria.Order}s.
+     *
+     * @param sort the {@link Sort} instance to be transformed into JPA {@link jakarta.persistence.criteria.Order}s.
+     * @param from must not be {@literal null}.
+     * @param cb   must not be {@literal null}.
+     * @return a {@link List} of {@link jakarta.persistence.criteria.Order}s.
+     */
+    public static List<jakarta.persistence.criteria.Order> toOrders(Sort sort, From<?, ?> from, CriteriaBuilder cb, boolean isDistinct) {
+
+        if (sort.isUnsorted()) {
+            return Collections.emptyList();
+        }
+
+        Assert.notNull(from, "From must not be null");
+        Assert.notNull(cb, "CriteriaBuilder must not be null");
+
+        List<jakarta.persistence.criteria.Order> orders = new ArrayList<>();
+
+        for (org.springframework.data.domain.Sort.Order order : sort) {
+            orders.add(toJpaOrder(order, from, cb, isDistinct));
+        }
+
+        return orders;
+    }
+
+    /**
+     * Returns whether the given JPQL query contains a constructor expression.
+     *
+     * @param query must not be {@literal null} or empty.
+     * @return whether the given JPQL query contains a constructor expression.
+     * @since 1.10
+     */
+    public static boolean hasConstructorExpression(String query) {
+
+        Assert.hasText(query, "Query must not be null or empty");
+
+        return CONSTRUCTOR_EXPRESSION.matcher(query).find();
+    }
+
+    /**
+     * Returns the projection part of the query, i.e. everything between {@code select} and {@code from}.
+     *
+     * @param query must not be {@literal null} or empty.
+     * @return the projection part of the query.
+     * @since 1.10.2
+     */
+    public static String getProjection(String query) {
+
+        Assert.hasText(query, "Query must not be null or empty");
+
+        Matcher matcher = PROJECTION_CLAUSE.matcher(query);
+        String projection = matcher.find() ? matcher.group(1) : "";
+        return projection.trim();
+    }
+
+    /**
+     * Creates a criteria API {@link jakarta.persistence.criteria.Order} from the given {@link Order}.
+     *
+     * @param order the order to transform into a JPA {@link jakarta.persistence.criteria.Order}
+     * @param from  the {@link From} the {@link Order} expression is based on
+     * @param cb    the {@link CriteriaBuilder} to build the {@link jakarta.persistence.criteria.Order} with
+     * @return Guaranteed to be not {@literal null}.
+     */
+    @SuppressWarnings("unchecked")
+    private static jakarta.persistence.criteria.Order toJpaOrder(Order order, From<?, ?> from, CriteriaBuilder cb, boolean isDistinct) {
+
+        PropertyPath property = PropertyPath.from(order.getProperty(), from.getJavaType());
+        Expression<?> expression = toExpressionRecursively(from, property, isDistinct);
+
+        if (order.isIgnoreCase() && String.class.equals(expression.getJavaType())) {
+            Expression<String> upper = cb.lower((Expression<String>) expression);
+            return order.isAscending() ? cb.asc(upper) : cb.desc(upper);
+        } else {
+            return order.isAscending() ? cb.asc(expression) : cb.desc(expression);
+        }
+    }
+
+    static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property, boolean isDistinct) {
+        return toExpressionRecursively(from, property, false, isDistinct);
+    }
+
+    static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property, boolean isForSelection, boolean isDistinct) {
+        return toExpressionRecursively(from, property, isForSelection, false, isDistinct);
+    }
+
+    /**
+     * Creates an expression with proper inner and left joins by recursively navigating the path
+     *
+     * @param from                 the {@link From}
+     * @param property             the property path
+     * @param isForSelection       is the property navigated for the selection or ordering part of the query?
+     * @param hasRequiredOuterJoin has a parent already required an outer join?
+     * @param <T>                  the type of the expression
+     * @return the expression
+     */
+    @SuppressWarnings("unchecked")
+    static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property, boolean isForSelection,
+                                                     boolean hasRequiredOuterJoin, boolean isDistinct) {
+
+        String segment = property.getSegment();
+
+        boolean isLeafProperty = !property.hasNext();
+
+        boolean requiresOuterJoin = requiresOuterJoin(from, property, isForSelection, hasRequiredOuterJoin);
+
+        // if it does not require an outer join and is a leaf, simply get the segment
+        if (!requiresOuterJoin && isLeafProperty) {
+            return from.get(segment);
+        }
+
+        // get or create the join
+        JoinType joinType = requiresOuterJoin ? JoinType.LEFT : JoinType.INNER;
+        Join<?, ?> join = getOrCreateJoin(from, segment, joinType, isDistinct);
+
+        // if it's a leaf, return the join
+        if (isLeafProperty) {
+            return (Expression<T>) join;
+        }
+
+        PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null");
+
+        // recurse with the next property
+        return toExpressionRecursively(join, nextProperty, isForSelection, requiresOuterJoin, isDistinct);
+    }
+
+    /**
+     * Checks if this attribute requires an outer join. This is the case e.g. if it hadn't already been fetched with an
+     * inner join and if it's an optional association, and if previous paths has already required outer joins. It also
+     * ensures outer joins are used even when Hibernate defaults to inner joins (HHH-12712 and HHH-12999).
+     *
+     * @param from                 the {@link From} to check for fetches.
+     * @param property             the property path
+     * @param isForSelection       is the property navigated for the selection or ordering part of the query? if true, we need
+     *                             to generate an explicit outer join in order to prevent Hibernate to use an inner join instead. see
+     *                             https://hibernate.atlassian.net/browse/HHH-12999
+     * @param hasRequiredOuterJoin has a parent already required an outer join?
+     * @return whether an outer join is to be used for integrating this attribute in a query.
+     */
+    private static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property, boolean isForSelection,
+                                             boolean hasRequiredOuterJoin) {
+
+        String segment = property.getSegment();
+
+        // already inner joined so outer join is useless
+        if (isAlreadyInnerJoined(from, segment))
+            return false;
+
+        Bindable<?> propertyPathModel;
+        Bindable<?> model = from.getModel();
+
+        // required for EclipseLink: we try to avoid using from.get as EclipseLink produces an inner join
+        // regardless of which join operation is specified next
+        // see: https://bugs.eclipse.org/bugs/show_bug.cgi?id=413892
+        // still occurs as of 2.7
+        ManagedType<?> managedType = null;
+        if (model instanceof ManagedType) {
+            managedType = (ManagedType<?>) model;
+        } else if (model instanceof SingularAttribute
+                && ((SingularAttribute<?, ?>) model).getType() instanceof ManagedType) {
+            managedType = (ManagedType<?>) ((SingularAttribute<?, ?>) model).getType();
+        }
+        if (managedType != null) {
+            propertyPathModel = (Bindable<?>) managedType.getAttribute(segment);
+        } else {
+            propertyPathModel = from.get(segment).getModel();
+        }
+
+        // is the attribute of Collection type?
+        boolean isPluralAttribute = model instanceof PluralAttribute;
+
+        boolean isLeafProperty = !property.hasNext();
+
+        if (propertyPathModel == null && isPluralAttribute) {
+            return true;
+        }
+
+        if (!(propertyPathModel instanceof Attribute)) {
+            return false;
+        }
+
+        Attribute<?, ?> attribute = (Attribute<?, ?>) propertyPathModel;
+
+        // not a persistent attribute type association (@OneToOne, @ManyToOne)
+        if (!ASSOCIATION_TYPES.containsKey(attribute.getPersistentAttributeType())) {
+            return false;
+        }
+
+        boolean isCollection = attribute.isCollection();
+        // if this path is an optional one to one attribute navigated from the not owning side we also need an
+        // explicit outer join to avoid https://hibernate.atlassian.net/browse/HHH-12712
+        // and https://github.com/eclipse-ee4j/jpa-api/issues/170
+        boolean isInverseOptionalOneToOne = PersistentAttributeType.ONE_TO_ONE == attribute.getPersistentAttributeType()
+                && StringUtils.hasText(getAnnotationProperty(attribute, "mappedBy", ""));
+
+        if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) {
+            return false;
+        }
+
+        return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true);
+    }
+
+    @Nullable
+    private static <T> T getAnnotationProperty(Attribute<?, ?> attribute, String propertyName, T defaultValue) {
+
+        Class<? extends Annotation> associationAnnotation = ASSOCIATION_TYPES.get(attribute.getPersistentAttributeType());
+
+        if (associationAnnotation == null) {
+            return defaultValue;
+        }
+
+        Member member = attribute.getJavaMember();
+
+        if (!(member instanceof AnnotatedElement)) {
+            return defaultValue;
+        }
+
+        Annotation annotation = AnnotationUtils.getAnnotation((AnnotatedElement) member, associationAnnotation);
+        return annotation == null ? defaultValue : (T) AnnotationUtils.getValue(annotation, propertyName);
+    }
+
+    /**
+     * Returns an existing join for the given attribute if one already exists or creates a new one if not.
+     *
+     * @param from      the {@link From} to get the current joins from.
+     * @param attribute the {@link Attribute} to look for in the current joins.
+     * @param joinType  the join type to create if none was found
+     * @return will never be {@literal null}.
+     */
+    private static Join<?, ?> getOrCreateJoin(From<?, ?> from, String attribute, JoinType joinType, boolean distinct) {
+
+        for (Fetch<?, ?> fetch : from.getFetches()) {
+            if (fetch.getAttribute().getName().equals(attribute))
+                return (Join<?, ?>) fetch;
+        }
+
+        if(!distinct)
+        {
+            for (Join<?, ?> join : from.getJoins()) {
+                if (join.getAttribute().getName().equals(attribute)) {
+                    return join;
+                }
+            }
+        }
+
+        return distinct ?
+                (Join<?, ?>) from.fetch(attribute, joinType) : from.join(attribute, joinType);
+    }
+
+    /**
+     * Return whether the given {@link From} contains an inner join for the attribute with the given name.
+     *
+     * @param from      the {@link From} to check for joins.
+     * @param attribute the attribute name to check.
+     * @return true if the attribute has already been inner joined
+     */
+    private static boolean isAlreadyInnerJoined(From<?, ?> from, String attribute) {
+
+        for (Fetch<?, ?> fetch : from.getFetches()) {
+
+            if (fetch.getAttribute().getName().equals(attribute) //
+                    && fetch.getJoinType().equals(JoinType.INNER)) {
+                return true;
+            }
+        }
+
+        for (Join<?, ?> join : from.getJoins()) {
+
+            if (join.getAttribute().getName().equals(attribute) //
+                    && join.getJoinType().equals(JoinType.INNER)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Check any given {@link JpaOrder#isUnsafe()} order for presence of at least one property offending the
+     * {@link #PUNCTATION_PATTERN} and throw an {@link Exception} indicating potential unsafe order by expression.
+     *
+     * @param order
+     */
+    static void checkSortExpression(Order order) {
+
+        if (order instanceof JpaOrder && ((JpaOrder) order).isUnsafe()) {
+            return;
+        }
+
+        if (PUNCTATION_PATTERN.matcher(order.getProperty()).find()) {
+            throw new InvalidDataAccessApiUsageException(String.format(UNSAFE_PROPERTY_REFERENCE, order));
+        }
+    }
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java
index 0927afc2..c1bdf2f9 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java
@@ -742,7 +742,7 @@ public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T
 		query.select(root);
 
 		if (sort.isSorted()) {
-			query.orderBy(toOrders(sort, root, builder));
+			query.orderBy(toOrders(sort, root, builder, query.isDistinct()));
 		}
 
 		return applyRepositoryMethodMetadata(em.createQuery(query));

@william00179
Copy link
Author

Hi @gregturn, just checking in to see if you've had an opportunity to have a look at this reproducer I provided?

@william00179
Copy link
Author

Hi @gregturn, following up to see if you've had a moment to check out my reproducer for this issue?

https://github.com/william00179/spring-data-reproducer-2756

The fix (seems) to be quite simple but I've not submitted a PR as I'm not certain on the most elegant way to make this change.

@dapriett
Copy link

Hi, running into this as well. Are there any known workarounds for this issue?

@william00179
Copy link
Author

Hi @dapriett,

Unfortunately the only workaround I've found thus far is to make the patches above to spring data jpa and used a forked version of the library.

@gregturn
Copy link
Contributor

gregturn commented Jun 1, 2023

I have looked at some parts of this reproducer. I tried tinkering with either .select or .multiselect, and saw no changes. I'm not sure if adjusting the Specification's DISTINCT settings is by design or a lucky side effect, but altering the SELECT's projection doesn't seem adjustable.

Postgres docs clearly show that adding an ORDER BY and a DISTINCT demands that any ordering columns be included in the SELECT clause, which is what is causing the final query to blow up.

As for the forked version of QueryUtils, I can't read amidst that giant patch file what was really changed to assess it. To be clear, QueryUtils is a beast and we are trying to minimize any/all future changes to that class. That's the reason we introduced an HQL/JPQL query parser for non-native queries and have that as our main solution for @Query-based solution.

Which makes me wonder how easy it would be to use @Query on your particular scenario (requires Spring Boot 3.1+).

@william00179
Copy link
Author

william00179 commented Jun 1, 2023

Hi Greg,

Unfortunately it's a complicated dynamic query, if I could have done it with @query I certainly would have.

Basically, the only change required to make it work in Query Utils is this

private static Join<?, ?> getOrCreateJoin(From<?, ?> from, String attribute, JoinType joinType, boolean distinct) {

        for (Fetch<?, ?> fetch : from.getFetches()) {
            if (fetch.getAttribute().getName().equals(attribute))
                return (Join<?, ?>) fetch;
        }

        if (!distinct) {
            for (Join<?, ?> join : from.getJoins()) {

                if (join.getAttribute().getName().equals(attribute)) {
                    return join;
                }
            }
        }

        return distinct ?
                (Join<?, ?>) from.fetch(attribute, joinType) : from.join(attribute, joinType);
    }

The other changes are all just allowing that isDistinct param to be passed around up to where we have access to the query variable and can get query.isDistinct()

With this change made, all the tests pass however there are a few method signatures that need to be updated to pass isDistinct through to this method.

All of our more complicated or dynamic queries are done via a Specification and we are currently having this issue in 3.1 but with this small patch its working well.

@Mom0aut
Copy link

Mom0aut commented Jun 16, 2023

Hey @gregturn, we got the same issue any chance this will be fixed in some future release ?

@Mom0aut
Copy link

Mom0aut commented Jul 11, 2023

We updated to Spring-Boot 3.1.1 and the order by expression is now generated correctly only for the joined object field ID.
Any other properties from an joined object still throws an error.

@phuongnq1995
Copy link

I'm really interested and looking forward to this feature. I would like to contribute the generic version of getOrCreateJoin.

public static <T, R> Join<T, R> getOrCreateJoin(Root<T> from, SingularAttribute<T, R> attribute, JoinType joinType, boolean distinct) {
    for (Fetch<?, ?> fetch : from.getFetches()) {
      if (fetch.getAttribute().getName().equals(attribute.getName())) {
        return (Join<T, R>) fetch;
      }
    }
    for (Join<T, ?> join : from.getJoins()) {
      if (join.getAttribute().getName().equals(attribute.getName())) {
        return (Join<T, R>) join;
      }
    }
    return distinct ?
        (Join<T, R>) from.fetch(attribute, joinType) : from.join(attribute, joinType);
  }

@goetzingert
Copy link

goetzingert commented Jan 10, 2024

Same issue for me. Order By on an ManyToOne Entity property that is also fetched in the Specification leads to above error.

Main issue is like mentioned above, that fetched relations are not being used and an other join is created

@Vladimir-Buttn
Copy link

+1 here

@christophstrobl
Copy link
Member

Thank you all for sharing additional information. Looking at the already provided reproducer from @william00179 it seems that switching from join to fetch in the Specification passed into the find method does pass the test without any modifications of QueryUtils that would potentially have implications on other vendors.
@goetzingert could you please provide a (failing) sample for the fetch scenario described that would be solved by chaning getOrCreateJoin to also look at getFetches().

@fabioformosa
Copy link

Any recent updates on this? It's still present in the latest version.

@william00179
Copy link
Author

This issue doesn't seem to be happening in Spring Boot 3.4.2 / Hibernate 6.4.4.

@christophstrobl I would think switching from join to fetch in all cases would lead to a lot more columns selected and could negatively affect performance?

I think it may still be worthwhile to implement the check to see if there is already a fetch or join to reuse as it will remove unnecessary joins that are currently occurring.

@fabioformosa
Copy link

fabioformosa commented Apr 9, 2024

This issue doesn't seem to be happening in Spring Boot 3.4.2 / Hibernate 6.4.4.

Did you mean Spring Boot 3.2.4 ?

We're facing the double join issue with Spring Boot 3.2.2

@william00179
Copy link
Author

Yes sorry I do mean 3.2.4, I transposed the numbers.

While we no longer get the error in this version, we do still see the same table being joined twice in some cases where it has been fetched by an entity graph and then also joined in a specification.

If the fetches were also considered in getOrCreateJoin it would remove this issue.

@tiennguyen0494
Copy link

tiennguyen0494 commented Jun 6, 2024

Hi @william00179
I have fixed my case by overriding the query before the Hibernate executes it
Here is my solution:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.hibernate.cfg.AvailableSettings;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;

@configuration
public class HibernateConfig {
@bean
public HibernatePropertiesCustomizer hibernateCustomizer() {
return (properties) -> properties.put(AvailableSettings.STATEMENT_INSPECTOR, new NytStatementInspector());
}
}


import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.hibernate.resource.jdbc.spi.StatementInspector;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

@component
@ConditionalOnProperty(prefix = "athena", name = "statement_inspector", havingValue = "true", matchIfMissing = true)
public class NytStatementInspector implements StatementInspector {

@Override
public String inspect(String sql) {
    // Regular expression to match SELECT and ORDER BY clauses
    String selectPattern = "\\bSELECT\\b(.*?)\\bFROM\\b";
    String orderByPattern = "\\bORDER\\s+BY\\b(.*?)$";

    Pattern selectRegex = Pattern.compile(selectPattern, Pattern.CASE_INSENSITIVE);
    Pattern orderByRegex = Pattern.compile(orderByPattern, Pattern.CASE_INSENSITIVE);

    Matcher selectMatcher = selectRegex.matcher(sql);
    Matcher orderByMatcher = orderByRegex.matcher(sql);

    // Check if both SELECT and ORDER BY clauses are present in the SQL
    if (selectMatcher.find() && orderByMatcher.find()) {
        // Extract column names from SELECT and ORDER BY clauses
        String selectColumns = selectMatcher.group(1).trim();
        String orderByColumns = orderByMatcher.group(1).trim();

        // Split the column names
        String[] selectColumnArray = selectColumns.split(",");
        String[] tmpOrderByColumnArray = orderByColumns.split(",");
        List<String> orderByColumnArray = new ArrayList<String>();

        for (String orderByItem : tmpOrderByColumnArray) {
            // The first space is a column.
            // For example: saktype3_.tittel asc limit ?
            var column = orderByItem.trim().split(" ")[0];
            orderByColumnArray.add(column);
        }

        List<String> columnsWillBeAppened = new ArrayList<String>();
        // Check if each column in ORDER BY is present in SELECT, if not, append it to
        // SELECT
        for (String orderByColumn : orderByColumnArray) {
            boolean found = false;
            for (String selectColumn : selectColumnArray) {
                if (selectColumn.trim().equalsIgnoreCase(orderByColumn.trim())) {
                    found = true;
                    break;
                }
            }
            if (!found) {
                // Append the column to SELECT
                columnsWillBeAppened.add(orderByColumn.trim());
            }
        }

        String joinedColumns = String.join(", ", columnsWillBeAppened);
        if (joinedColumns.endsWith(", ")) {
            joinedColumns = new StringBuffer(joinedColumns).substring(0, joinedColumns.length() - 2).toString();
        }

        int index = sql.indexOf("from");
        sql = new StringBuilder(sql).insert(index - 1, ", " + joinedColumns).toString();
        System.out.println("New SQL: " + sql);
    }

    return sql;
}

}

Thank you!

@ben-ganley
Copy link

This issue has occurred again with an upgrade to Spring Boot 3.3.4.

Upon investigation its caused by the version bump of Hibernate from 6.5.2 to 6.5.3.

When the query is generated in 6.5.2 the table is only joined one time, the joined table columns are selected and also used in the order by.

When the query is generated in 6.5.3 the table is joined once and used in the order by, and then joined again and only used in the select, leading to the exception.

This is happening independently of the Spring Data version.

This is also occurring in the latest builds 6.6.0.Final and 6.6.1.Final.

@jpmacunha
Copy link

This issue has occurred again with an upgrade to Spring Boot 3.3.4.

Upon investigation its caused by the version bump of Hibernate from 6.5.2 to 6.5.3.

When the query is generated in 6.5.2 the table is only joined one time, the joined table columns are selected and also used in the order by.

When the query is generated in 6.5.3 the table is joined once and used in the order by, and then joined again and only used in the select, leading to the exception.

This is happening independently of the Spring Data version.

This is also occurring in the latest builds 6.6.0.Final and 6.6.1.Final.

I'm facing the exact same problem after upgrading to spring boot 3.3.4

@william00179
Copy link
Author

This is still occurring in Spring Boot 3.4.0 and Hibernate 6.6.3. For this release and all previous affected releases, I've been having to use a forked and patched version of this lib.

Is there anything else I can do to help get this fix merged? I'm happy to provide a PR in addition to the reproducer I have provided previously.

If you have a different suggestion about how we might be able to fix this, please let me know and I'd be happy to implement it and provide a PR.

@jpmacunha
Copy link

This is still occurring in Spring Boot 3.4.0 and Hibernate 6.6.3. For this release and all previous affected releases, I've been having to use a forked and patched version of this lib.

Is there anything else I can do to help get this fix merged? I'm happy to provide a PR in addition to the reproducer I have provided previously.

If you have a different suggestion about how we might be able to fix this, please let me know and I'd be happy to implement it and provide a PR.

@william00179 would be awesome if you could provide the reproducer here:
https://hibernate.atlassian.net/browse/HHH-18672

@mp911de mp911de assigned mp911de and unassigned christophstrobl Jan 29, 2025
@mp911de mp911de added type: enhancement A general enhancement status: invalid An issue that we don't feel is valid and removed status: feedback-provided Feedback has been provided type: enhancement A general enhancement labels Jan 29, 2025
@mp911de
Copy link
Member

mp911de commented Jan 29, 2025

Coming back to the issue here, the reproducer uses Root.join(…) instead of an advised Root.fetch(…). SQL-wise, both variants materialize in the same JOIN clause, however select projections are different.

Zooming out, Sort with CriteriaQuery is a convenience feature allowing you to specify order by clauses without all the hassle of going through JPA's CriteriaBuilder API. That being said, there are multiple ways to achieve sorting on collection elements.

Switching the specification from join to fetch happens to make the sort work without affecting the resulting SQL statement.

Introducing the suggested changes to getOrCreateJoin would render a second JOIN, see the following SQL statements:

Additional Root.fetch(…) in QueryUtils:

select distinct u1_0.id,la2_0.user_id,la2_0.id,la2_0.attempt_at,la2_0.risk 
from users u1_0 
join login_attempts la1_0 on u1_0.id=la1_0.user_id 
join login_attempts la2_0 on u1_0.id=la2_0.user_id 
where la1_0.risk=? order by la2_0.attempt_at

Using Root.fetch(…) in the specification

select distinct u1_0.id,la1_0.user_id,la1_0.id,la1_0.attempt_at,la1_0.risk 
from users u1_0 
join login_attempts la1_0 on u1_0.id=la1_0.user_id 
where la1_0.risk=? order by la1_0.attempt_at

That being said, please use Root.fetch(…) in your specification when using DISTINCT queries along with sorting on these properties. If you find a case where our current handling isn't sufficient, then we're happy to discuss this over a new ticket and reproducer.

@mp911de mp911de closed this as not planned Won't fix, can't repro, duplicate, stale Jan 29, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: invalid An issue that we don't feel is valid
Projects
None yet
Development

No branches or pull requests