diff --git a/pom.xml b/pom.xml index 07101c166..a230dab8b 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,7 @@ 42.2.19 3.0.0-SNAPSHOT 0.10.3 + 4.3 org.hibernate diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml index 5960d72cc..e4f17e4ac 100644 --- a/spring-data-jpa/pom.xml +++ b/spring-data-jpa/pom.xml @@ -226,6 +226,14 @@ test + + com.github.jsqlparser + jsqlparser + ${jsqlparser.version} + provided + true + + diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java index 02f4ee9cc..7f950b018 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java @@ -15,14 +15,6 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; import jakarta.persistence.Query; @@ -31,6 +23,14 @@ import jakarta.persistence.Tuple; import jakarta.persistence.TupleElement; import jakarta.persistence.TypedQuery; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + import org.springframework.core.convert.converter.Converter; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.EntityGraph; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java index 66f388667..2294ee18f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java @@ -34,6 +34,7 @@ import org.springframework.util.Assert; * @author Tom Hombergs * @author David Madden * @author Mark Paluch + * @author Diego Krupitza */ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery { @@ -55,8 +56,8 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery { * @param parser must not be {@literal null}. */ public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, - @Nullable String countQueryString, - QueryMethodEvaluationContextProvider evaluationContextProvider, SpelExpressionParser parser) { + @Nullable String countQueryString, QueryMethodEvaluationContextProvider evaluationContextProvider, + SpelExpressionParser parser) { super(method, em); @@ -65,10 +66,12 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery { Assert.notNull(parser, "Parser must not be null!"); this.evaluationContextProvider = evaluationContextProvider; - this.query = new ExpressionBasedStringQuery(queryString, method.getEntityInformation(), parser); + this.query = new ExpressionBasedStringQuery(queryString, method.getEntityInformation(), parser, + method.isNativeQuery()); DeclaredQuery countQuery = query.deriveCountQuery(countQueryString, method.getCountQueryProjection()); - this.countQuery = ExpressionBasedStringQuery.from(countQuery, method.getEntityInformation(), parser); + this.countQuery = ExpressionBasedStringQuery.from(countQuery, method.getEntityInformation(), parser, + method.isNativeQuery()); this.parser = parser; @@ -79,7 +82,8 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery { @Override public Query doCreateQuery(JpaParametersParameterAccessor accessor) { - String sortedQueryString = QueryUtils.applySorting(query.getQueryString(), accessor.getSort(), query.getAlias()); + String sortedQueryString = QueryEnhancerFactory.forQuery(query) // + .applySorting(accessor.getSort(), query.getAlias()); ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor); Query query = createJpaQuery(sortedQueryString, processor.getReturnedType()); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java index 9aa455554..2359260da 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java @@ -24,6 +24,7 @@ import org.springframework.util.ObjectUtils; * A wrapper for a String representation of a query offering information about the query. * * @author Jens Schauder + * @author Diego Krupitza * @since 2.0.3 */ interface DeclaredQuery { @@ -32,10 +33,11 @@ interface DeclaredQuery { * Creates a {@literal DeclaredQuery} from a query {@literal String}. * * @param query might be {@literal null} or empty. + * @param nativeQuery is a given query is native or not * @return a {@literal DeclaredQuery} instance even for a {@literal null} or empty argument. */ - static DeclaredQuery of(@Nullable String query) { - return ObjectUtils.isEmpty(query) ? EmptyDeclaredQuery.EMPTY_QUERY : new StringQuery(query); + static DeclaredQuery of(@Nullable String query, boolean nativeQuery) { + return ObjectUtils.isEmpty(query) ? EmptyDeclaredQuery.EMPTY_QUERY : new StringQuery(query, nativeQuery); } /** @@ -100,4 +102,13 @@ interface DeclaredQuery { * @since 2.0.6 */ boolean usesJdbcStyleParameters(); + + /** + * Return whether the query is a native query of not. + * + * @return true if native query otherwise false + */ + default boolean isNativeQuery() { + return false; + } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java new file mode 100644 index 000000000..7504d8135 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java @@ -0,0 +1,74 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import org.springframework.data.domain.Sort; + +import java.util.Set; + +/** + * The implementation of {@link QueryEnhancer} using {@link QueryUtils}. + * + * @author Diego Krupitza + */ +public class DefaultQueryEnhancer implements QueryEnhancer { + + private final DeclaredQuery query; + + public DefaultQueryEnhancer(DeclaredQuery query) { + this.query = query; + } + + @Override + public String getExistsQueryString(String entityName, String countQueryPlaceHolder, Iterable idAttributes) { + return QueryUtils.getExistsQueryString(entityName, countQueryPlaceHolder, idAttributes); + } + + @Override + public String getQueryString(String template, String entityName) { + return QueryUtils.getQueryString(template, entityName); + } + + @Override + public String applySorting(Sort sort, String alias) { + return QueryUtils.applySorting(this.query.getQueryString(), sort, alias); + } + + @Override + public String detectAlias() { + return QueryUtils.detectAlias(this.query.getQueryString()); + } + + @Override + public String createCountQueryFor(String countProjection) { + return QueryUtils.createCountQueryFor(this.query.getQueryString(), countProjection); + } + + @Override + public String getProjection() { + return QueryUtils.getProjection(this.query.getQueryString()); + } + + @Override + public Set getJoinAliases() { + return QueryUtils.getOuterJoinAliases(this.query.getQueryString()); + } + + @Override + public DeclaredQuery getQuery() { + return this.query; + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyDeclaredQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyDeclaredQuery.java index cb4e18bda..725719785 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyDeclaredQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyDeclaredQuery.java @@ -69,7 +69,7 @@ class EmptyDeclaredQuery implements DeclaredQuery { Assert.hasText(countQuery, "CountQuery must not be empty!"); - return DeclaredQuery.of(countQuery); + return DeclaredQuery.of(countQuery, false); } @Override diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java index eb773c198..74fffaa86 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java @@ -36,6 +36,7 @@ import org.springframework.util.Assert; * @author Oliver Gierke * @author Tom Hombergs * @author Michael J. Simons + * @author Diego Krupitza */ class ExpressionBasedStringQuery extends StringQuery { @@ -55,9 +56,11 @@ class ExpressionBasedStringQuery extends StringQuery { * @param query must not be {@literal null} or empty. * @param metadata must not be {@literal null}. * @param parser must not be {@literal null}. + * @param nativeQuery is a given query is native or not */ - public ExpressionBasedStringQuery(String query, JpaEntityMetadata metadata, SpelExpressionParser parser) { - super(renderQueryIfExpressionOrReturnQuery(query, metadata, parser)); + public ExpressionBasedStringQuery(String query, JpaEntityMetadata metadata, SpelExpressionParser parser, + boolean nativeQuery) { + super(renderQueryIfExpressionOrReturnQuery(query, metadata, parser), nativeQuery && !containsExpression(query)); } /** @@ -66,11 +69,12 @@ class ExpressionBasedStringQuery extends StringQuery { * @param query the original query. Must not be {@literal null}. * @param metadata the {@link JpaEntityMetadata} for the given entity. Must not be {@literal null}. * @param parser Parser for resolving SpEL expressions. Must not be {@literal null}. + * @param nativeQuery * @return A query supporting SpEL expressions. */ - static ExpressionBasedStringQuery from(DeclaredQuery query, JpaEntityMetadata metadata, - SpelExpressionParser parser) { - return new ExpressionBasedStringQuery(query.getQueryString(), metadata, parser); + static ExpressionBasedStringQuery from(DeclaredQuery query, JpaEntityMetadata metadata, SpelExpressionParser parser, + boolean nativeQuery) { + return new ExpressionBasedStringQuery(query.getQueryString(), metadata, parser, nativeQuery); } /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java new file mode 100644 index 000000000..220ddc868 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java @@ -0,0 +1,347 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.select.*; +import net.sf.jsqlparser.util.SelectUtils; +import org.springframework.data.domain.Sort; +import org.springframework.data.util.Streamable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +import static org.springframework.data.jpa.repository.query.JSqlParserUtils.*; +import static org.springframework.data.jpa.repository.query.QueryUtils.checkSortExpression; + +/** + * The implementation of {@link QueryEnhancer} using JSqlParser. + * + * @author Diego Krupitza + */ +public class JSqlParserQueryEnhancer implements QueryEnhancer { + + private static final String DEFAULT_TABLE_ALIAS = "x"; + + private final DeclaredQuery query; + + /** + * @param query the query we want to enhance. Must not be {@literal null}. + */ + public JSqlParserQueryEnhancer(DeclaredQuery query) { + this.query = query; + } + + @Override + public String getExistsQueryString(String entityName, String countQueryPlaceHolder, Iterable idAttributes) { + final Table tableNameWithAlias = getTableWithAlias(entityName, DEFAULT_TABLE_ALIAS); + Function jSqlCount = getJSqlCount(Collections.singletonList(countQueryPlaceHolder), false); + + Select select = SelectUtils.buildSelectFromTableAndSelectItems(tableNameWithAlias, + new SelectExpressionItem(jSqlCount)); + + PlainSelect selectBody = (PlainSelect) select.getSelectBody(); + + List equalityExpressions = Streamable.of(idAttributes).stream() // + .map(field -> { + Expression tableNameField = new Column().withTable(tableNameWithAlias).withColumnName(field); + Expression inputField = new Column(":".concat(field)); + return new EqualsTo(tableNameField, inputField); + }).collect(Collectors.toList()); + + if (equalityExpressions.size() > 1) { + AndExpression rootOfWhereClause = concatenateWithAndExpression(equalityExpressions); + selectBody.setWhere(rootOfWhereClause); + } else if (equalityExpressions.size() == 1) { + selectBody.setWhere(equalityExpressions.get(0)); + } + + return selectBody.toString(); + } + + @Override + public String getQueryString(String template, String entityName) { + Assert.hasText(entityName, "Entity name must not be null or empty!"); + return String.format(template, entityName); + } + + @Override + public String applySorting(Sort sort, String alias) { + String queryString = query.getQueryString(); + Assert.hasText(queryString, "Query must not be null or empty!"); + + if (sort.isUnsorted()) { + return queryString; + } + + Select selectStatement = parseSelectStatement(queryString); + PlainSelect selectBody = (PlainSelect) selectStatement.getSelectBody(); + + final Set joinAliases = getJoinAliases(selectBody); + + final Set selectionAliases = getSelectionAliases(selectBody); + + List orderByElements = sort.stream() // + .map(order -> getOrderClause(joinAliases, selectionAliases, alias, order)) // + .collect(Collectors.toList()); + + if (CollectionUtils.isEmpty(selectBody.getOrderByElements())) { + selectBody.setOrderByElements(new ArrayList<>()); + } + + selectBody.getOrderByElements().addAll(orderByElements); + + return selectBody.toString(); + + } + + /** + * Returns the aliases used inside the selection part in the query. + * + * @param selectBody a {@link PlainSelect} containing a query. Must not be {@literal null}. + * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}. + */ + private Set getSelectionAliases(PlainSelect selectBody) { + + if (CollectionUtils.isEmpty(selectBody.getSelectItems())) { + return new HashSet<>(); + } + + return selectBody.getSelectItems().stream() // + .filter(SelectExpressionItem.class::isInstance) // + .map(item -> ((SelectExpressionItem) item).getAlias()) // + .filter(Objects::nonNull) // + .map(Alias::getName) // + .collect(Collectors.toSet()); + } + + /** + * Returns the aliases used inside the selection part in the query. + * + * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}. + */ + Set getSelectionAliases() { + Select selectStatement = parseSelectStatement(this.query.getQueryString()); + PlainSelect selectBody = (PlainSelect) selectStatement.getSelectBody(); + return this.getSelectionAliases(selectBody); + } + + /** + * Returns the aliases used for {@code 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}. + */ + private Set getJoinAliases(String query) { + return getJoinAliases((PlainSelect) parseSelectStatement(query).getSelectBody()); + } + + /** + * Returns the aliases used for {@code join}s. + * + * @param selectBody the selection body 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}. + */ + private Set getJoinAliases(PlainSelect selectBody) { + + if (CollectionUtils.isEmpty(selectBody.getJoins())) { + return new HashSet<>(); + } + + return selectBody.getJoins().stream() // + .map(join -> join.getRightItem().getAlias()) // + .filter(Objects::nonNull) // + .map(Alias::getName) // + .collect(Collectors.toSet()); + } + + /** + * Returns the order clause for the given {@link Sort.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 {@link OrderByElement} containing an order clause. Guaranteed to be not {@literal null}. + */ + private OrderByElement getOrderClause(final Set joinAliases, final Set selectionAliases, + final String alias, final Sort.Order order) { + + final OrderByElement orderByElement = new OrderByElement(); + orderByElement.setAsc(order.getDirection().isAscending()); + orderByElement.setAscDescPresent(true); + + final String property = order.getProperty(); + + checkSortExpression(order); + + if (selectionAliases.contains(property)) { + Expression orderExpression = order.isIgnoreCase() ? getJSqlLower(property) : new Column(property); + + orderByElement.setExpression(orderExpression); + return orderByElement; + } + + boolean qualifyReference = joinAliases // + .parallelStream() // + .map(joinAlias -> joinAlias.concat(".")) // + .noneMatch(property::startsWith); + + boolean functionIndicator = property.contains("("); + + String reference = qualifyReference && !functionIndicator && StringUtils.hasText(alias) + ? String.format("%s.%s", alias, property) + : property; + Expression orderExpression = order.isIgnoreCase() ? getJSqlLower(reference) : new Column(reference); + orderByElement.setExpression(orderExpression); + return orderByElement; + } + + @Override + public String detectAlias() { + return detectAlias(this.query.getQueryString()); + } + + /** + * Resolves the alias for the entity to be retrieved from the given JPA query. Note that you only provide valid Query + * strings. Things such as from User u will throw an {@link IllegalArgumentException}. + * + * @param query must not be {@literal null}. + * @return Might return {@literal null}. + */ + private String detectAlias(String query) { + Select selectStatement = parseSelectStatement(query); + PlainSelect selectBody = (PlainSelect) selectStatement.getSelectBody(); + return detectAlias(selectBody); + } + + /** + * Resolves the alias for the entity to be retrieved from the given {@link PlainSelect}. Note that you only provide + * valid Query strings. Things such as from User u will throw an {@link IllegalArgumentException}. + * + * @param selectBody must not be {@literal null}. + * @return Might return {@literal null}. + */ + private static String detectAlias(PlainSelect selectBody) { + Alias alias = selectBody.getFromItem().getAlias(); + return alias == null ? null : alias.getName(); + } + + @Override + public String createCountQueryFor(String countProjection) { + + Assert.hasText(this.query.getQueryString(), "OriginalQuery must not be null or empty!"); + + Select selectStatement = parseSelectStatement(this.query.getQueryString()); + PlainSelect selectBody = (PlainSelect) selectStatement.getSelectBody(); + + // remove order by + selectBody.setOrderByElements(null); + + if (StringUtils.hasText(countProjection)) { + Function jSqlCount = getJSqlCount(Collections.singletonList(countProjection), false); + selectBody.setSelectItems(Collections.singletonList(new SelectExpressionItem(jSqlCount))); + return selectBody.toString(); + } + + boolean distinct = selectBody.getDistinct() != null; + selectBody.setDistinct(null); // reset possible distinct + + String tableAlias = detectAlias(selectBody); + + // is never null + List selectItems = selectBody.getSelectItems(); + + if (onlyASingleColumnProjection(selectItems)) { + SelectExpressionItem singleProjection = (SelectExpressionItem) selectItems.get(0); + + Column column = (Column) singleProjection.getExpression(); + String countProp = column.getFullyQualifiedName(); + + Function jSqlCount = getJSqlCount(Collections.singletonList(countProp), distinct); + selectBody.setSelectItems(Collections.singletonList(new SelectExpressionItem(jSqlCount))); + return selectBody.toString(); + } + + String countProp = tableAlias == null ? "*" : tableAlias; + + Function jSqlCount = getJSqlCount(Collections.singletonList(countProp), distinct); + selectBody.setSelectItems(Collections.singletonList(new SelectExpressionItem(jSqlCount))); + + return selectBody.toString(); + + } + + @Override + public String getProjection() { + Assert.hasText(query.getQueryString(), "Query must not be null or empty!"); + + Select selectStatement = parseSelectStatement(query.getQueryString()); + PlainSelect selectBody = (PlainSelect) selectStatement.getSelectBody(); + + return selectBody.getSelectItems() // + .stream() // + .map(Object::toString) // + .collect(Collectors.joining(", ")).trim(); + } + + @Override + public Set getJoinAliases() { + return this.getJoinAliases(this.query.getQueryString()); + } + + /** + * Parses a query string with JSqlParser. + * + * @param query the query to parse + * @return the parsed query + */ + private static Select parseSelectStatement(String query) { + try { + return (Select) CCJSqlParserUtil.parse(query); + } catch (JSQLParserException e) { + throw new IllegalArgumentException("The query you provided is not a valid SQL Query!", e); + } + } + + /** + * Checks whether a given projection only contains a single column definition (aka without functions, etc) + * + * @param projection the projection to analyse + * @return true when the projection only contains a single column definition otherwise false + */ + private boolean onlyASingleColumnProjection(List projection) { + // this is unfortunately the only way to check without any hacky & hard string regex magic + return projection.size() == 1 && projection.get(0) instanceof SelectExpressionItem + && (((SelectExpressionItem) projection.get(0)).getExpression()) instanceof Column; + } + + @Override + public DeclaredQuery getQuery() { + return this.query; + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserUtils.java new file mode 100644 index 000000000..1cc4eb1d6 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserUtils.java @@ -0,0 +1,123 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * A utility class for JSqlParser. + * + * @author Diego Krupitza + */ +public final class JSqlParserUtils { + + private JSqlParserUtils() { + } + + /** + * Generates a JSqlParser table from an entity name and an optional alias name + * + * @param entityName the name of the table + * @param alias the optional alias. Might be {@literal null} or empty + * @return the newly generated table + */ + public static Table getTableWithAlias(String entityName, String alias) { + Table table = new Table(entityName); + return StringUtils.hasText(alias) ? table.withAlias(new Alias(alias)) : table; + } + + /** + * Concatenates a list of expression with AND. + * + * @param expressions the list of expressions to concatenate. Has to be non empty and with size >= 2 + * @return the root of the concatenated expression + */ + public static AndExpression concatenateWithAndExpression(List expressions) { + + if (CollectionUtils.isEmpty(expressions) || expressions.size() == 1) { + throw new IllegalArgumentException( + "The list of expression has to be at least of length 2! Otherwise it is not possible to concatinate with an"); + } + + AndExpression rootAndExpression = new AndExpression(); + AndExpression currentLocation = rootAndExpression; + + // traverse the list with looking 1 element ahead + for (int i = 0; i < expressions.size(); i++) { + Expression currentExpression = expressions.get(i); + if (currentLocation.getLeftExpression() == null) { + currentLocation.setLeftExpression(currentExpression); + } else if (currentLocation.getRightExpression() == null && i == expressions.size() - 1) { + currentLocation.setRightExpression(currentExpression); + } else { + AndExpression nextAndExpression = new AndExpression(); + nextAndExpression.setLeftExpression(currentExpression); + + currentLocation.setRightExpression(nextAndExpression); + currentLocation = (AndExpression) currentLocation.getRightExpression(); + } + } + + return rootAndExpression; + } + + /** + * Generates a count function call, based on the {@code countFields}. + * + * @param countFields the non-empty list of fields that are used for counting + * @param distinct if it should be a distinct count + * @return the generated count function call + */ + public static Function getJSqlCount(final List countFields, final boolean distinct) { + List countColumns = countFields // + .stream() // + .map(Column::new) // + .collect(Collectors.toList()); + + ExpressionList countExpression = new ExpressionList(countColumns); + return new Function() // + .withName("count") // + .withParameters(countExpression) // + .withDistinct(distinct); + } + + /** + * Generates a lower function call, based on the {@code column}. + * + * @param column the non-empty column to use as param for lower + * @return the generated lower function call + */ + public static Function getJSqlLower(String column) { + List expressions = Collections.singletonList(new Column(column)); + ExpressionList lowerParamExpression = new ExpressionList(expressions); + return new Function() // + .withName("lower") // + .withParameters(lowerParamExpression); + } + +} 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 ad61f599f..4afd0a79a 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 @@ -166,10 +166,8 @@ public class JpaQueryCreator extends AbstractQueryCreator typeToRead = returnedType.getReturnedType(); - query = typeToRead.isInterface() - ? query.multiselect(selections) - : query.select((Selection) builder.construct(typeToRead, - selections.toArray(new Selection[0]))); + query = typeToRead.isInterface() ? query.multiselect(selections) + : query.select((Selection) builder.construct(typeToRead, selections.toArray(new Selection[0]))); } else if (tree.isExistsProjection()) { @@ -241,83 +239,83 @@ public class JpaQueryCreator extends AbstractQueryCreator first = provider.next(part); - ParameterMetadata second = provider.next(part); - return builder.between(getComparablePath(root, part), first.getExpression(), second.getExpression()); - case AFTER: - case GREATER_THAN: - return builder.greaterThan(getComparablePath(root, part), - provider.next(part, Comparable.class).getExpression()); - case GREATER_THAN_EQUAL: - return builder.greaterThanOrEqualTo(getComparablePath(root, part), - provider.next(part, Comparable.class).getExpression()); - case BEFORE: - case LESS_THAN: - return builder.lessThan(getComparablePath(root, part), provider.next(part, Comparable.class).getExpression()); - case LESS_THAN_EQUAL: - return builder.lessThanOrEqualTo(getComparablePath(root, part), - provider.next(part, Comparable.class).getExpression()); - case IS_NULL: - return getTypedPath(root, part).isNull(); - case IS_NOT_NULL: - return getTypedPath(root, part).isNotNull(); - case NOT_IN: - // cast required for eclipselink workaround, see DATAJPA-433 - return upperIfIgnoreCase(getTypedPath(root, part)) - .in((Expression>) provider.next(part, Collection.class).getExpression()).not(); - case IN: - // cast required for eclipselink workaround, see DATAJPA-433 - return upperIfIgnoreCase(getTypedPath(root, part)) - .in((Expression>) provider.next(part, Collection.class).getExpression()); - case STARTING_WITH: - case ENDING_WITH: - case CONTAINING: - case NOT_CONTAINING: + case BETWEEN: + ParameterMetadata first = provider.next(part); + ParameterMetadata second = provider.next(part); + return builder.between(getComparablePath(root, part), first.getExpression(), second.getExpression()); + case AFTER: + case GREATER_THAN: + return builder.greaterThan(getComparablePath(root, part), + provider.next(part, Comparable.class).getExpression()); + case GREATER_THAN_EQUAL: + return builder.greaterThanOrEqualTo(getComparablePath(root, part), + provider.next(part, Comparable.class).getExpression()); + case BEFORE: + case LESS_THAN: + return builder.lessThan(getComparablePath(root, part), provider.next(part, Comparable.class).getExpression()); + case LESS_THAN_EQUAL: + return builder.lessThanOrEqualTo(getComparablePath(root, part), + provider.next(part, Comparable.class).getExpression()); + case IS_NULL: + return getTypedPath(root, part).isNull(); + case IS_NOT_NULL: + return getTypedPath(root, part).isNotNull(); + case NOT_IN: + // cast required for eclipselink workaround, see DATAJPA-433 + return upperIfIgnoreCase(getTypedPath(root, part)) + .in((Expression>) provider.next(part, Collection.class).getExpression()).not(); + case IN: + // cast required for eclipselink workaround, see DATAJPA-433 + return upperIfIgnoreCase(getTypedPath(root, part)) + .in((Expression>) provider.next(part, Collection.class).getExpression()); + case STARTING_WITH: + case ENDING_WITH: + case CONTAINING: + case NOT_CONTAINING: - if (property.getLeafProperty().isCollection()) { + if (property.getLeafProperty().isCollection()) { - Expression> propertyExpression = traversePath(root, property); - ParameterExpression parameterExpression = provider.next(part).getExpression(); + Expression> propertyExpression = traversePath(root, property); + ParameterExpression parameterExpression = provider.next(part).getExpression(); - // Can't just call .not() in case of negation as EclipseLink chokes on that. - return type.equals(NOT_CONTAINING) ? isNotMember(builder, parameterExpression, propertyExpression) - : isMember(builder, parameterExpression, propertyExpression); - } + // Can't just call .not() in case of negation as EclipseLink chokes on that. + return type.equals(NOT_CONTAINING) ? isNotMember(builder, parameterExpression, propertyExpression) + : isMember(builder, parameterExpression, propertyExpression); + } - case LIKE: - case NOT_LIKE: - Expression stringPath = getTypedPath(root, part); - Expression propertyExpression = upperIfIgnoreCase(stringPath); - Expression parameterExpression = upperIfIgnoreCase(provider.next(part, String.class).getExpression()); - Predicate like = builder.like(propertyExpression, parameterExpression, escape.getEscapeCharacter()); - return type.equals(NOT_LIKE) || type.equals(NOT_CONTAINING) ? like.not() : like; - case TRUE: - Expression truePath = getTypedPath(root, part); - return builder.isTrue(truePath); - case FALSE: - Expression falsePath = getTypedPath(root, part); - return builder.isFalse(falsePath); - case SIMPLE_PROPERTY: - ParameterMetadata expression = provider.next(part); - Expression path = getTypedPath(root, part); - return expression.isIsNullParameter() ? path.isNull() - : builder.equal(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression())); - case NEGATING_SIMPLE_PROPERTY: - return builder.notEqual(upperIfIgnoreCase(getTypedPath(root, part)), - upperIfIgnoreCase(provider.next(part).getExpression())); - case IS_EMPTY: - case IS_NOT_EMPTY: + case LIKE: + case NOT_LIKE: + Expression stringPath = getTypedPath(root, part); + Expression propertyExpression = upperIfIgnoreCase(stringPath); + Expression parameterExpression = upperIfIgnoreCase(provider.next(part, String.class).getExpression()); + Predicate like = builder.like(propertyExpression, parameterExpression, escape.getEscapeCharacter()); + return type.equals(NOT_LIKE) || type.equals(NOT_CONTAINING) ? like.not() : like; + case TRUE: + Expression truePath = getTypedPath(root, part); + return builder.isTrue(truePath); + case FALSE: + Expression falsePath = getTypedPath(root, part); + return builder.isFalse(falsePath); + case SIMPLE_PROPERTY: + ParameterMetadata expression = provider.next(part); + Expression path = getTypedPath(root, part); + return expression.isIsNullParameter() ? path.isNull() + : builder.equal(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression())); + case NEGATING_SIMPLE_PROPERTY: + return builder.notEqual(upperIfIgnoreCase(getTypedPath(root, part)), + upperIfIgnoreCase(provider.next(part).getExpression())); + case IS_EMPTY: + case IS_NOT_EMPTY: - if (!property.getLeafProperty().isCollection()) { - throw new IllegalArgumentException("IsEmpty / IsNotEmpty can only be used on collection properties!"); - } + if (!property.getLeafProperty().isCollection()) { + throw new IllegalArgumentException("IsEmpty / IsNotEmpty can only be used on collection properties!"); + } - Expression> collectionPath = traversePath(root, property); - return type.equals(IS_NOT_EMPTY) ? builder.isNotEmpty(collectionPath) : builder.isEmpty(collectionPath); + Expression> collectionPath = traversePath(root, property); + return type.equals(IS_NOT_EMPTY) ? builder.isNotEmpty(collectionPath) : builder.isEmpty(collectionPath); - default: - throw new IllegalArgumentException("Unsupported keyword " + type); + default: + throw new IllegalArgumentException("Unsupported keyword " + type); } } @@ -342,22 +340,22 @@ public class JpaQueryCreator extends AbstractQueryCreator) builder.upper((Expression) expression); + + case WHEN_POSSIBLE: + + if (canUpperCase(expression)) { return (Expression) builder.upper((Expression) expression); + } - case WHEN_POSSIBLE: + case NEVER: + default: - if (canUpperCase(expression)) { - return (Expression) builder.upper((Expression) expression); - } - - case NEVER: - default: - - return (Expression) expression; + return (Expression) expression; } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java index c44bf7eb7..2c998d4db 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java @@ -155,7 +155,7 @@ public class JpaQueryMethod extends QueryMethod { String annotatedQuery = getAnnotatedQuery(); - if (!DeclaredQuery.of(annotatedQuery).hasNamedParameter()) { + if (!DeclaredQuery.of(annotatedQuery, this.isNativeQuery.get()).hasNamedParameter()) { return; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java index 73f7763a9..e79a39a3e 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java @@ -78,7 +78,7 @@ final class NamedQuery extends AbstractJpaQuery { Query query = em.createNamedQuery(queryName); String queryString = extractor.extractQueryString(query); - this.declaredQuery = DeclaredQuery.of(queryString); + this.declaredQuery = DeclaredQuery.of(queryString, false); boolean weNeedToCreateCountQuery = !namedCountQueryIsPresent && method.getParameters().hasPageableParameter(); boolean cantExtractQuery = !this.extractor.canExtractQuery(); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java new file mode 100644 index 000000000..b8fba6e99 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java @@ -0,0 +1,116 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import java.util.Set; + +import org.springframework.data.domain.Sort; +import org.springframework.lang.Nullable; + +/** + * This interface describes the API for enhancing a given Query. + * + * @author Diego Krupitza + */ +public interface QueryEnhancer { + + /** + * 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}. + */ + String getExistsQueryString(String entityName, String countQueryPlaceHolder, Iterable idAttributes); + + /** + * 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}. + */ + String getQueryString(String template, String entityName); + + /** + * Adds {@literal order by} clause to the JPQL query. Uses the first alias to bind the sorting property to. + * + * @param sort the sort specification to apply. + * @return the modified query string. + */ + default String applySorting(Sort sort) { + return applySorting(sort, detectAlias()); + } + + /** + * Adds {@literal order by} clause to the JPQL query. + * + * @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. + */ + String applySorting(Sort sort, @Nullable String alias); + + /** + * Resolves the alias for the entity to be retrieved from the given JPA query. + * + * @return Might return {@literal null}. + */ + @Nullable + String detectAlias(); + + /** + * Creates a count projected query from the given original query. + * + * @return Guaranteed to be not {@literal null}. + */ + default String createCountQueryFor() { + return createCountQueryFor(null); + } + + /** + * Creates a count projected query from the given original query using the provided countProjection. + * + * @param countProjection may be {@literal null}. + * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}. + */ + String createCountQueryFor(@Nullable String countProjection); + + /** + * Returns whether the given JPQL query contains a constructor expression. + * + * @return whether the given JPQL query contains a constructor expression. + */ + default boolean hasConstructorExpression() { + return QueryUtils.hasConstructorExpression(getQuery().getQueryString()); + } + + /** + * Returns the projection part of the query, i.e. everything between {@code select} and {@code from}. + * + * @return the projection part of the query. + */ + String getProjection(); + + Set getJoinAliases(); + + /** + * Gets the query we want to use for enhancements. + * + * @return non null {@link DeclaredQuery} that wraps the query + */ + DeclaredQuery getQuery(); +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactory.java new file mode 100644 index 000000000..d6123ddee --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactory.java @@ -0,0 +1,75 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Encapsulates different strategies for the creation of a {@link QueryEnhancer} from a {@link DeclaredQuery}. + * + * @author Diego Krupitza + */ +public final class QueryEnhancerFactory { + + private static final Log LOG = LogFactory.getLog(QueryEnhancerFactory.class); + + private static final boolean JSQLPARSER_IN_CLASSPATH = isJSqlParserInClassPath(); + + private QueryEnhancerFactory() { + } + + /** + * Creates a new {@link QueryEnhancer} for the given {@link DeclaredQuery}. + * + * @param query must not be {@literal null}. + * @return an implementation of {@link QueryEnhancer} that suits the query the most + */ + public static QueryEnhancer forQuery(DeclaredQuery query) { + if (qualifiesForJSqlParserUsage(query)) { + return new JSqlParserQueryEnhancer(query); + } else { + return new DefaultQueryEnhancer(query); + } + } + + /** + * Checks if a given query can be process with the JSqlParser under the condition that the parser is in the classpath. + * + * @param query the query we want to check + * @return true if JSqlParser is in the classpath and the query is classified as a native query otherwise + * false + */ + private static boolean qualifiesForJSqlParserUsage(DeclaredQuery query) { + return JSQLPARSER_IN_CLASSPATH && query.isNativeQuery(); + } + + /** + * Checks whether JSqlParser is in classpath or not. + * + * @return true when in classpath otherwise false + */ + private static boolean isJSqlParserInClassPath() { + try { + Class.forName("net.sf.jsqlparser.parser.JSqlParser", false, QueryEnhancerFactory.class.getClassLoader()); + LOG.info("JSqlParser is in classpath. If applicable JSqlParser will be used."); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java index 1defa4a3f..d14fea67e 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java @@ -49,7 +49,8 @@ interface QueryParameterSetter { void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandling errorHandling); /** Noop implementation */ - QueryParameterSetter NOOP = (query, values, errorHandling) -> {}; + QueryParameterSetter NOOP = (query, values, errorHandling) -> { + }; /** * {@link QueryParameterSetter} for named or indexed parameters that might have a {@link TemporalType} specified. 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 28bf1c7dc..dc20aad83 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 @@ -56,7 +56,7 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** - * Simple utility class to create JPA queries. + * Simple utility class to create JPA queries using the default implementation of a custom parser. * * @author Oliver Gierke * @author Kevin Raymond @@ -74,6 +74,7 @@ import org.springframework.util.StringUtils; * @author Andriy Redko * @author Peter Großmann * @author Greg Turnquist + * @author Diego Krupitza */ public abstract class QueryUtils { @@ -282,7 +283,7 @@ public abstract class QueryUtils { * @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 a order clause. Guaranteed to be not {@literal null}. + * @return a String containing an order clause. Guaranteed to be not {@literal null}. */ private static String getOrderClause(Set joinAliases, Set selectionAlias, @Nullable String alias, Order order) { @@ -811,7 +812,7 @@ public abstract class QueryUtils { * * @param order */ - private static void checkSortExpression(Order order) { + static void checkSortExpression(Order order) { if (order instanceof JpaOrder && ((JpaOrder) order).isUnsafe()) { return; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java index fef47a6f9..37f3af731 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java @@ -46,6 +46,7 @@ import org.springframework.util.StringUtils; * @author Oliver Wehrens * @author Mark Paluch * @author Jens Schauder + * @author Diego Krupitza */ class StringQuery implements DeclaredQuery { @@ -55,6 +56,8 @@ class StringQuery implements DeclaredQuery { private final boolean hasConstructorExpression; private final boolean containsPageableInSpel; private final boolean usesJdbcStyleParameters; + private final boolean isNative; + private final QueryEnhancer queryEnhancer; /** * Creates a new {@link StringQuery} from the given JPQL query. @@ -62,10 +65,11 @@ class StringQuery implements DeclaredQuery { * @param query must not be {@literal null} or empty. */ @SuppressWarnings("deprecation") - StringQuery(String query) { + StringQuery(String query, boolean isNative) { Assert.hasText(query, "Query must not be null or empty!"); + this.isNative = isNative; this.bindings = new ArrayList<>(); this.containsPageableInSpel = query.contains("#pageable"); @@ -74,8 +78,10 @@ class StringQuery implements DeclaredQuery { this.bindings, queryMeta); this.usesJdbcStyleParameters = queryMeta.usesJdbcStyleParameters; - this.alias = QueryUtils.detectAlias(query); - this.hasConstructorExpression = QueryUtils.hasConstructorExpression(query); + + this.queryEnhancer = QueryEnhancerFactory.forQuery(this); + this.alias = this.queryEnhancer.detectAlias(); + this.hasConstructorExpression = this.queryEnhancer.hasConstructorExpression(); } /** @@ -86,7 +92,7 @@ class StringQuery implements DeclaredQuery { } String getProjection() { - return QueryUtils.getProjection(query); + return this.queryEnhancer.getProjection(); } @Override @@ -98,8 +104,9 @@ class StringQuery implements DeclaredQuery { @SuppressWarnings("deprecation") public DeclaredQuery deriveCountQuery(@Nullable String countQuery, @Nullable String countQueryProjection) { - return DeclaredQuery - .of(countQuery != null ? countQuery : QueryUtils.createCountQueryFor(query, countQueryProjection)); + return DeclaredQuery.of( + countQuery != null ? countQuery : this.queryEnhancer.createCountQueryFor(countQueryProjection), // + this.isNative); } @Override @@ -138,6 +145,11 @@ class StringQuery implements DeclaredQuery { return containsPageableInSpel; } + @Override + public boolean isNativeQuery() { + return isNative; + } + /** * A parser that extracts the parameter bindings from a given query string. * @@ -227,7 +239,8 @@ class StringQuery implements DeclaredQuery { Integer parameterIndex = getParameterIndex(parameterIndexString); String typeSource = matcher.group(COMPARISION_TYPE_GROUP); - Assert.isTrue(parameterIndexString != null || parameterName != null, () -> String.format("We need either a name or an index! Offending query string: %s", query)); + Assert.isTrue(parameterIndexString != null || parameterName != null, + () -> String.format("We need either a name or an index! Offending query string: %s", query)); String expression = spelExtractor.getParameter(parameterName == null ? parameterIndexString : parameterName); String replacement = null; @@ -246,36 +259,36 @@ class StringQuery implements DeclaredQuery { switch (ParameterBindingType.of(typeSource)) { - case LIKE: + case LIKE: - Type likeType = LikeParameterBinding.getLikeTypeFrom(matcher.group(2)); - replacement = matcher.group(3); + Type likeType = LikeParameterBinding.getLikeTypeFrom(matcher.group(2)); + replacement = matcher.group(3); - if (parameterIndex != null) { - checkAndRegister(new LikeParameterBinding(parameterIndex, likeType, expression), bindings); - } else { - checkAndRegister(new LikeParameterBinding(parameterName, likeType, expression), bindings); + if (parameterIndex != null) { + checkAndRegister(new LikeParameterBinding(parameterIndex, likeType, expression), bindings); + } else { + checkAndRegister(new LikeParameterBinding(parameterName, likeType, expression), bindings); - replacement = ":" + parameterName; - } + replacement = ":" + parameterName; + } - break; + break; - case IN: + case IN: - if (parameterIndex != null) { - checkAndRegister(new InParameterBinding(parameterIndex, expression), bindings); - } else { - checkAndRegister(new InParameterBinding(parameterName, expression), bindings); - } + if (parameterIndex != null) { + checkAndRegister(new InParameterBinding(parameterIndex, expression), bindings); + } else { + checkAndRegister(new InParameterBinding(parameterName, expression), bindings); + } - break; + break; - case AS_IS: // fall-through we don't need a special parameter binding for the given parameter. - default: + case AS_IS: // fall-through we don't need a special parameter binding for the given parameter. + default: - bindings.add(parameterIndex != null ? new ParameterBinding(null, parameterIndex, expression) - : new ParameterBinding(parameterName, null, expression)); + bindings.add(parameterIndex != null ? new ParameterBinding(null, parameterIndex, expression) + : new ParameterBinding(parameterName, null, expression)); } if (replacement != null) { 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 ba8affb9f..b9b7e1cf2 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 @@ -103,10 +103,12 @@ import org.springframework.transaction.annotation.Transactional; @Transactional public class UserRepositoryTests { - @PersistenceContext EntityManager em; + @PersistenceContext + EntityManager em; // CUT - @Autowired UserRepository repository; + @Autowired + UserRepository repository; // Test fixture private User firstUser; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryUtilsUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryUtilsUnitTests.java new file mode 100644 index 000000000..ec08e3ea9 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryUtilsUnitTests.java @@ -0,0 +1,530 @@ +/* + * Copyright 2008-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.jpa.repository.query.QueryUtils.*; + +import java.util.Collections; +import java.util.Set; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.Test; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; +import org.springframework.data.jpa.domain.JpaSort; + +/** + * Unit test for {@link QueryUtils}. + * + * @author Oliver Gierke + * @author Thomas Darimont + * @author Komi Innocent + * @author Christoph Strobl + * @author Jens Schauder + * @author Florian Lüdiger + * @author Grégoire Druant + * @author Mohammad Hewedy + * @author Greg Turnquist + */ +class DefaultQueryUtilsUnitTests { + + private static final String QUERY = "select u from User u"; + private static final String FQ_QUERY = "select u from org.acme.domain.User$Foo_Bar u"; + private static final String SIMPLE_QUERY = "from User u"; + private static final String COUNT_QUERY = "select count(u) from User u"; + + private static final String QUERY_WITH_AS = "select u from User as u where u.username = ?"; + + @Test + void createsCountQueryCorrectly() { + assertCountQuery(QUERY, COUNT_QUERY); + } + + @Test + void createsCountQueriesCorrectlyForCapitalLetterJPQL() { + + assertCountQuery("FROM User u WHERE u.foo.bar = ?", "select count(u) FROM User u WHERE u.foo.bar = ?"); + + assertCountQuery("SELECT u FROM User u where u.foo.bar = ?", "select count(u) FROM User u where u.foo.bar = ?"); + } + + @Test + void createsCountQueryForDistinctQueries() { + + assertCountQuery("select distinct u from User u where u.foo = ?", + "select count(distinct u) from User u where u.foo = ?"); + } + + @Test + void createsCountQueryForConstructorQueries() { + + assertCountQuery("select distinct new User(u.name) from User u where u.foo = ?", + "select count(distinct u) from User u where u.foo = ?"); + } + + @Test + void createsCountQueryForJoins() { + + assertCountQuery("select distinct new User(u.name) from User u left outer join u.roles r WHERE r = ?", + "select count(distinct u) from User u left outer join u.roles r WHERE r = ?"); + } + + @Test + void createsCountQueryForQueriesWithSubSelects() { + + assertCountQuery("select u from User u left outer join u.roles r where r in (select r from Role)", + "select count(u) from User u left outer join u.roles r where r in (select r from Role)"); + } + + @Test + void createsCountQueryForAliasesCorrectly() { + + assertCountQuery("select u from User as u", "select count(u) from User as u"); + } + + @Test + void allowsShortJpaSyntax() { + + assertCountQuery(SIMPLE_QUERY, COUNT_QUERY); + } + + @Test + void detectsAliasCorrectly() { + + assertThat(detectAlias(QUERY)).isEqualTo("u"); + assertThat(detectAlias(SIMPLE_QUERY)).isEqualTo("u"); + assertThat(detectAlias(COUNT_QUERY)).isEqualTo("u"); + assertThat(detectAlias(QUERY_WITH_AS)).isEqualTo("u"); + assertThat(detectAlias("SELECT FROM USER U")).isEqualTo("U"); + assertThat(detectAlias("select u from User u")).isEqualTo("u"); + assertThat(detectAlias("select u from com.acme.User u")).isEqualTo("u"); + assertThat(detectAlias("select u from T05User u")).isEqualTo("u"); + } + + @Test + void allowsFullyQualifiedEntityNamesInQuery() { + + assertThat(detectAlias(FQ_QUERY)).isEqualTo("u"); + assertCountQuery(FQ_QUERY, "select count(u) from org.acme.domain.User$Foo_Bar u"); + } + + @Test // DATAJPA-252 + void detectsJoinAliasesCorrectly() { + + Set aliases = getOuterJoinAliases("select p from Person p left outer join x.foo b2_$ar where …"); + assertThat(aliases).hasSize(1); + assertThat(aliases).contains("b2_$ar"); + + aliases = getOuterJoinAliases("select p from Person p left join x.foo b2_$ar where …"); + assertThat(aliases).hasSize(1); + assertThat(aliases).contains("b2_$ar"); + + aliases = getOuterJoinAliases( + "select p from Person p left outer join x.foo as b2_$ar, left join x.bar as foo where …"); + assertThat(aliases).hasSize(2); + assertThat(aliases).contains("b2_$ar", "foo"); + + aliases = getOuterJoinAliases( + "select p from Person p left join x.foo as b2_$ar, left outer join x.bar foo where …"); + assertThat(aliases).hasSize(2); + assertThat(aliases).contains("b2_$ar", "foo"); + } + + @Test // DATAJPA-252 + void doesNotPrefixOrderReferenceIfOuterJoinAliasDetected() { + + String query = "select p from Person p left join p.address address"; + assertThat(applySorting(query, Sort.by("address.city"))).endsWith("order by address.city asc"); + assertThat(applySorting(query, Sort.by("address.city", "lastname"), "p")) + .endsWith("order by address.city asc, p.lastname asc"); + } + + @Test // DATAJPA-252 + void extendsExistingOrderByClausesCorrectly() { + + String query = "select p from Person p order by p.lastname asc"; + assertThat(applySorting(query, Sort.by("firstname"), "p")).endsWith("order by p.lastname asc, p.firstname asc"); + } + + @Test // DATAJPA-296 + void appliesIgnoreCaseOrderingCorrectly() { + + Sort sort = Sort.by(Order.by("firstname").ignoreCase()); + + String query = "select p from Person p"; + assertThat(applySorting(query, sort, "p")).endsWith("order by lower(p.firstname) asc"); + } + + @Test // DATAJPA-296 + void appendsIgnoreCaseOrderingCorrectly() { + + Sort sort = Sort.by(Order.by("firstname").ignoreCase()); + + String query = "select p from Person p order by p.lastname asc"; + assertThat(applySorting(query, sort, "p")).endsWith("order by p.lastname asc, lower(p.firstname) asc"); + } + + @Test // DATAJPA-342 + void usesReturnedVariableInCOuntProjectionIfSet() { + + assertCountQuery("select distinct m.genre from Media m where m.user = ?1 order by m.genre asc", + "select count(distinct m.genre) from Media m where m.user = ?1"); + } + + @Test // DATAJPA-343 + void projectsCOuntQueriesForQueriesWithSubselects() { + + assertCountQuery("select o from Foo o where cb.id in (select b from Bar b)", + "select count(o) from Foo o where cb.id in (select b from Bar b)"); + } + + @Test // DATAJPA-148 + void doesNotPrefixSortsIfFunction() { + + Sort sort = Sort.by("sum(foo)"); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> applySorting("select p from Person p", sort, "p")); + } + + @Test // DATAJPA-377 + void removesOrderByInGeneratedCountQueryFromOriginalQueryIfPresent() { + + assertCountQuery("select distinct m.genre from Media m where m.user = ?1 OrDer By m.genre ASC", + "select count(distinct m.genre) from Media m where m.user = ?1"); + } + + @Test // DATAJPA-375 + void findsExistingOrderByIndependentOfCase() { + + Sort sort = Sort.by("lastname"); + String query = applySorting("select p from Person p ORDER BY p.firstname", sort, "p"); + assertThat(query).endsWith("ORDER BY p.firstname, p.lastname asc"); + } + + @Test // DATAJPA-409 + void createsCountQueryForNestedReferenceCorrectly() { + assertCountQuery("select a.b from A a", "select count(a.b) from A a"); + } + + @Test // DATAJPA-420 + void createsCountQueryForScalarSelects() { + assertCountQuery("select p.lastname,p.firstname from Person p", "select count(p) from Person p"); + } + + @Test // DATAJPA-456 + void createCountQueryFromTheGivenCountProjection() { + assertThat(createCountQueryFor("select p.lastname,p.firstname from Person p", "p.lastname")) + .isEqualTo("select count(p.lastname) from Person p"); + } + + @Test // DATAJPA-726 + void detectsAliassesInPlainJoins() { + + String query = "select p from Customer c join c.productOrder p where p.delayed = true"; + Sort sort = Sort.by("p.lineItems"); + + assertThat(applySorting(query, sort, "c")).endsWith("order by p.lineItems asc"); + } + + @Test // DATAJPA-736 + void supportsNonAsciiCharactersInEntityNames() { + assertThat(createCountQueryFor("select u from Usèr u")).isEqualTo("select count(u) from Usèr u"); + } + + @Test // DATAJPA-798 + void detectsAliasInQueryContainingLineBreaks() { + assertThat(detectAlias("select \n u \n from \n User \nu")).isEqualTo("u"); + } + + @Test // DATAJPA-815 + void doesPrefixPropertyWith() { + + String query = "from Cat c join Dog d"; + Sort sort = Sort.by("dPropertyStartingWithJoinAlias"); + + assertThat(applySorting(query, sort, "c")).endsWith("order by c.dPropertyStartingWithJoinAlias asc"); + } + + @Test // DATAJPA-938 + void detectsConstructorExpressionInDistinctQuery() { + assertThat(hasConstructorExpression("select distinct new Foo() from Bar b")).isTrue(); + } + + @Test // DATAJPA-938 + void detectsComplexConstructorExpression() { + + assertThat(hasConstructorExpression("select new foo.bar.Foo(ip.id, ip.name, sum(lp.amount)) " // + + "from Bar lp join lp.investmentProduct ip " // + + "where (lp.toDate is null and lp.fromDate <= :now and lp.fromDate is not null) and lp.accountId = :accountId " // + + "group by ip.id, ip.name, lp.accountId " // + + "order by ip.name ASC")).isTrue(); + } + + @Test // DATAJPA-938 + void detectsConstructorExpressionWithLineBreaks() { + assertThat(hasConstructorExpression("select new foo.bar.FooBar(\na.id) from DtoA a ")).isTrue(); + } + + @Test // DATAJPA-960 + void doesNotQualifySortIfNoAliasDetected() { + assertThat(applySorting("from mytable where ?1 is null", Sort.by("firstname"))).endsWith("order by firstname asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotAllowWhitespaceInSort() { + + Sort sort = Sort.by("case when foo then bar"); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> applySorting("select p from Person p", sort, "p")); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixUnsageJpaSortFunctionCalls() { + + JpaSort sort = JpaSort.unsafe("sum(foo)"); + assertThat(applySorting("select p from Person p", sort, "p")).endsWith("order by sum(foo) asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixMultipleAliasedFunctionCalls() { + + String query = "SELECT AVG(m.price) AS avgPrice, SUM(m.stocks) AS sumStocks FROM Magazine m"; + Sort sort = Sort.by("avgPrice", "sumStocks"); + + assertThat(applySorting(query, sort, "m")).endsWith("order by avgPrice asc, sumStocks asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixSingleAliasedFunctionCalls() { + + String query = "SELECT AVG(m.price) AS avgPrice FROM Magazine m"; + Sort sort = Sort.by("avgPrice"); + + assertThat(applySorting(query, sort, "m")).endsWith("order by avgPrice asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void prefixesSingleNonAliasedFunctionCallRelatedSortProperty() { + + String query = "SELECT AVG(m.price) AS avgPrice FROM Magazine m"; + Sort sort = Sort.by("someOtherProperty"); + + assertThat(applySorting(query, sort, "m")).endsWith("order by m.someOtherProperty asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void prefixesNonAliasedFunctionCallRelatedSortPropertyWhenSelectClauseContainesAliasedFunctionForDifferentProperty() { + + String query = "SELECT m.name, AVG(m.price) AS avgPrice FROM Magazine m"; + Sort sort = Sort.by("name", "avgPrice"); + + assertThat(applySorting(query, sort, "m")).endsWith("order by m.name asc, avgPrice asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWithMultipleNumericParameters() { + + String query = "SELECT SUBSTRING(m.name, 2, 5) AS trimmedName FROM Magazine m"; + Sort sort = Sort.by("trimmedName"); + + assertThat(applySorting(query, sort, "m")).endsWith("order by trimmedName asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWithMultipleStringParameters() { + + String query = "SELECT CONCAT(m.name, 'foo') AS extendedName FROM Magazine m"; + Sort sort = Sort.by("extendedName"); + + assertThat(applySorting(query, sort, "m")).endsWith("order by extendedName asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWithUnderscores() { + + String query = "SELECT AVG(m.price) AS avg_price FROM Magazine m"; + Sort sort = Sort.by("avg_price"); + + assertThat(applySorting(query, sort, "m")).endsWith("order by avg_price asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWithDots() { + + String query = "SELECT AVG(m.price) AS m.avg FROM Magazine m"; + Sort sort = Sort.by("m.avg"); + + assertThat(applySorting(query, sort, "m")).endsWith("order by m.avg asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWhenQueryStringContainsMultipleWhiteSpaces() { + + String query = "SELECT AVG( m.price ) AS avgPrice FROM Magazine m"; + Sort sort = Sort.by("avgPrice"); + + assertThat(applySorting(query, sort, "m")).endsWith("order by avgPrice asc"); + } + + @Test // DATAJPA-1000 + void discoversCorrectAliasForJoinFetch() { + + Set aliases = QueryUtils + .getOuterJoinAliases("SELECT DISTINCT user FROM User user LEFT JOIN FETCH user.authorities AS authority"); + + assertThat(aliases).containsExactly("authority"); + } + + @Test // DATAJPA-1171 + void doesNotContainStaticClauseInExistsQuery() { + + assertThat(QueryUtils.getExistsQueryString("entity", "x", Collections.singleton("id"))) // + .endsWith("WHERE x.id = :id"); + } + + @Test // DATAJPA-1363 + void discoversAliasWithComplexFunction() { + + assertThat(QueryUtils + .getFunctionAliases("select new MyDto(sum(case when myEntity.prop3=0 then 1 else 0 end) as myAlias")) // + .contains("myAlias"); + } + + @Test // DATAJPA-1506 + void detectsAliasWithGroupAndOrderBy() { + + assertThat(detectAlias("select * from User group by name")).isNull(); + assertThat(detectAlias("select * from User order by name")).isNull(); + assertThat(detectAlias("select * from User u group by name")).isEqualTo("u"); + assertThat(detectAlias("select * from User u order by name")).isEqualTo("u"); + } + + @Test // DATAJPA-1500 + void createCountQuerySupportsWhitespaceCharacters() { + + assertThat(createCountQueryFor("select * from User user\n" + // + " where user.age = 18\n" + // + " order by user.name\n ")).isEqualTo("select count(user) from User user\n" + // + " where user.age = 18\n "); + } + + @Test + void createCountQuerySupportsLineBreaksInSelectClause() { + + assertThat(createCountQueryFor("select user.age,\n" + // + " user.name\n" + // + " from User user\n" + // + " where user.age = 18\n" + // + " order\nby\nuser.name\n ")).isEqualTo("select count(user) from User user\n" + // + " where user.age = 18\n "); + } + + @Test // DATAJPA-1061 + void appliesSortCorrectlyForFieldAliases() { + + String query = "SELECT m.price, lower(m.title) AS title, a.name as authorName FROM Magazine m INNER JOIN m.author a"; + Sort sort = Sort.by("authorName"); + + String fullQuery = applySorting(query, sort); + + assertThat(fullQuery).endsWith("order by authorName asc"); + } + + @Test // GH-2280 + void appliesOrderingCorrectlyForFieldAliasWithIgnoreCase() { + + String query = "SELECT customer.id as id, customer.name as name FROM CustomerEntity customer"; + Sort sort = Sort.by(Order.by("name").ignoreCase()); + + String fullQuery = applySorting(query, sort); + + assertThat(fullQuery).isEqualTo( + "SELECT customer.id as id, customer.name as name FROM CustomerEntity customer order by lower(name) asc"); + } + + @Test // DATAJPA-1061 + void appliesSortCorrectlyForFunctionAliases() { + + String query = "SELECT m.price, lower(m.title) AS title, a.name as authorName FROM Magazine m INNER JOIN m.author a"; + Sort sort = Sort.by("title"); + + String fullQuery = applySorting(query, sort); + + assertThat(fullQuery).endsWith("order by title asc"); + } + + @Test // DATAJPA-1061 + void appliesSortCorrectlyForSimpleField() { + + String query = "SELECT m.price, lower(m.title) AS title, a.name as authorName FROM Magazine m INNER JOIN m.author a"; + Sort sort = Sort.by("price"); + + String fullQuery = applySorting(query, sort); + + assertThat(fullQuery).endsWith("order by m.price asc"); + } + + @Test + void createCountQuerySupportsLineBreakRightAfterDistinct() { + + assertThat(createCountQueryFor("select\ndistinct\nuser.age,\n" + // + "user.name\n" + // + "from\nUser\nuser")).isEqualTo(createCountQueryFor("select\ndistinct user.age,\n" + // + "user.name\n" + // + "from\nUser\nuser")); + } + + @Test + void detectsAliasWithGroupAndOrderByWithLineBreaks() { + + assertThat(detectAlias("select * from User group\nby name")).isNull(); + assertThat(detectAlias("select * from User order\nby name")).isNull(); + assertThat(detectAlias("select * from User u group\nby name")).isEqualTo("u"); + assertThat(detectAlias("select * from User u order\nby name")).isEqualTo("u"); + assertThat(detectAlias("select * from User\nu\norder \n by name")).isEqualTo("u"); + } + + @Test // DATAJPA-1679 + void findProjectionClauseWithDistinct() { + + SoftAssertions.assertSoftly(sofly -> { + sofly.assertThat(QueryUtils.getProjection("select * from x")).isEqualTo("*"); + sofly.assertThat(QueryUtils.getProjection("select a, b, c from x")).isEqualTo("a, b, c"); + sofly.assertThat(QueryUtils.getProjection("select distinct a, b, c from x")).isEqualTo("a, b, c"); + sofly.assertThat(QueryUtils.getProjection("select DISTINCT a, b, c from x")).isEqualTo("a, b, c"); + }); + } + + @Test // DATAJPA-1696 + void findProjectionClauseWithSubselect() { + + // This is not a required behavior, in fact the opposite is, + // but it documents a current limitation. + // to fix this without breaking findProjectionClauseWithIncludedFrom we need a more sophisticated parser. + assertThat(QueryUtils.getProjection("select * from (select x from y)")).isNotEqualTo("*"); + } + + @Test // DATAJPA-1696 + void findProjectionClauseWithIncludedFrom() { + assertThat(QueryUtils.getProjection("select x, frommage, y from t")).isEqualTo("x, frommage, y"); + } + + private static void assertCountQuery(String originalQuery, String countQuery) { + assertThat(createCountQueryFor(originalQuery)).isEqualTo(countQuery); + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQueryUnitTests.java index 7a747cb98..ac8148850 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQueryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQueryUnitTests.java @@ -35,13 +35,15 @@ import org.springframework.expression.spel.standard.SpelExpressionParser; * @author Jens Schauder * @author Mark Paluch * @author Michael J. Simons + * @author Diego Krupitza */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class ExpressionBasedStringQueryUnitTests { private static final SpelExpressionParser SPEL_PARSER = new SpelExpressionParser(); - @Mock JpaEntityMetadata metadata; + @Mock + JpaEntityMetadata metadata; @Test // DATAJPA-170 void shouldReturnQueryWithDomainTypeExpressionReplacedWithSimpleDomainTypeName() { @@ -49,7 +51,7 @@ class ExpressionBasedStringQueryUnitTests { when(metadata.getEntityName()).thenReturn("User"); String source = "select from #{#entityName} u where u.firstname like :firstname"; - StringQuery query = new ExpressionBasedStringQuery(source, metadata, SPEL_PARSER); + StringQuery query = new ExpressionBasedStringQuery(source, metadata, SPEL_PARSER, false); assertThat(query.getQueryString()).isEqualTo("select from User u where u.firstname like :firstname"); } @@ -58,7 +60,7 @@ class ExpressionBasedStringQueryUnitTests { when(metadata.getEntityName()).thenReturn("User"); - StringQuery query = new ExpressionBasedStringQuery("select u from #{#entityName} u", metadata, SPEL_PARSER); + StringQuery query = new ExpressionBasedStringQuery("select u from #{#entityName} u", metadata, SPEL_PARSER, true); assertThat(query.getAlias()).isEqualTo("u"); assertThat(query.getQueryString()).isEqualTo("select u from User u"); } @@ -71,7 +73,7 @@ class ExpressionBasedStringQueryUnitTests { + "+ \"AND (LOWER(n.server) LIKE LOWER(NULLIF(text(concat('%',:#{#networkRequest.server},'%')), '')) OR :#{#networkRequest.server} IS NULL)\"\n" + "+ \"AND (n.createdAt >= :#{#networkRequest.createdTime.startDateTime}) AND (n.createdAt <=:#{#networkRequest.createdTime.endDateTime})\"\n" + "+ \"AND (n.updatedAt >= :#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=:#{#networkRequest.updatedTime.endDateTime})", - metadata, SPEL_PARSER); + metadata, SPEL_PARSER, false); assertThat(query.getParameterBindings()).hasSize(8); } @@ -80,13 +82,39 @@ class ExpressionBasedStringQueryUnitTests { void shouldDetectBindParameterCountCorrectlyWithJDBCStyleParameters() { StringQuery query = new ExpressionBasedStringQuery( - "select n from #{#entityName} n where (LOWER(n.name) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.name},'%')), '')) OR ?#{#networkRequest.name} IS NULL )\"\n" - + "+ \"AND (LOWER(n.server) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.server},'%')), '')) OR ?#{#networkRequest.server} IS NULL)\"\n" - + "+ \"AND (n.createdAt >= ?#{#networkRequest.createdTime.startDateTime}) AND (n.createdAt <=?#{#networkRequest.createdTime.endDateTime})\"\n" - + "+ \"AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})", - metadata, SPEL_PARSER); + "select n from #{#entityName} n where (LOWER(n.name) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.name},'%')), '')) OR ?#{#networkRequest.name} IS NULL )\"\n" + + "+ \"AND (LOWER(n.server) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.server},'%')), '')) OR ?#{#networkRequest.server} IS NULL)\"\n" + + "+ \"AND (n.createdAt >= ?#{#networkRequest.createdTime.startDateTime}) AND (n.createdAt <=?#{#networkRequest.createdTime.endDateTime})\"\n" + + "+ \"AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})", + metadata, SPEL_PARSER, false); assertThat(query.getParameterBindings()).hasSize(8); } + @Test + void shouldDetectComplexNativeQueriesWithSpelAsNonNative() { + StringQuery query = new ExpressionBasedStringQuery( + "select n from #{#entityName} n where (LOWER(n.name) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.name},'%')), '')) OR ?#{#networkRequest.name} IS NULL )\"\n" + + "+ \"AND (LOWER(n.server) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.server},'%')), '')) OR ?#{#networkRequest.server} IS NULL)\"\n" + + "+ \"AND (n.createdAt >= ?#{#networkRequest.createdTime.startDateTime}) AND (n.createdAt <=?#{#networkRequest.createdTime.endDateTime})\"\n" + + "+ \"AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})", + metadata, SPEL_PARSER, true); + + assertThat(query.isNativeQuery()).isFalse(); + } + + @Test + void shouldDetectSimpleNativeQueriesWithSpelAsNonNative() { + StringQuery query = new ExpressionBasedStringQuery("select n from #{#entityName} n", metadata, SPEL_PARSER, true); + + assertThat(query.isNativeQuery()).isFalse(); + } + + @Test + void shouldDetectSimpleNativeQueriesWithoutSpelAsNonNative() { + StringQuery query = new ExpressionBasedStringQuery("select u from User u", metadata, SPEL_PARSER, true); + + assertThat(query.isNativeQuery()).isTrue(); + } + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryMethodUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryMethodUnitTests.java index dd84a156a..348821256 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryMethodUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryMethodUnitTests.java @@ -74,8 +74,10 @@ public class JpaQueryMethodUnitTests { private static final String METHOD_NAME = "findByFirstname"; - @Mock QueryExtractor extractor; - @Mock RepositoryMetadata metadata; + @Mock + QueryExtractor extractor; + @Mock + RepositoryMetadata metadata; private ProjectionFactory factory = new SpelAwareProxyProjectionFactory(); private Method invalidReturnType; @@ -518,7 +520,7 @@ public class JpaQueryMethodUnitTests { interface ValidRepository extends Repository { - @Query(value = "query", nativeQuery = true) + @Query(value = "Select u from User u where u.lastname = ?1", nativeQuery = true) List findByLastname(String lastname); @Query(name = "HateoasAwareSpringDataWebConfiguration.bar") diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java index 7c4f764fe..742dcf045 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java @@ -23,6 +23,7 @@ import org.springframework.data.jpa.repository.query.StringQuery.ParameterBindin * Unit tests for the {@link ParameterBindingParser}. * * @author Jens Schauder + * @author Diego Krupitza */ class ParameterBindingParserUnitTests { @@ -65,7 +66,7 @@ class ParameterBindingParserUnitTests { private void checkHasParameter(SoftAssertions softly, String query, boolean containsParameter, String label) { - StringQuery stringQuery = new StringQuery(query); + StringQuery stringQuery = new StringQuery(query, false); softly.assertThat(stringQuery.getParameterBindings().size()) // .describedAs(String.format("<%s> (%s)", query, label)) // diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactoryUnitTests.java new file mode 100644 index 000000000..1eb04239e --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactoryUnitTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link QueryEnhancerFactory}. + * + * @author Diego Krupitza + */ +class QueryEnhancerFactoryUnitTests { + + @Test + void createsDefaultImplementationForNonNativeQuery() { + StringQuery query = new StringQuery("Select new User(u.firstname) from User u", false); + + QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query); + assertThat(queryEnhancer) // + .isInstanceOf(DefaultQueryEnhancer.class); + } + + @Test + void createsJSqlImplementationForNativeQuery() { + StringQuery query = new StringQuery("Select * from User", true); + + QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query); + assertThat(queryEnhancer) // + .isInstanceOf(JSqlParserQueryEnhancer.class); + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java new file mode 100644 index 000000000..7ba11d74b --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java @@ -0,0 +1,707 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.JpaSort; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.*; + +/** + * Unit test for {@link QueryEnhancer}. + * + * @author Diego Krupitza + */ +class QueryEnhancerUnitTests { + + private static final String QUERY = "select u from User u"; + private static final String FQ_QUERY = "select u from org.acme.domain.User$Foo_Bar u"; + private static final String SIMPLE_QUERY = "from User u"; + private static final String COUNT_QUERY = "select count(u) from User u"; + + private static final String QUERY_WITH_AS = "select u from User as u where u.username = ?"; + + @Test + void createsCountQueryCorrectly() { + assertCountQuery(QUERY, COUNT_QUERY, true); + } + + @Test + void createsCountQueriesCorrectlyForCapitalLetterJPQL() { + + assertCountQuery("FROM User u WHERE u.foo.bar = ?", "select count(u) FROM User u WHERE u.foo.bar = ?", false); + + assertCountQuery("SELECT u FROM User u where u.foo.bar = ?", "select count(u) FROM User u where u.foo.bar = ?", + true); + } + + @Test + void createsCountQueryForDistinctQueries() { + + assertCountQuery("select distinct u from User u where u.foo = ?", + "select count(distinct u) from User u where u.foo = ?", true); + } + + @Test + void createsCountQueryForConstructorQueries() { + + assertCountQuery("select distinct new User(u.name) from User u where u.foo = ?", + "select count(distinct u) from User u where u.foo = ?", false); + } + + @Test + void createsCountQueryForJoinsNoneNative() { + + assertCountQuery("select distinct new User(u.name) from User u left outer join u.roles r WHERE r = ?", + "select count(distinct u) from User u left outer join u.roles r WHERE r = ?", false); + } + + @Test + void createsCountQueryForJoinsNative() { + + assertCountQuery("select distinct u.name from User u left outer join u.roles r WHERE r = ?", + "select count(distinct u.name) from User u left outer join u.roles r WHERE r = ?", true); + } + + @Test + void createsCountQueryForQueriesWithSubSelects() { + + assertCountQuery("select u from User u left outer join u.roles r where r in (select r from Role)", + "select count(u) from User u left outer join u.roles r where r in (select r from Role)", true); + } + + @Test + void createsCountQueryForAliasesCorrectly() { + + assertCountQuery("select u from User as u", "select count(u) from User as u", true); + } + + @Test + void allowsShortJpaSyntax() { + + assertCountQuery(SIMPLE_QUERY, COUNT_QUERY, false); + } + + @ParameterizedTest + @MethodSource("detectsAliasWithUCorrectlySource") + void detectsAliasWithUCorrectly(DeclaredQuery query, String alias) { + assertThat(getEnhancer(query).detectAlias()).isEqualTo(alias); + } + + public static Stream detectsAliasWithUCorrectlySource() { + return Stream.of( // + Arguments.of(new StringQuery(QUERY, true), "u"), // + Arguments.of(new StringQuery(SIMPLE_QUERY, false), "u"), // + Arguments.of(new StringQuery(COUNT_QUERY, true), "u"), // + Arguments.of(new StringQuery(QUERY_WITH_AS, true), "u"), // + Arguments.of(new StringQuery("SELECT FROM USER U", false), "U"), // + Arguments.of(new StringQuery("select u from User u", true), "u"), // + Arguments.of(new StringQuery("select u from com.acme.User u", true), "u"), // + Arguments.of(new StringQuery("select u from T05User u", true), "u") // + ); + } + + @Test + void allowsFullyQualifiedEntityNamesInQuery() { + + StringQuery query = new StringQuery(FQ_QUERY, true); + assertThat(getEnhancer(query).detectAlias()).isEqualTo("u"); + assertCountQuery(FQ_QUERY, "select count(u) from org.acme.domain.User$Foo_Bar u", true); + } + + @Test // DATAJPA-252 + void doesNotPrefixOrderReferenceIfOuterJoinAliasDetected() { + + StringQuery query = new StringQuery("select p from Person p left join p.address address", true); + endsIgnoringCase(getEnhancer(query).applySorting(Sort.by("address.city")), "order by address.city asc"); + endsIgnoringCase(getEnhancer(query).applySorting(Sort.by("address.city", "lastname"), "p"), + "order by address.city asc, p.lastname asc"); + } + + @Test // DATAJPA-252 + void extendsExistingOrderByClausesCorrectly() { + + StringQuery query = new StringQuery("select p from Person p order by p.lastname asc", true); + endsIgnoringCase(getEnhancer(query).applySorting(Sort.by("firstname"), "p"), + "order by p.lastname asc, p.firstname asc"); + } + + @Test // DATAJPA-296 + void appliesIgnoreCaseOrderingCorrectly() { + + Sort sort = Sort.by(Sort.Order.by("firstname").ignoreCase()); + + StringQuery query = new StringQuery("select p from Person p", true); + endsIgnoringCase(getEnhancer(query).applySorting(sort, "p"), "order by lower(p.firstname) asc"); + } + + @Test // DATAJPA-296 + void appendsIgnoreCaseOrderingCorrectly() { + + Sort sort = Sort.by(Sort.Order.by("firstname").ignoreCase()); + + StringQuery query = new StringQuery("select p from Person p order by p.lastname asc", true); + endsIgnoringCase(getEnhancer(query).applySorting(sort, "p"), "order by p.lastname asc, lower(p.firstname) asc"); + } + + @Test // DATAJPA-342 + void usesReturnedVariableInCOuntProjectionIfSet() { + + assertCountQuery("select distinct m.genre from Media m where m.user = ?1 order by m.genre asc", + "select count(distinct m.genre) from Media m where m.user = ?1", true); + } + + @Test // DATAJPA-343 + void projectsCountQueriesForQueriesWithSubSelects() { + + assertCountQuery("select o from Foo o where cb.id in (select b from Bar b)", + "select count(o) from Foo o where cb.id in (select b from Bar b)", true); + } + + @Test // DATAJPA-148 + void doesNotPrefixSortsIfFunction() { + StringQuery query = new StringQuery("select p from Person p", true); + Sort sort = Sort.by("sum(foo)"); + + QueryEnhancer enhancer = getEnhancer(query); + + assertThatThrownBy(() -> enhancer.applySorting(sort, "p")) // + .isInstanceOf(InvalidDataAccessApiUsageException.class); + } + + @Test // DATAJPA-377 + void removesOrderByInGeneratedCountQueryFromOriginalQueryIfPresent() { + + assertCountQuery("select distinct m.genre from Media m where m.user = ?1 OrDer By m.genre ASC", + "select count(distinct m.genre) from Media m where m.user = ?1", true); + } + + @Test // DATAJPA-375 + void findsExistingOrderByIndependentOfCase() { + + Sort sort = Sort.by("lastname"); + StringQuery originalQuery = new StringQuery("select p from Person p ORDER BY p.firstname", true); + String query = getEnhancer(originalQuery).applySorting(sort, "p"); + endsIgnoringCase(query, "ORDER BY p.firstname, p.lastname asc"); + } + + @Test // DATAJPA-409 + void createsCountQueryForNestedReferenceCorrectly() { + assertCountQuery("select a.b from A a", "select count(a.b) from A a", true); + } + + @Test // DATAJPA-420 + void createsCountQueryForScalarSelects() { + assertCountQuery("select p.lastname,p.firstname from Person p", "select count(p) from Person p", true); + } + + @Test // DATAJPA-456 + void createCountQueryFromTheGivenCountProjection() { + StringQuery query = new StringQuery("select p.lastname,p.firstname from Person p", true); + assertThat(getEnhancer(query).createCountQueryFor("p.lastname")) + .isEqualToIgnoringCase("select count(p.lastname) from Person p"); + } + + @Test // DATAJPA-726 + void detectsAliassesInPlainJoins() { + + StringQuery query = new StringQuery("select p from Customer c join c.productOrder p where p.delaye = true", true); + Sort sort = Sort.by("p.lineItems"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "c"), "order by p.lineItems asc"); + } + + @Test // DATAJPA-736 + void supportsNonAsciiCharactersInEntityNames() { + StringQuery query = new StringQuery("select u from Usèr u", true); + assertThat(getEnhancer(query).createCountQueryFor()).isEqualToIgnoringCase("select count(u) from Usèr u"); + } + + @Test // DATAJPA-798 + void detectsAliasInQueryContainingLineBreaks() { + StringQuery query = new StringQuery("select \n u \n from \n User \nu", true); + assertThat(getEnhancer(query).detectAlias()).isEqualTo("u"); + } + + @Test // DATAJPA-815 + void doesPrefixPropertyWithNonNative() { + + StringQuery query = new StringQuery("from Cat c join Dog d", false); + Sort sort = Sort.by("dPropertyStartingWithJoinAlias"); + + assertThat(getEnhancer(query).applySorting(sort, "c")).endsWith("order by c.dPropertyStartingWithJoinAlias asc"); + } + + @Test // DATAJPA-815 + void doesPrefixPropertyWithNative() { + StringQuery query = new StringQuery("Select * from Cat c join Dog d", true); + Sort sort = Sort.by("dPropertyStartingWithJoinAlias"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "c"), "order by c.dPropertyStartingWithJoinAlias asc"); + } + + @Test // DATAJPA-938 + void detectsConstructorExpressionInDistinctQuery() { + StringQuery query = new StringQuery("select distinct new Foo() from Bar b", false); + assertThat(getEnhancer(query).hasConstructorExpression()).isTrue(); + } + + @Test // DATAJPA-938 + void detectsComplexConstructorExpression() { + + StringQuery query = new StringQuery("select new foo.bar.Foo(ip.id, ip.name, sum(lp.amount)) " // + + "from Bar lp join lp.investmentProduct ip " // + + "where (lp.toDate is null and lp.fromDate <= :now and lp.fromDate is not null) and lp.accountId = :accountId " + // + + "group by ip.id, ip.name, lp.accountId " // + + "order by ip.name ASC", false); + + assertThat(getEnhancer(query).hasConstructorExpression()).isTrue(); + } + + @Test // DATAJPA-938 + void detectsConstructorExpressionWithLineBreaks() { + StringQuery query = new StringQuery("select new foo.bar.FooBar(\na.id) from DtoA a ", false); + assertThat(getEnhancer(query).hasConstructorExpression()).isTrue(); + } + + @Test // DATAJPA-960 + void doesNotQualifySortIfNoAliasDetectedNonNative() { + StringQuery query = new StringQuery("from mytable where ?1 is null", false); + assertThat(getEnhancer(query).applySorting(Sort.by("firstname"))).endsWith("order by firstname asc"); + } + + @Test // DATAJPA-960 + void doesNotQualifySortIfNoAliasDetectedNative() { + StringQuery query = new StringQuery("Select * from mytable where ?1 is null", true); + endsIgnoringCase(getEnhancer(query).applySorting(Sort.by("firstname")), "order by firstname asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotAllowWhitespaceInSort() { + + StringQuery query = new StringQuery("select p from Person p", true); + + Sort sort = Sort.by("case when foo then bar"); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> getEnhancer(query).applySorting(sort, "p")); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixUnsageJpaSortFunctionCalls() { + + JpaSort sort = JpaSort.unsafe("sum(foo)"); + StringQuery query = new StringQuery("select p from Person p", true); + endsIgnoringCase(getEnhancer(query).applySorting(sort, "p"), "order by sum(foo) asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixMultipleAliasedFunctionCalls() { + + StringQuery query = new StringQuery("SELECT AVG(m.price) AS avgPrice, SUM(m.stocks) AS sumStocks FROM Magazine m", + true); + Sort sort = Sort.by("avgPrice", "sumStocks"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "m"), "order by avgPrice asc, sumStocks asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixSingleAliasedFunctionCalls() { + + StringQuery query = new StringQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true); + Sort sort = Sort.by("avgPrice"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "m"), "order by avgPrice asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void prefixesSingleNonAliasedFunctionCallRelatedSortProperty() { + + StringQuery query = new StringQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true); + Sort sort = Sort.by("someOtherProperty"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "m"), "order by m.someOtherProperty asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void prefixesNonAliasedFunctionCallRelatedSortPropertyWhenSelectClauseContainesAliasedFunctionForDifferentProperty() { + + StringQuery query = new StringQuery("SELECT m.name, AVG(m.price) AS avgPrice FROM Magazine m", true); + Sort sort = Sort.by("name", "avgPrice"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "m"), "order by m.name asc, avgPrice asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWithMultipleNumericParameters() { + + StringQuery query = new StringQuery("SELECT SUBSTRING(m.name, 2, 5) AS trimmedName FROM Magazine m", true); + Sort sort = Sort.by("trimmedName"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "m"), "order by trimmedName asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWithMultipleStringParameters() { + + StringQuery query = new StringQuery("SELECT CONCAT(m.name, 'foo') AS extendedName FROM Magazine m", true); + Sort sort = Sort.by("extendedName"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "m"), "order by extendedName asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWithUnderscores() { + + StringQuery query = new StringQuery("SELECT AVG(m.price) AS avg_price FROM Magazine m", true); + Sort sort = Sort.by("avg_price"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "m"), "order by avg_price asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWithDots() { + + StringQuery query = new StringQuery("SELECT AVG(m.price) AS m.avg FROM Magazine m", false); + Sort sort = Sort.by("m.avg"); + + assertThat(getEnhancer(query).applySorting(sort, "m")).endsWith("order by m.avg asc"); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWithDotsNativeQuery() { + + // this is invalid since the '.' character is not allowed. Not in sql nor in JPQL. + assertThatThrownBy(() -> new StringQuery("SELECT AVG(m.price) AS m.avg FROM Magazine m", true)) // + .isInstanceOf(IllegalArgumentException.class); + } + + @Test // DATAJPA-965, DATAJPA-970 + void doesNotPrefixAliasedFunctionCallNameWhenQueryStringContainsMultipleWhiteSpaces() { + + StringQuery query = new StringQuery("SELECT AVG( m.price ) AS avgPrice FROM Magazine m", true); + Sort sort = Sort.by("avgPrice"); + + endsIgnoringCase(getEnhancer(query).applySorting(sort, "m"), "order by avgPrice asc"); + } + + @Test // DATAJPA-1000 + void discoversCorrectAliasForJoinFetch() { + + String queryString = "SELECT DISTINCT user FROM User user LEFT JOIN user.authorities AS authority"; + Set aliases = QueryUtils.getOuterJoinAliases(queryString); + + StringQuery nativeQuery = new StringQuery(queryString, true); + Set joinAliases = new JSqlParserQueryEnhancer(nativeQuery).getJoinAliases(); + + assertThat(aliases).containsExactly("authority"); + assertThat(joinAliases).containsExactly("authority"); + } + + @Test // DATAJPA-1171 + void doesNotContainStaticClauseInExistsQuery() { + endsIgnoringCase(QueryUtils.getExistsQueryString("entity", "x", Collections.singleton("id")), "WHERE x.id = :id"); + } + + @Test // DATAJPA-1363 + void discoversAliasWithComplexFunction() { + + assertThat( + QueryUtils.getFunctionAliases("select new MyDto(sum(case when myEntity.prop3=0 then 1 else 0 end) as myAlias")) // + .contains("myAlias"); + } + + @Test // DATAJPA-1506 + void detectsAliasWithGroupAndOrderBy() { + + StringQuery queryWithGroupNoAlias = new StringQuery("select * from User group by name", true); + StringQuery queryWithGroupAlias = new StringQuery("select * from User u group by name", true); + + StringQuery queryWithOrderNoAlias = new StringQuery("select * from User order by name", true); + StringQuery queryWithOrderAlias = new StringQuery("select * from User u order by name", true); + + assertThat(getEnhancer(queryWithGroupNoAlias).detectAlias()).isNull(); + assertThat(getEnhancer(queryWithOrderNoAlias).detectAlias()).isNull(); + assertThat(getEnhancer(queryWithGroupAlias).detectAlias()).isEqualTo("u"); + assertThat(getEnhancer(queryWithOrderAlias).detectAlias()).isEqualTo("u"); + } + + @Test // DATAJPA-1500 + void createCountQuerySupportsWhitespaceCharacters() { + + StringQuery query = new StringQuery("select * from User user\n" + // + " where user.age = 18\n" + // + " order by user.name\n ", true); + + assertThat(getEnhancer(query).createCountQueryFor()) + .isEqualToIgnoringCase("select count(user) from User user where user.age = 18"); + } + + @Test + void createCountQuerySupportsLineBreaksInSelectClause() { + StringQuery query = new StringQuery("select user.age,\n" + // + " user.name\n" + // + " from User user\n" + // + " where user.age = 18\n" + // + " order\nby\nuser.name\n ", true); + assertThat(getEnhancer(query).createCountQueryFor()) + .isEqualToIgnoringCase("select count(user) from User user where user.age = 18"); + } + + @Test // DATAJPA-1061 + void appliesSortCorrectlyForFieldAliases() { + + StringQuery query = new StringQuery( + "SELECT m.price, lower(m.title) AS title, a.name as authorName FROM Magazine m INNER JOIN m.author a", + true); + Sort sort = Sort.by("authorName"); + + String fullQuery = getEnhancer(query).applySorting(sort); + + endsIgnoringCase(fullQuery, "order by authorName asc"); + } + + @Test // GH-2280 + void appliesOrderingCorrectlyForFieldAliasWithIgnoreCase() { + + StringQuery query = new StringQuery("SELECT customer.id as id, customer.name as name FROM CustomerEntity customer", + true); + Sort sort = Sort.by(Sort.Order.by("name").ignoreCase()); + + String fullQuery = getEnhancer(query).applySorting(sort); + + assertThat(fullQuery).isEqualToIgnoringCase( + "SELECT customer.id as id, customer.name as name FROM CustomerEntity customer order by lower(name) asc"); + } + + @Test // DATAJPA-1061 + void appliesSortCorrectlyForFunctionAliases() { + + StringQuery query = new StringQuery( + "SELECT m.price, lower(m.title) AS title, a.name as authorName FROM Magazine m INNER JOIN m.author a", + true); + Sort sort = Sort.by("title"); + + String fullQuery = getEnhancer(query).applySorting(sort); + + endsIgnoringCase(fullQuery, "order by title asc"); + } + + @Test // DATAJPA-1061 + void appliesSortCorrectlyForSimpleField() { + + StringQuery query = new StringQuery( + "SELECT m.price, lower(m.title) AS title, a.name as authorName FROM Magazine m INNER JOIN m.author a", + true); + Sort sort = Sort.by("price"); + + String fullQuery = getEnhancer(query).applySorting(sort); + + endsIgnoringCase(fullQuery, "order by m.price asc"); + } + + @Test + void createCountQuerySupportsLineBreakRightAfterDistinct() { + + StringQuery query1 = new StringQuery("select\ndistinct\nuser.age,\n" + // + "user.name\n" + // + "from\nUser\nuser", true); + + StringQuery query2 = new StringQuery("select\ndistinct user.age,\n" + // + "user.name\n" + // + "from\nUser\nuser", true); + + assertThat(getEnhancer(query1).createCountQueryFor()).isEqualTo(getEnhancer(query2).createCountQueryFor()); + } + + @Test + void detectsAliasWithGroupAndOrderByWithLineBreaks() { + + StringQuery queryWithGroupAndLineBreak = new StringQuery("select * from User group\nby name", true); + StringQuery queryWithGroupAndLineBreakAndAlias = new StringQuery("select * from User u group\nby name", true); + + assertThat(getEnhancer(queryWithGroupAndLineBreak).detectAlias()).isNull(); + assertThat(getEnhancer(queryWithGroupAndLineBreakAndAlias).detectAlias()).isEqualTo("u"); + + StringQuery queryWithOrderAndLineBreak = new StringQuery("select * from User order\nby name", true); + StringQuery queryWithOrderAndLineBreakAndAlias = new StringQuery("select * from User u order\nby name", true); + StringQuery queryWithOrderAndMultipleLineBreakAndAlias = new StringQuery("select * from User\nu\norder \n by name", + true); + + assertThat(getEnhancer(queryWithOrderAndLineBreak).detectAlias()).isNull(); + assertThat(getEnhancer(queryWithOrderAndLineBreakAndAlias).detectAlias()).isEqualTo("u"); + assertThat(getEnhancer(queryWithOrderAndMultipleLineBreakAndAlias).detectAlias()).isEqualTo("u"); + } + + @ParameterizedTest // DATAJPA-1679 + @MethodSource("findProjectionClauseWithDistinctSource") + void findProjectionClauseWithDistinct(DeclaredQuery query, String expected) { + + SoftAssertions.assertSoftly(sofly -> { + sofly.assertThat(getEnhancer(query).getProjection()).isEqualTo(expected); + }); + } + + public static Stream findProjectionClauseWithDistinctSource() { + return Stream.of( // + Arguments.of(new StringQuery("select * from x", true), "*"), // + Arguments.of(new StringQuery("select a, b, c from x", true), "a, b, c"), // + Arguments.of(new StringQuery("select distinct a, b, c from x", true), "a, b, c"), // + Arguments.of(new StringQuery("select DISTINCT a, b, c from x", true), "a, b, c") // + ); + } + + @Test // DATAJPA-1696 + void findProjectionClauseWithSubselect() { + + // This is not a required behavior, in fact the opposite is, + // but it documents a current limitation. + // to fix this without breaking findProjectionClauseWithIncludedFrom we need a more sophisticated parser. + assertThat(QueryUtils.getProjection("select * from (select x from y)")).isNotEqualTo("*"); + } + + @Test // DATAJPA-1696 + void findProjectionClauseWithSubselectNative() { + + // This is a required behavior the testcase in #findProjectionClauseWithSubselect tells why + String queryString = "select * from (select x from y)"; + StringQuery query = new StringQuery(queryString, true); + assertThat(getEnhancer(query).getProjection()).isEqualTo("*"); + } + + @Test // DATAJPA-1696 + void findProjectionClauseWithIncludedFrom() { + StringQuery query = new StringQuery("select x, frommage, y from t", true); + assertThat(getEnhancer(query).getProjection()).isEqualTo("x, frommage, y"); + } + + @Test + void countProjectionDistrinctQueryIncludesNewLineAfterFromAndBeforeJoin() { + StringQuery originalQuery = new StringQuery( + "SELECT DISTINCT entity1\nFROM Entity1 entity1\nLEFT JOIN Entity2 entity2 ON entity1.key = entity2.key", true); + + assertCountQuery(originalQuery, + "select count(DISTINCT entity1) FROM Entity1 entity1 LEFT JOIN Entity2 entity2 ON entity1.key = entity2.key"); + } + + @Test + void countProjectionDistinctQueryIncludesNewLineAfterEntity() { + StringQuery originalQuery = new StringQuery( + "SELECT DISTINCT entity1\nFROM Entity1 entity1 LEFT JOIN Entity2 entity2 ON entity1.key = entity2.key", true); + assertCountQuery(originalQuery, + "select count(DISTINCT entity1) FROM Entity1 entity1 LEFT JOIN Entity2 entity2 ON entity1.key = entity2.key"); + } + + @Test + void countProjectionDistinctQueryIncludesNewLineAfterEntityAndBeforeWhere() { + StringQuery originalQuery = new StringQuery( + "SELECT DISTINCT entity1\nFROM Entity1 entity1 LEFT JOIN Entity2 entity2 ON entity1.key = entity2.key\nwhere entity1.id = 1799", + true); + assertCountQuery(originalQuery, + "select count(DISTINCT entity1) FROM Entity1 entity1 LEFT JOIN Entity2 entity2 ON entity1.key = entity2.key where entity1.id = 1799"); + } + + @Test + void createsCountQueriesCorrectlyForCapitalLetter() { + assertCountQuery("SELECT u FROM User u where u.foo.bar = ?", "select count(u) FROM User u where u.foo.bar = ?", + true); + } + + @ParameterizedTest // DATAJPA-252 + @MethodSource("detectsJoinAliasesCorrectlySource") + void detectsJoinAliasesCorrectly(String queryString, List aliases) { + + StringQuery nativeQuery = new StringQuery(queryString, true); + StringQuery nonNativeQuery = new StringQuery(queryString, false); + + Set nativeJoinAliases = getEnhancer(nativeQuery).getJoinAliases(); + Set nonNativeJoinAliases = getEnhancer(nonNativeQuery).getJoinAliases(); + + assertThat(nonNativeJoinAliases).containsAll(nativeJoinAliases); + assertThat(nativeJoinAliases) // + .hasSize(aliases.size()) // + .containsAll(aliases); + + } + + @Test // GH-2441 + void correctFunctionAliasWithComplexNestedFunctions() { + String queryString = "\nSELECT \nCAST(('{' || string_agg(distinct array_to_string(c.institutes_ids, ','), ',') || '}') AS bigint[]) as institutesIds\nFROM\ncity c"; + StringQuery nativeQuery = new StringQuery(queryString, true); + + JSqlParserQueryEnhancer queryEnhancer = (JSqlParserQueryEnhancer) getEnhancer(nativeQuery); + + assertThat(queryEnhancer.getSelectionAliases()).contains("institutesIds"); + } + + @Test // GH-2441 + void correctApplySortOnComplexNestedFunctionQuery() { + String queryString = "SELECT dd.institutesIds FROM (\n" + " SELECT\n" + + " CAST(('{' || string_agg(distinct array_to_string(c.institutes_ids, ','), ',') || '}') AS bigint[]) as institutesIds\n" + + " FROM\n" + " city c\n" + + " ) dd"; + + StringQuery nativeQuery = new StringQuery(queryString, true); + + QueryEnhancer queryEnhancer = getEnhancer(nativeQuery); + + String result = queryEnhancer.applySorting(Sort.by(new Sort.Order(Sort.Direction.ASC, "institutesIds"))); + assertThat(result).containsIgnoringCase("order by dd.institutesIds"); + } + + public static Stream detectsJoinAliasesCorrectlySource() { + return Stream.of( // + Arguments.of("select p from Person p left outer join x.foo b2_$ar", Collections.singletonList("b2_$ar")), // + Arguments.of("select p from Person p left join x.foo b2_$ar", Collections.singletonList("b2_$ar")), // + Arguments.of("select p from Person p left outer join x.foo as b2_$ar, left join x.bar as foo", + Arrays.asList("b2_$ar", "foo")), // + Arguments.of("select p from Person p left join x.foo as b2_$ar, left outer join x.bar foo", + Arrays.asList("b2_$ar", "foo")) // + + ); + } + + private static void assertCountQuery(String originalQuery, String countQuery, boolean nativeQuery) { + assertCountQuery(new StringQuery(originalQuery, nativeQuery), countQuery); + } + + private static void assertCountQuery(StringQuery originalQuery, String countQuery) { + assertThat(getEnhancer(originalQuery).createCountQueryFor()).isEqualToIgnoringCase(countQuery); + } + + private static void endsIgnoringCase(String original, String endWithIgnoreCase) { + // https://github.com/assertj/assertj-core/pull/2451 + // can be removed when upgrading to version 3.23.0 assertJ + assertThat(original.toUpperCase()).endsWith(endWithIgnoreCase.toUpperCase()); + } + + private static QueryEnhancer getEnhancer(DeclaredQuery query) { + return QueryEnhancerFactory.forQuery(query); + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java index 68e38e492..a0081a413 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java @@ -53,14 +53,14 @@ class QueryParameterSetterFactoryUnitTests { @Test // DATAJPA-1058 void noExceptionWhenQueryDoesNotContainNamedParameters() { - setterFactory.create(binding, DeclaredQuery.of("QueryStringWithOutNamedParameter")); + setterFactory.create(binding, DeclaredQuery.of("QueryStringWithOutNamedParameter", false)); } @Test // DATAJPA-1058 void exceptionWhenQueryContainNamedParametersAndMethodParametersAreNotNamed() { assertThatExceptionOfType(IllegalStateException.class) // - .isThrownBy(() -> setterFactory.create(binding, DeclaredQuery.of("QueryStringWith :NamedParameter"))) // + .isThrownBy(() -> setterFactory.create(binding, DeclaredQuery.of("QueryStringWith :NamedParameter", false))) // .withMessageContaining("Java 8") // .withMessageContaining("@Param") // .withMessageContaining("-parameters"); @@ -77,7 +77,7 @@ class QueryParameterSetterFactoryUnitTests { when(binding.getRequiredPosition()).thenReturn(1); assertThatExceptionOfType(IllegalArgumentException.class) // - .isThrownBy(() -> setterFactory.create(binding, DeclaredQuery.of("QueryStringWith :NamedParameter"))) // + .isThrownBy(() -> setterFactory.create(binding, DeclaredQuery.of("QueryStringWith :NamedParameter", false))) // .withMessage("At least 1 parameter(s) provided but only 0 parameter(s) present in query."); } @@ -91,7 +91,7 @@ class QueryParameterSetterFactoryUnitTests { when(binding.getRequiredPosition()).thenReturn(1); assertThatExceptionOfType(IllegalArgumentException.class) // - .isThrownBy(() -> setterFactory.create(binding, DeclaredQuery.of("QueryStringWith ?1"))) // + .isThrownBy(() -> setterFactory.create(binding, DeclaredQuery.of("QueryStringWith ?1", false))) // .withMessage("At least 1 parameter(s) provided but only 0 parameter(s) present in query."); } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java index 8fde63658..d048d70bb 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java @@ -71,7 +71,8 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; @ContextConfiguration("classpath:infrastructure.xml") public class QueryUtilsIntegrationTests { - @PersistenceContext EntityManager em; + @PersistenceContext + EntityManager em; @Test // DATAJPA-403 void reusesExistingJoinForExpression() { @@ -139,8 +140,8 @@ public class QueryUtilsIntegrationTests { CriteriaQuery query = builder.createQuery(InvoiceItem.class); Root root = query.from(InvoiceItem.class); - QueryUtils - .toExpressionRecursively(root, PropertyPath.from("invoice.order.customer.name", InvoiceItem.class), false); + QueryUtils.toExpressionRecursively(root, PropertyPath.from("invoice.order.customer.name", InvoiceItem.class), + false); assertThat(getInnerJoins(root)).hasSize(1); // join invoice Join rootInnerJoin = getInnerJoins(root).iterator().next(); @@ -162,8 +163,8 @@ public class QueryUtilsIntegrationTests { root.join("invoice", JoinType.LEFT).join("order", JoinType.LEFT); // when navigating through a path with nested optionals - QueryUtils - .toExpressionRecursively(root, PropertyPath.from("invoice.order.customer.name", InvoiceItem.class), false); + QueryUtils.toExpressionRecursively(root, PropertyPath.from("invoice.order.customer.name", InvoiceItem.class), + false); // assert that existing joins are reused and no additional joins are created assertThat(getInnerJoins(root)).isEmpty(); // no inner join invoice @@ -185,8 +186,8 @@ public class QueryUtilsIntegrationTests { // given an existing inner join an nested optional root.join("invoice").join("order"); - QueryUtils - .toExpressionRecursively(root, PropertyPath.from("invoice.order.customer.name", InvoiceItem.class), false); + QueryUtils.toExpressionRecursively(root, PropertyPath.from("invoice.order.customer.name", InvoiceItem.class), + false); // assert that no useless left joins are created assertThat(getInnerJoins(root)).hasSize(1); // join invoice @@ -347,32 +348,40 @@ public class QueryUtilsIntegrationTests { @SuppressWarnings("unused") static class Merchant { - @Id String id; - @OneToMany Set employees; + @Id + String id; + @OneToMany + Set employees; - @OneToOne Address address; + @OneToOne + Address address; } @Entity @SuppressWarnings("unused") static class Address { - @Id String id; - @OneToOne(mappedBy = "address") Merchant merchant; + @Id + String id; + @OneToOne(mappedBy = "address") + Merchant merchant; } @Entity @SuppressWarnings("unused") static class Employee { - @Id String id; - @OneToMany Set credentials; + @Id + String id; + @OneToMany + Set credentials; } @Entity @SuppressWarnings("unused") static class Credential { - @Id String id; + @Id + String id; String uid; } @@ -390,7 +399,8 @@ public class QueryUtilsIntegrationTests { } @Override - public void clearCachedProviders() {} + public void clearCachedProviders() { + } } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsUnitTests.java index b2640fea2..cf7cb8614 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsUnitTests.java @@ -51,7 +51,7 @@ class QueryUtilsUnitTests { private static final String QUERY_WITH_AS = "select u from User as u where u.username = ?"; @Test - void createsCountQueryCorrectly() { + void createsCountQueryCorrectly() throws Exception { assertCountQuery(QUERY, COUNT_QUERY); } @@ -64,47 +64,47 @@ class QueryUtilsUnitTests { } @Test - void createsCountQueryForDistinctQueries() { + void createsCountQueryForDistinctQueries() throws Exception { assertCountQuery("select distinct u from User u where u.foo = ?", "select count(distinct u) from User u where u.foo = ?"); } @Test - void createsCountQueryForConstructorQueries() { + void createsCountQueryForConstructorQueries() throws Exception { assertCountQuery("select distinct new User(u.name) from User u where u.foo = ?", "select count(distinct u) from User u where u.foo = ?"); } @Test - void createsCountQueryForJoins() { + void createsCountQueryForJoins() throws Exception { assertCountQuery("select distinct new User(u.name) from User u left outer join u.roles r WHERE r = ?", "select count(distinct u) from User u left outer join u.roles r WHERE r = ?"); } @Test - void createsCountQueryForQueriesWithSubSelects() { + void createsCountQueryForQueriesWithSubSelects() throws Exception { assertCountQuery("select u from User u left outer join u.roles r where r in (select r from Role)", "select count(u) from User u left outer join u.roles r where r in (select r from Role)"); } @Test - void createsCountQueryForAliasesCorrectly() { + void createsCountQueryForAliasesCorrectly() throws Exception { assertCountQuery("select u from User as u", "select count(u) from User as u"); } @Test - void allowsShortJpaSyntax() { + void allowsShortJpaSyntax() throws Exception { assertCountQuery(SIMPLE_QUERY, COUNT_QUERY); } @Test - void detectsAliasCorrectly() { + void detectsAliasCorrectly() throws Exception { assertThat(detectAlias(QUERY)).isEqualTo("u"); assertThat(detectAlias(SIMPLE_QUERY)).isEqualTo("u"); @@ -400,8 +400,8 @@ class QueryUtilsUnitTests { @Test // DATAJPA-1363 void discoversAliasWithComplexFunction() { - assertThat( - QueryUtils.getFunctionAliases("select new MyDto(sum(case when myEntity.prop3=0 then 1 else 0 end) as myAlias")) // + assertThat(QueryUtils + .getFunctionAliases("select new MyDto(sum(case when myEntity.prop3=0 then 1 else 0 end) as myAlias")) // .contains("myAlias"); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StringQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StringQueryUnitTests.java index adec631c1..926047477 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StringQueryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StringQueryUnitTests.java @@ -37,6 +37,7 @@ import org.springframework.data.repository.query.parser.Part.Type; * @author Jens Schauder * @author Nils Borrmann * @author Andriy Redko + * @author Diego Krupitza */ class StringQueryUnitTests { @@ -46,7 +47,7 @@ class StringQueryUnitTests { void doesNotConsiderPlainLikeABinding() { String source = "select from User u where u.firstname like :firstname"; - StringQuery query = new StringQuery(source); + StringQuery query = new StringQuery(source, false); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo(source); @@ -63,7 +64,8 @@ class StringQueryUnitTests { @Test // DATAJPA-292 void detectsPositionalLikeBindings() { - StringQuery query = new StringQuery("select u from User u where u.firstname like %?1% or u.lastname like %?2"); + StringQuery query = new StringQuery("select u from User u where u.firstname like %?1% or u.lastname like %?2", + true); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()) @@ -86,7 +88,7 @@ class StringQueryUnitTests { @Test // DATAJPA-292 void detectsNamedLikeBindings() { - StringQuery query = new StringQuery("select u from User u where u.firstname like %:firstname"); + StringQuery query = new StringQuery("select u from User u where u.firstname like %:firstname", true); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like :firstname"); @@ -104,7 +106,7 @@ class StringQueryUnitTests { void detectsNamedInParameterBindings() { String queryString = "select u from User u where u.id in :ids"; - StringQuery query = new StringQuery(queryString); + StringQuery query = new StringQuery(queryString, true); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo(queryString); @@ -121,7 +123,7 @@ class StringQueryUnitTests { void detectsMultipleNamedInParameterBindings() { String queryString = "select u from User u where u.id in :ids and u.name in :names and foo = :bar"; - StringQuery query = new StringQuery(queryString); + StringQuery query = new StringQuery(queryString, true); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo(queryString); @@ -140,7 +142,7 @@ class StringQueryUnitTests { void detectsPositionalInParameterBindings() { String queryString = "select u from User u where u.id in ?1"; - StringQuery query = new StringQuery(queryString); + StringQuery query = new StringQuery(queryString, true); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo(queryString); @@ -157,7 +159,7 @@ class StringQueryUnitTests { void detectsMultiplePositionalInParameterBindings() { String queryString = "select u from User u where u.id in ?1 and u.names in ?2 and foo = ?3"; - StringQuery query = new StringQuery(queryString); + StringQuery query = new StringQuery(queryString, true); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo(queryString); @@ -174,19 +176,19 @@ class StringQueryUnitTests { @Test // DATAJPA-373 void handlesMultipleNamedLikeBindingsCorrectly() { - new StringQuery("select u from User u where u.firstname like %:firstname or foo like :bar"); + new StringQuery("select u from User u where u.firstname like %:firstname or foo like :bar", true); } @Test // DATAJPA-292, DATAJPA-362 void rejectsDifferentBindingsForRepeatedParameter() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new StringQuery("select u from User u where u.firstname like %?1 and u.lastname like ?1%")); + assertThatIllegalArgumentException().isThrownBy( + () -> new StringQuery("select u from User u where u.firstname like %?1 and u.lastname like ?1%", true)); } @Test // DATAJPA-461 void treatsGreaterThanBindingAsSimpleBinding() { - StringQuery query = new StringQuery("select u from User u where u.createdDate > ?1"); + StringQuery query = new StringQuery("select u from User u where u.createdDate > ?1", true); List bindings = query.getParameterBindings(); assertThat(bindings).hasSize(1); @@ -199,7 +201,7 @@ class StringQueryUnitTests { void removesLikeBindingsFromQueryIfQueryContainsSimpleBinding() { StringQuery query = new StringQuery("SELECT a FROM Article a WHERE a.overview LIKE %:escapedWord% ESCAPE '~'" - + " OR a.content LIKE %:escapedWord% ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC"); + + " OR a.content LIKE %:escapedWord% ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC", true); List bindings = query.getParameterBindings(); @@ -217,7 +219,7 @@ class StringQueryUnitTests { @Test // DATAJPA-483 void detectsInBindingWithParentheses() { - StringQuery query = new StringQuery("select count(we) from MyEntity we where we.status in (:statuses)"); + StringQuery query = new StringQuery("select count(we) from MyEntity we where we.status in (:statuses)", true); List bindings = query.getParameterBindings(); @@ -230,7 +232,7 @@ class StringQueryUnitTests { @Test // DATAJPA-545 void detectsInBindingWithSpecialFrenchCharactersInParentheses() { - StringQuery query = new StringQuery("select * from MyEntity where abonnés in (:abonnés)"); + StringQuery query = new StringQuery("select * from MyEntity where abonnés in (:abonnés)", true); List bindings = query.getParameterBindings(); @@ -243,7 +245,7 @@ class StringQueryUnitTests { @Test // DATAJPA-545 void detectsInBindingWithSpecialCharactersInParentheses() { - StringQuery query = new StringQuery("select * from MyEntity where øre in (:øre)"); + StringQuery query = new StringQuery("select * from MyEntity where øre in (:øre)", true); List bindings = query.getParameterBindings(); @@ -256,7 +258,7 @@ class StringQueryUnitTests { @Test // DATAJPA-545 void detectsInBindingWithSpecialAsianCharactersInParentheses() { - StringQuery query = new StringQuery("select * from MyEntity where 생일 in (:생일)"); + StringQuery query = new StringQuery("select * from MyEntity where 생일 in (:생일)", true); List bindings = query.getParameterBindings(); @@ -269,7 +271,7 @@ class StringQueryUnitTests { @Test // DATAJPA-545 void detectsInBindingWithSpecialCharactersAndWordCharactersMixedInParentheses() { - StringQuery query = new StringQuery("select * from MyEntity where foo in (:ab1babc생일233)"); + StringQuery query = new StringQuery("select * from MyEntity where foo in (:ab1babc생일233)", true); List bindings = query.getParameterBindings(); @@ -281,14 +283,14 @@ class StringQueryUnitTests { @Test // DATAJPA-362 void rejectsDifferentBindingsForRepeatedParameter2() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new StringQuery("select u from User u where u.firstname like ?1 and u.lastname like %?1")); + assertThatIllegalArgumentException().isThrownBy( + () -> new StringQuery("select u from User u where u.firstname like ?1 and u.lastname like %?1", true)); } @Test // DATAJPA-712 void shouldReplaceAllNamedExpressionParametersWithInClause() { - StringQuery query = new StringQuery("select a from A a where a.b in :#{#bs} and a.c in :#{#cs}"); + StringQuery query = new StringQuery("select a from A a where a.b in :#{#bs} and a.c in :#{#cs}", true); String queryString = query.getQueryString(); assertThat(queryString).isEqualTo("select a from A a where a.b in :__$synthetic$__1 and a.c in :__$synthetic$__2"); @@ -297,7 +299,7 @@ class StringQueryUnitTests { @Test // DATAJPA-712 void shouldReplaceAllPositionExpressionParametersWithInClause() { - StringQuery query = new StringQuery("select a from A a where a.b in ?#{#bs} and a.c in ?#{#cs}"); + StringQuery query = new StringQuery("select a from A a where a.b in ?#{#bs} and a.c in ?#{#cs}", true); String queryString = query.getQueryString(); softly.assertThat(queryString).isEqualTo("select a from A a where a.b in ?1 and a.c in ?2"); @@ -310,9 +312,11 @@ class StringQueryUnitTests { @Test // DATAJPA-864 void detectsConstructorExpressions() { - softly.assertThat(new StringQuery("select new Dto(a.foo, a.bar) from A a").hasConstructorExpression()).isTrue(); - softly.assertThat(new StringQuery("select new Dto (a.foo, a.bar) from A a").hasConstructorExpression()).isTrue(); - softly.assertThat(new StringQuery("select a from A a").hasConstructorExpression()).isFalse(); + softly.assertThat(new StringQuery("select new Dto(a.foo, a.bar) from A a", false).hasConstructorExpression()) + .isTrue(); + softly.assertThat(new StringQuery("select new Dto (a.foo, a.bar) from A a", false).hasConstructorExpression()) + .isTrue(); + softly.assertThat(new StringQuery("select a from A a", true).hasConstructorExpression()).isFalse(); softly.assertAll(); } @@ -325,8 +329,8 @@ class StringQueryUnitTests { void detectsConstructorExpressionForDefaultConstructor() { // Parentheses required - softly.assertThat(new StringQuery("select new Dto() from A a").hasConstructorExpression()).isTrue(); - softly.assertThat(new StringQuery("select new Dto from A a").hasConstructorExpression()).isFalse(); + softly.assertThat(new StringQuery("select new Dto() from A a", false).hasConstructorExpression()).isTrue(); + softly.assertThat(new StringQuery("select new Dto from A a", false).hasConstructorExpression()).isFalse(); softly.assertAll(); } @@ -334,7 +338,7 @@ class StringQueryUnitTests { @Test // DATAJPA-1179 void bindingsMatchQueryForIdenticalSpelExpressions() { - StringQuery query = new StringQuery("select a from A a where a.first = :#{#exp} or a.second = :#{#exp}"); + StringQuery query = new StringQuery("select a from A a where a.first = :#{#exp} or a.second = :#{#exp}", true); List bindings = query.getParameterBindings(); softly.assertThat(bindings).isNotEmpty(); @@ -351,18 +355,18 @@ class StringQueryUnitTests { @Test // DATAJPA-1235 void getProjection() { - checkProjection("SELECT something FROM", "something", "uppercase is supported"); - checkProjection("select something from", "something", "single expression"); - checkProjection("select x, y, z from", "x, y, z", "tuple"); - checkProjection("sect x, y, z from", "", "missing select"); - checkProjection("select x, y, z fron", "", "missing from"); + checkProjection("SELECT something FROM", "something", "uppercase is supported", false); + checkProjection("select something from", "something", "single expression", false); + checkProjection("select x, y, z from", "x, y, z", "tuple", false); + checkProjection("sect x, y, z from", "", "missing select", false); + checkProjection("select x, y, z fron", "", "missing from", false); softly.assertAll(); } - void checkProjection(String query, String expected, String description) { + void checkProjection(String query, String expected, String description, boolean nativeQuery) { - softly.assertThat(new StringQuery(query).getProjection()) // + softly.assertThat(new StringQuery(query, nativeQuery).getProjection()) // .as("%s (%s)", description, query) // .isEqualTo(expected); } @@ -370,25 +374,25 @@ class StringQueryUnitTests { @Test // DATAJPA-1235 void getAlias() { - checkAlias("from User u", "u", "simple query"); - checkAlias("select count(u) from User u", "u", "count query"); - checkAlias("select u from User as u where u.username = ?", "u", "with as"); - checkAlias("SELECT FROM USER U", "U", "uppercase"); - checkAlias("select u from User u", "u", "simple query"); - checkAlias("select u from com.acme.User u", "u", "fully qualified package name"); - checkAlias("select u from T05User u", "u", "interesting entity name"); - checkAlias("from User ", null, "trailing space"); - checkAlias("from User", null, "no trailing space"); - checkAlias("from User as bs", "bs", "ignored as"); - checkAlias("from User as AS", "AS", "ignored as using the second"); - checkAlias("from User asas", "asas", "asas is weird but legal"); + checkAlias("from User u", "u", "simple query", false); + checkAlias("select count(u) from User u", "u", "count query", true); + checkAlias("select u from User as u where u.username = ?", "u", "with as", true); + checkAlias("SELECT FROM USER U", "U", "uppercase", false); + checkAlias("select u from User u", "u", "simple query", true); + checkAlias("select u from com.acme.User u", "u", "fully qualified package name", true); + checkAlias("select u from T05User u", "u", "interesting entity name", true); + checkAlias("from User ", null, "trailing space", false); + checkAlias("from User", null, "no trailing space", false); + checkAlias("from User as bs", "bs", "ignored as", false); + checkAlias("from User as AS", "AS", "ignored as using the second", false); + checkAlias("from User asas", "asas", "asas is weird but legal", false); softly.assertAll(); } - private void checkAlias(String query, String expected, String description) { + private void checkAlias(String query, String expected, String description, boolean nativeQuery) { - softly.assertThat(new StringQuery(query).getAlias()) // + softly.assertThat(new StringQuery(query, nativeQuery).getAlias()) // .as("%s (%s)", description, query) // .isEqualTo(expected); } @@ -396,32 +400,32 @@ class StringQueryUnitTests { @Test // DATAJPA-1200 void testHasNamedParameter() { - checkHasNamedParameter("select something from x where id = :id", true, "named parameter"); - checkHasNamedParameter("in the :id middle", true, "middle"); - checkHasNamedParameter(":id start", true, "beginning"); - checkHasNamedParameter(":id", true, "alone"); - checkHasNamedParameter("select something from x where id = :id", true, "named parameter"); - checkHasNamedParameter(":UPPERCASE", true, "uppercase"); - checkHasNamedParameter(":lowercase", true, "lowercase"); - checkHasNamedParameter(":2something", true, "beginning digit"); - checkHasNamedParameter(":2", true, "only digit"); - checkHasNamedParameter(":.something", true, "dot"); - checkHasNamedParameter(":_something", true, "underscore"); - checkHasNamedParameter(":$something", true, "dollar"); - checkHasNamedParameter(":\uFE0F", true, "non basic latin emoji"); // - checkHasNamedParameter(":\u4E01", true, "chinese japanese korean"); + checkHasNamedParameter("select something from x where id = :id", true, "named parameter", true); + checkHasNamedParameter("in the :id middle", true, "middle", false); + checkHasNamedParameter(":id start", true, "beginning", false); + checkHasNamedParameter(":id", true, "alone", false); + checkHasNamedParameter("select something from x where id = :id", true, "named parameter", true); + checkHasNamedParameter(":UPPERCASE", true, "uppercase", false); + checkHasNamedParameter(":lowercase", true, "lowercase", false); + checkHasNamedParameter(":2something", true, "beginning digit", false); + checkHasNamedParameter(":2", true, "only digit", false); + checkHasNamedParameter(":.something", true, "dot", false); + checkHasNamedParameter(":_something", true, "underscore", false); + checkHasNamedParameter(":$something", true, "dollar", false); + checkHasNamedParameter(":\uFE0F", true, "non basic latin emoji", false); // + checkHasNamedParameter(":\u4E01", true, "chinese japanese korean", false); - checkHasNamedParameter("no bind variable", false, "no bind variable"); - checkHasNamedParameter(":\u2004whitespace", false, "non basic latin whitespace"); - checkHasNamedParameter("select something from x where id = ?1", false, "indexed parameter"); - checkHasNamedParameter("::", false, "double colon"); - checkHasNamedParameter(":", false, "end of query"); - checkHasNamedParameter(":\u0003", false, "non-printable"); - checkHasNamedParameter(":*", false, "basic latin emoji"); - checkHasNamedParameter("\\:", false, "escaped colon"); - checkHasNamedParameter("::id", false, "double colon with identifier"); - checkHasNamedParameter("\\:id", false, "escaped colon with identifier"); - checkHasNamedParameter("select something from x where id = #something", false, "hash"); + checkHasNamedParameter("no bind variable", false, "no bind variable", false); + checkHasNamedParameter(":\u2004whitespace", false, "non basic latin whitespace", false); + checkHasNamedParameter("select something from x where id = ?1", false, "indexed parameter", true); + checkHasNamedParameter("::", false, "double colon", false); + checkHasNamedParameter(":", false, "end of query", false); + checkHasNamedParameter(":\u0003", false, "non-printable", false); + checkHasNamedParameter(":*", false, "basic latin emoji", false); + checkHasNamedParameter("\\:", false, "escaped colon", false); + checkHasNamedParameter("::id", false, "double colon with identifier", false); + checkHasNamedParameter("\\:id", false, "escaped colon with identifier", false); + checkHasNamedParameter("select something from x where id = #something", false, "hash", true); softly.assertAll(); } @@ -429,11 +433,12 @@ class StringQueryUnitTests { @Test // DATAJPA-1235 void ignoresQuotedNamedParameterLookAlike() { - checkNumberOfNamedParameters("select something from blah where x = '0:name'", 0, "single quoted"); - checkNumberOfNamedParameters("select something from blah where x = \"0:name\"", 0, "double quoted"); - checkNumberOfNamedParameters("select something from blah where x = '\"0':name", 1, "double quote in single quotes"); - checkNumberOfNamedParameters("select something from blah where x = \"'0\":name", 1, - "single quote in double quotes"); + checkNumberOfNamedParameters("select something from blah where x = '0:name'", 0, "single quoted", false); + checkNumberOfNamedParameters("select something from blah where x = \"0:name\"", 0, "double quoted", false); + checkNumberOfNamedParameters("select something from blah where x = '\"0':name", 1, "double quote in single quotes", + false); + checkNumberOfNamedParameters("select something from blah where x = \"'0\":name", 1, "single quote in double quotes", + false); softly.assertAll(); } @@ -442,7 +447,7 @@ class StringQueryUnitTests { void detectsMultiplePositionalParameterBindingsWithoutIndex() { String queryString = "select u from User u where u.id in ? and u.names in ? and foo = ?"; - StringQuery query = new StringQuery(queryString); + StringQuery query = new StringQuery(queryString, false); softly.assertThat(query.getQueryString()).isEqualTo(queryString); softly.assertThat(query.hasParameterBindings()).isTrue(); @@ -464,14 +469,14 @@ class StringQueryUnitTests { for (String testQuery : testQueries) { Assertions.assertThatExceptionOfType(IllegalArgumentException.class) // - .describedAs(testQuery).isThrownBy(() -> new StringQuery(testQuery)); + .describedAs(testQuery).isThrownBy(() -> new StringQuery(testQuery, false)); } } @Test // DATAJPA-1307 void makesUsageOfJdbcStyleParameterAvailable() { - softly.assertThat(new StringQuery("something = ?").usesJdbcStyleParameters()).isTrue(); + softly.assertThat(new StringQuery("something = ?", false).usesJdbcStyleParameters()).isTrue(); List testQueries = Arrays.asList( // "something = ?1", // @@ -481,7 +486,7 @@ class StringQueryUnitTests { for (String testQuery : testQueries) { - softly.assertThat(new StringQuery(testQuery) // + softly.assertThat(new StringQuery(testQuery, false) // .usesJdbcStyleParameters()) // .describedAs(testQuery) // .isFalse(); @@ -494,7 +499,7 @@ class StringQueryUnitTests { void questionMarkInStringLiteral() { String queryString = "select '? ' from dual"; - StringQuery query = new StringQuery(queryString); + StringQuery query = new StringQuery(queryString, false); softly.assertThat(query.getQueryString()).isEqualTo(queryString); softly.assertThat(query.hasParameterBindings()).isFalse(); @@ -515,7 +520,7 @@ class StringQueryUnitTests { "select a, b from C"); for (String queryString : queriesWithoutDefaultProjection) { - softly.assertThat(new StringQuery(queryString).isDefaultProjection()) // + softly.assertThat(new StringQuery(queryString, true).isDefaultProjection()) // .describedAs(queryString) // .isFalse(); } @@ -532,7 +537,7 @@ class StringQueryUnitTests { ); for (String queryString : queriesWithDefaultProjection) { - softly.assertThat(new StringQuery(queryString).isDefaultProjection()) // + softly.assertThat(new StringQuery(queryString, true).isDefaultProjection()) // .describedAs(queryString) // .isTrue(); } @@ -544,7 +549,7 @@ class StringQueryUnitTests { void usingPipesWithNamedParameter() { String queryString = "SELECT u FROM User u WHERE u.lastname LIKE '%'||:name||'%'"; - StringQuery query = new StringQuery(queryString); + StringQuery query = new StringQuery(queryString, true); assertThat(query.getParameterBindings()) // .extracting(ParameterBinding::getName) // @@ -555,16 +560,16 @@ class StringQueryUnitTests { void usingGreaterThanWithNamedParameter() { String queryString = "SELECT u FROM User u WHERE :age>u.age"; - StringQuery query = new StringQuery(queryString); + StringQuery query = new StringQuery(queryString, true); assertThat(query.getParameterBindings()) // .extracting(ParameterBinding::getName) // .containsExactly("age"); } - void checkNumberOfNamedParameters(String query, int expectedSize, String label) { + void checkNumberOfNamedParameters(String query, int expectedSize, String label, boolean nativeQuery) { - DeclaredQuery declaredQuery = DeclaredQuery.of(query); + DeclaredQuery declaredQuery = DeclaredQuery.of(query, nativeQuery); softly.assertThat(declaredQuery.hasNamedParameter()) // .describedAs("hasNamed Parameter " + label) // @@ -574,9 +579,9 @@ class StringQueryUnitTests { .hasSize(expectedSize); } - private void checkHasNamedParameter(String query, boolean expected, String label) { + private void checkHasNamedParameter(String query, boolean expected, String label, boolean nativeQuery) { - softly.assertThat(new StringQuery(query).hasNamedParameter()) // + softly.assertThat(new StringQuery(query, nativeQuery).hasNamedParameter()) // .describedAs(String.format("<%s> (%s)", query, label)) // .isEqualTo(expected); }