diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml
index a63272e45c..cc4bd54949 100644
--- a/spring-data-jpa/pom.xml
+++ b/spring-data-jpa/pom.xml
@@ -86,6 +86,12 @@
true
+
+ org.junit.platform
+ junit-platform-launcher
+ test
+
+
org.hsqldb
hsqldb
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
index f7c6f7dd1c..0ffcc6b660 100644
--- 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
@@ -17,8 +17,11 @@
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
+import org.springframework.core.SpringProperties;
import org.springframework.data.jpa.provider.PersistenceProvider;
+import org.springframework.lang.Nullable;
import org.springframework.util.ClassUtils;
+import org.springframework.util.StringUtils;
/**
* Encapsulates different strategies for the creation of a {@link QueryEnhancer} from a {@link DeclaredQuery}.
@@ -26,20 +29,17 @@
* @author Diego Krupitza
* @author Greg Turnquist
* @author Mark Paluch
+ * @author Christoph Strobl
* @since 2.7.0
*/
public final class QueryEnhancerFactory {
private static final Log LOG = LogFactory.getLog(QueryEnhancerFactory.class);
-
- private static final boolean jSqlParserPresent = ClassUtils.isPresent("net.sf.jsqlparser.parser.JSqlParser",
- QueryEnhancerFactory.class.getClassLoader());
+ private static final NativeQueryEnhancer NATIVE_QUERY_ENHANCER;
static {
- if (jSqlParserPresent) {
- LOG.info("JSqlParser is in classpath; If applicable, JSqlParser will be used");
- }
+ NATIVE_QUERY_ENHANCER = NativeQueryEnhancer.select(QueryEnhancerFactory.class.getClassLoader());
if (PersistenceProvider.ECLIPSELINK.isPresent()) {
LOG.info("EclipseLink is in classpath; If applicable, EQL parser will be used.");
@@ -48,7 +48,6 @@ public final class QueryEnhancerFactory {
if (PersistenceProvider.HIBERNATE.isPresent()) {
LOG.info("Hibernate is in classpath; If applicable, HQL parser will be used.");
}
-
}
private QueryEnhancerFactory() {}
@@ -62,15 +61,7 @@ private QueryEnhancerFactory() {}
public static QueryEnhancer forQuery(DeclaredQuery query) {
if (query.isNativeQuery()) {
-
- if (jSqlParserPresent) {
- /*
- * If JSqlParser fails, throw some alert signaling that people should write a custom Impl.
- */
- return new JSqlParserQueryEnhancer(query);
- }
-
- return new DefaultQueryEnhancer(query);
+ return getNativeQueryEnhancer(query);
}
if (PersistenceProvider.HIBERNATE.isPresent()) {
@@ -82,4 +73,56 @@ public static QueryEnhancer forQuery(DeclaredQuery query) {
}
}
+ /**
+ * Get the native query enhancer for the given {@link DeclaredQuery query} based on {@link #NATIVE_QUERY_ENHANCER}.
+ *
+ * @param query the declared query.
+ * @return new instance of {@link QueryEnhancer}.
+ */
+ private static QueryEnhancer getNativeQueryEnhancer(DeclaredQuery query) {
+
+ if (NATIVE_QUERY_ENHANCER.equals(NativeQueryEnhancer.JSQL)) {
+ return new JSqlParserQueryEnhancer(query);
+ }
+ return new DefaultQueryEnhancer(query);
+ }
+
+ /**
+ * Possible choices for the {@link #NATIVE_PARSER_PROPERTY}. Read current selection via {@link #select(ClassLoader)}.
+ */
+ enum NativeQueryEnhancer {
+
+ AUTO, DEFAULT, JSQL;
+
+ static final String NATIVE_PARSER_PROPERTY = "spring.data.jpa.query.native.parser";
+
+ private static NativeQueryEnhancer from(@Nullable String name) {
+ if (!StringUtils.hasText(name)) {
+ return AUTO;
+ }
+ return NativeQueryEnhancer.valueOf(name.toUpperCase());
+ }
+
+ /**
+ * @param classLoader ClassLoader to look up available libraries.
+ * @return the current selection considering classpath avialability and user selection via
+ * {@link #NATIVE_PARSER_PROPERTY}.
+ */
+ static NativeQueryEnhancer select(ClassLoader classLoader) {
+
+ if (!ClassUtils.isPresent("net.sf.jsqlparser.parser.JSqlParser", classLoader)) {
+ return NativeQueryEnhancer.DEFAULT;
+ }
+
+ NativeQueryEnhancer selected = NativeQueryEnhancer.from(SpringProperties.getProperty(NATIVE_PARSER_PROPERTY));
+ if (selected.equals(NativeQueryEnhancer.AUTO) || selected.equals(NativeQueryEnhancer.JSQL)) {
+ LOG.info("JSqlParser is in classpath; If applicable, JSqlParser will be used.");
+ return NativeQueryEnhancer.JSQL;
+ }
+
+ LOG.info("JSqlParser is in classpath but won't be used due to user choice.");
+ return selected;
+ }
+ }
+
}
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
index 61fe6a2aa3..d650277bd6 100644
--- 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
@@ -15,15 +15,24 @@
*/
package org.springframework.data.jpa.repository.query;
-import static org.assertj.core.api.Assertions.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.stream.Stream;
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.data.jpa.repository.query.QueryEnhancerFactory.NativeQueryEnhancer;
+import org.springframework.data.jpa.util.ClassPathExclusions;
+import org.springframework.lang.Nullable;
/**
* Unit tests for {@link QueryEnhancerFactory}.
*
* @author Diego Krupitza
* @author Greg Turnquist
+ * @author Christoph Strobl
*/
class QueryEnhancerFactoryUnitTests {
@@ -52,4 +61,56 @@ void createsJSqlImplementationForNativeQuery() {
assertThat(queryEnhancer) //
.isInstanceOf(JSqlParserQueryEnhancer.class);
}
+
+ @ParameterizedTest // GH-2989
+ @MethodSource("nativeEnhancerSelectionArgs")
+ void createsNativeImplementationAccordingToUserChoice(@Nullable String selection, NativeQueryEnhancer enhancer) {
+
+ withSystemProperty(NativeQueryEnhancer.NATIVE_PARSER_PROPERTY, selection, () -> {
+ assertThat(NativeQueryEnhancer.select(this.getClass().getClassLoader())).isEqualTo(enhancer);
+ });
+ }
+
+ @Test // GH-2989
+ @ClassPathExclusions(packages = { "net.sf.jsqlparser.parser" })
+ void selectedDefaultImplementationIfJsqlNotAvailable() {
+
+ assertThat(assertThat(NativeQueryEnhancer.select(this.getClass().getClassLoader()))
+ .isEqualTo(NativeQueryEnhancer.DEFAULT));
+ }
+
+ @Test // GH-2989
+ @ClassPathExclusions(packages = { "net.sf.jsqlparser.parser" })
+ void selectedDefaultImplementationIfJsqlNotAvailableEvenIfExplicitlyStated/* or should we raise an error? */() {
+
+ withSystemProperty(NativeQueryEnhancer.NATIVE_PARSER_PROPERTY, "jsql", () -> {
+ assertThat(NativeQueryEnhancer.select(this.getClass().getClassLoader())).isEqualTo(NativeQueryEnhancer.DEFAULT);
+ });
+ }
+
+ void withSystemProperty(String property, @Nullable String value, Runnable exeution) {
+
+ String currentValue = System.getProperty(property);
+ if (value != null) {
+ System.setProperty(property, value);
+ } else {
+ System.clearProperty(property);
+ }
+ try {
+ exeution.run();
+ } finally {
+ if (currentValue != null) {
+ System.setProperty(property, currentValue);
+ } else {
+ System.clearProperty(property);
+ }
+ }
+
+ }
+
+ static Stream nativeEnhancerSelectionArgs() {
+ return Stream.of(Arguments.of(null, NativeQueryEnhancer.JSQL), Arguments.of("", NativeQueryEnhancer.JSQL),
+ Arguments.of("auto", NativeQueryEnhancer.JSQL), Arguments.of("default", NativeQueryEnhancer.DEFAULT),
+ Arguments.of("jsql", NativeQueryEnhancer.JSQL));
+ }
}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusions.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusions.java
new file mode 100644
index 0000000000..aa25c22739
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusions.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 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.util;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * Annotation used to exclude entries from the classpath. Simplified version of ClassPathExclusions.
+ *
+ * @author Christoph Strobl
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ ElementType.TYPE, ElementType.METHOD })
+@Documented
+@ExtendWith(ClassPathExclusionsExtension.class)
+public @interface ClassPathExclusions {
+
+ /**
+ * One or more packages that should be excluded from the classpath.
+ *
+ * @return the excluded packages
+ */
+ String[] packages();
+
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusionsExtension.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusionsExtension.java
new file mode 100644
index 0000000000..46dcd05fb5
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusionsExtension.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2024 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.util;
+
+import java.lang.reflect.Method;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.InvocationInterceptor;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.junit.platform.engine.discovery.DiscoverySelectors;
+import org.junit.platform.launcher.Launcher;
+import org.junit.platform.launcher.LauncherDiscoveryRequest;
+import org.junit.platform.launcher.TestPlan;
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
+import org.junit.platform.launcher.core.LauncherFactory;
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
+import org.junit.platform.launcher.listeners.TestExecutionSummary;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * Simplified version of ModifiedClassPathExtension.
+ *
+ * @author Christoph Strobl
+ */
+class ClassPathExclusionsExtension implements InvocationInterceptor {
+
+ @Override
+ public void interceptBeforeAllMethod(Invocation invocation,
+ ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable {
+ intercept(invocation, extensionContext);
+ }
+
+ @Override
+ public void interceptBeforeEachMethod(Invocation invocation,
+ ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable {
+ intercept(invocation, extensionContext);
+ }
+
+ @Override
+ public void interceptAfterEachMethod(Invocation invocation,
+ ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable {
+ intercept(invocation, extensionContext);
+ }
+
+ @Override
+ public void interceptAfterAllMethod(Invocation invocation,
+ ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable {
+ intercept(invocation, extensionContext);
+ }
+
+ @Override
+ public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext,
+ ExtensionContext extensionContext) throws Throwable {
+ interceptMethod(invocation, invocationContext, extensionContext);
+ }
+
+ @Override
+ public void interceptTestTemplateMethod(Invocation invocation,
+ ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable {
+ interceptMethod(invocation, invocationContext, extensionContext);
+ }
+
+ private void interceptMethod(Invocation invocation, ReflectiveInvocationContext invocationContext,
+ ExtensionContext extensionContext) throws Throwable {
+
+ if (isModifiedClassPathClassLoader(extensionContext)) {
+ invocation.proceed();
+ return;
+ }
+
+ Class> testClass = extensionContext.getRequiredTestClass();
+ Method testMethod = invocationContext.getExecutable();
+ PackageExcludingClassLoader modifiedClassLoader = PackageExcludingClassLoader.get(testClass, testMethod);
+ if (modifiedClassLoader == null) {
+ invocation.proceed();
+ return;
+ }
+ invocation.skip();
+ ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
+ Thread.currentThread().setContextClassLoader(modifiedClassLoader);
+ try {
+ runTest(extensionContext.getUniqueId());
+ } finally {
+ Thread.currentThread().setContextClassLoader(originalClassLoader);
+ }
+ }
+
+ private void runTest(String testId) throws Throwable {
+
+ LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
+ .selectors(DiscoverySelectors.selectUniqueId(testId)).build();
+ Launcher launcher = LauncherFactory.create();
+ TestPlan testPlan = launcher.discover(request);
+ SummaryGeneratingListener listener = new SummaryGeneratingListener();
+ launcher.registerTestExecutionListeners(listener);
+ launcher.execute(testPlan);
+ TestExecutionSummary summary = listener.getSummary();
+ if (!CollectionUtils.isEmpty(summary.getFailures())) {
+ throw summary.getFailures().get(0).getException();
+ }
+ }
+
+ private void intercept(Invocation invocation, ExtensionContext extensionContext) throws Throwable {
+ if (isModifiedClassPathClassLoader(extensionContext)) {
+ invocation.proceed();
+ return;
+ }
+ invocation.skip();
+ }
+
+ private boolean isModifiedClassPathClassLoader(ExtensionContext extensionContext) {
+ Class> testClass = extensionContext.getRequiredTestClass();
+ ClassLoader classLoader = testClass.getClassLoader();
+ return classLoader.getClass().getName().equals(PackageExcludingClassLoader.class.getName());
+ }
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/PackageExcludingClassLoader.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/PackageExcludingClassLoader.java
new file mode 100644
index 0000000000..b768c90fd4
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/PackageExcludingClassLoader.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2024 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.util;
+
+import java.io.File;
+import java.lang.management.ManagementFactory;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collector;
+import java.util.stream.Stream;
+
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Simplified version of ModifiedClassPathClassLoader.
+ *
+ * @author Christoph Strobl
+ */
+class PackageExcludingClassLoader extends URLClassLoader {
+
+ private final Set excludedPackages;
+ private final ClassLoader junitLoader;
+
+ PackageExcludingClassLoader(URL[] urls, ClassLoader parent, Collection excludedPackages,
+ ClassLoader junitClassLoader) {
+
+ super(urls, parent);
+ this.excludedPackages = Set.copyOf(excludedPackages);
+ this.junitLoader = junitClassLoader;
+ }
+
+ @Override
+ public Class> loadClass(String name) throws ClassNotFoundException {
+
+ if (name.startsWith("org.junit") || name.startsWith("org.hamcrest")) {
+ return Class.forName(name, false, this.junitLoader);
+ }
+
+ String packageName = ClassUtils.getPackageName(name);
+ if (this.excludedPackages.contains(packageName)) {
+ throw new ClassNotFoundException(name);
+ }
+ return super.loadClass(name);
+ }
+
+ static PackageExcludingClassLoader get(Class> testClass, Method testMethod) {
+
+ List excludedPackages = readExcludedPackages(testClass, testMethod);
+
+ if (excludedPackages.isEmpty()) {
+ return null;
+ }
+
+ ClassLoader testClassClassLoader = testClass.getClassLoader();
+ Stream urls = null;
+ if (testClassClassLoader instanceof URLClassLoader urlClassLoader) {
+ urls = Stream.of(urlClassLoader.getURLs());
+ } else {
+ urls = Stream.of(ManagementFactory.getRuntimeMXBean().getClassPath().split(File.pathSeparator))
+ .map(PackageExcludingClassLoader::toURL);
+ }
+
+ return new PackageExcludingClassLoader(urls.toArray(URL[]::new), testClassClassLoader.getParent(), excludedPackages,
+ testClassClassLoader);
+ }
+
+ private static List readExcludedPackages(Class> testClass, Method testMethod) {
+
+ return Stream.of( //
+ AnnotatedElementUtils.findMergedAnnotation(testClass, ClassPathExclusions.class),
+ AnnotatedElementUtils.findMergedAnnotation(testMethod, ClassPathExclusions.class) //
+ ).filter(Objects::nonNull) //
+ .map(ClassPathExclusions::packages) //
+ .collect(new CombingArrayCollector());
+ }
+
+ private static URL toURL(String entry) {
+ try {
+ return new File(entry).toURI().toURL();
+ } catch (Exception ex) {
+ throw new IllegalArgumentException(ex);
+ }
+ }
+
+ private static class CombingArrayCollector implements Collector, List> {
+
+ @Override
+ public Supplier> supplier() {
+ return ArrayList::new;
+ }
+
+ @Override
+ public BiConsumer, T[]> accumulator() {
+ return (target, values) -> target.addAll(Arrays.asList(values));
+ }
+
+ @Override
+ public BinaryOperator> combiner() {
+ return (r1, r2) -> {
+ r1.addAll(r2);
+ return r1;
+ };
+ }
+
+ @Override
+ public Function, List> finisher() {
+ return i -> (List) i;
+ }
+
+ @Override
+ public Set characteristics() {
+ return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH));
+ }
+ }
+}
diff --git a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc
index 9b86d9ee6d..1681d2b7d7 100644
--- a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc
+++ b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc
@@ -304,6 +304,11 @@ public interface UserRepository extends JpaRepository {
----
====
+[TIP]
+====
+It is possible to disable usage of `JSqlParser` for parsing natvie queries although it is available in classpath by setting `spring.data.jpa.query.native.parser=default` via the `spring.properties` file or a system property.
+====
+
A similar approach also works with named native queries, by adding the `.count` suffix to a copy of your query. You probably need to register a result set mapping for your count query, though.
[[jpa.query-methods.sorting]]