From 3cb7e909cbe77683c7d764e8f443e3668e224830 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 28 Jan 2025 10:06:52 +0100 Subject: [PATCH] Polishing. Refine temporal literal handling. Update documentation. See #3172 Original pull request: #3187 --- .../data/jpa/domain/JpaSort.java | 39 +- .../query/HqlOrderExpressionVisitor.java | 401 ++++++++++++------ .../data/jpa/repository/query/QueryUtils.java | 11 +- .../jpa/repository/UserRepositoryTests.java | 33 ++ .../HqlOrderExpressionVisitorUnitTests.java | 30 +- .../modules/ROOT/pages/jpa/query-methods.adoc | 11 + 6 files changed, 369 insertions(+), 156 deletions(-) diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java index 771b5361a..89e4f35bf 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java @@ -18,10 +18,6 @@ package org.springframework.data.jpa.domain; import jakarta.persistence.metamodel.Attribute; import jakarta.persistence.metamodel.PluralAttribute; -import org.springframework.data.domain.Sort; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; - import java.io.Serial; import java.util.ArrayList; import java.util.Arrays; @@ -29,8 +25,15 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import org.springframework.data.domain.Sort; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + /** - * Sort option for queries that wraps JPA meta-model {@link Attribute}s for sorting. + * Sort option for queries that wraps JPA metamodel {@link Attribute}s for sorting. + *

+ * {@link JpaSort#unsafe} accepts unsafe sort expressions, i. e. the String provided is not necessarily a property but + * can be an arbitrary expression piped into the query execution. * * @author Thomas Darimont * @author Oliver Gierke @@ -44,7 +47,7 @@ public class JpaSort extends Sort { @Serial private static final long serialVersionUID = 1L; private JpaSort(Direction direction, List> paths) { - this(Collections.emptyList(), direction, paths); + this(Collections. emptyList(), direction, paths); } private JpaSort(List orders, @Nullable Direction direction, List> paths) { @@ -76,7 +79,7 @@ public class JpaSort extends Sort { /** * Creates a new {@link JpaSort} for the given direction and attributes. * - * @param direction the sorting direction. + * @param direction the sorting direction. * @param attributes must not be {@literal null} or empty. */ public static JpaSort of(Direction direction, Attribute... attributes) { @@ -87,7 +90,7 @@ public class JpaSort extends Sort { * Creates a new {@link JpaSort} for the given direction and {@link Path}s. * * @param direction the sorting direction. - * @param paths must not be {@literal null} or empty. + * @param paths must not be {@literal null} or empty. */ public static JpaSort of(Direction direction, Path... paths) { return new JpaSort(direction, Arrays.asList(paths)); @@ -96,7 +99,7 @@ public class JpaSort extends Sort { /** * Returns a new {@link JpaSort} with the given sorting criteria added to the current one. * - * @param direction can be {@literal null}. + * @param direction can be {@literal null}. * @param attributes must not be {@literal null}. * @return */ @@ -111,7 +114,7 @@ public class JpaSort extends Sort { * Returns a new {@link JpaSort} with the given sorting criteria added to the current one. * * @param direction can be {@literal null}. - * @param paths must not be {@literal null}. + * @param paths must not be {@literal null}. * @return */ public JpaSort and(@Nullable Direction direction, Path... paths) { @@ -130,7 +133,7 @@ public class JpaSort extends Sort { /** * Returns a new {@link JpaSort} with the given sorting criteria added to the current one. * - * @param direction can be {@literal null}. + * @param direction can be {@literal null}. * @param properties must not be {@literal null} or empty. * @return */ @@ -148,7 +151,7 @@ public class JpaSort extends Sort { orders.add(new JpaOrder(direction, property)); } - return new JpaSort(orders, direction, Collections.>emptyList()); + return new JpaSort(orders, direction, Collections.> emptyList()); } /** @@ -219,7 +222,7 @@ public class JpaSort extends Sort { /** * Creates new unsafe {@link JpaSort} based on given {@link Direction} and properties. * - * @param direction must not be {@literal null}. + * @param direction must not be {@literal null}. * @param properties must not be {@literal null} or empty. * @return */ @@ -235,7 +238,7 @@ public class JpaSort extends Sort { /** * Creates new unsafe {@link JpaSort} based on given {@link Direction} and properties. * - * @param direction must not be {@literal null}. + * @param direction must not be {@literal null}. * @param properties must not be {@literal null} or empty. * @return */ @@ -327,7 +330,7 @@ public class JpaSort extends Sort { * {@link Sort#DEFAULT_DIRECTION} * * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}. - * @param property must not be {@literal null}. + * @param property must not be {@literal null}. */ private JpaOrder(@Nullable Direction direction, String property) { this(direction, property, NullHandling.NATIVE); @@ -337,8 +340,8 @@ public class JpaSort extends Sort { * Creates a new {@link Order} instance. if order is {@literal null} then order defaults to * {@link Sort#DEFAULT_DIRECTION}. * - * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}. - * @param property must not be {@literal null}. + * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}. + * @param property must not be {@literal null}. * @param nullHandlingHint can be {@literal null}, will default to {@link NullHandling#NATIVE}. */ private JpaOrder(@Nullable Direction direction, String property, NullHandling nullHandlingHint) { @@ -346,7 +349,7 @@ public class JpaSort extends Sort { } private JpaOrder(@Nullable Direction direction, String property, boolean ignoreCase, NullHandling nullHandling, - boolean unsafe) { + boolean unsafe) { super(direction, property, ignoreCase, nullHandling); this.unsafe = unsafe; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitor.java index ade370c0f..e5915f19e 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitor.java @@ -15,6 +15,8 @@ */ package org.springframework.data.jpa.repository.query; +import static java.time.format.DateTimeFormatter.*; + import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Expression; import jakarta.persistence.criteria.From; @@ -24,9 +26,18 @@ import jakarta.persistence.criteria.TemporalField; import java.math.BigDecimal; import java.math.BigInteger; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.Temporal; import java.util.Collection; import java.util.HexFormat; +import java.util.Locale; +import java.util.function.BiFunction; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; @@ -47,24 +58,48 @@ import org.springframework.util.Assert; * @author Mark Paluch * @since 4.0 */ -@SuppressWarnings("ConstantValue") +@SuppressWarnings({ "unchecked", "rawtypes", "ConstantValue" }) class HqlOrderExpressionVisitor extends HqlBaseVisitor> { + private static final DateTimeFormatter DATE_TIME = new DateTimeFormatterBuilder().parseCaseInsensitive() + .append(ISO_LOCAL_DATE).optionalStart().appendLiteral(' ').optionalEnd().optionalStart().appendLiteral('T') + .optionalEnd().append(ISO_LOCAL_TIME).optionalStart().appendLiteral(' ').optionalEnd().optionalStart() + .appendZoneOrOffsetId().optionalEnd().toFormatter(); + + private static final DateTimeFormatter DATE_TIME_FORMATTER_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd", + Locale.ENGLISH); + + private static final DateTimeFormatter DATE_TIME_FORMATTER_TIME = DateTimeFormatter.ofPattern("HH:mm:ss", + Locale.ENGLISH); + + private static final String UNSUPPORTED_TEMPLATE = "We can't handle %s in an ORDER BY clause through JpaSort.unsafe(…)"; + private final CriteriaBuilder cb; private final Path from; - private static String UNSUPPORTED_TEMPLATE = "We can't handle %s in an ORDER BY clause through JpaSort.unsafe"; + private final BiFunction, PropertyPath, Expression> expressionFactory; - HqlOrderExpressionVisitor(CriteriaBuilder cb, Path from) { + /** + * @param cb criteria builder. + * @param from from path (i.e. root entity). + * @param expressionFactory factory to create expressions such as + * {@link QueryUtils#toExpressionRecursively(From, PropertyPath)}. + */ + HqlOrderExpressionVisitor(CriteriaBuilder cb, Path from, + BiFunction, PropertyPath, Expression> expressionFactory) { this.cb = cb; this.from = from; + this.expressionFactory = expressionFactory; } /** * Extract the {@link org.springframework.data.jpa.domain.JpaSort.JpaOrder}'s property and parse it as an HQL * {@literal sortExpression}. * - * @param jpaOrder + * @param jpaOrder must not be {@literal null}. * @return criteriaExpression + * @throws IllegalArgumentException thrown if the order yields no sort expression. + * @throws UnsupportedOperationException thrown if the order contains an unsupported expression. + * @throws BadJpqlGrammarException thrown if the order contains a syntax errors. */ Expression createCriteriaExpression(Sort.Order jpaOrder) { @@ -100,32 +135,24 @@ class HqlOrderExpressionVisitor extends HqlBaseVisitor> { } @Override - @SuppressWarnings("rawtypes") public Expression visitRelationalExpression(HqlParser.RelationalExpressionContext ctx) { Expression left = visitRequired(ctx.expression(0)); Expression right = visitRequired(ctx.expression(1)); String op = ctx.op.getText(); - if (op.equals("=")) { - return cb.equal(left, right); - } else if (op.equals(">")) { - return cb.greaterThan(left, right); - } else if (op.equals(">=")) { - return cb.greaterThanOrEqualTo(left, right); - } else if (op.equals("<")) { - return cb.lessThan(left, right); - } else if (op.equals("<=")) { - return cb.lessThanOrEqualTo(left, right); - } else if (op.equals("<>") || op.equals("!=") || op.equals("^=")) { - return cb.notEqual(left, right); - } else { - throw new UnsupportedOperationException("Unsupported comparison operator: " + op); - } + return switch (op) { + case "=" -> cb.equal(left, right); + case ">" -> cb.greaterThan(left, right); + case ">=" -> cb.greaterThanOrEqualTo(left, right); + case "<" -> cb.lessThan(left, right); + case "<=" -> cb.lessThanOrEqualTo(left, right); + case "<>", "!=", "^=" -> cb.notEqual(left, right); + default -> throw new UnsupportedOperationException("Unsupported comparison operator: " + op); + }; } @Override - @SuppressWarnings("rawtypes") public Expression visitBetweenExpression(HqlParser.BetweenExpressionContext ctx) { Expression condition = visitRequired(ctx.expression(0)); @@ -244,7 +271,7 @@ class HqlOrderExpressionVisitor extends HqlBaseVisitor> { } Expression[] arguments = ctx.genericFunctionArguments().expressionOrPredicate().stream() // - .map(expressionOrPredicateContext -> visitRequired(expressionOrPredicateContext)) // + .map(this::visitRequired) // .toArray(Expression[]::new); return cb.function(functionName, Object.class, arguments); @@ -371,7 +398,6 @@ class HqlOrderExpressionVisitor extends HqlBaseVisitor> { } @Override - @SuppressWarnings({ "rawtypes", "unchecked" }) public Expression visitTruncFunction(HqlParser.TruncFunctionContext ctx) { Expression expr = visitRequired(ctx.expression().get(0)); @@ -497,21 +523,6 @@ class HqlOrderExpressionVisitor extends HqlBaseVisitor> { throw new UnsupportedOperationException("Unsupported literal: " + ctx.getText()); } - static String getDecimals(TerminalNode input) { - - String text = input.getText(); - StringBuilder result = new StringBuilder(text.length()); - - for (int i = 0; i < text.length(); i++) { - char c = text.charAt(i); - if (Character.isDigit(c) || c == '-' || c == '+' || c == '.') { - result.append(c); - } - } - - return result.toString(); - } - @Override public Expression visitDateTimeLiteral(HqlParser.DateTimeLiteralContext ctx) { @@ -526,6 +537,97 @@ class HqlOrderExpressionVisitor extends HqlBaseVisitor> { return null; } + @Override + public Expression visitJdbcTimeLiteral(HqlParser.JdbcTimeLiteralContext ctx) { + + if (ctx.time() != null) { + return visitRequired(ctx.time()); + } + + return cb.literal(DATE_TIME_FORMATTER_TIME.parse(unquoteTemporal(ctx.genericTemporalLiteralText()))); + } + + @Override + public Expression visitDate(HqlParser.DateContext ctx) { + return cb.literal(LocalDate.from(DATE_TIME_FORMATTER_DATE.parse(unquoteTemporal(ctx)))); + } + + @Override + public Expression visitTime(HqlParser.TimeContext ctx) { + return cb.literal(LocalTime.from(DATE_TIME_FORMATTER_TIME.parse(unquoteTemporal(ctx)))); + } + + @Override + public Expression visitJdbcDateLiteral(HqlParser.JdbcDateLiteralContext ctx) { + + if (ctx.date() != null) { + return visitRequired(ctx.date()); + } + + return cb + .literal(LocalDate.from(DATE_TIME_FORMATTER_DATE.parse(unquoteTemporal(ctx.genericTemporalLiteralText())))); + } + + @Override + public Expression visitJdbcTimestampLiteral(HqlParser.JdbcTimestampLiteralContext ctx) { + + if (ctx.dateTime() != null) { + return visitRequired(ctx.dateTime()); + } + + return cb.literal(LocalDateTime.from(DATE_TIME.parse(unquoteTemporal(ctx.genericTemporalLiteralText())))); + } + + @Override + public Expression visitLocalDateTime(HqlParser.LocalDateTimeContext ctx) { + return cb.literal(LocalDateTime.from(DATE_TIME.parse(unquoteTemporal(ctx.getText())))); + } + + @Override + public Expression visitZonedDateTime(HqlParser.ZonedDateTimeContext ctx) { + return cb.literal(ZonedDateTime.parse(ctx.getText())); + } + + @Override + public Expression visitOffsetDateTime(HqlParser.OffsetDateTimeContext ctx) { + return cb.literal(OffsetDateTime.parse(ctx.getText())); + } + + @Override + public Expression visitOffsetDateTimeWithMinutes(HqlParser.OffsetDateTimeWithMinutesContext ctx) { + return cb.literal(OffsetDateTime.parse(ctx.getText())); + } + + @Override + public Expression visitLocalDateTimeLiteral(HqlParser.LocalDateTimeLiteralContext ctx) { + return visitRequired(ctx.localDateTime()); + } + + @Override + public Expression visitZonedDateTimeLiteral(HqlParser.ZonedDateTimeLiteralContext ctx) { + return visitRequired(ctx.zonedDateTime()); + } + + @Override + public Expression visitOffsetDateTimeLiteral(HqlParser.OffsetDateTimeLiteralContext ctx) { + return visitRequired(ctx.offsetDateTime() != null ? ctx.offsetDateTime() : ctx.offsetDateTimeWithMinutes()); + } + + @Override + public Expression visitDateLiteral(HqlParser.DateLiteralContext ctx) { + return visitRequired(ctx.date()); + } + + @Override + public Expression visitTimeLiteral(HqlParser.TimeLiteralContext ctx) { + return visitRequired(ctx.time()); + } + + @Override + public Expression visitDateTime(HqlParser.DateTimeContext ctx) { + return super.visitDateTime(ctx); + } + @Override public Expression visitGroupedExpression(HqlParser.GroupedExpressionContext ctx) { return visit(ctx.expression()); @@ -579,10 +681,67 @@ class HqlOrderExpressionVisitor extends HqlBaseVisitor> { @Override public Expression visitSimplePath(HqlParser.SimplePathContext ctx) { - return QueryUtils.toExpressionRecursively((From) from, PropertyPath.from(ctx.getText(), from.getJavaType())); + return expressionFactory.apply((From) from, PropertyPath.from(ctx.getText(), from.getJavaType())); } - String getString(HqlParser.IdentifierContext context) { + @Override + public Expression visitCaseList(HqlParser.CaseListContext ctx) { + return visit(ctx.simpleCaseExpression() != null ? ctx.simpleCaseExpression() : ctx.searchedCaseExpression()); + } + + @Override + public Expression visitSimpleCaseExpression(HqlParser.SimpleCaseExpressionContext ctx) { + + CriteriaBuilder.SimpleCase simpleCase = cb.selectCase(visit(ctx.expressionOrPredicate(0))); + + ctx.caseWhenExpressionClause().forEach(caseWhenExpressionClauseContext -> { + simpleCase.when( // + visitRequired(caseWhenExpressionClauseContext.expression()), // + visitRequired(caseWhenExpressionClauseContext.expressionOrPredicate())); + }); + + if (ctx.expressionOrPredicate().size() == 2) { + simpleCase.otherwise(visitRequired(ctx.expressionOrPredicate(1))); + } + + return simpleCase; + } + + @Override + public Expression visitSearchedCaseExpression(HqlParser.SearchedCaseExpressionContext ctx) { + + CriteriaBuilder.Case searchedCase = cb.selectCase(); + + ctx.caseWhenPredicateClause().forEach(caseWhenPredicateClauseContext -> { + searchedCase.when( // + visitRequired(caseWhenPredicateClauseContext.predicate()), // + visit(caseWhenPredicateClauseContext.expressionOrPredicate())); + }); + + if (ctx.expressionOrPredicate() != null) { + searchedCase.otherwise(visit(ctx.expressionOrPredicate())); + } + + return searchedCase; + } + + @Override + public Expression visitParameter(HqlParser.ParameterContext ctx) { + throw new UnsupportedOperationException(String.format(UNSUPPORTED_TEMPLATE, "a parameter argument")); + } + + private Expression visitRequired(ParseTree ctx) { + + Expression expression = visit(ctx); + + if (expression == null) { + throw new UnsupportedOperationException("No result for expression: " + ctx.getText()); + } + + return (Expression) expression; + } + + private String getString(HqlParser.IdentifierContext context) { HqlParser.NakedIdentifierContext ni = context.nakedIdentifier(); @@ -595,113 +754,85 @@ class HqlOrderExpressionVisitor extends HqlBaseVisitor> { return text; } - @Override - public Expression visitCaseList(HqlParser.CaseListContext ctx) { - if (ctx.simpleCaseExpression() != null) { - return visit(ctx.simpleCaseExpression()); - } else { - return visit(ctx.searchedCaseExpression()); - } - } + private static String getDecimals(TerminalNode input) { - @Override - public Expression visitSimpleCaseExpression(HqlParser.SimpleCaseExpressionContext ctx) { - CriteriaBuilder.SimpleCase simpleCase = cb.selectCase(visit(ctx.expressionOrPredicate(0))); - ctx.caseWhenExpressionClause().forEach(caseWhenExpressionClauseContext -> { - simpleCase.when( // - visitRequired(caseWhenExpressionClauseContext.expression()), // - visitRequired(caseWhenExpressionClauseContext.expressionOrPredicate())); - }); - if (ctx.expressionOrPredicate().size() == 2) { - simpleCase.otherwise(visitRequired(ctx.expressionOrPredicate(1))); - } - return simpleCase; - } + String text = input.getText(); + StringBuilder result = new StringBuilder(text.length()); - @Override - public Expression visitSearchedCaseExpression(HqlParser.SearchedCaseExpressionContext ctx) { - CriteriaBuilder.Case searchedCase = cb.selectCase(); - ctx.caseWhenPredicateClause().forEach(caseWhenPredicateClauseContext -> { - searchedCase.when( // - visitRequired(caseWhenPredicateClauseContext.predicate()), // - visit(caseWhenPredicateClauseContext.expressionOrPredicate())); - }); - if (ctx.expressionOrPredicate() != null) { - searchedCase.otherwise(visit(ctx.expressionOrPredicate())); - } - return searchedCase; - } - - @Override - public Expression visitParameter(HqlParser.ParameterContext ctx) { - throw new UnsupportedOperationException(String.format(UNSUPPORTED_TEMPLATE, "a parameter argument")); - } - - @SuppressWarnings("unchecked") - private Expression visitRequired(ParseTree ctx) { - - Expression expression = visit(ctx); - - if (expression == null) { - throw new UnsupportedOperationException("No result for expression: " + ctx.getText()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (Character.isDigit(c) || c == '-' || c == '+' || c == '.') { + result.append(c); + } } - return (Expression) expression; + return result.toString(); + } + + private static String unquoteTemporal(ParseTree node) { + return unquoteTemporal(node.getText()); + } + + private static String unquoteTemporal(String temporal) { + if (temporal.startsWith("'") && temporal.endsWith("'")) { + temporal = temporal.substring(1, temporal.length() - 1); + } + return temporal; } private static String unquoteIdentifier(String text) { int end = text.length() - 1; - assert text.charAt(0) == '`' && text.charAt(end) == '`'; + + Assert.isTrue(text.charAt(0) == '`' && text.charAt(end) == '`', + "Quoted identifier does not end with the same delimiter"); + // Unquote a parsed quoted identifier and handle escape sequences - final StringBuilder sb = new StringBuilder(text.length() - 2); + StringBuilder sb = new StringBuilder(text.length() - 2); for (int i = 1; i < end; i++) { + char c = text.charAt(i); - switch (c) { - case '\\': - if (i + 1 < end) { - char nextChar = text.charAt(++i); - switch (nextChar) { - case 'b': - c = '\b'; - break; - case 't': - c = '\t'; - break; - case 'n': - c = '\n'; - break; - case 'f': - c = '\f'; - break; - case 'r': - c = '\r'; - break; - case '\\': - c = '\\'; - break; - case '\'': - c = '\''; - break; - case '"': - c = '"'; - break; - case '`': - c = '`'; - break; - case 'u': - c = (char) Integer.parseInt(text.substring(i + 1, i + 5), 16); - i += 4; - break; - default: - sb.append('\\'); - c = nextChar; - break; - } + if (c == '\\') { + if (i + 1 < end) { + char nextChar = text.charAt(++i); + switch (nextChar) { + case 'b': + c = '\b'; + break; + case 't': + c = '\t'; + break; + case 'n': + c = '\n'; + break; + case 'f': + c = '\f'; + break; + case 'r': + c = '\r'; + break; + case '\\': + c = '\\'; + break; + case '\'': + c = '\''; + break; + case '"': + c = '"'; + break; + case '`': + c = '`'; + break; + case 'u': + c = (char) Integer.parseInt(text.substring(i + 1, i + 5), 16); + i += 4; + break; + default: + sb.append('\\'); + c = nextChar; + break; } - break; - default: - break; + } } sb.append(c); } @@ -715,7 +846,7 @@ class HqlOrderExpressionVisitor extends HqlBaseVisitor> { Assert.isTrue(delimiter == text.charAt(end), "Quoted identifier does not end with the same delimiter"); // Unescape the parsed literal - final StringBuilder sb = new StringBuilder(text.length() - 2); + StringBuilder sb = new StringBuilder(text.length() - 2); for (int i = 1; i < end; i++) { char c = text.charAt(i); switch (c) { 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 e51d305e0..bbb638eda 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 @@ -725,8 +725,15 @@ public abstract class QueryUtils { @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); + Expression expression; + + if (order instanceof JpaOrder jpaOrder && jpaOrder.isUnsafe()) { + expression = new HqlOrderExpressionVisitor(cb, from, QueryUtils::toExpressionRecursively) + .createCriteriaExpression(order); + } else { + PropertyPath property = PropertyPath.from(order.getProperty(), from.getJavaType()); + expression = toExpressionRecursively(from, property); + } Nulls nulls = toNulls(order.getNullHandling()); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java index 0e2c25a86..c7891101f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java @@ -60,6 +60,7 @@ import org.springframework.data.domain.*; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.domain.Sort.Order; import org.springframework.data.jpa.domain.DeleteSpecification; +import org.springframework.data.jpa.domain.JpaSort; import org.springframework.data.jpa.domain.PredicateSpecification; import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.UpdateSpecification; @@ -3227,6 +3228,38 @@ class UserRepositoryTests { assertThat(users).extracting(User::getId).containsExactly(expected.getId()); } + @Test // GH-3172 + void specificationShouldApplyUnsafeSort() { + + flushTestUsers(); + firstUser.setManager(firstUser); + secondUser.setManager(firstUser); + thirdUser.setManager(secondUser); + fourthUser.setManager(secondUser); + repository.saveAllAndFlush(List.of(firstUser, secondUser, thirdUser, fourthUser)); + + PredicateSpecification spec = userHasFirstname("Oliver").or(userHasLastname("Matthews")); + + List result = repository.findBy(spec, q -> q.sortBy(JpaSort.unsafe("LENGTH(firstname)")).all()); + + assertThat(result).containsExactly(thirdUser, firstUser); + } + + @Test // GH-3172 + void findAllShouldApplyUnsafeSort() { + + flushTestUsers(); + firstUser.setManager(firstUser); + secondUser.setManager(firstUser); + thirdUser.setManager(secondUser); + fourthUser.setManager(secondUser); + repository.saveAllAndFlush(List.of(firstUser, secondUser, thirdUser, fourthUser)); + + assertThat( + repository.findAll(JpaSort.unsafe("case when firstname ilike 'O%' escape '^' then 'A' else firstname end"))) + .containsExactly(firstUser, thirdUser, secondUser, fourthUser); + } + @Test // DATAJPA-1233, GH-3756 void handlesCountQueriesWithLessParametersSingleParam() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitorUnitTests.java index cff6bea21..98ac54ca7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitorUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitorUnitTests.java @@ -120,6 +120,33 @@ class HqlOrderExpressionVisitorUnitTests { assertThat(renderOrderBy(JpaSort.unsafe("age + 0x12"), "u")).startsWithIgnoringCase("order by u.age + 18"); } + @Test // GH-3172 + void temporalLiterals() { + + // JDBC + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {ts '2024-01-01 12:34:56'}"), "u")) + .startsWithIgnoringCase("order by u.createdAt + 2024-01-01T12:34:56"); + + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {ts '2012-01-03 09:00:00.000000001'}"), "u")) + .startsWithIgnoringCase("order by u.createdAt + 2012-01-03T09:00:00.000000001"); + + // Hibernate NPE + assertThatNullPointerException().isThrownBy(() -> renderOrderBy(JpaSort.unsafe("createdAt + {t '12:34:56'}"), "u")); + + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {d '2024-01-01'}"), "u")) + .startsWithIgnoringCase("order by u.createdAt + 2024-01-01"); + + // JPQL + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {ts 2024-01-01 12:34:56}"), "u")) + .startsWithIgnoringCase("order by u.createdAt + 2024-01-01T12:34:56"); + + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {t 12:34:56}"), "u")) + .startsWithIgnoringCase("order by u.createdAt + 12:34:56"); + + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {d 2024-01-01}"), "u")) + .startsWithIgnoringCase("order by u.createdAt + 2024-01-01"); + } + @Test // GH-3172 void arithmetic() { @@ -221,7 +248,8 @@ class HqlOrderExpressionVisitorUnitTests { CriteriaQuery query = em.getCriteriaBuilder().createQuery(User.class); Selection from = query.from(User.class).alias(alias); - HqlOrderExpressionVisitor extractor = new HqlOrderExpressionVisitor(em.getCriteriaBuilder(), (Path) from); + HqlOrderExpressionVisitor extractor = new HqlOrderExpressionVisitor(em.getCriteriaBuilder(), (Path) from, + QueryUtils::toExpressionRecursively); Expression expression = extractor.createCriteriaExpression(sort.stream().findFirst().get()); return query.select(from).orderBy(em.getCriteriaBuilder().asc(expression, Nulls.NONE)); diff --git a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc index 63991208f..044e5268c 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc @@ -383,6 +383,17 @@ Throws Exception. <4> Valid `Sort` expression pointing to aliased function. ==== +=== JpaSort.unsafe(…) limitations + +`JpaSort.unsafe(…)` operates in two modes: + +* When used with derived Queries or String-based Queries, the order string is appended to the query. +* When used with Query by Example or Specifications (that use `CriteriaQuery`), order expressions are parsed and added to the `CriteriaQuery` as expressions. +Query expressions can contain function calls, various clauses (such as `CASE WHEN`, arithmetic expressions) or property paths. +Order translation does not support subquery expressions, `TREAT` and `CAST`.` + +[[jpa.query-methods.paging]] + [[jpa.query-methods.scroll]] == Scrolling Large Query Results