diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc9a83255f68..789f85844260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: toolchain: false - version: 21 toolchain: true - - version: 23 + - version: 24 toolchain: true exclude: - os: diff --git a/build.gradle b/build.gradle index 372b2d8328a4..1dae7ba3d3a9 100644 --- a/build.gradle +++ b/build.gradle @@ -97,7 +97,7 @@ configure([rootProject] + javaProjects) { project -> // TODO Uncomment link to JUnit 5 docs once we execute Gradle with Java 18+. // See https://github.com/spring-projects/spring-framework/issues/27497 // - // "https://junit.org/junit5/docs/5.13.3/api/", + // "https://junit.org/junit5/docs/5.13.4/api/", "https://www.reactive-streams.org/reactive-streams-1.0.3-javadoc/", //"https://javadoc.io/static/io.rsocket/rsocket-core/1.1.1/", "https://r2dbc.io/spec/1.0.0.RELEASE/api/", diff --git a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc index 25a6570b1321..1ab948e144cd 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc @@ -1,24 +1,26 @@ [[beans-classpath-scanning]] = Classpath Scanning and Managed Components -Most examples in this chapter use XML to specify the configuration metadata that produces -each `BeanDefinition` within the Spring container. The previous section -(xref:core/beans/annotation-config.adoc[Annotation-based Container Configuration]) demonstrates how to provide a lot of the configuration -metadata through source-level annotations. Even in those examples, however, the "base" -bean definitions are explicitly defined in the XML file, while the annotations drive only -the dependency injection. This section describes an option for implicitly detecting the -candidate components by scanning the classpath. Candidate components are classes that -match against a filter criteria and have a corresponding bean definition registered with -the container. This removes the need to use XML to perform bean registration. Instead, you -can use annotations (for example, `@Component`), AspectJ type expressions, or your own +Most examples in this chapter use XML to specify the configuration metadata that +produces each `BeanDefinition` within the Spring container. The previous section +(xref:core/beans/annotation-config.adoc[Annotation-based Container Configuration]) +demonstrates how to provide a lot of the configuration metadata through source-level +annotations. Even in those examples, however, the "base" bean definitions are explicitly +defined in the XML file, while the annotations drive only the dependency injection. + +This section describes an option for implicitly detecting the candidate components by +scanning the classpath. Candidate components are classes that match against a filter +criteria and have a corresponding bean definition registered with the container. +This removes the need to use XML to perform bean registration. Instead, you can use +annotations (for example, `@Component`), AspectJ type expressions, or your own custom filter criteria to select which classes have bean definitions registered with the container. [NOTE] ==== You can define beans using Java rather than using XML files. Take a look at the -`@Configuration`, `@Bean`, `@Import`, and `@DependsOn` annotations for examples of how to -use these features. +`@Configuration`, `@Bean`, `@Import`, and `@DependsOn` annotations for examples +of how to use these features. ==== @@ -830,10 +832,10 @@ definitions, there is no notion of bean definition inheritance, and inheritance hierarchies at the class level are irrelevant for metadata purposes. For details on web-specific scopes such as "`request`" or "`session`" in a Spring context, -see xref:core/beans/factory-scopes.adoc#beans-factory-scopes-other[Request, Session, Application, and WebSocket Scopes]. As with the pre-built annotations for those scopes, -you may also compose your own scoping annotations by using Spring's meta-annotation -approach: for example, a custom annotation meta-annotated with `@Scope("prototype")`, -possibly also declaring a custom scoped-proxy mode. +see xref:core/beans/factory-scopes.adoc#beans-factory-scopes-other[Request, Session, Application, and WebSocket Scopes]. +As with the pre-built annotations for those scopes, you may also compose your own scoping +annotations by using Spring's meta-annotation approach: for example, a custom annotation +meta-annotated with `@Scope("prototype")`, possibly also declaring a custom scoped-proxy mode. NOTE: To provide a custom strategy for scope resolution rather than relying on the annotation-based approach, you can implement the @@ -875,7 +877,8 @@ Kotlin:: ---- When using certain non-singleton scopes, it may be necessary to generate proxies for the -scoped objects. The reasoning is described in xref:core/beans/factory-scopes.adoc#beans-factory-scopes-other-injection[Scoped Beans as Dependencies]. +scoped objects. The reasoning is described in +xref:core/beans/factory-scopes.adoc#beans-factory-scopes-other-injection[Scoped Beans as Dependencies]. For this purpose, a scoped-proxy attribute is available on the component-scan element. The three possible values are: `no`, `interfaces`, and `targetClass`. For example, the following configuration results in standard JDK dynamic proxies: diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc index 8230b453bbe4..f15948bea144 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc @@ -234,10 +234,13 @@ Kotlin:: ====== -- -URI path patterns can also have embedded `${...}` placeholders that are resolved on startup -by using `PropertySourcesPlaceholderConfigurer` against local, system, environment, and -other property sources. You can use this, for example, to parameterize a base URL based on -some external configuration. +URI path patterns can also have: + +- Embedded `${...}` placeholders that are resolved on startup via +`PropertySourcesPlaceholderConfigurer` against local, system, environment, and +other property sources. This is useful, for example, to parameterize a base URL based on +external configuration. +- SpEL expressions `#{...}`. NOTE: Spring WebFlux uses `PathPattern` and the `PathPatternParser` for URI path matching support. Both classes are located in `spring-web` and are expressly designed for use with HTTP URL diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc index 902a56ac7f4a..64f5abec6c49 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc @@ -217,10 +217,13 @@ Kotlin:: ---- ====== -URI path patterns can also have embedded `${...}` placeholders that are resolved on startup -by using `PropertySourcesPlaceholderConfigurer` against local, system, environment, and -other property sources. You can use this, for example, to parameterize a base URL based on -some external configuration. +URI path patterns can also have: + +- Embedded `${...}` placeholders that are resolved on startup via +`PropertySourcesPlaceholderConfigurer` against local, system, environment, and +other property sources. This is useful, for example, to parameterize a base URL based on +external configuration. +- SpEL expression `#{...}`. [[mvc-ann-requestmapping-pattern-comparison]] diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index ecc9e1f149b5..96b106f330ff 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -7,31 +7,31 @@ javaPlatform { } dependencies { - api(platform("com.fasterxml.jackson:jackson-bom:2.18.4")) - api(platform("io.micrometer:micrometer-bom:1.14.9")) - api(platform("io.netty:netty-bom:4.1.123.Final")) + api(platform("com.fasterxml.jackson:jackson-bom:2.18.4.1")) + api(platform("io.micrometer:micrometer-bom:1.14.10")) + api(platform("io.netty:netty-bom:4.1.124.Final")) api(platform("io.netty:netty5-bom:5.0.0.Alpha5")) - api(platform("io.projectreactor:reactor-bom:2024.0.8")) + api(platform("io.projectreactor:reactor-bom:2024.0.9")) api(platform("io.rsocket:rsocket-bom:1.1.5")) - api(platform("org.apache.groovy:groovy-bom:4.0.27")) + api(platform("org.apache.groovy:groovy-bom:4.0.28")) api(platform("org.apache.logging.log4j:log4j-bom:2.21.1")) api(platform("org.assertj:assertj-bom:3.27.3")) - api(platform("org.eclipse.jetty:jetty-bom:12.0.23")) - api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.23")) + api(platform("org.eclipse.jetty:jetty-bom:12.0.25")) + api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.25")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3")) - api(platform("org.junit:junit-bom:5.13.3")) + api(platform("org.junit:junit-bom:5.13.4")) api(platform("org.mockito:mockito-bom:5.18.0")) constraints { api("com.fasterxml:aalto-xml:1.3.2") api("com.fasterxml.woodstox:woodstox-core:6.7.0") - api("com.github.ben-manes.caffeine:caffeine:3.2.1") + api("com.github.ben-manes.caffeine:caffeine:3.2.2") api("com.github.librepdf:openpdf:1.3.43") api("com.google.code.findbugs:findbugs:3.0.1") api("com.google.code.findbugs:jsr305:3.0.2") api("com.google.code.gson:gson:2.13.1") - api("com.google.protobuf:protobuf-java-util:4.30.2") + api("com.google.protobuf:protobuf-java-util:4.31.1") api("com.h2database:h2:2.3.232") api("com.jayway.jsonpath:json-path:2.9.0") api("com.oracle.database.jdbc:ojdbc11:21.9.0.0") @@ -129,16 +129,16 @@ dependencies { api("org.hibernate:hibernate-core-jakarta:5.6.15.Final") api("org.hibernate:hibernate-validator:7.0.5.Final") api("org.hsqldb:hsqldb:2.7.4") - api("org.htmlunit:htmlunit:4.13.0") + api("org.htmlunit:htmlunit:4.14.0") api("org.javamoney:moneta:1.4.4") - api("org.jruby:jruby:9.4.12.0") + api("org.jruby:jruby:9.4.13.0") api("org.junit.support:testng-engine:1.0.5") api("org.mozilla:rhino:1.7.15") api("org.ogce:xpp3:1.1.6") api("org.python:jython-standalone:2.7.4") api("org.quartz-scheduler:quartz:2.3.2") - api("org.seleniumhq.selenium:htmlunit3-driver:4.33.0") - api("org.seleniumhq.selenium:selenium-java:4.34.0") + api("org.seleniumhq.selenium:htmlunit3-driver:4.34.0") + api("org.seleniumhq.selenium:selenium-java:4.35.0") api("org.skyscreamer:jsonassert:1.5.3") api("org.slf4j:slf4j-api:2.0.17") api("org.testng:testng:7.11.0") diff --git a/gradle.properties b/gradle.properties index ba3a4f72931c..02cd835359f4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=6.2.9-SNAPSHOT +version=6.2.10 org.gradle.caching=true org.gradle.jvmargs=-Xmx2048m diff --git a/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java b/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java index 8e4b99c293b5..325a6e532870 100644 --- a/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java +++ b/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java @@ -121,12 +121,12 @@ private static BeanDefinition registerOrEscalateApcAsRequired( Assert.notNull(registry, "BeanDefinitionRegistry must not be null"); if (registry.containsBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME)) { - BeanDefinition apcDefinition = registry.getBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME); - if (!cls.getName().equals(apcDefinition.getBeanClassName())) { - int currentPriority = findPriorityForClass(apcDefinition.getBeanClassName()); + BeanDefinition beanDefinition = registry.getBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME); + if (!cls.getName().equals(beanDefinition.getBeanClassName())) { + int currentPriority = findPriorityForClass(beanDefinition.getBeanClassName()); int requiredPriority = findPriorityForClass(cls); if (currentPriority < requiredPriority) { - apcDefinition.setBeanClassName(cls.getName()); + beanDefinition.setBeanClassName(cls.getName()); } } return null; @@ -134,8 +134,8 @@ private static BeanDefinition registerOrEscalateApcAsRequired( RootBeanDefinition beanDefinition = new RootBeanDefinition(cls); beanDefinition.setSource(source); - beanDefinition.getPropertyValues().add("order", Ordered.HIGHEST_PRECEDENCE); beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + beanDefinition.getPropertyValues().add("order", Ordered.HIGHEST_PRECEDENCE); registry.registerBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME, beanDefinition); return beanDefinition; } diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java index 196db14cfa9f..c2bef3c19d86 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java @@ -117,6 +117,7 @@ else if (advised.getTargetSource() == AdvisedSupport.EMPTY_TARGET_SOURCE && } proxyFactory.addAdvisor(this.advisor); customizeProxyFactory(proxyFactory); + proxyFactory.setPreFiltered(true); // Use original ClassLoader if bean class not locally loaded in overriding class loader ClassLoader classLoader = getProxyClassLoader(); diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java index fd5f98aa1be8..f456ed39241d 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java @@ -24,9 +24,9 @@ import org.springframework.lang.Nullable; /** - * Extension of {@link AbstractAutoProxyCreator} which implements {@link BeanFactoryAware}, - * adds exposure of the original target class for each proxied bean - * ({@link AutoProxyUtils#ORIGINAL_TARGET_CLASS_ATTRIBUTE}), + * Extension of {@link AbstractAdvisingBeanPostProcessor} which implements + * {@link BeanFactoryAware}, adds exposure of the original target class for each + * proxied bean ({@link AutoProxyUtils#ORIGINAL_TARGET_CLASS_ATTRIBUTE}), * and participates in an externally enforced target-class mode for any given bean * ({@link AutoProxyUtils#PRESERVE_TARGET_CLASS_ATTRIBUTE}). * This post-processor is therefore aligned with {@link AbstractAutoProxyCreator}. diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java index 4173274a3946..cbe60f9a525c 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java @@ -1455,11 +1455,18 @@ protected void checkForAliasCircle(String name, String alias) { } } + @Override + protected void addSingleton(String beanName, Object singletonObject) { + super.addSingleton(beanName, singletonObject); + Predicate> filter = (beanType -> beanType != Object.class && beanType.isInstance(singletonObject)); + this.allBeanNamesByType.keySet().removeIf(filter); + this.singletonBeanNamesByType.keySet().removeIf(filter); + } + @Override public void registerSingleton(String beanName, Object singletonObject) throws IllegalStateException { super.registerSingleton(beanName, singletonObject); updateManualSingletonNames(set -> set.add(beanName), set -> !this.beanDefinitionMap.containsKey(beanName)); - clearByTypeCache(); } @Override diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java index 3fc5065b5991..7b53e70ff3a0 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java @@ -103,7 +103,7 @@ public void setAsText(String text) throws IllegalArgumentException { if (resource == null) { setValue(null); } - else if (nioPathCandidate && !resource.exists()) { + else if (nioPathCandidate && (!resource.isFile() || !resource.exists())) { setValue(Paths.get(text).normalize()); } else { diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/DefaultListableBeanFactoryTests.java b/spring-beans/src/test/java/org/springframework/beans/factory/DefaultListableBeanFactoryTests.java index b05c53d87a0d..dc964ad5ff4b 100644 --- a/spring-beans/src/test/java/org/springframework/beans/factory/DefaultListableBeanFactoryTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/factory/DefaultListableBeanFactoryTests.java @@ -3202,6 +3202,29 @@ void nonPublicEnum() { assertThat(holder.getNonPublicEnum()).isEqualTo(NonPublicEnum.VALUE_1); } + @Test + void mostSpecificCacheEntryForTypeMatching() { + RootBeanDefinition bd1 = new RootBeanDefinition(); + bd1.setFactoryBeanName("config"); + bd1.setFactoryMethodName("create"); + lbf.registerBeanDefinition("config", new RootBeanDefinition(BeanWithFactoryMethod.class)); + lbf.registerBeanDefinition("bd1", bd1); + lbf.registerBeanDefinition("bd2", new RootBeanDefinition(NestedTestBean.class)); + lbf.freezeConfiguration(); + + String[] allBeanNames = lbf.getBeanNamesForType(Object.class); + String[] nestedBeanNames = lbf.getBeanNamesForType(NestedTestBean.class); + assertThat(lbf.getType("bd1")).isEqualTo(TestBean.class); + assertThat(lbf.getBeanNamesForType(TestBean.class)).containsExactly("bd1"); + assertThat(lbf.getBeanNamesForType(DerivedTestBean.class)).isEmpty(); + lbf.getBean("bd1"); + assertThat(lbf.getType("bd1")).isEqualTo(DerivedTestBean.class); + assertThat(lbf.getBeanNamesForType(TestBean.class)).containsExactly("bd1"); + assertThat(lbf.getBeanNamesForType(DerivedTestBean.class)).containsExactly("bd1"); + assertThat(lbf.getBeanNamesForType(NestedTestBean.class)).isSameAs(nestedBeanNames); + assertThat(lbf.getBeanNamesForType(Object.class)).isSameAs(allBeanNames); + } + private int registerBeanDefinitions(Properties p) { return registerBeanDefinitions(p, null); @@ -3418,7 +3441,7 @@ public void setName(String name) { } public TestBean create() { - TestBean tb = new TestBean(); + DerivedTestBean tb = new DerivedTestBean(); tb.setName(this.name); return tb; } @@ -3646,11 +3669,11 @@ private static class FactoryBeanDependentBean { private FactoryBean factoryBean; - public final FactoryBean getFactoryBean() { + public FactoryBean getFactoryBean() { return this.factoryBean; } - public final void setFactoryBean(final FactoryBean factoryBean) { + public void setFactoryBean(FactoryBean factoryBean) { this.factoryBean = factoryBean; } } diff --git a/spring-context/src/main/java/org/springframework/context/MessageSource.java b/spring-context/src/main/java/org/springframework/context/MessageSource.java index 509aed07124a..df6218d3d5b4 100644 --- a/spring-context/src/main/java/org/springframework/context/MessageSource.java +++ b/spring-context/src/main/java/org/springframework/context/MessageSource.java @@ -55,7 +55,7 @@ public interface MessageSource { * @see java.text.MessageFormat */ @Nullable - String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale); + String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale); /** * Try to resolve the message. Treat as an error if the message can't be found. @@ -71,7 +71,7 @@ public interface MessageSource { * @see #getMessage(MessageSourceResolvable, Locale) * @see java.text.MessageFormat */ - String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException; + String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException; /** * Try to resolve the message using all the attributes contained within the @@ -91,6 +91,6 @@ public interface MessageSource { * @see MessageSourceResolvable#getDefaultMessage() * @see java.text.MessageFormat */ - String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException; + String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException; } diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ReflectiveScan.java b/spring-context/src/main/java/org/springframework/context/annotation/ReflectiveScan.java index c71da8589e77..b83bff6ef9a5 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ReflectiveScan.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ReflectiveScan.java @@ -51,9 +51,9 @@ * ignored. * * @author Stephane Nicoll + * @since 6.2 * @see Reflective @Reflective * @see RegisterReflection @RegisterReflection - * @since 6.2 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) diff --git a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java index 9756570a924e..ee8fd7dddadb 100644 --- a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java +++ b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java @@ -1502,17 +1502,17 @@ protected BeanFactory getInternalParentBeanFactory() { @Override @Nullable - public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) { + public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale) { return getMessageSource().getMessage(code, args, defaultMessage, locale); } @Override - public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException { + public String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException { return getMessageSource().getMessage(code, args, locale); } @Override - public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException { + public String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException { return getMessageSource().getMessage(resolvable, locale); } diff --git a/spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java b/spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java index ae42985a4ab3..9abd0e44da00 100644 --- a/spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java +++ b/spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java @@ -138,7 +138,7 @@ protected boolean isUseCodeAsDefaultMessage() { @Override @Nullable - public final String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) { + public final String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale) { String msg = getMessageInternal(code, args, locale); if (msg != null) { return msg; @@ -150,7 +150,7 @@ public final String getMessage(String code, @Nullable Object[] args, @Nullable S } @Override - public final String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException { + public final String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException { String msg = getMessageInternal(code, args, locale); if (msg != null) { return msg; @@ -159,11 +159,16 @@ public final String getMessage(String code, @Nullable Object[] args, Locale loca if (fallback != null) { return fallback; } - throw new NoSuchMessageException(code, locale); + if (locale == null ) { + throw new NoSuchMessageException(code); + } + else { + throw new NoSuchMessageException(code, locale); + } } @Override - public final String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException { + public final String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException { String[] codes = resolvable.getCodes(); if (codes != null) { for (String code : codes) { @@ -177,7 +182,13 @@ public final String getMessage(MessageSourceResolvable resolvable, Locale locale if (defaultMessage != null) { return defaultMessage; } - throw new NoSuchMessageException(!ObjectUtils.isEmpty(codes) ? codes[codes.length - 1] : "", locale); + String code = !ObjectUtils.isEmpty(codes) ? codes[codes.length - 1] : ""; + if (locale == null ) { + throw new NoSuchMessageException(code); + } + else { + throw new NoSuchMessageException(code, locale); + } } @@ -284,7 +295,7 @@ protected String getMessageFromParent(String code, @Nullable Object[] args, Loca * @see #getDefaultMessage(String) */ @Nullable - protected String getDefaultMessage(MessageSourceResolvable resolvable, Locale locale) { + protected String getDefaultMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) { String defaultMessage = resolvable.getDefaultMessage(); String[] codes = resolvable.getCodes(); if (defaultMessage != null) { @@ -331,7 +342,7 @@ protected String getDefaultMessage(String code) { * @return an array of arguments with any MessageSourceResolvables resolved */ @Override - protected Object[] resolveArguments(@Nullable Object[] args, Locale locale) { + protected Object[] resolveArguments(@Nullable Object[] args, @Nullable Locale locale) { if (ObjectUtils.isEmpty(args)) { return super.resolveArguments(args, locale); } diff --git a/spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java b/spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java index 6d15a713e0ab..952990a9e3f8 100644 --- a/spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java +++ b/spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java @@ -55,7 +55,7 @@ public MessageSource getParentMessageSource() { @Override @Nullable - public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) { + public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale) { if (this.parentMessageSource != null) { return this.parentMessageSource.getMessage(code, args, defaultMessage, locale); } @@ -68,17 +68,22 @@ else if (defaultMessage != null) { } @Override - public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException { + public String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException { if (this.parentMessageSource != null) { return this.parentMessageSource.getMessage(code, args, locale); } else { - throw new NoSuchMessageException(code, locale); + if (locale == null) { + throw new NoSuchMessageException(code); + } + else { + throw new NoSuchMessageException(code, locale); + } } } @Override - public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException { + public String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException { if (this.parentMessageSource != null) { return this.parentMessageSource.getMessage(resolvable, locale); } @@ -88,7 +93,12 @@ public String getMessage(MessageSourceResolvable resolvable, Locale locale) thro } String[] codes = resolvable.getCodes(); String code = (codes != null && codes.length > 0 ? codes[0] : ""); - throw new NoSuchMessageException(code, locale); + if (locale == null) { + throw new NoSuchMessageException(code); + } + else { + throw new NoSuchMessageException(code, locale); + } } } diff --git a/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java b/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java index 6f1bba4f6fb1..5ce2c329feae 100644 --- a/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java +++ b/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java @@ -98,7 +98,7 @@ protected boolean isAlwaysUseMessageFormat() { * @return the rendered default message (with resolved arguments) * @see #formatMessage(String, Object[], java.util.Locale) */ - protected String renderDefaultMessage(String defaultMessage, @Nullable Object[] args, Locale locale) { + protected String renderDefaultMessage(String defaultMessage, @Nullable Object[] args, @Nullable Locale locale) { return formatMessage(defaultMessage, args, locale); } @@ -112,7 +112,7 @@ protected String renderDefaultMessage(String defaultMessage, @Nullable Object[] * @param locale the Locale used for formatting * @return the formatted message (with resolved arguments) */ - protected String formatMessage(String msg, @Nullable Object[] args, Locale locale) { + protected String formatMessage(String msg, @Nullable Object[] args, @Nullable Locale locale) { if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) { return msg; } @@ -146,7 +146,7 @@ protected String formatMessage(String msg, @Nullable Object[] args, Locale local * @param locale the Locale to create a {@code MessageFormat} for * @return the {@code MessageFormat} instance */ - protected MessageFormat createMessageFormat(String msg, Locale locale) { + protected MessageFormat createMessageFormat(String msg, @Nullable Locale locale) { return new MessageFormat(msg, locale); } @@ -158,7 +158,7 @@ protected MessageFormat createMessageFormat(String msg, Locale locale) { * @param locale the Locale to resolve against * @return the resolved argument array */ - protected Object[] resolveArguments(@Nullable Object[] args, Locale locale) { + protected Object[] resolveArguments(@Nullable Object[] args, @Nullable Locale locale) { return (args != null ? args : new Object[0]); } diff --git a/spring-context/src/main/java/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.java b/spring-context/src/main/java/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.java index 0f3cf3d6b534..9d7d61d52c41 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.java +++ b/spring-context/src/main/java/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.java @@ -376,7 +376,7 @@ public void stop(Runnable callback) { @Override public boolean isRunning() { - return this.triggerLifecycle.isRunning(); + return (this.triggerLifecycle.isRunning() || this.fixedDelayLifecycle.isRunning()); } @Override diff --git a/spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java b/spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java index 39b7287a793f..2aed7cc6967e 100644 --- a/spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java @@ -361,12 +361,22 @@ public long lastModified() throws IOException { * @throws IOException if thrown from URLConnection methods */ protected void customizeConnection(URLConnection con) throws IOException { - ResourceUtils.useCachesIfNecessary(con); + useCachesIfNecessary(con); if (con instanceof HttpURLConnection httpCon) { customizeConnection(httpCon); } } + /** + * Apply {@link URLConnection#setUseCaches useCaches} if necessary. + * @param con the URLConnection to customize + * @since 6.2.10 + * @see ResourceUtils#useCachesIfNecessary(URLConnection) + */ + void useCachesIfNecessary(URLConnection con) { + ResourceUtils.useCachesIfNecessary(con); + } + /** * Customize the given {@link HttpURLConnection} before fetching the resource. *

Can be overridden in subclasses for configuring request headers and timeouts. diff --git a/spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java b/spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java index 300b5be4a3f2..1878432af633 100644 --- a/spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java @@ -109,7 +109,9 @@ public WritableByteChannel writableChannel() throws IOException { @Override public Resource createRelative(String relativePath) throws MalformedURLException { - return new FileUrlResource(createRelativeURL(relativePath)); + FileUrlResource resource = new FileUrlResource(createRelativeURL(relativePath)); + resource.useCaches = this.useCaches; + return resource; } } diff --git a/spring-core/src/main/java/org/springframework/core/io/UrlResource.java b/spring-core/src/main/java/org/springframework/core/io/UrlResource.java index 80ce1680590d..8ca9b80c0dcf 100644 --- a/spring-core/src/main/java/org/springframework/core/io/UrlResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/UrlResource.java @@ -67,6 +67,12 @@ public class UrlResource extends AbstractFileResolvingResource { @Nullable private volatile String cleanedUrl; + /** + * Whether to use URLConnection caches ({@code null} means default). + */ + @Nullable + volatile Boolean useCaches; + /** * Create a new {@code UrlResource} based on the given URL object. @@ -216,11 +222,22 @@ private String getCleanedUrl() { return cleanedUrl; } + /** + * Set an explicit flag for {@link URLConnection#setUseCaches}, + * to be applied for any {@link URLConnection} operation in this resource. + *

By default, caching will be applied only to jar resources. + * An explicit {@code true} flag applies caching to all resources, whereas an + * explicit {@code false} flag turns off caching for jar resources as well. + * @since 6.2.10 + * @see ResourceUtils#useCachesIfNecessary + */ + public void setUseCaches(boolean useCaches) { + this.useCaches = useCaches; + } + /** * This implementation opens an InputStream for the given URL. - *

It sets the {@code useCaches} flag to {@code false}, - * mainly to avoid jar file locking on Windows. * @see java.net.URL#openConnection() * @see java.net.URLConnection#setUseCaches(boolean) * @see java.net.URLConnection#getInputStream() @@ -251,6 +268,17 @@ protected void customizeConnection(URLConnection con) throws IOException { } } + @Override + void useCachesIfNecessary(URLConnection con) { + Boolean useCaches = this.useCaches; + if (useCaches != null) { + con.setUseCaches(useCaches); + } + else { + super.useCachesIfNecessary(con); + } + } + /** * This implementation returns the underlying URL reference. */ @@ -305,7 +333,9 @@ public File getFile() throws IOException { */ @Override public Resource createRelative(String relativePath) throws MalformedURLException { - return new UrlResource(createRelativeURL(relativePath)); + UrlResource resource = new UrlResource(createRelativeURL(relativePath)); + resource.useCaches = this.useCaches; + return resource; } /** diff --git a/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java b/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java index 1634796d1573..0ecabb7e19d9 100644 --- a/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java +++ b/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java @@ -260,7 +260,8 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol private PathMatcher pathMatcher = new AntPathMatcher(); - private boolean useCaches = true; + @Nullable + private Boolean useCaches; private final Map rootDirCache = new ConcurrentHashMap<>(); @@ -342,10 +343,12 @@ public PathMatcher getPathMatcher() { * the {@link JarURLConnection} level as well as within this resolver instance. *

Note that {@link JarURLConnection#setDefaultUseCaches} can be turned off * independently. This resolver-level setting is designed to only enforce - * {@code JarURLConnection#setUseCaches(false)} if necessary but otherwise - * leaves the JVM-level default in place. + * {@code JarURLConnection#setUseCaches(true/false)} if necessary but otherwise + * leaves the JVM-level default in place (if this setter has not been called). + *

As of 6.2.10, this setting propagates to {@link UrlResource#setUseCaches}. * @since 6.1.19 * @see JarURLConnection#setUseCaches + * @see UrlResource#setUseCaches * @see #clearCache() */ public void setUseCaches(boolean useCaches) { @@ -355,7 +358,11 @@ public void setUseCaches(boolean useCaches) { @Override public Resource getResource(String location) { - return getResourceLoader().getResource(location); + Resource resource = getResourceLoader().getResource(location); + if (this.useCaches != null && resource instanceof UrlResource urlResource) { + urlResource.setUseCaches(this.useCaches); + } + return resource; } @Override @@ -473,20 +480,27 @@ protected Resource convertClassLoaderURL(URL url) { } } else { + UrlResource resource = null; String urlString = url.toString(); String cleanedPath = StringUtils.cleanPath(urlString); if (!cleanedPath.equals(urlString)) { // Prefer cleaned URL, aligned with UrlResource#createRelative(String) try { // Retain original URL instance, potentially including custom URLStreamHandler. - return new UrlResource(new URL(url, cleanedPath)); + resource = new UrlResource(new URL(url, cleanedPath)); } catch (MalformedURLException ex) { // Fallback to regular URL construction below... } } // Retain original URL instance, potentially including custom URLStreamHandler. - return new UrlResource(url); + if (resource == null) { + resource = new UrlResource(url); + } + if (this.useCaches != null) { + resource.setUseCaches(this.useCaches); + } + return resource; } } @@ -505,6 +519,9 @@ protected void addAllClassLoaderJarRoots(@Nullable ClassLoader classLoader, Set< UrlResource jarResource = (ResourceUtils.URL_PROTOCOL_JAR.equals(url.getProtocol()) ? new UrlResource(url) : new UrlResource(ResourceUtils.JAR_URL_PREFIX + url + ResourceUtils.JAR_URL_SEPARATOR)); + if (this.useCaches != null) { + jarResource.setUseCaches(this.useCaches); + } if (jarResource.exists()) { result.add(jarResource); } @@ -556,7 +573,7 @@ protected void addClassPathManifestEntries(Set result) { Set entries = this.manifestEntriesCache; if (entries == null) { entries = getClassPathManifestEntries(); - if (this.useCaches) { + if (this.useCaches == null || this.useCaches) { this.manifestEntriesCache = entries; } } @@ -577,7 +594,7 @@ private Set getClassPathManifestEntries() { try { File jar = new File(path).getAbsoluteFile(); if (jar.isFile() && seen.add(jar)) { - manifestEntries.add(ClassPathManifestEntry.of(jar)); + manifestEntries.add(ClassPathManifestEntry.of(jar, this.useCaches)); manifestEntries.addAll(getClassPathManifestEntriesFromJar(jar)); } } @@ -616,7 +633,7 @@ private Set getClassPathManifestEntriesFromJar(File jar) } File candidate = new File(parent, path); if (candidate.isFile() && candidate.getCanonicalPath().contains(parent.getCanonicalPath())) { - manifestEntries.add(ClassPathManifestEntry.of(candidate)); + manifestEntries.add(ClassPathManifestEntry.of(candidate, this.useCaches)); } } } @@ -710,7 +727,7 @@ else if (commonPrefix.equals(rootDirPath)) { if (rootDirResources == null) { // Lookup for specific directory, creating a cache entry for it. rootDirResources = getResources(rootDirPath); - if (this.useCaches) { + if (this.useCaches == null || this.useCaches) { this.rootDirCache.put(rootDirPath, rootDirResources); } } @@ -729,7 +746,11 @@ else if (commonPrefix.equals(rootDirPath)) { if (resolvedUrl != null) { rootDirUrl = resolvedUrl; } - rootDirResource = new UrlResource(rootDirUrl); + UrlResource urlResource = new UrlResource(rootDirUrl); + if (this.useCaches != null) { + urlResource.setUseCaches(this.useCaches); + } + rootDirResource = urlResource; } if (rootDirUrl.getProtocol().startsWith(ResourceUtils.URL_PROTOCOL_VFS)) { result.addAll(VfsResourceMatchingDelegate.findMatchingResources(rootDirUrl, subPattern, getPathMatcher())); @@ -865,8 +886,8 @@ protected Set doFindPathMatchingJarResources(Resource rootDirResource, if (con instanceof JarURLConnection jarCon) { // Should usually be the case for traditional JAR files. - if (!this.useCaches) { - jarCon.setUseCaches(false); + if (this.useCaches != null) { + jarCon.setUseCaches(this.useCaches); } try { jarFile = jarCon.getJarFile(); @@ -931,7 +952,7 @@ protected Set doFindPathMatchingJarResources(Resource rootDirResource, } } } - if (this.useCaches) { + if (this.useCaches == null || this.useCaches) { // Cache jar entries in TreeSet for efficient searching on re-encounter. this.jarEntriesCache.put(jarFileUrl, entriesCache); } @@ -1257,10 +1278,10 @@ private record ClassPathManifestEntry(Resource resource, @Nullable Resource alte private static final String JARFILE_URL_PREFIX = ResourceUtils.JAR_URL_PREFIX + ResourceUtils.FILE_URL_PREFIX; - static ClassPathManifestEntry of(File file) throws MalformedURLException { + static ClassPathManifestEntry of(File file, @Nullable Boolean useCaches) throws MalformedURLException { String path = fixPath(file.getAbsolutePath()); - Resource resource = asJarFileResource(path); - Resource alternative = createAlternative(path); + Resource resource = asJarFileResource(path, useCaches); + Resource alternative = createAlternative(path, useCaches); return new ClassPathManifestEntry(resource, alternative); } @@ -1281,18 +1302,22 @@ private static String fixPath(String path) { * @return the alternative form or {@code null} */ @Nullable - private static Resource createAlternative(String path) { + private static Resource createAlternative(String path, @Nullable Boolean useCaches) { try { String alternativePath = path.startsWith("/") ? path.substring(1) : "/" + path; - return asJarFileResource(alternativePath); + return asJarFileResource(alternativePath, useCaches); } catch (MalformedURLException ex) { return null; } } - private static Resource asJarFileResource(String path) throws MalformedURLException { - return new UrlResource(JARFILE_URL_PREFIX + path + ResourceUtils.JAR_URL_SEPARATOR); + private static Resource asJarFileResource(String path, @Nullable Boolean useCaches) throws MalformedURLException { + UrlResource resource = new UrlResource(JARFILE_URL_PREFIX + path + ResourceUtils.JAR_URL_SEPARATOR); + if (useCaches != null) { + resource.setUseCaches(useCaches); + } + return resource; } } diff --git a/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java b/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java index adb9eae4aa58..33b35c4b37d3 100644 --- a/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java +++ b/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java @@ -364,7 +364,6 @@ public void close() { this.active = false; Set threads = this.activeThreads; if (threads != null) { - threads.forEach(Thread::interrupt); synchronized (threads) { try { if (!threads.isEmpty()) { @@ -375,6 +374,7 @@ public void close() { Thread.currentThread().interrupt(); } } + threads.forEach(Thread::interrupt); } } } diff --git a/spring-core/src/main/java/org/springframework/util/StringUtils.java b/spring-core/src/main/java/org/springframework/util/StringUtils.java index 8b2553ce2766..760fc3da87a4 100644 --- a/spring-core/src/main/java/org/springframework/util/StringUtils.java +++ b/spring-core/src/main/java/org/springframework/util/StringUtils.java @@ -16,7 +16,6 @@ package org.springframework.util; -import java.io.ByteArrayOutputStream; import java.nio.charset.Charset; import java.util.ArrayDeque; import java.util.ArrayList; @@ -25,6 +24,7 @@ import java.util.Collections; import java.util.Deque; import java.util.Enumeration; +import java.util.HexFormat; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; @@ -803,54 +803,60 @@ public static boolean pathEquals(String path1, String path2) { } /** - * Decode the given encoded URI component value. Based on the following rules: - *

- * @param source the encoded String - * @param charset the character set + * Decode the given encoded URI component value by replacing "{@code %xy}" sequences + * by an hexadecimal representation of the character in the specified charset, letting other + * characters unchanged. + * @param source the encoded {@code String} + * @param charset the character encoding to use to decode the "{@code %xy}" sequences * @return the decoded value * @throws IllegalArgumentException when the given source contains invalid encoded sequences * @since 5.0 - * @see java.net.URLDecoder#decode(String, String) + * @see java.net.URLDecoder#decode(String, String) java.net.URLDecoder#decode for HTML form decoding */ public static String uriDecode(String source, Charset charset) { int length = source.length(); - if (length == 0) { + int firstPercentIndex = source.indexOf('%'); + if (length == 0 || firstPercentIndex < 0) { return source; } - Assert.notNull(charset, "Charset must not be null"); - ByteArrayOutputStream baos = new ByteArrayOutputStream(length); - boolean changed = false; - for (int i = 0; i < length; i++) { - int ch = source.charAt(i); + StringBuilder output = new StringBuilder(length); + output.append(source, 0, firstPercentIndex); + byte[] bytes = null; + int i = firstPercentIndex; + while (i < length) { + char ch = source.charAt(i); if (ch == '%') { - if (i + 2 < length) { - char hex1 = source.charAt(i + 1); - char hex2 = source.charAt(i + 2); - int u = Character.digit(hex1, 16); - int l = Character.digit(hex2, 16); - if (u == -1 || l == -1) { - throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\""); + try { + if (bytes == null) { + bytes = new byte[(length - i) / 3]; } - baos.write((char) ((u << 4) + l)); - i += 2; - changed = true; + + int pos = 0; + while (i + 2 < length && ch == '%') { + bytes[pos++] = (byte) HexFormat.fromHexDigits(source, i + 1, i + 3); + i += 3; + if (i < length) { + ch = source.charAt(i); + } + } + + if (i < length && ch == '%') { + throw new IllegalArgumentException("Incomplete trailing escape (%) pattern"); + } + + output.append(new String(bytes, 0, pos, charset)); } - else { + catch (NumberFormatException ex) { throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\""); } } else { - baos.write(ch); + output.append(ch); + i++; } } - return (changed ? StreamUtils.copyToString(baos, charset) : source); + return output.toString(); } /** diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java index c01516b12763..56701af1e242 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java @@ -21,7 +21,6 @@ import java.sql.SQLException; import java.sql.Types; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -48,11 +47,6 @@ public class GenericTableMetaDataProvider implements TableMetaDataProvider { /** Logger available to subclasses. */ protected static final Log logger = LogFactory.getLog(TableMetaDataProvider.class); - /** Database products we know not supporting the use of a String[] for generated keys. */ - private static final List productsNotSupportingGeneratedKeysColumnNameArray = - Arrays.asList("Apache Derby", "HSQL Database Engine"); - - /** The name of the user currently connected. */ @Nullable private final String userName; @@ -95,45 +89,14 @@ protected GenericTableMetaDataProvider(DatabaseMetaData databaseMetaData) throws @Override public void initializeWithMetaData(DatabaseMetaData databaseMetaData) throws SQLException { try { - if (databaseMetaData.supportsGetGeneratedKeys()) { - logger.debug("GetGeneratedKeys is supported"); - setGetGeneratedKeysSupported(true); - } - else { - logger.debug("GetGeneratedKeys is not supported"); - setGetGeneratedKeysSupported(false); - } + setGetGeneratedKeysSupported(databaseMetaData.supportsGetGeneratedKeys()); + setGeneratedKeysColumnNameArraySupported(isGetGeneratedKeysSupported()); } catch (SQLException ex) { if (logger.isWarnEnabled()) { logger.warn("Error retrieving 'DatabaseMetaData.supportsGetGeneratedKeys': " + ex.getMessage()); } } - try { - String databaseProductName = databaseMetaData.getDatabaseProductName(); - if (productsNotSupportingGeneratedKeysColumnNameArray.contains(databaseProductName)) { - if (logger.isDebugEnabled()) { - logger.debug("GeneratedKeysColumnNameArray is not supported for " + databaseProductName); - } - setGeneratedKeysColumnNameArraySupported(false); - } - else { - if (isGetGeneratedKeysSupported()) { - if (logger.isDebugEnabled()) { - logger.debug("GeneratedKeysColumnNameArray is supported for " + databaseProductName); - } - setGeneratedKeysColumnNameArraySupported(true); - } - else { - setGeneratedKeysColumnNameArraySupported(false); - } - } - } - catch (SQLException ex) { - if (logger.isWarnEnabled()) { - logger.warn("Error retrieving 'DatabaseMetaData.getDatabaseProductName': " + ex.getMessage()); - } - } try { this.databaseVersion = databaseMetaData.getDatabaseProductVersion(); @@ -225,19 +188,23 @@ else if (isStoresLowerCaseIdentifiers()) { } } + /** + * This implementation delegates to {@link #catalogNameToUse}. + */ @Override @Nullable public String metaDataCatalogNameToUse(@Nullable String catalogName) { return catalogNameToUse(catalogName); } + /** + * This implementation delegates to {@link #schemaNameToUse}. + * @see #getDefaultSchema() + */ @Override @Nullable public String metaDataSchemaNameToUse(@Nullable String schemaName) { - if (schemaName == null) { - return schemaNameToUse(getDefaultSchema()); - } - return schemaNameToUse(schemaName); + return schemaNameToUse(schemaName != null ? schemaName : getDefaultSchema()); } /** @@ -401,7 +368,7 @@ private void processTableColumns(DatabaseMetaData databaseMetaData, TableMetaDat try { tableColumns = databaseMetaData.getColumns( metaDataCatalogName, metaDataSchemaName, metaDataTableName, null); - while (tableColumns.next()) { + while (tableColumns != null && tableColumns.next()) { String columnName = tableColumns.getString("COLUMN_NAME"); int dataType = tableColumns.getInt("DATA_TYPE"); if (dataType == Types.DECIMAL) { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java index fa9338a7d2ee..cfea217acf16 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java @@ -19,12 +19,16 @@ import java.sql.DatabaseMetaData; import java.sql.SQLException; +import org.springframework.lang.Nullable; + /** * The PostgreSQL specific implementation of {@link TableMetaDataProvider}. * Supports a feature for retrieving generated keys without the JDBC 3.0 - * {@code getGeneratedKeys} support. + * {@code getGeneratedKeys} support. Also, it processes PostgreSQL-returned + * catalog and schema names from {@code DatabaseMetaData} in the given case. * * @author Thomas Risberg + * @author Juergen Hoeller * @since 2.5 */ public class PostgresTableMetaDataProvider extends GenericTableMetaDataProvider { @@ -34,6 +38,18 @@ public PostgresTableMetaDataProvider(DatabaseMetaData databaseMetaData) throws S } + @Override + @Nullable + public String metaDataCatalogNameToUse(@Nullable String catalogName) { + return catalogName; + } + + @Override + @Nullable + public String metaDataSchemaNameToUse(@Nullable String schemaName) { + return (schemaName != null ? schemaName : getDefaultSchema()); + } + @Override public boolean isGetGeneratedKeysSimulated() { return true; diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java index 46672b4b0a5e..fd796de79cab 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java @@ -34,12 +34,18 @@ * *

Designed for use with {@link org.springframework.jdbc.core.JdbcTemplate} * as well as {@link org.springframework.jdbc.core.simple.JdbcClient}, to be - * passed in as a parameter value wrapping the target content value. Can be - * combined with {@link org.springframework.jdbc.core.SqlParameterValue} for - * specifying a SQL type, for example, + * passed in as a parameter value wrapping the target content value. + * + *

Can be combined with {@link org.springframework.jdbc.core.SqlParameterValue} + * for specifying a SQL type, for example, * {@code new SqlParameterValue(Types.BLOB, new SqlBinaryValue(myContent))}. * With most database drivers, the type hint is not actually necessary. * + *

Note: Only specify {@code Types.BLOB} in case of an actual BLOB, preferring + * {@code Types.LONGVARBINARY} otherwise. With PostgreSQL, {@code Types.ARRAY} + * has to be specified for BYTEA columns, rather than {@code Types.BLOB}. This + * is in contrast to {@link SqlLobValue} where byte array handling was lenient. + * * @author Juergen Hoeller * @since 6.1.4 * @see SqlCharacterValue diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java index e5f3f42deebd..97b1587c558d 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java @@ -33,12 +33,17 @@ * *

Designed for use with {@link org.springframework.jdbc.core.JdbcTemplate} * as well as {@link org.springframework.jdbc.core.simple.JdbcClient}, to be - * passed in as a parameter value wrapping the target content value. Can be - * combined with {@link org.springframework.jdbc.core.SqlParameterValue} for - * specifying a SQL type, for example, + * passed in as a parameter value wrapping the target content value. + * + *

Can be combined with {@link org.springframework.jdbc.core.SqlParameterValue} + * for specifying a SQL type, for example, * {@code new SqlParameterValue(Types.CLOB, new SqlCharacterValue(myContent))}. * With most database drivers, the type hint is not actually necessary. * + *

Note: Only specify {@code Types.CLOB} in case of an actual CLOB, preferring + * {@code Types.LONGVARCHAR} otherwise. This is in contrast to {@link SqlLobValue} + * where char sequence handling was lenient. + * * @author Juergen Hoeller * @since 6.1.4 * @see SqlBinaryValue diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/ScriptUtils.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/ScriptUtils.java index b93ab0b6a92b..ec40441a7aaa 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/ScriptUtils.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/ScriptUtils.java @@ -258,18 +258,29 @@ public static void executeSqlScript(Connection connection, EncodedResource resou for (String statement : statements) { stmtNumber++; try { - stmt.execute(statement); - int rowsAffected = stmt.getUpdateCount(); + boolean hasResultSet = stmt.execute(statement); + int updateCount = -1; if (logger.isDebugEnabled()) { - logger.debug(rowsAffected + " returned as update count for SQL: " + statement); - SQLWarning warningToLog = stmt.getWarnings(); - while (warningToLog != null) { - logger.debug("SQLWarning ignored: SQL state '" + warningToLog.getSQLState() + - "', error code '" + warningToLog.getErrorCode() + - "', message [" + warningToLog.getMessage() + "]"); - warningToLog = warningToLog.getNextWarning(); - } + logSqlWarnings(stmt); } + do { + if (hasResultSet) { + // We invoke getResultSet() to ensure the JDBC driver processes + // it, but we intentionally ignore the returned ResultSet since + // we cannot do anything meaningful with it here. + stmt.getResultSet(); + if (logger.isDebugEnabled()) { + logger.debug("ResultSet returned for SQL: " + statement); + } + } + else { + updateCount = stmt.getUpdateCount(); + if (updateCount >= 0 && logger.isDebugEnabled()) { + logger.debug(updateCount + " returned as update count for SQL: " + statement); + } + } + hasResultSet = stmt.getMoreResults(); + } while (hasResultSet || updateCount != -1); } catch (SQLException ex) { boolean dropStatement = StringUtils.startsWithIgnoreCase(statement.trim(), "drop"); @@ -307,6 +318,16 @@ public static void executeSqlScript(Connection connection, EncodedResource resou } } + private static void logSqlWarnings(Statement stmt) throws SQLException { + SQLWarning warningToLog = stmt.getWarnings(); + while (warningToLog != null) { + logger.debug("SQLWarning ignored: SQL state '" + warningToLog.getSQLState() + + "', error code '" + warningToLog.getErrorCode() + + "', message [" + warningToLog.getMessage() + "]"); + warningToLog = warningToLog.getNextWarning(); + } + } + /** * Read a script from the provided resource, using the supplied comment prefixes * and statement separator, and build a {@code String} containing the lines. diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java index fd4daf2064df..55130eb1ec9b 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java @@ -74,7 +74,7 @@ void shutdownDatabase() { @Test void updateWithGeneratedKeys() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -92,7 +92,7 @@ void updateWithGeneratedKeys() { @Test void updateWithGeneratedKeysAndKeyColumnNames() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -110,7 +110,7 @@ void updateWithGeneratedKeysAndKeyColumnNames() { @Test void updateWithGeneratedKeysUsingNamedParameters() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -129,7 +129,7 @@ void updateWithGeneratedKeysUsingNamedParameters() { @Test void updateWithGeneratedKeysAndKeyColumnNamesUsingNamedParameters() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -217,7 +217,7 @@ void selectWithReusedNamedParameterListFromBeanProperties() { private static void assertResults(List users) { - assertThat(users).containsExactly(new User(2, "John", "John"), new User(3, "John", "Smith")); + assertThat(users).containsExactly(new User(1, "John", "John"), new User(2, "John", "Smith")); } record Name(String name) {} diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcInsertIntegrationTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcInsertIntegrationTests.java index 02928652e530..720fbb330a42 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcInsertIntegrationTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcInsertIntegrationTests.java @@ -323,7 +323,7 @@ protected void assertNumRows(long count) { protected void insertJaneSmith(SimpleJdbcInsert insert) { Number id = insert.executeAndReturnKey(Map.of("first_name", "Jane", "last_name", "Smith")); - assertThat(id.intValue()).isEqualTo(2); + assertThat(id.intValue()).isEqualTo(1); assertNumRows(2); } diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/init/ScriptUtilsIntegrationTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/init/ScriptUtilsIntegrationTests.java index 8eb91b296893..f59db0e6a46b 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/init/ScriptUtilsIntegrationTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/init/ScriptUtilsIntegrationTests.java @@ -16,13 +16,24 @@ package org.springframework.jdbc.datasource.init; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.jdbc.core.DataClassRowMapper; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; import static org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript; /** @@ -32,16 +43,22 @@ * @since 4.0.3 * @see ScriptUtilsTests */ +@ParameterizedClass +@EnumSource(EmbeddedDatabaseType.class) class ScriptUtilsIntegrationTests extends AbstractDatabaseInitializationTests { + @Parameter + EmbeddedDatabaseType databaseType; + + @Override protected EmbeddedDatabaseType getEmbeddedDatabaseType() { - return EmbeddedDatabaseType.HSQL; + return this.databaseType; } @BeforeEach void setUpSchema() throws SQLException { - executeSqlScript(db.getConnection(), usersSchema()); + executeSqlScript(db.getConnection(), encodedResource(usersSchema()), false, true, "--", null, "/*", "*/"); } @Test @@ -59,4 +76,52 @@ void executeSqlScriptContainingSingleQuotesNestedInsideDoubleQuotes() throws SQL assertUsersDatabaseCreated("Hoeller", "Brannen"); } + @Test + @SuppressWarnings("unchecked") + void statementWithMultipleResultSets() throws SQLException { + // Derby does not support multiple statements/ResultSets within a single Statement. + assumeThat(this.databaseType).isNotSameAs(EmbeddedDatabaseType.DERBY); + + EncodedResource resource = encodedResource(resource("users-data.sql")); + executeSqlScript(db.getConnection(), resource, false, true, "--", null, "/*", "*/"); + + assertUsersInDatabase(user("Sam", "Brannen")); + + resource = encodedResource(inlineResource(""" + SELECT last_name FROM users WHERE id = 0; + UPDATE users SET first_name = 'Jane' WHERE id = 0; + UPDATE users SET last_name = 'Smith' WHERE id = 0; + SELECT last_name FROM users WHERE id = 0; + GO + """)); + + String separator = "GO\n"; + executeSqlScript(db.getConnection(), resource, false, true, "--", separator, "/*", "*/"); + + assertUsersInDatabase(user("Jane", "Smith")); + } + + private void assertUsersInDatabase(User... expectedUsers) { + List users = jdbcTemplate.query("SELECT * FROM users WHERE id = 0", + new DataClassRowMapper<>(User.class)); + assertThat(users).containsExactly(expectedUsers); + } + + + private static EncodedResource encodedResource(Resource resource) { + return new EncodedResource(resource); + } + + private static Resource inlineResource(String sql) { + byte[] bytes = sql.getBytes(StandardCharsets.UTF_8); + return new ByteArrayResource(bytes, "inline SQL"); + } + + private static User user(String firstName, String lastName) { + return new User(0, firstName, lastName); + } + + record User(int id, String firstName, String lastName) { + } + } diff --git a/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema-with-custom-schema.sql b/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema-with-custom-schema.sql index 6da1c2978205..0959c7e6955b 100644 --- a/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema-with-custom-schema.sql +++ b/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema-with-custom-schema.sql @@ -5,7 +5,7 @@ SET SCHEMA my_schema; DROP TABLE users IF EXISTS; CREATE TABLE users ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY, + id INTEGER GENERATED BY DEFAULT AS IDENTITY(START WITH 0) PRIMARY KEY, first_name VARCHAR(50) NOT NULL, last_name VARCHAR(50) NOT NULL ); diff --git a/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema.sql b/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema.sql index 523c4a7c2b19..d9cb2918b1c6 100644 --- a/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema.sql +++ b/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema.sql @@ -1,7 +1,7 @@ DROP TABLE users IF EXISTS; CREATE TABLE users ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY, + id INTEGER GENERATED BY DEFAULT AS IDENTITY(START WITH 0) PRIMARY KEY, first_name VARCHAR(50) NOT NULL, last_name VARCHAR(50) NOT NULL ); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StubWebApplicationContext.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StubWebApplicationContext.java index e1bdca74cd94..f3661f093aa0 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StubWebApplicationContext.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StubWebApplicationContext.java @@ -357,17 +357,17 @@ public boolean containsLocalBean(String name) { @Override @Nullable - public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) { + public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale) { return this.messageSource.getMessage(code, args, defaultMessage, locale); } @Override - public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException { + public String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException { return this.messageSource.getMessage(code, args, locale); } @Override - public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException { + public String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException { return this.messageSource.getMessage(resolvable, locale); } diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java index d480170357f9..bcf368462541 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java @@ -20,6 +20,8 @@ import org.springframework.context.ApplicationListener; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalApplicationListenerMethodAdapter; import org.springframework.transaction.event.TransactionalEventListenerFactory; /** @@ -37,20 +39,22 @@ public class RestrictedTransactionalEventListenerFactory extends TransactionalEv @Override public ApplicationListener createApplicationListener(String beanName, Class type, Method method) { - Transactional txAnn = AnnotatedElementUtils.findMergedAnnotation(method, Transactional.class); - - if (txAnn == null) { - txAnn = AnnotatedElementUtils.findMergedAnnotation(type, Transactional.class); - } - - if (txAnn != null) { - Propagation propagation = txAnn.propagation(); - if (propagation != Propagation.REQUIRES_NEW && propagation != Propagation.NOT_SUPPORTED) { - throw new IllegalStateException("@TransactionalEventListener method must not be annotated with " + - "@Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: " + method); + TransactionalApplicationListenerMethodAdapter adapter = + new TransactionalApplicationListenerMethodAdapter(beanName, type, method); + if (adapter.getTransactionPhase() != TransactionPhase.BEFORE_COMMIT) { + Transactional txAnn = AnnotatedElementUtils.findMergedAnnotation(method, Transactional.class); + if (txAnn == null) { + txAnn = AnnotatedElementUtils.findMergedAnnotation(type, Transactional.class); + } + if (txAnn != null) { + Propagation propagation = txAnn.propagation(); + if (propagation != Propagation.REQUIRES_NEW && propagation != Propagation.NOT_SUPPORTED) { + throw new IllegalStateException("@TransactionalEventListener method must not be annotated with " + + "@Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: " + method); + } } } - return super.createApplicationListener(beanName, type, method); + return adapter; } } diff --git a/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java b/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java index 54283661ccf7..aa32b9f37a72 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java @@ -157,6 +157,13 @@ void withAsyncTransactionalAnnotation() { assertThatNoException().isThrownBy(() -> factory.createApplicationListener("test", SampleEvents.class, m)); } + @Test + void withTransactionalAnnotationBeforeCommit() { + RestrictedTransactionalEventListenerFactory factory = new RestrictedTransactionalEventListenerFactory(); + Method m = ReflectionUtils.findMethod(SampleEvents.class, "withTransactionalAnnotationBeforeCommit", String.class); + assertThatNoException().isThrownBy(() -> factory.createApplicationListener("test", SampleEvents.class, m)); + } + @Test void withTransactionalAnnotationOnEnclosingClass() { RestrictedTransactionalEventListenerFactory factory = new RestrictedTransactionalEventListenerFactory(); @@ -277,6 +284,11 @@ public void withTransactionalNotSupportedAnnotation(String data) { public void withAsyncTransactionalAnnotation(String data) { } + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + @Transactional + public void withTransactionalAnnotationBeforeCommit(String data) { + } + @Transactional static class SampleEventsWithTransactionalAnnotation { diff --git a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java index f24351ca09f7..65968b53a9fa 100644 --- a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java +++ b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java @@ -110,7 +110,6 @@ protected ProblemDetail() { * @param type the problem type */ public void setType(URI type) { - Assert.notNull(type, "'type' is required"); this.type = type; } @@ -251,7 +250,7 @@ public Map getProperties() { @Override public boolean equals(@Nullable Object other) { return (this == other || (other instanceof ProblemDetail that && - getType().equals(that.getType()) && + ObjectUtils.nullSafeEquals(getType(), that.getType()) && ObjectUtils.nullSafeEquals(getTitle(), that.getTitle()) && this.status == that.status && ObjectUtils.nullSafeEquals(this.detail, that.detail) && diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java index e2955266ab36..0d14667f83a5 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java @@ -37,6 +37,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.Flow; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -97,12 +98,13 @@ public URI getURI() { @SuppressWarnings("NullAway") protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body body) throws IOException { CompletableFuture> responseFuture = null; + TimeoutHandler timeoutHandler = null; try { HttpRequest request = buildRequest(headers, body); responseFuture = this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); if (this.timeout != null) { - TimeoutHandler timeoutHandler = new TimeoutHandler(responseFuture, this.timeout); + timeoutHandler = new TimeoutHandler(responseFuture, this.timeout); HttpResponse response = responseFuture.get(); InputStream inputStream = timeoutHandler.wrapInputStream(response); return new JdkClientHttpResponse(response, inputStream); @@ -120,8 +122,11 @@ protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body catch (ExecutionException ex) { Throwable cause = ex.getCause(); - if (cause instanceof CancellationException) { - throw new HttpTimeoutException("Request timed out"); + if (cause instanceof CancellationException ce) { + if (timeoutHandler != null) { + timeoutHandler.handleCancellationException(ce); + } + throw new IOException("Request cancelled", cause); } if (cause instanceof UncheckedIOException uioEx) { throw uioEx.getCause(); @@ -136,6 +141,12 @@ else if (cause instanceof IOException ioEx) { throw new IOException(cause.getMessage(), cause); } } + catch (CancellationException ex) { + if (timeoutHandler != null) { + timeoutHandler.handleCancellationException(ex); + } + throw new IOException("Request cancelled", ex); + } } private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) { @@ -234,12 +245,15 @@ private static final class TimeoutHandler { private final CompletableFuture timeoutFuture; + private final AtomicBoolean timeout = new AtomicBoolean(false); + private TimeoutHandler(CompletableFuture> future, Duration timeout) { this.timeoutFuture = new CompletableFuture() .completeOnTimeout(null, timeout.toMillis(), TimeUnit.MILLISECONDS); this.timeoutFuture.thenRun(() -> { + this.timeout.set(true); if (future.cancel(true) || future.isCompletedExceptionally() || !future.isDone()) { return; } @@ -250,7 +264,6 @@ private TimeoutHandler(CompletableFuture> future, Dura // ignore } }); - } @Nullable @@ -268,6 +281,12 @@ public void close() throws IOException { } }; } + + public void handleCancellationException(CancellationException ex) throws HttpTimeoutException { + if (this.timeout.get()) { + throw new HttpTimeoutException(ex.getMessage()); + } + } } } diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java index a2f330b74b66..3e3dba15a02b 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java @@ -105,16 +105,20 @@ /** * The path mapping URIs — for example, {@code "/profile"}. - *

Ant-style path patterns are also supported (for example, {@code "/profile/**"}). - * At the method level, relative paths (for example, {@code "edit"}) are supported + *

Ant-style path patterns are also supported, e.g. {@code "/profile/**"}. + * At the method level, relative paths, e.g., {@code "edit"} are supported * within the primary mapping expressed at the type level. - * Path mapping URIs may contain placeholders (for example, "/${profile_path}"). + * Path mapping URIs may contain property placeholders, e.g. "/${profile_path}", + * and SpEL expressions, e.g. {@code "/profile/#{@bean.property}"}. *

Supported at the type level as well as at the method level! * When used at the type level, all method-level mappings inherit * this primary mapping, narrowing it for a specific handler method. *

NOTE: A handler method that is not mapped to any path * explicitly is effectively mapped to an empty path. * @since 4.2 + * @see org.springframework.beans.factory.config.EmbeddedValueResolver + * @see org.springframework.context.expression.StandardBeanExpressionResolver + * @see org.springframework.context.support.AbstractApplicationContext */ @AliasFor("value") String[] path() default {}; diff --git a/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java b/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java index b8722a2e1f3e..aca6d7c63583 100644 --- a/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java @@ -116,7 +116,7 @@ private static class MethodArgumentErrorMessageSource extends StaticMessageSourc @Override @Nullable - protected String getDefaultMessage(MessageSourceResolvable resolvable, Locale locale) { + protected String getDefaultMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) { String message = super.getDefaultMessage(resolvable, locale); return (resolvable instanceof FieldError error ? error.getField() + ": " + message : message); } diff --git a/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java b/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java index b1c71154d850..ec0b7f629b9f 100644 --- a/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java +++ b/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java @@ -30,7 +30,6 @@ * * @author Rossen Stoyanchev * @since 6.2 - * * @see RFC 3986 */ abstract class RfcUriParser { @@ -78,10 +77,10 @@ private static void fail(InternalParser parser, String message) { * @param query the query, if present * @param fragment the fragment, if present */ - record UriRecord(@Nullable String scheme, boolean isOpaque, - @Nullable String user, @Nullable String host, @Nullable String port, - @Nullable String path, @Nullable String query, @Nullable String fragment) { - + record UriRecord( + @Nullable String scheme, boolean isOpaque, + @Nullable String user, @Nullable String host, @Nullable String port, + @Nullable String path, @Nullable String query, @Nullable String fragment) { } @@ -130,6 +129,7 @@ public void handleEnd(InternalParser parser) { } }, + HOST_OR_PATH { @Override @@ -158,6 +158,7 @@ public void handleEnd(InternalParser parser) { } }, + SCHEME_OR_PATH { @Override @@ -188,6 +189,7 @@ public void handleEnd(InternalParser parser) { } }, + HOST { @Override @@ -229,6 +231,7 @@ public void handleEnd(InternalParser parser) { } }, + IPV6 { @Override @@ -259,6 +262,7 @@ public void handleEnd(InternalParser parser) { } }, + PORT { @Override @@ -291,6 +295,7 @@ public void handleEnd(InternalParser parser) { } }, + PATH { @Override @@ -319,6 +324,7 @@ public void handleEnd(InternalParser parser) { } }, + QUERY { @Override @@ -334,7 +340,9 @@ public void handleEnd(InternalParser parser) { } }, + FRAGMENT { + @Override public void handleNext(InternalParser parser, char c, int i) { } @@ -345,6 +353,7 @@ public void handleEnd(InternalParser parser) { } }, + WILDCARD { @Override @@ -358,6 +367,7 @@ public void handleEnd(InternalParser parser) { } }; + /** * Method to handle each character from the input string. * @param parser provides access to parsing state, and helper methods @@ -429,6 +439,7 @@ public InternalParser(String uri) { this.uri = uri; } + // Check internal state public boolean hasScheme() { @@ -451,6 +462,7 @@ public boolean isAtStartOfComponent() { return (this.index == this.componentIndex); } + // Top-level parse loop, iterate over chars and delegate to states public UriRecord parse() { @@ -475,6 +487,7 @@ public char charAtIndex() { return this.uri.charAt(this.index); } + // Transitions and index updates public void advanceTo(State state) { @@ -500,6 +513,7 @@ public void index(int index) { this.index = index; } + // Component capture public InternalParser resolveIfOpaque() { @@ -593,6 +607,7 @@ public InternalParser markPercentEncoding() { return this; } + // Encoding and curly bracket handling /** @@ -643,6 +658,7 @@ else if (c == '}') { return (this.openCurlyBracketCount > 0); } + @Override public String toString() { return "[State=" + this.state + ", index=" + this.index + ", componentIndex=" + this.componentIndex + diff --git a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java index 0b89fb3bd06a..7711470fa99c 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java @@ -373,15 +373,16 @@ public static String decode(String source, String encoding) { } /** - * Decode the given encoded URI component. - *

See {@link StringUtils#uriDecode(String, Charset)} for the decoding rules. - * @param source the encoded String - * @param charset the character encoding to use + * Decode the given encoded URI component value by replacing "{@code %xy}" sequences + * by an hexadecimal representation of the character in the specified charset, letting other + * characters unchanged. + * @param source the encoded {@code String} + * @param charset the character encoding to use to decode the "{@code %xy}" sequences * @return the decoded value * @throws IllegalArgumentException when the given source contains invalid encoded sequences * @since 5.0 * @see StringUtils#uriDecode(String, Charset) - * @see java.net.URLDecoder#decode(String, String) + * @see java.net.URLDecoder#decode(String, String) java.net.URLDecoder#decode for HTML form decoding */ public static String decode(String source, Charset charset) { return StringUtils.uriDecode(source, charset); diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java new file mode 100644 index 000000000000..300af1ea221c --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2002-present 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.http.client; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link JdkClientHttpRequest}. + */ +class JdkClientHttpRequestTests { + + private final HttpClient client = mock(HttpClient.class); + + @AutoClose("shutdownNow") + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + + @Test + @SuppressWarnings("unchecked") + void futureCancelledAfterTimeout() { + CompletableFuture> future = new CompletableFuture<>(); + when(client.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(future); + + assertThatThrownBy(() -> createRequest(Duration.ofMillis(10)).executeInternal(new HttpHeaders(), null)) + .isExactlyInstanceOf(HttpTimeoutException.class); + } + + @Test + @SuppressWarnings("unchecked") + void futureCancelled() { + CompletableFuture> future = new CompletableFuture<>(); + future.cancel(true); + when(client.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(future); + + assertThatThrownBy(() -> createRequest(null).executeInternal(new HttpHeaders(), null)) + .isExactlyInstanceOf(IOException.class); + } + + private JdkClientHttpRequest createRequest(Duration timeout) { + return new JdkClientHttpRequest(client, URI.create("https://abc.com"), HttpMethod.GET, executor, timeout); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/util/UriUtilsTests.java b/spring-web/src/test/java/org/springframework/web/util/UriUtilsTests.java index 53587c51bacf..ff3159a5e20e 100644 --- a/spring-web/src/test/java/org/springframework/web/util/UriUtilsTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/UriUtilsTests.java @@ -107,12 +107,21 @@ void decode() { assertThat(UriUtils.decode("T%C5%8Dky%C5%8D", CHARSET)).as("Invalid encoded result").isEqualTo("T\u014dky\u014d"); assertThat(UriUtils.decode("/Z%C3%BCrich", CHARSET)).as("Invalid encoded result").isEqualTo("/Z\u00fcrich"); assertThat(UriUtils.decode("T\u014dky\u014d", CHARSET)).as("Invalid encoded result").isEqualTo("T\u014dky\u014d"); + assertThat(UriUtils.decode("%20\u2019", CHARSET)).as("Invalid encoded result").isEqualTo(" \u2019"); + assertThat(UriUtils.decode("\u015bp\u0159\u00ec\u0144\u0121", CHARSET)).as("Invalid encoded result").isEqualTo("śpřìńġ"); + assertThat(UriUtils.decode("%20\u015bp\u0159\u00ec\u0144\u0121", CHARSET)).as("Invalid encoded result").isEqualTo(" śpřìńġ"); } @Test void decodeInvalidSequence() { assertThatIllegalArgumentException().isThrownBy(() -> UriUtils.decode("foo%2", CHARSET)); + assertThatIllegalArgumentException().isThrownBy(() -> + UriUtils.decode("foo%", CHARSET)); + assertThatIllegalArgumentException().isThrownBy(() -> + UriUtils.decode("%", CHARSET)); + assertThatIllegalArgumentException().isThrownBy(() -> + UriUtils.decode("%zz", CHARSET)); } @Test diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterTests.java index f768d641e4ca..3e2940181cc4 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterTests.java @@ -393,7 +393,7 @@ public String handle(Model model) { } - private static class SseController { + static class SseController { public ResponseEntity handle(@RequestParam String q) throws IOException { if (q.equals("sse")) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/PathResourceResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/PathResourceResolverTests.java index e0dd66c78671..48cf379740fe 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/PathResourceResolverTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/PathResourceResolverTests.java @@ -183,21 +183,19 @@ private static class TestUrlResource extends UrlResource { private String relativePath; - public TestUrlResource(String path) throws MalformedURLException { super(path); } - - public String getSavedRelativePath() { - return this.relativePath; - } - @Override public Resource createRelative(String relativePath) { this.relativePath = relativePath; return this; } + + public String getSavedRelativePath() { + return this.relativePath; + } } }