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]]