From e4009f47c4781e18e79a78ed35b3f510ab6793d2 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 5 Mar 2025 16:51:40 +0100 Subject: [PATCH] Introduce `BoundKeyExpirationOperations`. And rename entry points for Hash Field expiration from expiration to hashFieldExpiration. Original Pull Request: #3115 --- .../core/BoundKeyExpirationOperations.java | 111 ++++++++++++++++++ .../data/redis/core/BoundKeyOperations.java | 16 +++ .../core/BoundOperationsProxyFactory.java | 7 +- .../DefaultBoundKeyExpirationOperations.java | 99 ++++++++++++++++ .../data/redis/core/RedisOperations.java | 10 ++ .../support/atomic/RedisAtomicDouble.java | 6 + .../support/atomic/RedisAtomicInteger.java | 5 + .../redis/support/atomic/RedisAtomicLong.java | 6 + .../core/RedisTemplateIntegrationTests.java | 43 +++++++ 9 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/springframework/data/redis/core/BoundKeyExpirationOperations.java create mode 100644 src/main/java/org/springframework/data/redis/core/DefaultBoundKeyExpirationOperations.java diff --git a/src/main/java/org/springframework/data/redis/core/BoundKeyExpirationOperations.java b/src/main/java/org/springframework/data/redis/core/BoundKeyExpirationOperations.java new file mode 100644 index 000000000..b50431b20 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/BoundKeyExpirationOperations.java @@ -0,0 +1,111 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.core; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.connection.ExpirationOptions; +import org.springframework.data.redis.core.types.Expiration; +import org.springframework.data.redis.core.types.Expirations; +import org.springframework.lang.Nullable; + +/** + * Key Expiration operations bound to a key. + * + * @author Mark Paluch + * @since 3.5 + */ +public interface BoundKeyExpirationOperations { + + /** + * Apply {@link Expiration} to the bound key without any additional constraints. + * + * @param expiration the expiration definition. + * @return changes to the key. {@literal null} when used in pipeline / transaction. + */ + default ExpireChanges.ExpiryChangeState expire(Expiration expiration) { + return expire(expiration, ExpirationOptions.none()); + } + + /** + * Apply {@link Expiration} to the bound key given {@link ExpirationOptions expiration options}. + * + * @param expiration the expiration definition. + * @param options expiration options. + * @return changes to the key. {@literal null} when used in pipeline / transaction. + */ + @Nullable + ExpireChanges.ExpiryChangeState expire(Expiration expiration, ExpirationOptions options); + + /** + * Set time to live for the bound key. + * + * @param timeout the amount of time after which the key will be expired, must not be {@literal null}. + * @return changes to the key. {@literal null} when used in pipeline / transaction. + * @throws IllegalArgumentException if the timeout is {@literal null}. + * @see Redis Documentation: EXPIRE + * @since 3.5 + */ + @Nullable + ExpireChanges.ExpiryChangeState expire(Duration timeout); + + /** + * Set the expiration for the bound key as a {@literal date} timestamp. + * + * @param expireAt must not be {@literal null}. + * @return changes to the key. {@literal null} when used in pipeline / transaction. + * @throws IllegalArgumentException if the instant is {@literal null} or too large to represent as a {@code Date}. + * @see Redis Documentation: EXPIRE + * @since 3.5 + */ + @Nullable + ExpireChanges.ExpiryChangeState expireAt(Instant expireAt); + + /** + * Remove the expiration from the bound key. + * + * @return changes to the key. {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: PERSIST + * @since 3.5 + */ + @Nullable + ExpireChanges.ExpiryChangeState persist(); + + /** + * Get the time to live for the bound key in seconds. + * + * @return the actual expirations in seconds for the key. {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: TTL + * @since 3.5 + */ + @Nullable + Expirations.TimeToLive getTimeToLive(); + + /** + * Get the time to live for the bound key and convert it to the given {@link TimeUnit}. + * + * @param timeUnit must not be {@literal null}. + * @return the actual expirations for the key in the given time unit. {@literal null} when used in pipeline / + * transaction. + * @see Redis Documentation: TTL + * @since 3.5 + */ + @Nullable + Expirations.TimeToLive getTimeToLive(TimeUnit timeUnit); + +} diff --git a/src/main/java/org/springframework/data/redis/core/BoundKeyOperations.java b/src/main/java/org/springframework/data/redis/core/BoundKeyOperations.java index e9d1f5e57..bb8b438a7 100644 --- a/src/main/java/org/springframework/data/redis/core/BoundKeyOperations.java +++ b/src/main/java/org/springframework/data/redis/core/BoundKeyOperations.java @@ -51,6 +51,16 @@ public interface BoundKeyOperations { @Nullable DataType getType(); + /** + * Returns a bound operations object to perform expiration operations on the bound key. + * + * @return the bound operations object to perform operations on the hash field expiration. + * @since 3.5 + */ + default BoundKeyExpirationOperations expiration() { + return new DefaultBoundKeyExpirationOperations<>(getOperations(), getKey()); + } + /** * Returns the expiration of this key. * @@ -127,4 +137,10 @@ public interface BoundKeyOperations { * @param newKey new key. Must not be {@literal null}. */ void rename(K newKey); + + /** + * @return never {@literal null}. + */ + RedisOperations getOperations(); + } diff --git a/src/main/java/org/springframework/data/redis/core/BoundOperationsProxyFactory.java b/src/main/java/org/springframework/data/redis/core/BoundOperationsProxyFactory.java index 3492553b2..2c4bfc5f4 100644 --- a/src/main/java/org/springframework/data/redis/core/BoundOperationsProxyFactory.java +++ b/src/main/java/org/springframework/data/redis/core/BoundOperationsProxyFactory.java @@ -143,7 +143,7 @@ class BoundOperationsProxyFactory { delegate.rename(invocation.getArguments()[0]); yield null; } - case "getOperations" -> delegate.getOps(); + case "getOperations" -> delegate.getOperations(); default -> method.getDeclaringClass() == boundOperationsInterface ? doInvoke(invocation, method, operationsTarget, true) : doInvoke(invocation, method, delegate, false); @@ -234,12 +234,15 @@ class BoundOperationsProxyFactory { key = newKey; } + @Override public DataType getType() { return type; } - public RedisOperations getOps() { + @Override + public RedisOperations getOperations() { return ops; } + } } diff --git a/src/main/java/org/springframework/data/redis/core/DefaultBoundKeyExpirationOperations.java b/src/main/java/org/springframework/data/redis/core/DefaultBoundKeyExpirationOperations.java new file mode 100644 index 000000000..9ff186cd3 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/DefaultBoundKeyExpirationOperations.java @@ -0,0 +1,99 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.core; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.connection.ExpirationOptions; +import org.springframework.data.redis.core.types.Expiration; +import org.springframework.data.redis.core.types.Expirations; +import org.springframework.lang.Nullable; + +/** + * Default {@link BoundKeyExpirationOperations} implementation. + * + * @author Mark Paluch + * @since 3.5 + */ +class DefaultBoundKeyExpirationOperations implements BoundKeyExpirationOperations { + + private final RedisOperations operations; + private final K key; + + public DefaultBoundKeyExpirationOperations(RedisOperations operations, K key) { + this.operations = operations; + this.key = key; + } + + @Nullable + @Override + public ExpireChanges.ExpiryChangeState expire(Expiration expiration, ExpirationOptions options) { + return operations.expire(key, expiration, options); + } + + @Nullable + @Override + public ExpireChanges.ExpiryChangeState expire(Duration timeout) { + + Boolean expire = operations.expire(key, timeout); + + return toExpiryChangeState(expire); + } + + @Nullable + @Override + public ExpireChanges.ExpiryChangeState expireAt(Instant expireAt) { + return toExpiryChangeState(operations.expireAt(key, expireAt)); + } + + @Nullable + @Override + public ExpireChanges.ExpiryChangeState persist() { + return toExpiryChangeState(operations.persist(key)); + } + + @Nullable + @Override + public Expirations.TimeToLive getTimeToLive() { + + Long expire = operations.getExpire(key); + + return expire == null ? null : Expirations.TimeToLive.of(expire, TimeUnit.SECONDS); + } + + @Nullable + @Override + public Expirations.TimeToLive getTimeToLive(TimeUnit timeUnit) { + + Long expire = operations.getExpire(key, timeUnit); + + return expire == null ? null : Expirations.TimeToLive.of(expire, timeUnit); + + } + + @Nullable + private static ExpireChanges.ExpiryChangeState toExpiryChangeState(@Nullable Boolean result) { + + if (result == null) { + return null; + } + + return ExpireChanges.ExpiryChangeState.of(result); + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/RedisOperations.java b/src/main/java/org/springframework/data/redis/core/RedisOperations.java index bab7675cc..bb11c66a0 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisOperations.java +++ b/src/main/java/org/springframework/data/redis/core/RedisOperations.java @@ -386,6 +386,16 @@ public interface RedisOperations { @Nullable ExpireChanges.ExpiryChangeState expire(K key, Expiration expiration, ExpirationOptions options); + /** + * Returns a bound operations object to perform expiration operations on the bound key. + * + * @return the bound operations object to perform operations on the hash field expiration. + * @since 3.5 + */ + default BoundKeyExpirationOperations expiration(K key) { + return new DefaultBoundKeyExpirationOperations<>(this, key); + } + /** * Remove the expiration from given {@code key}. * diff --git a/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicDouble.java b/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicDouble.java index a60d55ad3..a95f78b3e 100644 --- a/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicDouble.java +++ b/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicDouble.java @@ -399,6 +399,11 @@ public class RedisAtomicDouble extends Number implements Serializable, BoundKeyO key = newKey; } + @Override + public RedisOperations getOperations() { + return generalOps; + } + @Override public int intValue() { return (int) get(); @@ -418,4 +423,5 @@ public class RedisAtomicDouble extends Number implements Serializable, BoundKeyO public double doubleValue() { return get(); } + } diff --git a/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicInteger.java b/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicInteger.java index ffdb6ff50..a4f3e65db 100644 --- a/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicInteger.java +++ b/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicInteger.java @@ -399,6 +399,11 @@ public class RedisAtomicInteger extends Number implements Serializable, BoundKey key = newKey; } + @Override + public RedisOperations getOperations() { + return generalOps; + } + @Override public int intValue() { return get(); diff --git a/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicLong.java b/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicLong.java index df7f96035..2e488697a 100644 --- a/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicLong.java +++ b/src/main/java/org/springframework/data/redis/support/atomic/RedisAtomicLong.java @@ -396,6 +396,11 @@ public class RedisAtomicLong extends Number implements Serializable, BoundKeyOpe key = newKey; } + @Override + public RedisOperations getOperations() { + return generalOps; + } + @Override public int intValue() { return (int) get(); @@ -415,4 +420,5 @@ public class RedisAtomicLong extends Number implements Serializable, BoundKeyOpe public double doubleValue() { return get(); } + } diff --git a/src/test/java/org/springframework/data/redis/core/RedisTemplateIntegrationTests.java b/src/test/java/org/springframework/data/redis/core/RedisTemplateIntegrationTests.java index 2bb8046f8..56f99db3b 100644 --- a/src/test/java/org/springframework/data/redis/core/RedisTemplateIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/core/RedisTemplateIntegrationTests.java @@ -34,6 +34,7 @@ import org.springframework.data.redis.ObjectFactory; import org.springframework.data.redis.Person; import org.springframework.data.redis.SettingsUtils; import org.springframework.data.redis.connection.DataType; +import org.springframework.data.redis.connection.ExpirationOptions; import org.springframework.data.redis.connection.RedisClusterConnection; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.StringRedisConnection; @@ -42,11 +43,13 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactor import org.springframework.data.redis.core.ZSetOperations.TypedTuple; import org.springframework.data.redis.core.query.SortQueryBuilder; import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.types.Expiration; import org.springframework.data.redis.serializer.GenericToStringSerializer; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.test.condition.EnabledIfLongRunningTest; +import org.springframework.data.redis.test.condition.EnabledOnCommand; import org.springframework.data.redis.test.extension.LettuceTestClientResources; import org.springframework.data.redis.test.extension.parametrized.MethodSource; import org.springframework.data.redis.test.extension.parametrized.ParameterizedRedisTest; @@ -503,6 +506,46 @@ public class RedisTemplateIntegrationTests { assertThat(redisTemplate.getExpire(key1, TimeUnit.MILLISECONDS)).isGreaterThan(0L); } + @ParameterizedRedisTest // GH-3114 + @EnabledOnCommand("SPUBLISH") // Redis 7.0 + void testBoundExpireAndGetExpireSeconds() { + + K key = keyFactory.instance(); + V value1 = valueFactory.instance(); + redisTemplate.boundValueOps(key).set(value1); + + BoundKeyExpirationOperations exp = redisTemplate.expiration(key); + + assertThat(exp.expire(Duration.ofSeconds(5))).isEqualTo(ExpireChanges.ExpiryChangeState.OK); + + assertThat(exp.getTimeToLive(TimeUnit.SECONDS)).satisfies(ttl -> { + assertThat(ttl.isPersistent()).isFalse(); + assertThat(ttl.value()).isGreaterThan(1); + }); + } + + @ParameterizedRedisTest // GH-3114 + @EnabledOnCommand("SPUBLISH") // Redis 7.0 + void testBoundExpireWithConditionsAndGetExpireSeconds() { + + K key = keyFactory.instance(); + V value1 = valueFactory.instance(); + redisTemplate.boundValueOps(key).set(value1); + + BoundKeyExpirationOperations exp = redisTemplate.expiration(key); + + assertThat(exp.expire(Duration.ofSeconds(5))).isEqualTo(ExpireChanges.ExpiryChangeState.OK); + assertThat(exp.expire(Expiration.from(Duration.ofSeconds(1)), ExpirationOptions.builder().gt().build())) + .isEqualTo(ExpireChanges.ExpiryChangeState.CONDITION_NOT_MET); + assertThat(exp.expire(Expiration.from(Duration.ofSeconds(10)), ExpirationOptions.builder().gt().build())) + .isEqualTo(ExpireChanges.ExpiryChangeState.OK); + + assertThat(exp.getTimeToLive(TimeUnit.SECONDS)).satisfies(ttl -> { + assertThat(ttl.isPersistent()).isFalse(); + assertThat(ttl.value()).isGreaterThan(5); + }); + } + @ParameterizedRedisTest void testGetExpireNoTimeUnit() { K key1 = keyFactory.instance();