diff --git a/spring-aspects/src/test/java/org/springframework/cache/aspectj/AbstractCacheAnnotationTests.java b/spring-aspects/src/test/java/org/springframework/cache/aspectj/AbstractCacheAnnotationTests.java index 8cec3f65ea54..0c6d3c969a06 100644 --- a/spring-aspects/src/test/java/org/springframework/cache/aspectj/AbstractCacheAnnotationTests.java +++ b/spring-aspects/src/test/java/org/springframework/cache/aspectj/AbstractCacheAnnotationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -48,6 +48,7 @@ * @author Chris Beams * @author Phillip Webb * @author Stephane Nicoll + * @author Yanming Zhou */ public abstract class AbstractCacheAnnotationTests { @@ -281,6 +282,19 @@ protected void testEvictAllEarly(CacheableService service) { assertThat(r4).isNotSameAs(r2); } + protected void testEvictWithOptionalKey(CacheableService service) { + Object o1 = new Object(); + Object r1 = service.cache(o1); + + service.evictWithOptionalKey(null); + Object r2 = service.cache(o1); + assertThat(r2).isSameAs(r1); + + service.evictWithOptionalKey(o1); + Object r3 = service.cache(o1); + assertThat(r3).isNotSameAs(r1); + } + protected void testConditionalExpression(CacheableService service) { Object r1 = service.conditional(4); Object r2 = service.conditional(4); @@ -305,6 +319,31 @@ protected void testConditionalExpressionSync(CacheableService service) { assertThat(r4).isSameAs(r3); } + + protected void testOptionalKey(CacheableService service) { + Object r1 = service.optional(4); + Object r2 = service.optional(4); + + assertThat(r2).isNotSameAs(r1); + + Object r3 = service.optional(3); + Object r4 = service.optional(3); + + assertThat(r4).isSameAs(r3); + } + + protected void testOptionalKeySync(CacheableService service) { + Object r1 = service.optionalSync(4); + Object r2 = service.optionalSync(4); + + assertThat(r2).isNotSameAs(r1); + + Object r3 = service.optionalSync(3); + Object r4 = service.optionalSync(3); + + assertThat(r4).isSameAs(r3); + } + protected void testUnlessExpression(CacheableService service) { Cache cache = this.cm.getCache("testCache"); cache.clear(); @@ -423,6 +462,18 @@ protected void testConditionalCacheUpdate(CacheableService service) { assertThat(Integer.parseInt(cache.get(three).get().toString())).isEqualTo(three); } + protected void testOptionalCacheUpdate(CacheableService service) { + int one = 1; + int three = 3; + + Cache cache = this.cm.getCache("testCache"); + assertThat(Integer.parseInt(service.optionalUpdate(one).toString())).isEqualTo(one); + assertThat(cache.get(one)).isNull(); + + assertThat(Integer.parseInt(service.optionalUpdate(three).toString())).isEqualTo(three); + assertThat(Integer.parseInt(cache.get(three).get().toString())).isEqualTo(three); + } + protected void testMultiCache(CacheableService service) { Object o1 = new Object(); Object o2 = new Object(); @@ -610,11 +661,21 @@ void testEvictWithKeyEarly() { testEvictWithKeyEarly(this.cs); } + @Test + void testEvictWithOptionalKey() { + testEvictWithOptionalKey(this.cs); + } + @Test void testConditionalExpression() { testConditionalExpression(this.cs); } + @Test + void testOptionalKey() { + testOptionalKey(this.cs); + } + @Test void testConditionalExpressionSync() { testConditionalExpressionSync(this.cs); @@ -819,6 +880,16 @@ void testClassConditionalUpdate() { testConditionalCacheUpdate(this.ccs); } + @Test + void testOptionalUpdate() { + testOptionalCacheUpdate(this.cs); + } + + @Test + void testClassOptionalUpdate() { + testOptionalCacheUpdate(this.ccs); + } + @Test void testMultiCache() { testMultiCache(this.cs); diff --git a/spring-aspects/src/test/java/org/springframework/cache/config/AnnotatedClassCacheableService.java b/spring-aspects/src/test/java/org/springframework/cache/config/AnnotatedClassCacheableService.java index 447c4cc0f617..5f220f62b417 100644 --- a/spring-aspects/src/test/java/org/springframework/cache/config/AnnotatedClassCacheableService.java +++ b/spring-aspects/src/test/java/org/springframework/cache/config/AnnotatedClassCacheableService.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -19,6 +19,8 @@ import java.io.IOException; import java.util.concurrent.atomic.AtomicLong; +import org.jspecify.annotations.Nullable; + import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; @@ -32,6 +34,7 @@ * @author Costin Leau * @author Phillip Webb * @author Stephane Nicoll + * @author Yanming Zhou */ @Cacheable("testCache") public class AnnotatedClassCacheableService implements CacheableService { @@ -73,6 +76,16 @@ public Object conditionalSync(int field) { return null; } + @Override + public Object optional(int field) { + return null; + } + + @Override + public Object optionalSync(int field) { + return null; + } + @Override @Cacheable(cacheNames = "testCache", unless = "#result > 10") public Object unless(int arg) { @@ -107,6 +120,11 @@ public void evictAllEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", key = "T(java.util.Optional).ofNullable(#name != null ? #p0 : null)") + public void evictWithOptionalKey(@Nullable Object arg1) { + } + @Override @Cacheable(cacheNames = "testCache", key = "#p0") public Object key(Object arg1, Object arg2) { @@ -167,6 +185,12 @@ public Object conditionalUpdate(Object arg) { return arg; } + @Override + @CachePut(cacheNames = "testCache", key = "T(java.util.Optional).ofNullable(#p0 == 3 ? #arg : null)") + public Object optionalUpdate(Object arg) { + return arg; + } + @Override public Object nullValue(Object arg1) { nullInvocations.incrementAndGet(); diff --git a/spring-aspects/src/test/java/org/springframework/cache/config/CacheableService.java b/spring-aspects/src/test/java/org/springframework/cache/config/CacheableService.java index 3ec6212bac60..1e8083f05bde 100644 --- a/spring-aspects/src/test/java/org/springframework/cache/config/CacheableService.java +++ b/spring-aspects/src/test/java/org/springframework/cache/config/CacheableService.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,8 @@ package org.springframework.cache.config; +import org.jspecify.annotations.Nullable; + /** * Copy of the shared {@code CacheableService}: necessary * due to issues with Gradle test fixtures and AspectJ configuration @@ -26,6 +28,7 @@ * @author Costin Leau * @author Phillip Webb * @author Stephane Nicoll + * @author Yanming Zhou */ public interface CacheableService { @@ -47,10 +50,16 @@ public interface CacheableService { void evictAllEarly(Object arg1); + void evictWithOptionalKey(@Nullable Object arg1); + T conditional(int field); T conditionalSync(int field); + T optional(int field); + + T optionalSync(int field); + T unless(int arg); T key(Object arg1, Object arg2); @@ -63,7 +72,9 @@ public interface CacheableService { T update(Object arg1); - T conditionalUpdate(Object arg2); + T conditionalUpdate(Object arg); + + T optionalUpdate(Object arg); Number nullInvocations(); diff --git a/spring-aspects/src/test/java/org/springframework/cache/config/DefaultCacheableService.java b/spring-aspects/src/test/java/org/springframework/cache/config/DefaultCacheableService.java index 47a3a83a34a1..6ee22b7eff3b 100644 --- a/spring-aspects/src/test/java/org/springframework/cache/config/DefaultCacheableService.java +++ b/spring-aspects/src/test/java/org/springframework/cache/config/DefaultCacheableService.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2025 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. @@ -19,6 +19,8 @@ import java.io.IOException; import java.util.concurrent.atomic.AtomicLong; +import org.jspecify.annotations.Nullable; + import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; @@ -34,6 +36,7 @@ * @author Costin Leau * @author Phillip Webb * @author Stephane Nicoll + * @author Yanming Zhou */ public class DefaultCacheableService implements CacheableService { @@ -94,6 +97,11 @@ public void evictAllEarly(Object arg1) { throw new RuntimeException("exception thrown - evict should still occur"); } + @Override + @CacheEvict(cacheNames = "testCache", key = "T(java.util.Optional).ofNullable(#p0 != null ? #p0 : null)") + public void evictWithOptionalKey(@Nullable Object arg1) { + } + @Override @Cacheable(cacheNames = "testCache", condition = "#p0 == 3") public Long conditional(int classField) { @@ -106,6 +114,18 @@ public Long conditionalSync(int classField) { return this.counter.getAndIncrement(); } + @Override + @Cacheable(cacheNames = "testCache", key = "T(java.util.Optional).ofNullable(#p0 == 3 ? #p0 : null)") + public Long optional(int classField) { + return this.counter.getAndIncrement(); + } + + @Override + @Cacheable(cacheNames = "testCache", sync = true, key = "T(java.util.Optional).ofNullable(#p0 == 3 ? #p0 : null)") + public Long optionalSync(int classField) { + return this.counter.getAndIncrement(); + } + @Override @Cacheable(cacheNames = "testCache", unless = "#result > 10") public Long unless(int arg) { @@ -172,6 +192,12 @@ public Long conditionalUpdate(Object arg) { return Long.valueOf(arg.toString()); } + @Override + @CachePut(cacheNames = "testCache", key = "T(java.util.Optional).ofNullable(#p0 == 3 ? #arg : null)") + public Long optionalUpdate(Object arg) { + return Long.valueOf(arg.toString()); + } + @Override @Cacheable("testCache") public Long nullValue(Object arg1) { diff --git a/spring-context/src/main/java/org/springframework/cache/annotation/CacheEvict.java b/spring-context/src/main/java/org/springframework/cache/annotation/CacheEvict.java index 43f095236a0a..23e17c481277 100644 --- a/spring-context/src/main/java/org/springframework/cache/annotation/CacheEvict.java +++ b/spring-context/src/main/java/org/springframework/cache/annotation/CacheEvict.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -36,6 +36,7 @@ * @author Costin Leau * @author Stephane Nicoll * @author Sam Brannen + * @author Yanming Zhou * @since 3.1 * @see CacheConfig */ @@ -67,6 +68,7 @@ * Spring Expression Language (SpEL) expression for computing the key dynamically. *

Default is {@code ""}, meaning all method parameters are considered as a key, * unless a custom {@link #keyGenerator} has been set. + *

Cache is not involved if evaluated result is an empty {@link java.util.Optional}. *

The SpEL expression evaluates against a dedicated context that provides the * following meta-data: *

    diff --git a/spring-context/src/main/java/org/springframework/cache/annotation/CachePut.java b/spring-context/src/main/java/org/springframework/cache/annotation/CachePut.java index 9bf3c5706c05..feaaf4fa7c26 100644 --- a/spring-context/src/main/java/org/springframework/cache/annotation/CachePut.java +++ b/spring-context/src/main/java/org/springframework/cache/annotation/CachePut.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -44,6 +44,7 @@ * @author Phillip Webb * @author Stephane Nicoll * @author Sam Brannen + * @author Yanming Zhou * @since 3.1 * @see CacheConfig */ @@ -75,6 +76,7 @@ * Spring Expression Language (SpEL) expression for computing the key dynamically. *

    Default is {@code ""}, meaning all method parameters are considered as a key, * unless a custom {@link #keyGenerator} has been set. + * Cache is not involved if evaluated result is an empty {@link java.util.Optional}. *

    The SpEL expression evaluates against a dedicated context that provides the * following meta-data: *

      diff --git a/spring-context/src/main/java/org/springframework/cache/annotation/Cacheable.java b/spring-context/src/main/java/org/springframework/cache/annotation/Cacheable.java index 2d7e41468c76..ba5f8c4cda13 100644 --- a/spring-context/src/main/java/org/springframework/cache/annotation/Cacheable.java +++ b/spring-context/src/main/java/org/springframework/cache/annotation/Cacheable.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 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. @@ -52,6 +52,7 @@ * @author Phillip Webb * @author Stephane Nicoll * @author Sam Brannen + * @author Yanming Zhou * @since 3.1 * @see CacheConfig */ @@ -90,6 +91,7 @@ /** * Spring Expression Language (SpEL) expression for computing the key dynamically. + *

      Cache is not involved if evaluated result is an empty {@link java.util.Optional}. *

      Default is {@code ""}, meaning all method parameters are considered as a key, * unless a custom {@link #keyGenerator} has been configured. *

      The SpEL expression evaluates against a dedicated context that provides the diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java index 8928382553cc..1cc8b70e8419 100644 --- a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -90,6 +90,7 @@ * @author Sam Brannen * @author Stephane Nicoll * @author Sebastien Deleuze + * @author Yanming Zhou * @since 3.1 */ public abstract class CacheAspectSupport extends AbstractCacheInvoker @@ -444,6 +445,14 @@ protected void clearMetadataCache() { CacheOperationContext context = contexts.get(CacheableOperation.class).iterator().next(); if (isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) { Object key = generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT); + if (key instanceof Optional optional) { + if (optional.isEmpty()) { + return null; + } + else { + key = optional.get(); + } + } Cache cache = context.getCaches().iterator().next(); if (CompletableFuture.class.isAssignableFrom(method.getReturnType())) { return doRetrieve(cache, key, () -> (CompletableFuture) invokeOperation(invoker)); @@ -481,6 +490,14 @@ protected void clearMetadataCache() { for (CacheOperationContext context : contexts.get(CacheableOperation.class)) { if (isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) { Object key = generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT); + if (key instanceof Optional optional) { + if (optional.isEmpty()) { + return null; + } + else { + key = optional.get(); + } + } Object cached = findInCaches(context, key, invoker, method, contexts); if (cached != null) { if (logger.isTraceEnabled()) { @@ -661,6 +678,14 @@ private void performCacheEvicts(List contexts, @Nullable if (key == null) { key = generateKey(context, result); } + if (key instanceof Optional optional) { + if (optional.isEmpty()) { + return; + } + else { + key = optional.get(); + } + } logInvalidating(context, operation, key); doEvict(cache, key, operation.isBeforeInvocation()); } @@ -1021,6 +1046,14 @@ public void performCachePut(@Nullable Object value) { if (key == null) { key = generateKey(this.context, value); } + if (key instanceof Optional optional) { + if (optional.isEmpty()) { + return; + } + else { + key = optional.get(); + } + } if (logger.isTraceEnabled()) { logger.trace("Creating cache entry for key '" + key + "' in cache(s) " + this.context.getCacheNames()); diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/KeyGenerator.java b/spring-context/src/main/java/org/springframework/cache/interceptor/KeyGenerator.java index a767b339f3e8..4b97a25bcdb3 100644 --- a/spring-context/src/main/java/org/springframework/cache/interceptor/KeyGenerator.java +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/KeyGenerator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2025 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. @@ -27,6 +27,7 @@ * @author Costin Leau * @author Chris Beams * @author Phillip Webb + * @author Yanming Zhou * @since 3.1 */ @FunctionalInterface @@ -34,6 +35,7 @@ public interface KeyGenerator { /** * Generate a key for the given method and its parameters. + *

      Cache is not involved if generated key is an empty {@link java.util.Optional}. * @param target the target instance * @param method the method being called * @param params the method parameters (with any var-args expanded)