JpaQueryTransformerSupport.java

package org.springframework.data.jpa.repository.query;

import static org.springframework.data.jpa.repository.query.QueryTokens.*;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

import org.springframework.dao.InvalidDataAccessApiUsageException;

import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.NullHandling;
import org.springframework.data.jpa.domain.JpaSort;
import org.springframework.util.ObjectUtils;

/**
 * Transformational operations needed to support either {@link HqlSortedQueryTransformer} or
 * {@link JpqlSortedQueryTransformer}.
 *
 * @author Greg Turnquist
 * @author Donghun Shin
 * @since 3.1
 */
class JpaQueryTransformerSupport {

	private static final Pattern PUNCTUATION_PATTERN = Pattern.compile(".*((?![._])[\\p{Punct}|\\s])");

	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(���)";

	private final Set<String> projectionAliases = new HashSet<>();

	/**
	 * Register an {@literal alias} so it can later be evaluated when applying {@link Sort}s.
	 *
	 * @param token
	 */
	void registerAlias(String token) {
		projectionAliases.add(token);
	}

	void registerAlias(QueryToken token) {
		projectionAliases.add(token.value());
	}

	/**
	 * Using the primary {@literal FROM} clause's alias and a {@link Sort}, construct all the {@literal ORDER BY}
	 * arguments.
	 *
	 * @param primaryFromAlias
	 * @param sort
	 * @return
	 */
	List<QueryToken> orderBy(@Nullable String primaryFromAlias, Sort sort) {

		List<QueryToken> tokens = new ArrayList<>();

		sort.forEach(order -> {

			checkSortExpression(order);

			StringBuilder builder = new StringBuilder();

			if (order.isIgnoreCase()) {
				builder.append(TOKEN_LOWER_FUNC.value());
			}

			builder.append(generateOrderByArgument(primaryFromAlias, order));

			if (order.isIgnoreCase()) {
				builder.append(TOKEN_CLOSE_PAREN);
			}
			builder.append(" ");

			builder.append(order.isDescending() ? TOKEN_DESC : TOKEN_ASC);

			if(order.getNullHandling() == NullHandling.NULLS_FIRST) {
				builder.append(" NULLS FIRST");
			} else if (order.getNullHandling() == NullHandling.NULLS_LAST) {
				builder.append(" NULLS LAST");
			}

			if (!tokens.isEmpty()) {
				tokens.add(TOKEN_COMMA);
			}

			tokens.add(QueryTokens.token(builder.toString()));
		});

		return tokens;
	}

	/**
	 * Check any given {@link JpaSort.JpaOrder#isUnsafe()} order for presence of at least one property offending the
	 * {@link #PUNCTUATION_PATTERN} and throw an {@link Exception} indicating potential unsafe order by expression.
	 *
	 * @param order
	 */
	private void checkSortExpression(Sort.Order order) {

		if (order instanceof JpaSort.JpaOrder jpaOrder && jpaOrder.isUnsafe()) {
			return;
		}

		if (PUNCTUATION_PATTERN.matcher(order.getProperty()).find()) {
			throw new InvalidDataAccessApiUsageException(String.format(UNSAFE_PROPERTY_REFERENCE, order));
		}
	}

	/**
	 * Using the {@code primaryFromAlias} and the {@link org.springframework.data.domain.Sort.Order}, construct a suitable
	 * argument to be added to an {@literal ORDER BY} expression.
	 *
	 * @param primaryFromAlias
	 * @param order
	 * @return
	 */
	private String generateOrderByArgument(@Nullable String primaryFromAlias, Sort.Order order) {

		if (shouldPrefixWithAlias(order, primaryFromAlias)) {
			return primaryFromAlias + "." + order.getProperty();
		} else {
			return order.getProperty();
		}
	}

	/**
	 * Determine when an {@link org.springframework.data.domain.Sort.Order} parameter should be prefixed with the primary
	 * FROM clause's alias.
	 *
	 * @param order
	 * @param primaryFromAlias
	 * @return boolean whether or not to apply the primary FROM clause's alias as a prefix
	 */
	private boolean shouldPrefixWithAlias(Sort.Order order, @Nullable String primaryFromAlias) {

		// If there is no primary alias
		if (ObjectUtils.isEmpty(primaryFromAlias)) {
			return false;
		}

		// If the Sort contains a function
		if (order.getProperty().contains("(")) {
			return false;
		}

		// If the Sort starts with the primary alias
		if (order.getProperty().startsWith(primaryFromAlias + ".")) {
			return false;
		}

		// If the Sort references an alias directly
		if (projectionAliases.contains(order.getProperty())) {
			return false;
		}

		// If the Sort property starts with an alias
		if (projectionAliases.stream().anyMatch(alias -> order.getProperty().startsWith(alias + "."))) {
			return false;
		}

		return true;
	}
}