diff --git a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java index dd03f14bc..c3f13ef4a 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java @@ -30,6 +30,7 @@ import org.springframework.data.geo.GeoResults; import org.springframework.data.geo.Metric; import org.springframework.data.geo.Point; import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.connection.convert.Converters; import org.springframework.data.redis.connection.convert.ListConverter; import org.springframework.data.redis.connection.convert.MapConverter; @@ -2571,6 +2572,11 @@ public class DefaultStringRedisConnection implements StringRedisConnection, Deco return convertAndReturn(delegate.hStrLen(key, field), Converters.identityConverter()); } + public @Nullable List expireHashField(byte[] key, org.springframework.data.redis.core.types.Expiration expiration, + FieldExpirationOptions options, byte[]... fields) { + return this.delegate.expireHashField(key, expiration, options, fields); + } + @Override public List hExpire(byte[] key, long seconds, byte[]... fields) { return this.delegate.hExpire(key, seconds, fields); @@ -2601,11 +2607,21 @@ public class DefaultStringRedisConnection implements StringRedisConnection, Deco return this.delegate.hTtl(key, fields); } + @Override + public List hpTtl(byte[] key, byte[]... fields) { + return this.delegate.hpTtl(key, fields); + } + @Override public List hTtl(byte[] key, TimeUnit timeUnit, byte[]... fields) { return this.delegate.hTtl(key, timeUnit, fields); } + public @Nullable List expireHashField(String key, org.springframework.data.redis.core.types.Expiration expiration, + FieldExpirationOptions options, String... fields) { + return expireHashField(serialize(key), expiration, options, serializeMulti(fields)); + } + @Override public List hExpire(String key, long seconds, String... fields) { return hExpire(serialize(key), seconds, serializeMulti(fields)); @@ -2641,6 +2657,11 @@ public class DefaultStringRedisConnection implements StringRedisConnection, Deco return hTtl(serialize(key), timeUnit, serializeMulti(fields)); } + @Override + public List hpTtl(String key, String... fields) { + return hTtl(serialize(key), serializeMulti(fields)); + } + @Override public void setClientName(byte[] name) { this.delegate.setClientName(name); diff --git a/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java index 3a83343ad..979cf5300 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java @@ -28,6 +28,7 @@ import org.springframework.data.geo.Distance; import org.springframework.data.geo.GeoResults; import org.springframework.data.geo.Metric; import org.springframework.data.geo.Point; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.connection.stream.ByteRecord; import org.springframework.data.redis.connection.stream.Consumer; import org.springframework.data.redis.connection.stream.MapRecord; @@ -1527,6 +1528,21 @@ public interface DefaultedRedisConnection extends RedisCommands, RedisCommandsPr return hashCommands().hTtl(key, timeUnit, fields); } + /** @deprecated in favor of {@link RedisConnection#hashCommands()}}. */ + @Override + @Deprecated + default List hpTtl(byte[] key, byte[]... fields) { + return hashCommands().hpTtl(key, fields); + } + + /** @deprecated in favor of {@link RedisConnection#hashCommands()}}. */ + @Override + @Deprecated + default @Nullable List expireHashField(byte[] key, org.springframework.data.redis.core.types.Expiration expiration, + FieldExpirationOptions options, byte[]... fields) { + return hashCommands().expireHashField(key, expiration, options, fields); + } + // GEO COMMANDS /** @deprecated in favor of {@link RedisConnection#geoCommands()}}. */ diff --git a/src/main/java/org/springframework/data/redis/connection/Hash.java b/src/main/java/org/springframework/data/redis/connection/Hash.java new file mode 100644 index 000000000..c8d0e8a24 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/Hash.java @@ -0,0 +1,115 @@ +/* + * 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.connection; + +import java.util.Objects; + +import org.springframework.lang.Contract; +import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; + +/** + * @author Christoph Strobl + * @since 3.5 + */ +public interface Hash { + + class FieldExpirationOptions { + + private static final FieldExpirationOptions NONE = new FieldExpirationOptions(null); + private @Nullable Condition condition; + + FieldExpirationOptions(@Nullable Condition condition) { + this.condition = condition; + } + + public static FieldExpirationOptions none() { + return NONE; + } + + @Contract("_ -> new") + public static FieldExpireOptionsBuilder builder() { + return new FieldExpireOptionsBuilder(); + } + + public @Nullable Condition getCondition() { + return condition; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FieldExpirationOptions that = (FieldExpirationOptions) o; + return ObjectUtils.nullSafeEquals(this.condition, that.condition); + } + + @Override + public int hashCode() { + return Objects.hash(condition); + } + + public static class FieldExpireOptionsBuilder { + + @Nullable Condition condition; + + @Contract("_ -> this") + public FieldExpireOptionsBuilder nx() { + this.condition = Condition.NX; + return this; + } + + @Contract("_ -> this") + public FieldExpireOptionsBuilder xx() { + this.condition = Condition.XX; + return this; + } + + @Contract("_ -> this") + public FieldExpireOptionsBuilder gt() { + this.condition = Condition.GT; + return this; + } + + @Contract("_ -> this") + public FieldExpireOptionsBuilder lt() { + this.condition = Condition.LT; + return this; + } + + @Contract("_ -> !null") + public FieldExpirationOptions build() { + return condition == null ? NONE : new FieldExpirationOptions(condition); + } + } + + public enum Condition { + + /** Set expiration only when the field has no expiration. */ + NX, + /** Set expiration only when the field has an existing expiration. */ + XX, + /** Set expiration only when the new expiration is greater than current one. */ + GT, + /** Set expiration only when the new expiration is greater than current one. */ + LT + } + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/ReactiveHashCommands.java b/src/main/java/org/springframework/data/redis/connection/ReactiveHashCommands.java index 35e343714..f58f4e32a 100644 --- a/src/main/java/org/springframework/data/redis/connection/ReactiveHashCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/ReactiveHashCommands.java @@ -26,10 +26,12 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import org.reactivestreams.Publisher; import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.connection.ReactiveRedisConnection.BooleanResponse; import org.springframework.data.redis.connection.ReactiveRedisConnection.Command; import org.springframework.data.redis.connection.ReactiveRedisConnection.CommandResponse; @@ -38,6 +40,7 @@ import org.springframework.data.redis.connection.ReactiveRedisConnection.KeyScan import org.springframework.data.redis.connection.ReactiveRedisConnection.MultiValueResponse; import org.springframework.data.redis.connection.ReactiveRedisConnection.NumericResponse; import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.types.Expiration; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -846,54 +849,59 @@ public interface ReactiveHashCommands { Flux> hStrLen(Publisher commands); /** - * @author Tihomir Mateev - * @see Redis Documentation: HEXPIRE * @since 3.5 */ - class Expire extends HashFieldsCommand { + class ExpireCommand extends HashFieldsCommand { - private final Duration ttl; - - /** - * Creates a new {@link Expire} given a {@code key}, a {@link List} of {@code fields} and a time-to-live - * - * @param key can be {@literal null}. - * @param fields must not be {@literal null}. - * @param ttl the duration of the time to live. - */ - private Expire(@Nullable ByteBuffer key, List fields, Duration ttl) { + private final Expiration expiration; + private final FieldExpirationOptions options; + private ExpireCommand(@Nullable ByteBuffer key, List fields, Expiration expiration, + FieldExpirationOptions options) { super(key, fields); - this.ttl = ttl; + this.expiration = expiration; + this.options = options; } - /** - * Specify the {@code fields} within the hash to set an expiration for. - * - * @param fields must not be {@literal null}. - * @return new instance of {@link Expire}. - */ - public static Expire expire(List fields, Duration ttl) { + public static ExpireCommand expire(List fields, long timeout, TimeUnit unit) { Assert.notNull(fields, "Field must not be null"); - return new Expire(null, fields, ttl); + return expire(fields, Expiration.from(timeout, unit)); } - /** - * Define the {@code key} the hash is stored at. - * - * @param key must not be {@literal null}. - * @return new instance of {@link Expire}. - */ - public Expire from(ByteBuffer key) { - return new Expire(key, getFields(), ttl); + public static ExpireCommand expire(List fields, Duration ttl) { + + Assert.notNull(fields, "Field must not be null"); + return expire(fields, Expiration.from(ttl)); } - /** - * @return the ttl. - */ - public Duration getTtl() { - return ttl; + public static ExpireCommand expire(List fields, Expiration expiration) { + return new ExpireCommand(null, fields, expiration, FieldExpirationOptions.none()); + } + + public static ExpireCommand expireAt(List fields, Instant ttl, TimeUnit precision) { + + if (precision.compareTo(TimeUnit.MILLISECONDS) > 0) { + return expire(fields, Expiration.unixTimestamp(ttl.getEpochSecond(), TimeUnit.SECONDS)); + } + + return expire(fields, Expiration.unixTimestamp(ttl.toEpochMilli(), TimeUnit.MILLISECONDS)); + } + + public ExpireCommand from(ByteBuffer key) { + return new ExpireCommand(key, getFields(), expiration, options); + } + + public ExpireCommand withOptions(FieldExpirationOptions options) { + return new ExpireCommand(getKey(), getFields(), getExpiration(), options); + } + + public Expiration getExpiration() { + return expiration; + } + + public FieldExpirationOptions getOptions() { + return options; } } @@ -903,51 +911,53 @@ public interface ReactiveHashCommands { * @param key must not be {@literal null}. * @param field must not be {@literal null}. * @param duration must not be {@literal null}. - * @return a {@link Mono} emitting the expiration result - {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; + * @return a {@link Mono} emitting the expiration result - {@code 2} indicating the specific field is deleted already + * due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); - * {@code -2} indicating there is no such field; + * {@code -2} indicating there is no such field; * @see Redis Documentation: HEXPIRE * @since 3.5 */ default Mono hExpire(ByteBuffer key, Duration duration, ByteBuffer field) { - Assert.notNull(duration, "Duration must not be null"); + Assert.notNull(duration, "Duration must not be null"); return hExpire(key, duration, Collections.singletonList(field)).singleOrEmpty(); } /** - * Expire a {@link List} of {@literal field} after a given {@link Duration} of time, measured in milliseconds, has passed. + * Expire a {@link List} of {@literal field} after a given {@link Duration} of time, measured in milliseconds, has + * passed. * * @param key must not be {@literal null}. * @param fields must not be {@literal null}. * @param duration must not be {@literal null}. - * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; - * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); - * {@code -2} indicating there is no such field; + * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is + * deleted already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time + * is set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition + * is not met); {@code -2} indicating there is no such field; * @see Redis Documentation: HEXPIRE * @since 3.5 */ default Flux hExpire(ByteBuffer key, Duration duration, List fields) { Assert.notNull(duration, "Duration must not be null"); - return hExpire(Flux.just(Expire.expire(fields, duration).from(key))) + return expireHashField(Flux.just(ExpireCommand.expire(fields, duration).from(key))) .mapNotNull(NumericResponse::getOutput); } /** - * Expire a {@link List} of {@literal field} after a given {@link Duration} of time, measured in milliseconds, has passed. + * Expire a {@link List} of {@literal field} after a given {@link Duration} of time, measured in milliseconds, has + * passed. * * @param commands must not be {@literal null}. - * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; - * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); - * {@code -2} indicating there is no such field; + * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is + * deleted already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time + * is set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition + * is not met); {@code -2} indicating there is no such field; * @since 3.5 * @see Redis Documentation: HEXPIRE */ - Flux> hExpire(Publisher commands); + Flux> expireHashField(Publisher commands); /** * Expire a given {@literal field} after a given {@link Duration} of time, measured in milliseconds, has passed. @@ -955,104 +965,41 @@ public interface ReactiveHashCommands { * @param key must not be {@literal null}. * @param field must not be {@literal null}. * @param duration must not be {@literal null}. - * @return a {@link Mono} emitting the expiration result - {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; + * @return a {@link Mono} emitting the expiration result - {@code 2} indicating the specific field is deleted already + * due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); - * {@code -2} indicating there is no such field; + * {@code -2} indicating there is no such field; * @see Redis Documentation: HEXPIRE * @since 3.5 */ default Mono hpExpire(ByteBuffer key, Duration duration, ByteBuffer field) { - Assert.notNull(duration, "Duration must not be null"); + Assert.notNull(duration, "Duration must not be null"); return hpExpire(key, duration, Collections.singletonList(field)).singleOrEmpty(); } /** - * Expire a {@link List} of {@literal field} after a given {@link Duration} of time, measured in milliseconds, has passed. + * Expire a {@link List} of {@literal field} after a given {@link Duration} of time, measured in milliseconds, has + * passed. * * @param key must not be {@literal null}. * @param fields must not be {@literal null}. * @param duration must not be {@literal null}. - * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; - * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); - * {@code -2} indicating there is no such field; + * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is + * deleted already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time + * is set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition + * is not met); {@code -2} indicating there is no such field; * @see Redis Documentation: HEXPIRE * @since 3.5 */ default Flux hpExpire(ByteBuffer key, Duration duration, List fields) { + Assert.notNull(duration, "Duration must not be null"); - - return hpExpire(Flux.just(Expire.expire(fields, duration).from(key))) + return expireHashField(Flux.just(new ExpireCommand(key, fields, + Expiration.from(duration.toMillis(), TimeUnit.MILLISECONDS), FieldExpirationOptions.none()))) .mapNotNull(NumericResponse::getOutput); } - /** - * Expire a {@link List} of {@literal field} after a given {@link Duration} of time, measured in milliseconds, has passed. - * - * @param commands must not be {@literal null}. - * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; - * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); - * {@code -2} indicating there is no such field; - * @since 3.5 - * @see Redis Documentation: HEXPIRE - */ - Flux> hpExpire(Publisher commands); - - /** - * @author Tihomir Mateev - * @see Redis Documentation: HEXPIREAT - * @since 3.5 - */ - class ExpireAt extends HashFieldsCommand { - - private final Instant expireAt; - - /** - * Creates a new {@link ExpireAt} given a {@code key}, a {@link List} of {@literal fields} and a {@link Instant} - * - * @param key can be {@literal null}. - * @param fields must not be {@literal null}. - * @param expireAt the {@link Instant} to expire at. - */ - private ExpireAt(@Nullable ByteBuffer key, List fields, Instant expireAt) { - - super(key, fields); - this.expireAt = expireAt; - } - - /** - * Specify the {@code fields} within the hash to set an expiration for. - * - * @param fields must not be {@literal null}. - * @return new instance of {@link ExpireAt}. - */ - public static ExpireAt expireAt(List fields, Instant expireAt) { - - Assert.notNull(fields, "Fields must not be null"); - return new ExpireAt(null, fields, expireAt); - } - - /** - * Define the {@code key} the hash is stored at. - * - * @param key must not be {@literal null}. - * @return new instance of {@link ExpireAt}. - */ - public ExpireAt from(ByteBuffer key) { - return new ExpireAt(key, getFields(), expireAt); - } - - /** - * @return the ttl. - */ - public Instant getExpireAt() { - return expireAt; - } - } - /** * Expire a given {@literal field} in a given {@link Instant} of time, indicated as an absolute * Unix timestamp in seconds since Unix epoch @@ -1060,10 +1007,10 @@ public interface ReactiveHashCommands { * @param key must not be {@literal null}. * @param field must not be {@literal null}. * @param expireAt must not be {@literal null}. - * @return a {@link Mono} emitting the expiration result - {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is in the past; {@code 1} indicating expiration time is - * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not - * met); {@code -2} indicating there is no such field; + * @return a {@link Mono} emitting the expiration result - {@code 2} indicating the specific field is deleted already + * due to expiration, or provided expiry interval is in the past; {@code 1} indicating expiration time is + * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is + * not met); {@code -2} indicating there is no such field; * @see Redis Documentation: HEXPIREAT * @since 3.5 */ @@ -1080,33 +1027,20 @@ public interface ReactiveHashCommands { * @param key must not be {@literal null}. * @param fields must not be {@literal null}. * @param expireAt must not be {@literal null}. - * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is in the past; {@code 1} indicating expiration time is - * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not - * met); {@code -2} indicating there is no such field; + * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is + * deleted already due to expiration, or provided expiry interval is in the past; {@code 1} indicating + * expiration time is set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | + * GT | LT condition is not met); {@code -2} indicating there is no such field; * @see Redis Documentation: HEXPIREAT * @since 3.5 */ default Flux hExpireAt(ByteBuffer key, Instant expireAt, List fields) { Assert.notNull(expireAt, "Duration must not be null"); - return hExpireAt(Flux.just(ExpireAt.expireAt(fields, expireAt).from(key))).mapNotNull(NumericResponse::getOutput); + return expireHashField(Flux.just(ExpireCommand.expireAt(fields, expireAt, TimeUnit.SECONDS).from(key))) + .mapNotNull(NumericResponse::getOutput); } - /** - * Expire a {@link List} of {@literal field} in a given {@link Instant} of time, indicated as an absolute - * Unix timestamp in seconds since Unix epoch - * - * @param commands must not be {@literal null}. - * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is in the past; {@code 1} indicating expiration time is - * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not - * met); {@code -2} indicating there is no such field; - * @since 3.5 - * @see Redis Documentation: HEXPIREAT - */ - Flux> hExpireAt(Publisher commands); - /** * Expire a given {@literal field} in a given {@link Instant} of time, indicated as an absolute * Unix timestamp in milliseconds since Unix epoch @@ -1114,10 +1048,10 @@ public interface ReactiveHashCommands { * @param key must not be {@literal null}. * @param field must not be {@literal null}. * @param expireAt must not be {@literal null}. - * @return a {@link Mono} emitting the expiration result - {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is in the past; {@code 1} indicating expiration time is - * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not - * met); {@code -2} indicating there is no such field; + * @return a {@link Mono} emitting the expiration result - {@code 2} indicating the specific field is deleted already + * due to expiration, or provided expiry interval is in the past; {@code 1} indicating expiration time is + * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is + * not met); {@code -2} indicating there is no such field; * @see Redis Documentation: HPEXPIREAT * @since 3.5 */ @@ -1134,47 +1068,32 @@ public interface ReactiveHashCommands { * @param key must not be {@literal null}. * @param fields must not be {@literal null}. * @param expireAt must not be {@literal null}. - * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is in the past; {@code 1} indicating expiration time is - * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not - * met); {@code -2} indicating there is no such field; + * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is + * deleted already due to expiration, or provided expiry interval is in the past; {@code 1} indicating + * expiration time is set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | + * GT | LT condition is not met); {@code -2} indicating there is no such field; * @see Redis Documentation: HPEXPIREAT * @since 3.5 */ default Flux hpExpireAt(ByteBuffer key, Instant expireAt, List fields) { Assert.notNull(expireAt, "Duration must not be null"); - return hpExpireAt(Flux.just(ExpireAt.expireAt(fields, expireAt).from(key))).mapNotNull(NumericResponse::getOutput); + return expireHashField(Flux.just(ExpireCommand.expireAt(fields, expireAt, TimeUnit.MILLISECONDS).from(key))) + .mapNotNull(NumericResponse::getOutput); } - /** - * Expire a {@link List} of {@literal field} in a given {@link Instant} of time, indicated as an absolute - * Unix timestamp in milliseconds since Unix epoch - * - * @param commands must not be {@literal null}. - * @return a {@link Flux} emitting the expiration results one by one, {@code 2} indicating the specific field is deleted - * already due to expiration, or provided expiry interval is in the past; {@code 1} indicating expiration time is - * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not - * met); {@code -2} indicating there is no such field; - * @since 3.5 - * @see Redis Documentation: HPEXPIREAT - */ - Flux> hpExpireAt(Publisher commands); - /** * Persist a given {@literal field} removing any associated expiration, measured as absolute * Unix timestamp in seconds since Unix epoch * * @param key must not be {@literal null}. * @param field must not be {@literal null}. - * @return a {@link Mono} emitting the persist result - {@code 1} indicating expiration time is removed; - * {@code -1} field has no expiration time to be removed; {@code -2} indicating there is no such field; - * + * @return a {@link Mono} emitting the persist result - {@code 1} indicating expiration time is removed; {@code -1} + * field has no expiration time to be removed; {@code -2} indicating there is no such field; * @see Redis Documentation: HPERSIST * @since 3.5 */ default Mono hPersist(ByteBuffer key, ByteBuffer field) { - return hPersist(key, Collections.singletonList(field)).singleOrEmpty(); } @@ -1183,14 +1102,13 @@ public interface ReactiveHashCommands { * * @param key must not be {@literal null}. * @param fields must not be {@literal null}. - * @return a {@link Flux} emitting the persisting results one by one - {@code 1} indicating expiration time is removed; - * {@code -1} field has no expiration time to be removed; {@code -2} indicating there is no such field; - * + * @return a {@link Flux} emitting the persisting results one by one - {@code 1} indicating expiration time is + * removed; {@code -1} field has no expiration time to be removed; {@code -2} indicating there is no such + * field; * @see Redis Documentation: HPERSIST * @since 3.5 */ default Flux hPersist(ByteBuffer key, List fields) { - return hPersist(Flux.just(new HashFieldsCommand(key, fields))).mapNotNull(NumericResponse::getOutput); } @@ -1198,9 +1116,9 @@ public interface ReactiveHashCommands { * Persist a given {@link List} of {@literal field} removing any associated expiration. * * @param commands must not be {@literal null}. - * @return a {@link Flux} emitting the persisting results one by one - {@code 1} indicating expiration time is removed; - * {@code -1} field has no expiration time to be removed; {@code -2} indicating there is no such field; - * * @since 3.5 + * @return a {@link Flux} emitting the persisting results one by one - {@code 1} indicating expiration time is + * removed; {@code -1} field has no expiration time to be removed; {@code -2} indicating there is no such + * field; * @since 3.5 * @see Redis Documentation: HPERSIST */ Flux> hPersist(Publisher commands); @@ -1210,9 +1128,9 @@ public interface ReactiveHashCommands { * * @param key must not be {@literal null}. * @param field must not be {@literal null}. - * @return a {@link Mono} emitting the TTL result - the time to live in seconds; or a negative value - * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. - * The command returns {@code -2} if the key does not exist; + * @return a {@link Mono} emitting the TTL result - the time to live in seconds; or a negative value to signal an + * error. The command returns {@code -1} if the key exists but has no associated expiration time. The command + * returns {@code -2} if the key does not exist; * @see Redis Documentation: HTTL * @since 3.5 */ @@ -1226,9 +1144,9 @@ public interface ReactiveHashCommands { * * @param key must not be {@literal null}. * @param fields must not be {@literal null}. - * @return a {@link Flux} emitting the TTL results one by one - the time to live in seconds; or a negative value - * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. - * The command returns {@code -2} if the key does not exist; + * @return a {@link Flux} emitting the TTL results one by one - the time to live in seconds; or a negative value to + * signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. + * The command returns {@code -2} if the key does not exist; * @see Redis Documentation: HTTL * @since 3.5 */ @@ -1241,23 +1159,22 @@ public interface ReactiveHashCommands { * Returns the time-to-live of all the given {@literal field} in the {@link List} in seconds. * * @param commands must not be {@literal null}. - * @return a {@link Flux} emitting the persisting results one by one - the time to live in seconds; or a negative value - * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. - * The command returns {@code -2} if the key does not exist; + * @return a {@link Flux} emitting the persisting results one by one - the time to live in seconds; or a negative + * value to signal an error. The command returns {@code -1} if the key exists but has no associated expiration + * time. The command returns {@code -2} if the key does not exist; * @since 3.5 * @see Redis Documentation: HTTL */ Flux> hTtl(Publisher commands); - /** * Returns the time-to-live of a given {@literal field} in milliseconds. * * @param key must not be {@literal null}. * @param field must not be {@literal null}. - * @return a {@link Mono} emitting the TTL result - the time to live in milliseconds; or a negative value - * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. - * The command returns {@code -2} if the key does not exist; + * @return a {@link Mono} emitting the TTL result - the time to live in milliseconds; or a negative value to signal an + * error. The command returns {@code -1} if the key exists but has no associated expiration time. The command + * returns {@code -2} if the key does not exist; * @see Redis Documentation: HPTTL * @since 3.5 */ @@ -1272,8 +1189,8 @@ public interface ReactiveHashCommands { * @param key must not be {@literal null}. * @param fields must not be {@literal null}. * @return a {@link Flux} emitting the TTL results one by one - the time to live in milliseconds; or a negative value - * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. - * The command returns {@code -2} if the key does not exist; + * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. + * The command returns {@code -2} if the key does not exist; * @see Redis Documentation: HPTTL * @since 3.5 */ @@ -1286,9 +1203,9 @@ public interface ReactiveHashCommands { * Returns the time-to-live of all the given {@literal field} in the {@link List} in milliseconds. * * @param commands must not be {@literal null}. - * @return a {@link Flux} emitting the persisting results one by one - the time to live in milliseconds; or a negative value - * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. - * The command returns {@code -2} if the key does not exist; + * @return a {@link Flux} emitting the persisting results one by one - the time to live in milliseconds; or a negative + * value to signal an error. The command returns {@code -1} if the key exists but has no associated expiration + * time. The command returns {@code -2} if the key does not exist; * @since 3.5 * @see Redis Documentation: HPTTL */ diff --git a/src/main/java/org/springframework/data/redis/connection/RedisHashCommands.java b/src/main/java/org/springframework/data/redis/connection/RedisHashCommands.java index 066833d52..5fde9d5db 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisHashCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisHashCommands.java @@ -15,11 +15,13 @@ */ package org.springframework.data.redis.connection; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.ScanOptions; import org.springframework.lang.Nullable; @@ -252,38 +254,83 @@ public interface RedisHashCommands { @Nullable Long hStrLen(byte[] key, byte[] field); + default @Nullable List expireHashField(byte[] key, org.springframework.data.redis.core.types.Expiration expiration, + byte[]... fields) { + return expireHashField(key, expiration, FieldExpirationOptions.none(), fields); + } + + + @Nullable List expireHashField(byte[] key, org.springframework.data.redis.core.types.Expiration expiration, + FieldExpirationOptions options, byte[]... fields); + + /** + * Set time to live for given {@code fields} in seconds. + * + * @param key must not be {@literal null}. + * @param seconds the amount of time after which the fields will be expired in seconds, must not be {@literal null}. + * @param fields must not be {@literal null}. + * @return a list of {@link Long} values for each of the fields provided: {@code 2} indicating the specific field is deleted + * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; + * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); + * {@code -2} indicating there is no such field; {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: HEXPIRE + * @since 3.5 + */ + @Nullable + List hExpire(byte[] key, long seconds, byte[]... fields); + /** - * Set time to live for given {@code field} in seconds. + * Set time to live for given {@code fields}. * * @param key must not be {@literal null}. - * @param seconds the amount of time after which the key will be expired in seconds, must not be {@literal null}. + * @param ttl the amount of time after which the fields will be expired in {@link Duration#toSeconds() seconds} precision, must not be {@literal null}. * @param fields must not be {@literal null}. * @return a list of {@link Long} values for each of the fields provided: {@code 2} indicating the specific field is deleted * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); * {@code -2} indicating there is no such field; {@literal null} when used in pipeline / transaction. * @see Redis Documentation: HEXPIRE - * @since 3.4 + * @since 3.5 */ @Nullable - List hExpire(byte[] key, long seconds, byte[]... fields); + default List hExpire(byte[] key, Duration ttl, byte[]... fields) { + return hExpire(key, ttl.toSeconds(), fields); + } /** - * Set time to live for given {@code field} in milliseconds. + * Set time to live for given {@code fields} in milliseconds. * * @param key must not be {@literal null}. - * @param millis the amount of time after which the key will be expired in milliseconds, must not be {@literal null}. + * @param millis the amount of time after which the fields will be expired in milliseconds, must not be {@literal null}. * @param fields must not be {@literal null}. * @return a list of {@link Long} values for each of the fields provided: {@code 2} indicating the specific field is deleted * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); * {@code -2} indicating there is no such field; {@literal null} when used in pipeline / transaction. * @see Redis Documentation: HPEXPIRE - * @since 3.4 + * @since 3.5 */ @Nullable List hpExpire(byte[] key, long millis, byte[]... fields); + /** + * Set time to live for given {@code fields} in milliseconds. + * + * @param key must not be {@literal null}. + * @param ttl the amount of time after which the fields will be expired in {@link Duration#toMillis() milliseconds} precision, must not be {@literal null}. + * @param fields must not be {@literal null}. + * @return a list of {@link Long} values for each of the fields provided: {@code 2} indicating the specific field is deleted + * already due to expiration, or provided expiry interval is 0; {@code 1} indicating expiration time is set/updated; + * {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not met); + * {@code -2} indicating there is no such field; {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: HPEXPIRE + * @since 3.5 + */ + @Nullable + default List hpExpire(byte[] key, Duration ttl, byte[]... fields) { + return hpExpire(key, ttl.toMillis(), fields); + } + /** * Set the expiration for given {@code field} as a {@literal UNIX} timestamp. * @@ -295,7 +342,7 @@ public interface RedisHashCommands { * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not * met); {@code -2} indicating there is no such field; {@literal null} when used in pipeline / transaction. * @see Redis Documentation: HEXPIREAT - * @since 3.4 + * @since 3.5 */ @Nullable List hExpireAt(byte[] key, long unixTime, byte[]... fields); @@ -311,7 +358,7 @@ public interface RedisHashCommands { * set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | GT | LT condition is not * met); {@code -2} indicating there is no such field; {@literal null} when used in pipeline / transaction. * @see Redis Documentation: HPEXPIREAT - * @since 3.4 + * @since 3.5 */ @Nullable List hpExpireAt(byte[] key, long unixTimeInMillis, byte[]... fields); @@ -325,27 +372,27 @@ public interface RedisHashCommands { * {@code -1} field has no expiration time to be removed; {@code -2} indicating there is no such field; * {@literal null} when used in pipeline / transaction.{@literal null} when used in pipeline / transaction. * @see Redis Documentation: HPERSIST - * @since 3.4 + * @since 3.5 */ @Nullable List hPersist(byte[] key, byte[]... fields); /** - * Get the time to live for {@code field} in seconds. + * Get the time to live for {@code fields} in seconds. * * @param key must not be {@literal null}. * @param fields must not be {@literal null}. * @return a list of {@link Long} values for each of the fields provided: the time to live in seconds; or a negative value - * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. - * The command returns {@code -2} if the key does not exist; {@literal null} when used in pipeline / transaction. + * to signal an error. The command returns {@code -1} if the field exists but has no associated expiration time. + * The command returns {@code -2} if the field does not exist; {@literal null} when used in pipeline / transaction. * @see Redis Documentation: HTTL - * @since 3.4 + * @since 3.5 */ @Nullable List hTtl(byte[] key, byte[]... fields); /** - * Get the time to live for {@code field} in and convert it to the given {@link TimeUnit}. + * Get the time to live for {@code fields} in and convert it to the given {@link TimeUnit}. * * @param key must not be {@literal null}. * @param timeUnit must not be {@literal null}. @@ -354,8 +401,24 @@ public interface RedisHashCommands { * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. * The command returns {@code -2} if the key does not exist; {@literal null} when used in pipeline / transaction. * @see Redis Documentation: HTTL - * @since 3.4 + * @since 3.5 */ @Nullable + // TODO: this is complete nonsense as it would jeopardize negative values + // TODO: this should be a List> List hTtl(byte[] key, TimeUnit timeUnit, byte[]... fields); + + /** + * Get the time to live for {@code fields} in milliseconds. + * + * @param key must not be {@literal null}. + * @param fields must not be {@literal null}. + * @return a list of {@link Long} values for each of the fields provided: the time to live in seconds; or a negative value + * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. + * The command returns {@code -2} if the key does not exist; {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: HTTL + * @since 3.5 + */ + @Nullable + List hpTtl(byte[] key, byte[]... fields); } diff --git a/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java b/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java index 414f178d9..49326637d 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java @@ -16,6 +16,7 @@ package org.springframework.data.redis.connection; import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -191,6 +192,20 @@ public interface RedisKeyCommands { @Nullable Boolean expire(byte[] key, long seconds); + /** + * Set time to live for given {@code key} using {@link Duration#toSeconds() seconds} precision. + * + * @param key must not be {@literal null}. + * @param duration + * @return {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: EXPIRE + * @since 3.5 + */ + @Nullable + default Boolean expire(byte[] key, Duration duration) { + return expire(key, duration.toSeconds()); + } + /** * Set time to live for given {@code key} in milliseconds. * @@ -202,6 +217,20 @@ public interface RedisKeyCommands { @Nullable Boolean pExpire(byte[] key, long millis); + /** + * Set time to live for given {@code key} using {@link Duration#toMillis() milliseconds} precision. + * + * @param key must not be {@literal null}. + * @param duration + * @return {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: PEXPIRE + * @since 3.5 + */ + @Nullable + default Boolean pExpire(byte[] key, Duration duration) { + return pExpire(key, duration.toMillis()); + } + /** * Set the expiration for given {@code key} as a {@literal UNIX} timestamp. * @@ -213,6 +242,21 @@ public interface RedisKeyCommands { @Nullable Boolean expireAt(byte[] key, long unixTime); + /** + * Set the expiration for given {@code key} as a {@literal UNIX} timestamp in {@link Instant#getEpochSecond() seconds} + * precision. + * + * @param key must not be {@literal null}. + * @param unixTime + * @return {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: EXPIREAT + * @since 3.5 + */ + @Nullable + default Boolean expireAt(byte[] key, Instant unixTime) { + return expireAt(key, unixTime.getEpochSecond()); + } + /** * Set the expiration for given {@code key} as a {@literal UNIX} timestamp in milliseconds. * @@ -224,6 +268,21 @@ public interface RedisKeyCommands { @Nullable Boolean pExpireAt(byte[] key, long unixTimeInMillis); + /** + * Set the expiration for given {@code key} as a {@literal UNIX} timestamp in {@link Instant#toEpochMilli() + * milliseconds} precision. + * + * @param key must not be {@literal null}. + * @param unixTime + * @return {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: PEXPIREAT + * @since 3.5 + */ + @Nullable + default Boolean pExpireAt(byte[] key, Instant unixTime) { + return pExpireAt(key, unixTime.toEpochMilli()); + } + /** * Remove the expiration from given {@code key}. * diff --git a/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java index f95b618cf..ed0101641 100644 --- a/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java @@ -2333,6 +2333,7 @@ public interface StringRedisConnection extends RedisConnection { @Nullable Long hStrLen(String key, String field); + // TODO: why why whay is this such a shitty api that there's missing all the NX, XX, GT Options /** * Set time to live for given {@code field} in seconds. * @@ -2412,7 +2413,7 @@ public interface StringRedisConnection extends RedisConnection { List hPersist(String key, String... fields); /** - * Get the time to live for {@code field} in seconds. + * Get the time to live for {@code fields} in seconds. * * @param key must not be {@literal null}. * @param fields must not be {@literal null}. @@ -2420,13 +2421,13 @@ public interface StringRedisConnection extends RedisConnection { * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. * The command returns {@code -2} if the key does not exist; {@literal null} when used in pipeline / transaction. * @see Redis Documentation: HTTL - * @since 3.4 + * @since 3.5 */ @Nullable List hTtl(String key, String... fields); /** - * Get the time to live for {@code field} in and convert it to the given {@link TimeUnit}. + * Get the time to live for {@code fields} in and convert it to the given {@link TimeUnit}. * * @param key must not be {@literal null}. * @param timeUnit must not be {@literal null}. @@ -2435,11 +2436,25 @@ public interface StringRedisConnection extends RedisConnection { * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. * The command returns {@code -2} if the key does not exist; {@literal null} when used in pipeline / transaction. * @see Redis Documentation: HTTL - * @since 3.4 + * @since 3.5 */ @Nullable List hTtl(String key, TimeUnit timeUnit, String... fields); + /** + * Get the time to live for {@code fields} in seconds. + * + * @param key must not be {@literal null}. + * @param fields must not be {@literal null}. + * @return a list of {@link Long} values for each of the fields provided: the time to live in milliseconds; or a negative value + * to signal an error. The command returns {@code -1} if the key exists but has no associated expiration time. + * The command returns {@code -2} if the key does not exist; {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: HTTL + * @since 3.5 + */ + @Nullable + List hpTtl(String key, String... fields); + // ------------------------------------------------------------------------- // Methods dealing with HyperLogLog // ------------------------------------------------------------------------- diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterHashCommands.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterHashCommands.java index c436afaee..3326a00d6 100644 --- a/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterHashCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterHashCommands.java @@ -15,6 +15,7 @@ */ package org.springframework.data.redis.connection.jedis; +import redis.clients.jedis.args.ExpiryOption; import redis.clients.jedis.params.ScanParams; import redis.clients.jedis.resps.ScanResult; @@ -26,13 +27,16 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.connection.RedisHashCommands; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.ScanCursor; import org.springframework.data.redis.core.ScanIteration; import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.types.Expiration; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; /** * Cluster {@link RedisHashCommands} implementation for Jedis. @@ -281,16 +285,54 @@ class JedisClusterHashCommands implements RedisHashCommands { ScanParams params = JedisConverters.toScanParams(options); - ScanResult> result = connection.getCluster().hscan(key, - JedisConverters.toBytes(cursorId), + ScanResult> result = connection.getCluster().hscan(key, JedisConverters.toBytes(cursorId), params); return new ScanIteration<>(CursorId.of(result.getCursor()), result.getResult()); } }.open(); } + @Nullable + @Override + public List expireHashField(byte[] key, Expiration expiration, FieldExpirationOptions options, + byte[]... fields) { + + if (expiration.isPersistent()) { + return hPersist(key, fields); + } + + if (ObjectUtils.nullSafeEquals(FieldExpirationOptions.none(), options)) { + if (ObjectUtils.nullSafeEquals(TimeUnit.MILLISECONDS, expiration.getTimeUnit())) { + if (expiration.isUnixTimestamp()) { + return hpExpireAt(key, expiration.getExpirationTimeInMilliseconds(), fields); + } + return hpExpire(key, expiration.getExpirationTimeInMilliseconds(), fields); + } + if (expiration.isUnixTimestamp()) { + return hExpireAt(key, expiration.getExpirationTimeInSeconds(), fields); + } + return hExpire(key, expiration.getExpirationTimeInSeconds(), fields); + } + + ExpiryOption option = ExpiryOption.valueOf(options.getCondition().name()); + + if (ObjectUtils.nullSafeEquals(TimeUnit.MILLISECONDS, expiration.getTimeUnit())) { + if (expiration.isUnixTimestamp()) { + return connection.getCluster().hpexpireAt(key, expiration.getExpirationTimeInMilliseconds(), option, fields); + } + return connection.getCluster().hpexpire(key, expiration.getExpirationTimeInMilliseconds(), option, fields); + } + + if (expiration.isUnixTimestamp()) { + return connection.getCluster().hexpireAt(key, expiration.getExpirationTimeInSeconds(), option, fields); + } + return connection.getCluster().hexpire(key, expiration.getExpirationTimeInSeconds(), option, fields); + + } + @Override public List hExpire(byte[] key, long seconds, byte[]... fields) { + Assert.notNull(key, "Key must not be null"); Assert.notNull(fields, "Fields must not be null"); @@ -368,8 +410,19 @@ class JedisClusterHashCommands implements RedisHashCommands { try { return connection.getCluster().httl(key, fields).stream() - .map(it -> it != null ? timeUnit.convert(it, TimeUnit.SECONDS) : null) - .toList(); + .map(it -> it != null ? timeUnit.convert(it, TimeUnit.SECONDS) : null).toList(); + } catch (Exception ex) { + throw convertJedisAccessException(ex); + } + } + + @Override + public List hpTtl(byte[] key, byte[]... fields) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(fields, "Fields must not be null"); + + try { + return connection.getCluster().hpttl(key, fields); } catch (Exception ex) { throw convertJedisAccessException(ex); } diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisHashCommands.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisHashCommands.java index 887412bb0..e8751e85c 100644 --- a/src/main/java/org/springframework/data/redis/connection/jedis/JedisHashCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisHashCommands.java @@ -16,6 +16,7 @@ package org.springframework.data.redis.connection.jedis; import redis.clients.jedis.Jedis; +import redis.clients.jedis.args.ExpiryOption; import redis.clients.jedis.commands.PipelineBinaryCommands; import redis.clients.jedis.params.ScanParams; import redis.clients.jedis.resps.ScanResult; @@ -28,6 +29,7 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.connection.RedisHashCommands; import org.springframework.data.redis.connection.convert.Converters; import org.springframework.data.redis.core.Cursor; @@ -37,6 +39,7 @@ import org.springframework.data.redis.core.ScanIteration; import org.springframework.data.redis.core.ScanOptions; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; /** * {@link RedisHashCommands} implementation for Jedis. @@ -152,7 +155,8 @@ class JedisHashCommands implements RedisHashCommands { List> convertedMapEntryList = new ArrayList<>(mapEntryList.size()); - mapEntryList.forEach(entry -> convertedMapEntryList.add(Converters.entryOf(entry.getKey(), entry.getValue()))); + mapEntryList + .forEach(entry -> convertedMapEntryList.add(Converters.entryOf(entry.getKey(), entry.getValue()))); return convertedMapEntryList; @@ -239,8 +243,8 @@ class JedisHashCommands implements RedisHashCommands { ScanParams params = JedisConverters.toScanParams(options); - ScanResult> result = connection.getJedis().hscan(key, - JedisConverters.toBytes(cursorId), params); + ScanResult> result = connection.getJedis().hscan(key, JedisConverters.toBytes(cursorId), + params); return new ScanIteration<>(CursorId.of(result.getCursor()), result.getResult()); } @@ -262,6 +266,46 @@ class JedisHashCommands implements RedisHashCommands { return connection.invoke().just(Jedis::hpexpire, PipelineBinaryCommands::hpexpire, key, millis, fields); } + @Override + public @Nullable List expireHashField(byte[] key, org.springframework.data.redis.core.types.Expiration expiration, + FieldExpirationOptions options, byte[]... fields) { + + if (expiration.isPersistent()) { + return hPersist(key, fields); + } + + if (ObjectUtils.nullSafeEquals(FieldExpirationOptions.none(), options)) { + if (ObjectUtils.nullSafeEquals(TimeUnit.MILLISECONDS, expiration.getTimeUnit())) { + if (expiration.isUnixTimestamp()) { + return hpExpireAt(key, expiration.getExpirationTimeInMilliseconds(), fields); + } + return hpExpire(key, expiration.getExpirationTimeInMilliseconds(), fields); + } + if (expiration.isUnixTimestamp()) { + return hExpireAt(key, expiration.getExpirationTimeInSeconds(), fields); + } + return hExpire(key, expiration.getExpirationTimeInSeconds(), fields); + } + + ExpiryOption option = ExpiryOption.valueOf(options.getCondition().name()); + + if (ObjectUtils.nullSafeEquals(TimeUnit.MILLISECONDS, expiration.getTimeUnit())) { + if (expiration.isUnixTimestamp()) { + return connection.invoke().just(Jedis::hpexpireAt, PipelineBinaryCommands::hpexpireAt, key, + expiration.getExpirationTimeInMilliseconds(), option, fields); + } + return connection.invoke().just(Jedis::hpexpire, PipelineBinaryCommands::hpexpire, key, + expiration.getExpirationTimeInMilliseconds(), option, fields); + } + + if (expiration.isUnixTimestamp()) { + return connection.invoke().just(Jedis::hexpireAt, PipelineBinaryCommands::hexpireAt, key, + expiration.getExpirationTimeInSeconds(), option, fields); + } + return connection.invoke().just(Jedis::hexpire, PipelineBinaryCommands::hexpire, key, + expiration.getExpirationTimeInSeconds(), option, fields); + } + @Override public List hExpireAt(byte[] key, long unixTime, byte[]... fields) { return connection.invoke().just(Jedis::hexpireAt, PipelineBinaryCommands::hexpireAt, key, unixTime, fields); @@ -269,7 +313,8 @@ class JedisHashCommands implements RedisHashCommands { @Override public List hpExpireAt(byte[] key, long unixTimeInMillis, byte[]... fields) { - return connection.invoke().just(Jedis::hpexpireAt, PipelineBinaryCommands::hpexpireAt, key, unixTimeInMillis, fields); + return connection.invoke().just(Jedis::hpexpireAt, PipelineBinaryCommands::hpexpireAt, key, unixTimeInMillis, + fields); } @Override @@ -288,6 +333,11 @@ class JedisHashCommands implements RedisHashCommands { .toList(Converters.secondsToTimeUnit(timeUnit)); } + @Override + public List hpTtl(byte[] key, byte[]... fields) { + return connection.invoke().just(Jedis::hpttl, PipelineBinaryCommands::hpttl, key, fields); + } + @Nullable @Override public Long hStrLen(byte[] key, byte[] field) { diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceHashCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceHashCommands.java index 01e683daa..16564fd1e 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceHashCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceHashCommands.java @@ -15,10 +15,12 @@ */ package org.springframework.data.redis.connection.lettuce; +import io.lettuce.core.ExpireArgs; import io.lettuce.core.KeyValue; import io.lettuce.core.MapScanCursor; import io.lettuce.core.ScanArgs; import io.lettuce.core.api.async.RedisHashAsyncCommands; +import io.lettuce.core.protocol.CommandArgs; import java.util.List; import java.util.Map; @@ -27,6 +29,7 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.connection.RedisHashCommands; import org.springframework.data.redis.connection.convert.Converters; import org.springframework.data.redis.core.Cursor; @@ -36,6 +39,7 @@ import org.springframework.data.redis.core.ScanIteration; import org.springframework.data.redis.core.ScanOptions; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; /** * @author Christoph Strobl @@ -210,6 +214,46 @@ class LettuceHashCommands implements RedisHashCommands { return hScan(key, CursorId.initial(), options); } + @Override + public @Nullable List expireHashField(byte[] key, org.springframework.data.redis.core.types.Expiration expiration, + FieldExpirationOptions options, byte[]... fields) { + + if (expiration.isPersistent()) { + return hPersist(key, fields); + } + + ExpireArgs option = new ExpireArgs() { + @Override + public void build(CommandArgs args) { + + if(ObjectUtils.nullSafeEquals(options, FieldExpirationOptions.none())) { + return; + } + + args.add(options.getCondition().name()); + } + }; + + if (ObjectUtils.nullSafeEquals(TimeUnit.MILLISECONDS, expiration.getTimeUnit())) { + if (expiration.isUnixTimestamp()) { + return connection.invoke().fromMany(RedisHashAsyncCommands::hpexpireat, key, + expiration.getExpirationTimeInMilliseconds(), option, fields).toList(); + } + return connection.invoke() + .fromMany(RedisHashAsyncCommands::hpexpire, key, expiration.getExpirationTimeInMilliseconds(), option, fields) + .toList(); + } + + if (expiration.isUnixTimestamp()) { + return connection.invoke() + .fromMany(RedisHashAsyncCommands::hexpireat, key, expiration.getExpirationTimeInSeconds(), option, fields) + .toList(); + } + return connection.invoke() + .fromMany(RedisHashAsyncCommands::hexpire, key, expiration.getExpirationTimeInSeconds(), option, fields) + .toList(); + } + @Override public List hExpire(byte[] key, long seconds, byte[]... fields) { return connection.invoke().fromMany(RedisHashAsyncCommands::hexpire, key, seconds, fields).toList(); @@ -246,6 +290,11 @@ class LettuceHashCommands implements RedisHashCommands { .toList(Converters.secondsToTimeUnit(timeUnit)); } + @Override + public List hpTtl(byte[] key, byte[]... fields) { + return connection.invoke().fromMany(RedisHashAsyncCommands::hpttl, key, fields).toList(); + } + /** * @param key * @param cursorId diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveHashCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveHashCommands.java index 33e9c162e..3cc7bfd9c 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveHashCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveHashCommands.java @@ -15,8 +15,10 @@ */ package org.springframework.data.redis.connection.lettuce; +import io.lettuce.core.ExpireArgs; import io.lettuce.core.KeyValue; import io.lettuce.core.ScanStream; +import io.lettuce.core.protocol.CommandArgs; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -25,10 +27,11 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.reactivestreams.Publisher; - +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.connection.ReactiveHashCommands; import org.springframework.data.redis.connection.ReactiveRedisConnection.BooleanResponse; import org.springframework.data.redis.connection.ReactiveRedisConnection.CommandResponse; @@ -38,6 +41,7 @@ import org.springframework.data.redis.connection.ReactiveRedisConnection.MultiVa import org.springframework.data.redis.connection.ReactiveRedisConnection.NumericResponse; import org.springframework.data.redis.connection.convert.Converters; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; /** * @author Christoph Strobl @@ -265,49 +269,48 @@ class LettuceReactiveHashCommands implements ReactiveHashCommands { } @Override - public Flux> hExpire(Publisher commands) { + public Flux> expireHashField(Publisher commands) { return connection.execute(cmd -> Flux.from(commands).concatMap(command -> { Assert.notNull(command.getKey(), "Key must not be null"); Assert.notNull(command.getFields(), "Fields must not be null"); - return cmd.hexpire(command.getKey(), command.getTtl().toSeconds(), command.getFields().toArray(ByteBuffer[]::new)) - .map(value -> new NumericResponse<>(command, value)); - })); - } + ByteBuffer[] fields = command.getFields().toArray(ByteBuffer[]::new); - @Override - public Flux> hpExpire(Publisher commands) { - return connection.execute(cmd -> Flux.from(commands).concatMap(command -> { + if (command.getExpiration().isPersistent()) { + return cmd.hpersist(command.getKey(), fields).map(value -> new NumericResponse<>(command, value)); + } - Assert.notNull(command.getKey(), "Key must not be null"); - Assert.notNull(command.getFields(), "Fields must not be null"); + ExpireArgs args = new ExpireArgs() { - return cmd.hpexpire(command.getKey(), command.getTtl().toMillis(), command.getFields().toArray(ByteBuffer[]::new)) - .map(value -> new NumericResponse<>(command, value)); - })); - } + @Override + public void build(CommandArgs args) { + super.build(args); + if (ObjectUtils.nullSafeEquals(command.getOptions(), FieldExpirationOptions.none())) { + return; + } - @Override - public Flux> hExpireAt(Publisher commands) { - return connection.execute(cmd -> Flux.from(commands).concatMap(command -> { + args.add(command.getOptions().getCondition().name()); + } + }; - Assert.notNull(command.getKey(), "Key must not be null"); - Assert.notNull(command.getFields(), "Fields must not be null"); + if (command.getExpiration().isUnixTimestamp()) { - return cmd.hexpireat(command.getKey(), command.getExpireAt().getEpochSecond(), command.getFields().toArray(ByteBuffer[]::new)) - .map(value -> new NumericResponse<>(command, value)); - })); - } + if (command.getExpiration().getTimeUnit().equals(TimeUnit.MILLISECONDS)) { + return cmd + .hpexpireat(command.getKey(), command.getExpiration().getExpirationTimeInMilliseconds(), args, fields) + .map(value -> new NumericResponse<>(command, value)); + } + return cmd.hexpireat(command.getKey(), command.getExpiration().getExpirationTimeInSeconds(), args, fields) + .map(value -> new NumericResponse<>(command, value)); + } - @Override - public Flux> hpExpireAt(Publisher commands) { - return connection.execute(cmd -> Flux.from(commands).concatMap(command -> { + if (command.getExpiration().getTimeUnit().equals(TimeUnit.MILLISECONDS)) { + return cmd.hpexpire(command.getKey(), command.getExpiration().getExpirationTimeInMilliseconds(), args, fields) + .map(value -> new NumericResponse<>(command, value)); + } - Assert.notNull(command.getKey(), "Key must not be null"); - Assert.notNull(command.getFields(), "Fields must not be null"); - - return cmd.hpexpireat(command.getKey(), command.getExpireAt().toEpochMilli(), command.getFields().toArray(ByteBuffer[]::new)) + return cmd.hexpire(command.getKey(), command.getExpiration().getExpirationTimeInSeconds(), args, fields) .map(value -> new NumericResponse<>(command, value)); })); } diff --git a/src/main/java/org/springframework/data/redis/core/BoundHashOperations.java b/src/main/java/org/springframework/data/redis/core/BoundHashOperations.java index ff9e5b130..0503c3309 100644 --- a/src/main/java/org/springframework/data/redis/core/BoundHashOperations.java +++ b/src/main/java/org/springframework/data/redis/core/BoundHashOperations.java @@ -23,6 +23,8 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; +import org.springframework.data.redis.core.types.Expiration; import org.springframework.lang.Nullable; /** @@ -157,6 +159,12 @@ public interface BoundHashOperations extends BoundKeyOperations { @Nullable Long lengthOfValue(HK hashKey); + default ExpireChanges expire(Expiration expiration, Collection hashKeys) { + return expire(expiration, FieldExpirationOptions.none(), hashKeys); + } + + ExpireChanges expire(Expiration expiration, FieldExpirationOptions options, Collection hashKeys); + /** * Set time to live for given {@code hashKey} (aka field). * @@ -171,7 +179,7 @@ public interface BoundHashOperations extends BoundKeyOperations { * @since 3.5 */ @Nullable - List expire(Duration timeout, Collection hashKeys); + ExpireChanges expire(Duration timeout, Collection hashKeys); /** * Set the expiration for given {@code hashKey} (aka field) as a {@literal date} timestamp. @@ -187,7 +195,7 @@ public interface BoundHashOperations extends BoundKeyOperations { * @since 3.5 */ @Nullable - List expireAt(Instant expireAt, Collection hashKeys); + ExpireChanges expireAt(Instant expireAt, Collection hashKeys); /** * Remove the expiration from given {@code hashKey} (aka field). @@ -200,7 +208,7 @@ public interface BoundHashOperations extends BoundKeyOperations { * @since 3.5 */ @Nullable - List persist(Collection hashKeys); + ExpireChanges persist(Collection hashKeys); /** * Get the time to live for {@code hashKey} (aka field) in seconds. @@ -213,7 +221,7 @@ public interface BoundHashOperations extends BoundKeyOperations { * @since 3.5 */ @Nullable - List getExpire(Collection hashKeys); + Expirations getExpire(Collection hashKeys); /** * Get the time to live for {@code hashKey} (aka field) and convert it to the given {@link TimeUnit}. @@ -227,7 +235,7 @@ public interface BoundHashOperations extends BoundKeyOperations { * @since 3.5 */ @Nullable - List getExpire(TimeUnit timeUnit, Collection hashKeys); + Expirations getExpire(TimeUnit timeUnit, Collection hashKeys); /** * Get size of hash at the bound key. diff --git a/src/main/java/org/springframework/data/redis/core/DefaultHashOperations.java b/src/main/java/org/springframework/data/redis/core/DefaultHashOperations.java index 5df4422e4..2be7e0bd3 100644 --- a/src/main/java/org/springframework/data/redis/core/DefaultHashOperations.java +++ b/src/main/java/org/springframework/data/redis/core/DefaultHashOperations.java @@ -27,7 +27,10 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import org.springframework.core.convert.converter.Converter; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.connection.convert.Converters; +import org.springframework.data.redis.core.Expirations.Timeouts; +import org.springframework.data.redis.core.types.Expiration; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -215,44 +218,88 @@ class DefaultHashOperations extends AbstractOperations imp } @Override - public List expire(K key, Duration duration, Collection hashKeys) { - byte[] rawKey = rawKey(key); - byte[][] rawHashKeys = rawHashKeys(hashKeys.toArray()); - long rawTimeout = duration.toMillis(); + public ExpireChanges expire(K key, Duration duration, Collection hashKeys) { - return execute(connection -> connection.hpExpire(rawKey, rawTimeout, rawHashKeys)); + List orderedKeys = List.copyOf(hashKeys); + + byte[] rawKey = rawKey(key); + byte[][] rawHashKeys = rawHashKeys(orderedKeys.toArray()); + boolean splitSecond = TimeoutUtils.hasMillis(duration); + + List raw = execute(connection -> { + if (splitSecond) { + return connection.hashCommands().hpExpire(rawKey, duration.toMillis(), rawHashKeys); + } + return connection.hashCommands().hExpire(rawKey, TimeoutUtils.toSeconds(duration), rawHashKeys); + }); + + return raw != null ? ExpireChanges.of(orderedKeys, raw) : null; } @Override - public List expireAt(K key, Instant instant, Collection hashKeys) { - byte[] rawKey = rawKey(key); - byte[][] rawHashKeys = rawHashKeys(hashKeys.toArray()); + public ExpireChanges expireAt(K key, Instant instant, Collection hashKeys) { - return execute(connection -> connection.hpExpireAt(rawKey, instant.toEpochMilli(), rawHashKeys)); + List orderedKeys = List.copyOf(hashKeys); + + byte[] rawKey = rawKey(key); + byte[][] rawHashKeys = rawHashKeys(orderedKeys.toArray()); + + Long millis = instant.toEpochMilli(); + + List raw = execute(connection -> TimeoutUtils.containsSplitSecond(millis) + ? connection.hashCommands().hpExpireAt(rawKey, millis, rawHashKeys) + : connection.hashCommands().hExpireAt(rawKey, instant.getEpochSecond(), rawHashKeys)); + + return raw != null ? ExpireChanges.of(orderedKeys, raw) : null; } @Override - public List persist(K key, Collection hashKeys) { - byte[] rawKey = rawKey(key); - byte[][] rawHashKeys = rawHashKeys(hashKeys.toArray()); + public ExpireChanges expire(K key, Expiration expiration, FieldExpirationOptions options, Collection hashKeys) { - return execute(connection -> connection.hPersist(rawKey, rawHashKeys)); + List orderedKeys = List.copyOf(hashKeys); + + byte[] rawKey = rawKey(key); + byte[][] rawHashKeys = rawHashKeys(orderedKeys.toArray()); + List raw = execute(connection -> connection.hashCommands().expireHashField(rawKey, expiration, options, rawHashKeys)); + + return raw != null ? ExpireChanges.of(orderedKeys, raw) : null; } @Override - public List getExpire(K key, Collection hashKeys) { - byte[] rawKey = rawKey(key); - byte[][] rawHashKeys = rawHashKeys(hashKeys.toArray()); + public ExpireChanges persist(K key, Collection hashKeys) { - return execute(connection -> connection.hTtl(rawKey, rawHashKeys)); + List orderedKeys = List.copyOf(hashKeys); + + byte[] rawKey = rawKey(key); + byte[][] rawHashKeys = rawHashKeys(orderedKeys.toArray()); + + List raw = execute(connection -> connection.hashCommands().hPersist(rawKey, rawHashKeys)); + + return raw != null ? ExpireChanges.of(orderedKeys, raw) : null; } @Override - public List getExpire(K key, TimeUnit timeUnit, Collection hashKeys) { - byte[] rawKey = rawKey(key); - byte[][] rawHashKeys = rawHashKeys(hashKeys.toArray()); + public Expirations getExpire(K key, TimeUnit timeUnit, Collection hashKeys) { - return execute(connection -> connection.hTtl(rawKey, timeUnit, rawHashKeys)); + if(timeUnit.compareTo(TimeUnit.MILLISECONDS) < 0) { + throw new IllegalArgumentException("%s precision is not supported must be >= MILLISECONDS".formatted(timeUnit)); + } + + List orderedKeys = List.copyOf(hashKeys); + + byte[] rawKey = rawKey(key); + byte[][] rawHashKeys = rawHashKeys(orderedKeys.toArray()); + + List raw = execute( + connection -> TimeUnit.MILLISECONDS.equals(timeUnit) ? connection.hashCommands().hpTtl(rawKey, rawHashKeys) + : connection.hashCommands().hTtl(rawKey, timeUnit, rawHashKeys)); + + if (raw == null) { + return null; + } + + Timeouts timeouts = new Timeouts(TimeUnit.MILLISECONDS.equals(timeUnit) ? timeUnit : TimeUnit.SECONDS, raw); + return Expirations.of(timeUnit, orderedKeys, timeouts); } @Override diff --git a/src/main/java/org/springframework/data/redis/core/DefaultReactiveHashOperations.java b/src/main/java/org/springframework/data/redis/core/DefaultReactiveHashOperations.java index c3e004c25..d373a7f06 100644 --- a/src/main/java/org/springframework/data/redis/core/DefaultReactiveHashOperations.java +++ b/src/main/java/org/springframework/data/redis/core/DefaultReactiveHashOperations.java @@ -15,20 +15,28 @@ */ package org.springframework.data.redis.core; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; +import org.springframework.data.redis.connection.ReactiveHashCommands.ExpireCommand; +import org.springframework.data.redis.connection.ReactiveRedisConnection.NumericResponse; +import org.springframework.data.redis.core.types.Expiration; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.ByteBuffer; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import org.reactivestreams.Publisher; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.redis.connection.ReactiveHashCommands; import org.springframework.data.redis.connection.convert.Converters; +import org.springframework.data.redis.core.Expirations.Timeouts; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -63,8 +71,7 @@ class DefaultReactiveHashOperations implements ReactiveHashOperations Assert.noNullElements(hashKeys, "Hash keys must not contain null elements"); return createMono(hashCommands -> Flux.fromArray(hashKeys) // - .map(hashKey -> (HK) hashKey) - .map(this::rawHashKey) // + .map(hashKey -> (HK) hashKey).map(this::rawHashKey) // .collectList() // .flatMap(hks -> hashCommands.hDel(rawKey(key), hks))); } @@ -86,8 +93,8 @@ class DefaultReactiveHashOperations implements ReactiveHashOperations Assert.notNull(key, "Key must not be null"); Assert.notNull(hashKey, "Hash key must not be null"); - return createMono(hashCommands -> hashCommands.hGet(rawKey(key), rawHashKey((HK) hashKey)) - .map(this::readHashValue)); + return createMono( + hashCommands -> hashCommands.hGet(rawKey(key), rawHashKey((HK) hashKey)).map(this::readHashValue)); } @Override @@ -109,8 +116,8 @@ class DefaultReactiveHashOperations implements ReactiveHashOperations Assert.notNull(key, "Key must not be null"); Assert.notNull(hashKey, "Hash key must not be null"); - return template.doCreateMono(connection -> connection.numberCommands() - .hIncrBy(rawKey(key), rawHashKey(hashKey), delta)); + return template + .doCreateMono(connection -> connection.numberCommands().hIncrBy(rawKey(key), rawHashKey(hashKey), delta)); } @Override @@ -119,8 +126,8 @@ class DefaultReactiveHashOperations implements ReactiveHashOperations Assert.notNull(key, "Key must not be null"); Assert.notNull(hashKey, "Hash key must not be null"); - return template.doCreateMono(connection -> connection.numberCommands() - .hIncrBy(rawKey(key), rawHashKey(hashKey), delta)); + return template + .doCreateMono(connection -> connection.numberCommands().hIncrBy(rawKey(key), rawHashKey(hashKey), delta)); } @Override @@ -137,8 +144,7 @@ class DefaultReactiveHashOperations implements ReactiveHashOperations Assert.notNull(key, "Key must not be null"); - return createMono(hashCommands -> hashCommands.hRandFieldWithValues(rawKey(key))) - .map(this::deserializeHashEntry); + return createMono(hashCommands -> hashCommands.hRandFieldWithValues(rawKey(key))).map(this::deserializeHashEntry); } @Override @@ -235,6 +241,78 @@ class DefaultReactiveHashOperations implements ReactiveHashOperations .map(this::deserializeHashEntry)); } + @Override + public Mono> expire(H key, Duration timeout, Collection hashKeys) { + return expire(key, Expiration.from(timeout), FieldExpirationOptions.none(), hashKeys); + } + + @Override + public Mono> expire(H key, Expiration expiration, FieldExpirationOptions options, Collection hashKeys) { + + List orderedKeys = List.copyOf(hashKeys); + ByteBuffer rawKey = rawKey(key); + List rawHashKeys = orderedKeys.stream().map(this::rawHashKey).toList(); + + Mono> raw =createFlux(connection -> { + return connection.expireHashField(Mono.just(ExpireCommand.expire(rawHashKeys, expiration).from(rawKey).withOptions(options))).map(NumericResponse::getOutput); + }).collectList(); + + return raw.map(values -> ExpireChanges.of(orderedKeys, values)); + } + + @Nullable + @Override + public Mono> expireAt(H key, Instant expireAt, Collection hashKeys) { + + List orderedKeys = List.copyOf(hashKeys); + ByteBuffer rawKey = rawKey(key); + List rawHashKeys = orderedKeys.stream().map(this::rawHashKey).toList(); + + Mono> raw = createFlux(connection -> connection.hExpireAt(rawKey, expireAt, rawHashKeys)).collectList(); + + return raw.map(values -> ExpireChanges.of(orderedKeys, values)); + } + + @Nullable + @Override + public Mono> persist(H key, Collection hashKeys) { + + List orderedKeys = List.copyOf(hashKeys); + ByteBuffer rawKey = rawKey(key); + List rawHashKeys = orderedKeys.stream().map(this::rawHashKey).toList(); + + Mono> raw = createFlux(connection -> connection.hPersist(rawKey, rawHashKeys)).collectList(); + + return raw.map(values -> ExpireChanges.of(orderedKeys, values)); + } + + @Nullable + @Override + public Mono> getExpire(H key, TimeUnit timeUnit, Collection hashKeys) { + + if (timeUnit.compareTo(TimeUnit.MILLISECONDS) < 0) { + throw new IllegalArgumentException("%s precision is not supported must be >= MILLISECONDS".formatted(timeUnit)); + } + + List orderedKeys = List.copyOf(hashKeys); + ByteBuffer rawKey = rawKey(key); + List rawHashKeys = orderedKeys.stream().map(this::rawHashKey).toList(); + + Mono> raw = createFlux(connection -> { + + if (TimeUnit.MILLISECONDS.equals(timeUnit)) { + return connection.hpTtl(rawKey, rawHashKeys); + } + return connection.hTtl(rawKey, rawHashKeys); + }).collectList(); + + return raw.map(values -> { + + Timeouts timeouts = new Timeouts(TimeUnit.MILLISECONDS.equals(timeUnit) ? timeUnit : TimeUnit.SECONDS, values); + return Expirations.of(timeUnit, orderedKeys, timeouts); + }); + } + @Override public Mono delete(H key) { diff --git a/src/main/java/org/springframework/data/redis/core/Expirations.java b/src/main/java/org/springframework/data/redis/core/Expirations.java new file mode 100644 index 000000000..958f90e3a --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/Expirations.java @@ -0,0 +1,303 @@ +/* + * 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.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; + +/** + * Value Object linking a number of keys to their {@link Expiration} retaining the order of the original source. + * Dedicated higher level methods interpret raw expiration values retrieved from a Redis Client. + *
    + *
  1. {@link #persistent()} returns keys that do not have an associated time to live
  2. + *
  3. {@link #missing()} returns keys that do not exist and therefore have no associated time to live
  4. + *
  5. {@link #expirations()} returns the ordered list of {@link Expiration expirations} based on the raw values
  6. + *
  7. {@link #expiring()} returns the expiring keys along with their {@link Duration time to live}
  8. + *
+ * + * @author Christoph Strobl + * @since 3.5 + */ +public class Expirations { // TODO: should we move this to let's say Hash.class or another place + + private final TimeUnit unit; + private final Map expirations; + + Expirations(TimeUnit unit, Map expirations) { + this.unit = unit; + this.expirations = expirations; + } + + /** + * Factory Method to create {@link Expirations} from raw sources provided in a given {@link TimeUnit}. + * + * @param targetUnit the actual time unit of the raw timeToLive values. + * @param keys the keys to associated with the raw values in timeToLive. Defines the actual order of entries within + * {@link Expirations}. + * @param timeouts the raw Redis time to live values. + * @return new instance of {@link Expirations}. + * @param the key type used + */ + public static Expirations of(TimeUnit targetUnit, List keys, Timeouts timeouts) { + + if (keys.size() != timeouts.size()) { + throw new IllegalArgumentException( + "Keys and Timeouts must be of same size but was %s vs %s".formatted(keys.size(), timeouts.size())); + } + if (keys.size() == 1) { + return new Expirations<>(targetUnit, + Map.of(keys.iterator().next(), Expiration.of(timeouts.raw().iterator().next(), timeouts.timeUnit()))); + } + + Map target = CollectionUtils.newLinkedHashMap(keys.size()); + for (int i = 0; i < keys.size(); i++) { + target.put(keys.get(i), Expiration.of(timeouts.get(i), timeouts.timeUnit())); + } + return new Expirations<>(targetUnit, target); + } + + /** + * @return an ordered set of keys that do not have a time to live. + */ + public Set persistent() { + return filterByState(Expiration.PERSISTENT); + } + + /** + * @return an ordered set of keys that do not exists and therefore do not have a time to live. + */ + public Set missing() { + return filterByState(Expiration.MISSING); + } + + /** + * @return an ordered set of all {@link Expirations expirations} where the {@link Expiration#value()} is using the + * {@link TimeUnit} defined in {@link #precision()}. + */ + public List expirations() { + return expirations.values().stream().map(it -> it.convert(this.unit)).toList(); + } + + /** + * @return the {@link TimeUnit} for {@link Expiration expirations} held by this instance. + */ + public TimeUnit precision() { + return unit; + } + + /** + * @return an ordered {@link List} of {@link java.util.Map.Entry entries} combining keys with their actual time to + * live. {@link Expiration#isMissing() Missing} and {@link Expiration#isPersistent() persistent} entries are + * skipped. + */ + public List> expiring() { + return expirations.entrySet().stream().filter(it -> !it.getValue().isMissing() && !it.getValue().isPersistent()) + .map(it -> Map.entry(it.getKey(), toDuration(it.getValue()))).toList(); + } + + /** + * @param key + * @return the {@link Expirations expirations} where the {@link Expiration#value()} is using the {@link TimeUnit} + * defined in {@link #precision()} or {@literal null} if no entry could be found. + */ + @Nullable + public Expiration expirationOf(K key) { + + Expiration expiration = expirations.get(key); + if (expiration == null) { + return null; + } + + return expiration.convert(this.unit); + } + + /** + * @param key + * @return the time to live value of the requested key if it exists and the expiration is neither + * {@link Expiration#isMissing() missing} nor {@link Expiration#isPersistent() persistent}, {@literal null} + * otherwise. + */ + @Nullable + public Duration ttlOf(K key) { + + Expiration expiration = expirationOf(key); + if (expiration == null) { + return null; + } + return toDuration(expiration); + } + + private Set filterByState(Expiration filter) { + return expirations.entrySet().stream().filter(entry -> entry.getValue().equals(filter)).map(Map.Entry::getKey) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @Nullable + static Duration toDuration(Expiration expiration) { + + if (expiration.sourceUnit == null) { + return null; + } + return Duration.of(expiration.raw(), expiration.sourceUnit.toChronoUnit()); + } + + public record Timeouts(TimeUnit timeUnit, List raw) { + + Long get(int index) { + return raw.get(index); + } + + public int size() { + return raw.size(); + } + } + + /** + * Expiration holds time to live {@link #raw()} values as returned by a Redis Client. {@link #value()} serves the + * actual timeout in the given temporal context converting the {@link #raw()} value into a target {@link TimeUnit}. + * Dedicated methods such as {@link #isPersistent()} allow interpretation of the raw result. {@link #MISSING} and + * {@link #PERSISTENT} mark predefined states returned by Redis indicating a time to live value could not be retrieved + * due to various reasons. + */ + public static class Expiration { // TODO: is Expiry a better name for this type? + + private final long raw; + @Nullable TimeUnit sourceUnit; + @Nullable TimeUnit targetUnit; + + public Expiration(long value) { + this(value, null); + } + + public Expiration(long value, @Nullable TimeUnit sourceUnit) { + this(value, sourceUnit, null); + } + + public Expiration(long value, @Nullable TimeUnit sourceUnit, @Nullable TimeUnit targetUnit) { + this.raw = value; + this.sourceUnit = sourceUnit; + this.targetUnit = targetUnit; + } + + /** + * The raw source value as returned by the Redis Client. + * + * @return the raw data + */ + public long raw() { + return raw; + } + + /** + * @return the {@link #raw()} value converted into the {@link #convert(TimeUnit) requested} target {@link TimeUnit}. + */ + public long value() { + + if (sourceUnit == null || targetUnit == null) { + return raw; + } + return targetUnit.convert(raw, sourceUnit); + } + + /** + * @param timeUnit must not be {@literal null}. + * @return the {@link Expiration} instance with new target {@link TimeUnit} set for obtaining the {@link #value() + * value}, or the same instance raw value cannot or must not be converted. + */ + public Expiration convert(TimeUnit timeUnit) { + + if (sourceUnit == null || ObjectUtils.nullSafeEquals(sourceUnit, timeUnit)) { + return this; + } + return new Expiration(raw, sourceUnit, timeUnit); + } + + /** + * Predefined {@link Expiration} for a key that does not exists and therefore does not have a time to live. + */ + public static Expiration MISSING = new Expiration(-2L); + + /** + * Predefined {@link Expiration} for a key that exists but does not expire. + */ + public static Expiration PERSISTENT = new Expiration(-1L); + + /** + * @return {@literal true} if key exists but does not expire. + */ + public boolean isPersistent() { + return PERSISTENT.equals(this); + } + + /** + * @return {@literal true} if key does not exists and therefore does not have a time to live. + */ + public boolean isMissing() { + return MISSING.equals(this); + } + + /** + * Factory method for creating {@link Expiration} instances, returning predefined ones if the value matches a known + * reserved state. + * + * @return the {@link Expiration} for the given raw value. + */ + static Expiration of(Number value, TimeUnit timeUnit) { + return switch (value.intValue()) { + case -2 -> MISSING; + case -1 -> PERSISTENT; + default -> new Expiration(value.longValue(), timeUnit); + }; + } + + @Override + public boolean equals(Object o) { + + if (o == this) { + return true; + } + + if (!(o instanceof Expiration that)) { + return false; + } + + if (!ObjectUtils.nullSafeEquals(this.sourceUnit, that.sourceUnit)) { + return false; + } + + if (!ObjectUtils.nullSafeEquals(this.targetUnit, that.targetUnit)) { + return false; + } + + return this.raw == that.raw; + } + + @Override + public int hashCode() { + return Objects.hash(raw); + } + } +} diff --git a/src/main/java/org/springframework/data/redis/core/ExpireChanges.java b/src/main/java/org/springframework/data/redis/core/ExpireChanges.java new file mode 100644 index 000000000..b9486f639 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/ExpireChanges.java @@ -0,0 +1,195 @@ +/* + * 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.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.springframework.util.CollectionUtils; + +/** + * Value Object linking a number of keys to their {@link ExpiryChangeState} retaining the order of the original source. + * Dedicated higher level methods interpret raw values retrieved from a Redis Client. + *
    + *
  1. {@link #ok()} returns keys for which the time to live has been set
  2. + *
  3. {@link #expired()} returns keys that have been expired
  4. + *
  5. {@link #missed()} returns keys for which the time to live could not be set because they do not exist
  6. + *
  7. {@link #skipped()} returns keys for which the time to live has not been set because a precondition was not + * met
  8. + *
+ * + * @author Christoph Strobl + * @since 3.5 + */ +public class ExpireChanges { + + private final Map changes; + + ExpireChanges(Map changes) { + this.changes = changes; + } + + /** + * Factory Method to create {@link ExpireChanges} from raw sources. + * + * @param keys the keys to associated with the raw values in states. Defines the actual order of entries within + * {@link ExpireChanges}. + * @param states the raw Redis state change values. + * @return new instance of {@link ExpireChanges}. + * @param the key type used + */ + public static ExpireChanges of(List keys, List states) { + + if (keys.size() == 1) { + return new ExpireChanges<>(Map.of(keys.iterator().next(), stateFromValue(states.iterator().next()))); + } + + Map target = CollectionUtils.newLinkedHashMap(keys.size()); + for (int i = 0; i < keys.size(); i++) { + target.put(keys.get(i), stateFromValue(states.get(i))); + } + return new ExpireChanges<>(target); + } + + /** + * @return an ordered {@link List} of the status changes. + */ + public List stateChanges() { + return List.copyOf(changes.values()); + } + + /** + * @return the status change for the given {@literal key}, or {@literal null} if {@link ExpiryChangeState} does not + * contain an entry for it. + */ + public ExpiryChangeState stateOf(K key) { + return changes.get(key); + } + + /** + * @return {@literal true} if all changes are {@link ExpiryChangeState#OK}. + */ + public boolean allOk() { + return allMach(ExpiryChangeState.OK::equals); + } + + /** + * @return {@literal true} if all changes are either ok {@link ExpiryChangeState#OK} or + * {@link ExpiryChangeState#EXPIRED}. + */ + public boolean allChanged() { + return allMach(it -> ExpiryChangeState.OK.equals(it) || ExpiryChangeState.EXPIRED.equals(it)); + } + + /** + * @return an ordered list of if all changes are {@link ExpiryChangeState#OK}. + */ + public Set ok() { + return filterByState(ExpiryChangeState.OK); + } + + /** + * @return an ordered list of if all changes are {@link ExpiryChangeState#EXPIRED}. + */ + public Set expired() { + return filterByState(ExpiryChangeState.EXPIRED); + } + + /** + * @return an ordered list of if all changes are {@link ExpiryChangeState#DOES_NOT_EXIST}. + */ + public Set missed() { + return filterByState(ExpiryChangeState.DOES_NOT_EXIST); + } + + /** + * @return an ordered list of if all changes are {@link ExpiryChangeState#CONDITION_NOT_MET}. + */ + public Set skipped() { + return filterByState(ExpiryChangeState.CONDITION_NOT_MET); + } + + public boolean allMach(Predicate predicate) { + return changes.values().stream().allMatch(predicate); + } + + private Set filterByState(ExpiryChangeState filter) { + return changes.entrySet().stream().filter(entry -> entry.getValue().equals(filter)).map(Map.Entry::getKey) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private static ExpiryChangeState stateFromValue(Number value) { + return ExpiryChangeState.of(value); + } + + public record ExpiryChangeState(long value) { + + public static final ExpiryChangeState DOES_NOT_EXIST = new ExpiryChangeState(-2L); + public static final ExpiryChangeState CONDITION_NOT_MET = new ExpiryChangeState(0L); + public static final ExpiryChangeState OK = new ExpiryChangeState(1L); + public static final ExpiryChangeState EXPIRED = new ExpiryChangeState(2L); + + static ExpiryChangeState of(Number value) { + return switch (value.intValue()) { + case -2 -> DOES_NOT_EXIST; + case 0 -> CONDITION_NOT_MET; + case 1 -> OK; + case 2 -> EXPIRED; + default -> new ExpiryChangeState(value.longValue()); + }; + } + + public boolean isOk() { + return OK.equals(this); + } + + public boolean isExpired() { + return EXPIRED.equals(this); + } + + public boolean isMissing() { + return DOES_NOT_EXIST.equals(this); + } + + public boolean isSkipped() { + return CONDITION_NOT_MET.equals(this); + } + + @Override + public boolean equals(Object o) { + + if (o == this) { + return true; + } + + if (!(o instanceof ExpiryChangeState that)) { + return false; + } + + return this.value == that.value; + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } +} diff --git a/src/main/java/org/springframework/data/redis/core/HashOperations.java b/src/main/java/org/springframework/data/redis/core/HashOperations.java index ea17d26e2..c32c33983 100644 --- a/src/main/java/org/springframework/data/redis/core/HashOperations.java +++ b/src/main/java/org/springframework/data/redis/core/HashOperations.java @@ -23,6 +23,8 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; +import org.springframework.data.redis.core.types.Expiration; import org.springframework.lang.Nullable; /** @@ -240,7 +242,7 @@ public interface HashOperations { * @since 3.5 */ @Nullable - List expire(H key, Duration timeout, Collection hashKeys); + ExpireChanges expire(H key, Duration timeout, Collection hashKeys); /** * Set the expiration for given {@code hashKey} (aka field) as a {@literal date} timestamp. @@ -257,7 +259,9 @@ public interface HashOperations { * @since 3.5 */ @Nullable - List expireAt(H key, Instant expireAt, Collection hashKeys); + ExpireChanges expireAt(H key, Instant expireAt, Collection hashKeys); + + ExpireChanges expire(H key, Expiration expiration, FieldExpirationOptions options, Collection hashKeys); /** * Remove the expiration from given {@code hashKey} (aka field). @@ -271,7 +275,7 @@ public interface HashOperations { * @since 3.5 */ @Nullable - List persist(H key, Collection hashKeys); + ExpireChanges persist(H key, Collection hashKeys); /** * Get the time to live for {@code hashKey} (aka field) in seconds. @@ -285,7 +289,9 @@ public interface HashOperations { * @since 3.5 */ @Nullable - List getExpire(H key, Collection hashKeys); + default Expirations getExpire(H key, Collection hashKeys) { + return getExpire(key, TimeUnit.SECONDS, hashKeys); + } /** * Get the time to live for {@code hashKey} (aka field) and convert it to the given {@link TimeUnit}. @@ -300,7 +306,8 @@ public interface HashOperations { * @since 3.5 */ @Nullable - List getExpire(H key, TimeUnit timeUnit, Collection hashKeys); + Expirations getExpire(H key, TimeUnit timeUnit, Collection hashKeys); + /** * @return never {@literal null}. */ diff --git a/src/main/java/org/springframework/data/redis/core/ReactiveHashOperations.java b/src/main/java/org/springframework/data/redis/core/ReactiveHashOperations.java index 2151590ec..2d0cbcaf1 100644 --- a/src/main/java/org/springframework/data/redis/core/ReactiveHashOperations.java +++ b/src/main/java/org/springframework/data/redis/core/ReactiveHashOperations.java @@ -15,13 +15,20 @@ */ package org.springframework.data.redis.core; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; +import org.springframework.data.redis.core.types.Expiration; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.ByteBuffer; +import java.time.Duration; +import java.time.Instant; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.springframework.lang.Nullable; /** * Reactive Redis operations for Hash Commands. @@ -230,10 +237,80 @@ public interface ReactiveHashOperations { */ Flux> scan(H key, ScanOptions options); + Mono> expire(H key, Duration timeout, Collection hashKeys); + + Mono> expire(H key, Expiration expiration, FieldExpirationOptions options, Collection hashKeys); + + /** + * Set the expiration for given {@code hashKey} (aka field) as a {@literal date} timestamp. + * + * @param key must not be {@literal null}. + * @param expireAt must not be {@literal null}. + * @param hashKeys must not be {@literal null}. + * @return a list of {@link Long} values for each of the fields provided: {@code 2} indicating the specific field is + * deleted already due to expiration, or provided expiry interval is in the past; {@code 1} indicating + * expiration time is set/updated; {@code 0} indicating the expiration time is not set (a provided NX | XX | + * GT | LT condition is not met); {@code -2} indicating there is no such field; {@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: HEXPIRE + * @since 3.5 + */ + @Nullable + Mono> expireAt(H key, Instant expireAt, Collection hashKeys); + + /** + * Remove the expiration from given {@code hashKey} (aka field). + * + * @param key must not be {@literal null}. + * @param hashKeys must not be {@literal null}. + * @return a list of {@link Long} values for each of the fields provided: {@code 1} indicating expiration time is + * removed; {@code -1} field has no expiration time to be removed; {@code -2} indicating there is no such + * field; {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: HPERSIST + * @since 3.5 + */ + @Nullable + Mono> persist(H key, Collection hashKeys); + + /** + * Get the time to live for {@code hashKey} (aka field) in seconds. + * + * @param key must not be {@literal null}. + * @param hashKeys must not be {@literal null}. + * @return a list of {@link Long} values for each of the fields provided: the time to live in seconds; or a negative + * value to signal an error. The command returns {@code -1} if the key exists but has no associated expiration + * time. The command returns {@code -2} if the key does not exist; {@literal null} when used in pipeline / + * transaction. + * @see Redis Documentation: HTTL + * @since 3.5 + */ + @Nullable + default Mono> getExpire(H key, Collection hashKeys) { + return getExpire(key, TimeUnit.SECONDS, hashKeys); + } + + /** + * Get the time to live for {@code hashKey} (aka field) and convert it to the given {@link TimeUnit}. + * + * @param key must not be {@literal null}. + * @param timeUnit must not be {@literal null}. + * @param hashKeys must not be {@literal null}. + * @return a list of {@link Long} values for each of the fields provided: the time to live in seconds; or a negative + * value to signal an error. The command returns {@code -1} if the key exists but has no associated expiration + * time. The command returns {@code -2} if the key does not exist; {@literal null} when used in pipeline / + * transaction. + * @see Redis Documentation: HTTL + * @since 3.5 + */ + @Nullable + Mono> getExpire(H key, TimeUnit timeUnit, Collection hashKeys); + /** * Removes the given {@literal key}. * * @param key must not be {@literal null}. */ Mono delete(H key); + } diff --git a/src/main/java/org/springframework/data/redis/core/TimeoutUtils.java b/src/main/java/org/springframework/data/redis/core/TimeoutUtils.java index 86d90ca88..c46e8478d 100644 --- a/src/main/java/org/springframework/data/redis/core/TimeoutUtils.java +++ b/src/main/java/org/springframework/data/redis/core/TimeoutUtils.java @@ -35,7 +35,11 @@ public abstract class TimeoutUtils { * @since 2.1 */ public static boolean hasMillis(Duration duration) { - return duration.toMillis() % 1000 != 0; + return containsSplitSecond(duration.toMillis()); + } + + public static boolean containsSplitSecond(long millis) { + return millis % 1000 != 0; } /** diff --git a/src/main/java/org/springframework/data/redis/core/types/Expiration.java b/src/main/java/org/springframework/data/redis/core/types/Expiration.java index 74eb9c383..a68dd516b 100644 --- a/src/main/java/org/springframework/data/redis/core/types/Expiration.java +++ b/src/main/java/org/springframework/data/redis/core/types/Expiration.java @@ -19,6 +19,7 @@ import java.time.Duration; import java.util.Objects; import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.core.TimeoutUtils; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -105,8 +106,8 @@ public class Expiration { Assert.notNull(duration, "Duration must not be null"); return duration.isZero() ? Expiration.persistent() - : duration.toMillis() % 1000 == 0 ? new Expiration(duration.getSeconds(), TimeUnit.SECONDS) - : new Expiration(duration.toMillis(), TimeUnit.MILLISECONDS); + : TimeoutUtils.hasMillis(duration) ? new Expiration(duration.toMillis(), TimeUnit.MILLISECONDS) + : new Expiration(duration.getSeconds(), TimeUnit.SECONDS); } /** diff --git a/src/main/java/org/springframework/data/redis/support/collections/DefaultRedisMap.java b/src/main/java/org/springframework/data/redis/support/collections/DefaultRedisMap.java index 547c35187..ad22195ad 100644 --- a/src/main/java/org/springframework/data/redis/support/collections/DefaultRedisMap.java +++ b/src/main/java/org/springframework/data/redis/support/collections/DefaultRedisMap.java @@ -20,13 +20,14 @@ import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.Date; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; import org.springframework.data.redis.connection.DataType; +import org.springframework.data.redis.core.Expirations; +import org.springframework.data.redis.core.ExpireChanges; import org.springframework.data.redis.core.BoundHashOperations; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisOperations; @@ -327,27 +328,27 @@ public class DefaultRedisMap implements RedisMap { } @Override - public List expire(Duration timeout, Collection hashKeys) { + public ExpireChanges expire(Duration timeout, Collection hashKeys) { return Objects.requireNonNull(hashOps.expire(timeout, hashKeys)); } @Override - public List expireAt(Instant expireAt, Collection hashKeys) { + public ExpireChanges expireAt(Instant expireAt, Collection hashKeys) { return Objects.requireNonNull(hashOps.expireAt(expireAt, hashKeys)); } @Override - public List persist(Collection hashKeys) { + public ExpireChanges persist(Collection hashKeys) { return Objects.requireNonNull(hashOps.persist(hashKeys)); } @Override - public List getExpire(Collection hashKeys) { + public Expirations getExpire(Collection hashKeys) { return Objects.requireNonNull(hashOps.getExpire(hashKeys)); } @Override - public List getExpire(TimeUnit timeUnit, Collection hashKeys) { + public Expirations getExpire(TimeUnit timeUnit, Collection hashKeys) { return Objects.requireNonNull(hashOps.getExpire(timeUnit, hashKeys)); } diff --git a/src/main/java/org/springframework/data/redis/support/collections/RedisMap.java b/src/main/java/org/springframework/data/redis/support/collections/RedisMap.java index 4b79cf029..54d002d54 100644 --- a/src/main/java/org/springframework/data/redis/support/collections/RedisMap.java +++ b/src/main/java/org/springframework/data/redis/support/collections/RedisMap.java @@ -19,11 +19,12 @@ import java.time.Duration; import java.time.Instant; import java.util.Collection; import java.util.Iterator; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.core.Expirations; +import org.springframework.data.redis.core.ExpireChanges; import org.springframework.lang.Nullable; /** @@ -91,7 +92,7 @@ public interface RedisMap extends RedisStore, ConcurrentMap { * @see Redis Documentation: HEXPIRE * @since 3.5 */ - List expire(Duration timeout, Collection hashKeys); + ExpireChanges expire(Duration timeout, Collection hashKeys); /** * Set the expiration for given hash {@code key} as a {@literal date} timestamp. @@ -106,7 +107,7 @@ public interface RedisMap extends RedisStore, ConcurrentMap { * @see Redis Documentation: HEXPIRE * @since 3.5 */ - List expireAt(Instant expireAt, Collection hashKeys); + ExpireChanges expireAt(Instant expireAt, Collection hashKeys); /** * Remove the expiration from given hash {@code key}. @@ -118,7 +119,7 @@ public interface RedisMap extends RedisStore, ConcurrentMap { * @see Redis Documentation: HPERSIST * @since 3.5 */ - List persist(Collection hashKeys); + ExpireChanges persist(Collection hashKeys); /** * Get the time to live for hash {@code key} in seconds. @@ -130,7 +131,7 @@ public interface RedisMap extends RedisStore, ConcurrentMap { * @see Redis Documentation: HTTL * @since 3.5 */ - List getExpire(Collection hashKeys); + Expirations getExpire(Collection hashKeys); /** * Get the time to live for hash {@code key} and convert it to the given {@link TimeUnit}. @@ -143,5 +144,5 @@ public interface RedisMap extends RedisStore, ConcurrentMap { * @see Redis Documentation: HTTL * @since 3.5 */ - List getExpire(TimeUnit timeUnit, Collection hashKeys); + Expirations getExpire(TimeUnit timeUnit, Collection hashKeys); } diff --git a/src/main/java/org/springframework/data/redis/support/collections/RedisProperties.java b/src/main/java/org/springframework/data/redis/support/collections/RedisProperties.java index 68981643d..54d7f0c9d 100644 --- a/src/main/java/org/springframework/data/redis/support/collections/RedisProperties.java +++ b/src/main/java/org/springframework/data/redis/support/collections/RedisProperties.java @@ -24,6 +24,8 @@ import java.util.Map.Entry; import java.util.concurrent.TimeUnit; import org.springframework.data.redis.connection.DataType; +import org.springframework.data.redis.core.Expirations; +import org.springframework.data.redis.core.ExpireChanges; import org.springframework.data.redis.core.BoundHashOperations; import org.springframework.data.redis.core.RedisOperations; import org.springframework.lang.Nullable; @@ -300,33 +302,38 @@ public class RedisProperties extends Properties implements RedisMap expire(Duration timeout, Collection hashKeys) { + public ExpireChanges expire(Duration timeout, Collection hashKeys) { + Collection keys = hashKeys.stream().map(key -> (String) key).toList(); - return Objects.requireNonNull(hashOps.expire(timeout, keys)); + return (ExpireChanges) hashOps.expire(timeout, keys); } @Override - public List expireAt(Instant expireAt, Collection hashKeys) { + public ExpireChanges expireAt(Instant expireAt, Collection hashKeys) { + Collection keys = hashKeys.stream().map(key -> (String) key).toList(); - return Objects.requireNonNull(hashOps.expireAt(expireAt, keys)); + return (ExpireChanges) hashOps.expireAt(expireAt, keys); } @Override - public List persist(Collection hashKeys) { + public ExpireChanges persist(Collection hashKeys) { + Collection keys = hashKeys.stream().map(key -> (String) key).toList(); - return Objects.requireNonNull(hashOps.persist(keys)); + return (ExpireChanges) hashOps.persist(keys); } @Override - public List getExpire(Collection hashKeys) { + public Expirations getExpire(Collection hashKeys) { + Collection keys = hashKeys.stream().map(key -> (String) key).toList(); - return Objects.requireNonNull(hashOps.getExpire(keys)); + return (Expirations) hashOps.getExpire(keys); } @Override - public List getExpire(TimeUnit timeUnit, Collection hashKeys) { + public Expirations getExpire(TimeUnit timeUnit, Collection hashKeys) { + Collection keys = hashKeys.stream().map(key -> (String) key).toList(); - return Objects.requireNonNull(hashOps.getExpire(timeUnit, keys)); + return (Expirations) hashOps.getExpire(timeUnit, keys); } } diff --git a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java index d42153cb6..ffa5bcd10 100644 --- a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java @@ -3436,6 +3436,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HEXPIRE") public void hExpireReturnsSuccessAndSetsTTL() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hExpire("hash-hexpire", 5L, "key-2")); actual.add(connection.hTtl("hash-hexpire", "key-2")); @@ -3449,6 +3450,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HEXPIRE") public void hExpireReturnsMinusTwoWhenFieldDoesNotExist() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hExpire("hash-hexpire", 5L, "missking-field")); actual.add(connection.hExpire("missing-key", 5L, "key-2")); @@ -3459,6 +3461,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HEXPIRE") public void hExpireReturnsTwoWhenZeroProvided() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hExpire("hash-hexpire", 0, "key-2")); @@ -3468,9 +3471,10 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HPEXPIRE") public void hpExpireReturnsSuccessAndSetsTTL() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hpExpire("hash-hexpire", 5000L, "key-2")); - actual.add(connection.hTtl("hash-hexpire", TimeUnit.MILLISECONDS,"key-2")); + actual.add(connection.hpTtl("hash-hexpire", "key-2")); List results = getResults(); assertThat(results.get(0)).isEqualTo(Boolean.TRUE); @@ -3481,6 +3485,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HPEXPIRE") public void hpExpireReturnsMinusTwoWhenFieldDoesNotExist() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hpExpire("hash-hexpire", 5L, "missing-field")); actual.add(connection.hpExpire("missing-key", 5L, "key-2")); @@ -3491,6 +3496,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HPEXPIRE") public void hpExpireReturnsTwoWhenZeroProvided() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hpExpire("hash-hexpire", 0, "key-2")); @@ -3500,6 +3506,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HEXPIREAT") public void hExpireAtReturnsSuccessAndSetsTTL() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); long inFiveSeconds = Instant.now().plusSeconds(5L).getEpochSecond(); @@ -3515,6 +3522,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HEXPIREAT") public void hExpireAtReturnsMinusTwoWhenFieldDoesNotExist() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); long inFiveSeconds = Instant.now().plusSeconds(5L).getEpochSecond(); @@ -3527,6 +3535,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HEXPIREAT") public void hExpireAtReturnsTwoWhenZeroProvided() { + long fiveSecondsAgo = Instant.now().minusSeconds(5L).getEpochSecond(); actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); @@ -3538,6 +3547,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HEXPIREAT") public void hpExpireAtReturnsSuccessAndSetsTTL() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); long inFiveSeconds = Instant.now().plusSeconds(5L).toEpochMilli(); @@ -3553,6 +3563,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HEXPIREAT") public void hpExpireAtReturnsMinusTwoWhenFieldDoesNotExist() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); long inFiveSeconds = Instant.now().plusSeconds(5L).toEpochMilli(); @@ -3565,6 +3576,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HPEXPIREAT") public void hpExpireAdReturnsTwoWhenZeroProvided() { + long fiveSecondsAgo = Instant.now().minusSeconds(5L).getEpochSecond(); actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); @@ -3576,6 +3588,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HPERSIST") public void hPersistReturnsSuccessAndPersistsField() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hExpire("hash-hexpire", 5L, "key-2")); actual.add(connection.hPersist("hash-hexpire", "key-2")); @@ -3587,6 +3600,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HPERSIST") public void hPersistReturnsMinusOneWhenFieldDoesNotHaveExpiration() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hPersist("hash-hexpire", "key-2")); @@ -3596,6 +3610,7 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HPERSIST") public void hPersistReturnsMinusTwoWhenFieldOrKeyMissing() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hPersist("hash-hexpire", "missing-field")); actual.add(connection.hPersist("missing-key", "key-2")); @@ -3606,15 +3621,27 @@ public abstract class AbstractConnectionIntegrationTests { @Test @EnabledOnCommand("HTTL") public void hTtlReturnsMinusOneWhenFieldHasNoExpiration() { + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); actual.add(connection.hTtl("hash-hexpire", "key-2")); verifyResults(Arrays.asList(Boolean.TRUE, List.of(-1L))); } + @Test + @EnabledOnCommand("HTTL") + public void hTtlReturnsMinusIndependendOfTimeUnitOneWhenFieldHasNoExpiration() { + + actual.add(connection.hSet("hash-hexpire", "key-2", "value-2")); + actual.add(connection.hTtl("hash-hexpire", TimeUnit.HOURS, "key-2")); + + verifyResults(Arrays.asList(Boolean.TRUE, List.of(-1L))); + } + @Test @EnabledOnCommand("HTTL") public void hTtlReturnsMinusTwoWhenFieldOrKeyMissing() { + actual.add(connection.hTtl("hash-hexpire", "missing-field")); actual.add(connection.hTtl("missing-key", "key-2")); diff --git a/src/test/java/org/springframework/data/redis/connection/jedis/JedisClusterConnectionTests.java b/src/test/java/org/springframework/data/redis/connection/jedis/JedisClusterConnectionTests.java index 022d2e6a5..4e41e6095 100644 --- a/src/test/java/org/springframework/data/redis/connection/jedis/JedisClusterConnectionTests.java +++ b/src/test/java/org/springframework/data/redis/connection/jedis/JedisClusterConnectionTests.java @@ -1041,6 +1041,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireReturnsSuccessAndSetsTTL() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1050,6 +1051,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireReturnsMinusTwoWhenFieldDoesNotExist() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); // missing field @@ -1059,6 +1061,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireReturnsTwoWhenZeroProvided() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1066,6 +1069,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireReturnsSuccessAndSetsTTL() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1075,6 +1079,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireReturnsMinusTwoWhenFieldDoesNotExist() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); // missing field @@ -1084,6 +1089,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireReturnsTwoWhenZeroProvided() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1091,6 +1097,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireAtReturnsSuccessAndSetsTTL() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); long inFiveSeconds = Instant.now().plusSeconds(5L).getEpochSecond(); @@ -1099,6 +1106,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireAtReturnsMinusTwoWhenFieldDoesNotExist() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); long inFiveSeconds = Instant.now().plusSeconds(5L).getEpochSecond(); @@ -1110,6 +1118,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireAdReturnsTwoWhenZeroProvided() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1117,6 +1126,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireAtReturnsSuccessAndSetsTTL() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); long inFiveSeconds = Instant.now().plusSeconds(5L).toEpochMilli(); @@ -1126,6 +1136,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireAtReturnsMinusTwoWhenFieldDoesNotExist() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); long inFiveSeconds = Instant.now().plusSeconds(5L).toEpochMilli(); @@ -1137,6 +1148,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireAdReturnsTwoWhenZeroProvided() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1144,6 +1156,7 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hPersistReturnsSuccessAndPersistsField() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hExpire(KEY_1_BYTES, 5L, KEY_2_BYTES)).contains(1L); @@ -1152,12 +1165,14 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hPersistReturnsMinusOneWhenFieldDoesNotHaveExpiration() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hPersist(KEY_1_BYTES, KEY_2_BYTES)).contains(-1L); } @Test + @EnabledOnCommand("HEXPIRE") public void hPersistReturnsMinusTwoWhenFieldOrKeyMissing() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1167,14 +1182,15 @@ public class JedisClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hTtlReturnsMinusOneWhenFieldHasNoExpiration() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hTtl(KEY_1_BYTES, KEY_2_BYTES)).contains(-1L); - } @Test + @EnabledOnCommand("HEXPIRE") public void hTtlReturnsMinusTwoWhenFieldOrKeyMissing() { assertThat(clusterConnection.hashCommands().hTtl(KEY_1_BYTES, KEY_1_BYTES)).contains(-2L); diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceClusterConnectionTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceClusterConnectionTests.java index c77932257..5611fb351 100644 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceClusterConnectionTests.java +++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceClusterConnectionTests.java @@ -1098,7 +1098,9 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireReturnsSuccessAndSetsTTL() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hExpire(KEY_1_BYTES, 5L, KEY_2_BYTES)).contains(1L); @@ -1106,7 +1108,9 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireReturnsMinusTwoWhenFieldDoesNotExist() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); // missing field assertThat(clusterConnection.hashCommands().hExpire(KEY_1_BYTES, 5L, KEY_1_BYTES)).contains(-2L); @@ -1115,6 +1119,7 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireReturnsTwoWhenZeroProvided() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1122,16 +1127,19 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireReturnsSuccessAndSetsTTL() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hpExpire(KEY_1_BYTES, 5000L, KEY_2_BYTES)).contains(1L); - assertThat(clusterConnection.hTtl(KEY_1_BYTES, TimeUnit.MILLISECONDS,KEY_2_BYTES)) + assertThat(clusterConnection.hTtl(KEY_1_BYTES, TimeUnit.MILLISECONDS, KEY_2_BYTES)) .allSatisfy(val -> assertThat(val).isBetween(0L, 5000L)); } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireReturnsMinusTwoWhenFieldDoesNotExist() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); // missing field assertThat(clusterConnection.hashCommands().hpExpire(KEY_1_BYTES, 5L, KEY_1_BYTES)).contains(-2L); @@ -1140,14 +1148,18 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireReturnsTwoWhenZeroProvided() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hpExpire(KEY_1_BYTES, 0L, KEY_2_BYTES)).contains(2L); } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireAtReturnsSuccessAndSetsTTL() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); long inFiveSeconds = Instant.now().plusSeconds(5L).getEpochSecond(); assertThat(clusterConnection.hashCommands().hExpireAt(KEY_1_BYTES, inFiveSeconds, KEY_2_BYTES)).contains(1L); @@ -1155,17 +1167,21 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireAtReturnsMinusTwoWhenFieldDoesNotExist() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); long inFiveSeconds = Instant.now().plusSeconds(5L).getEpochSecond(); // missing field assertThat(clusterConnection.hashCommands().hExpireAt(KEY_1_BYTES, inFiveSeconds, KEY_1_BYTES)).contains(-2L); + // missing key assertThat(clusterConnection.hashCommands().hExpireAt(KEY_2_BYTES, inFiveSeconds, KEY_2_BYTES)).contains(-2L); } @Test + @EnabledOnCommand("HEXPIRE") public void hExpireAdReturnsTwoWhenZeroProvided() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1173,16 +1189,20 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireAtReturnsSuccessAndSetsTTL() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); long inFiveSeconds = Instant.now().plusSeconds(5L).toEpochMilli(); assertThat(clusterConnection.hashCommands().hpExpireAt(KEY_1_BYTES, inFiveSeconds, KEY_2_BYTES)).contains(1L); - assertThat(clusterConnection.hTtl(KEY_1_BYTES, TimeUnit.MILLISECONDS, KEY_2_BYTES)) - .allSatisfy(val -> assertThat(val).isBetween(0L, 5000L)); + assertThat(clusterConnection.hpTtl(KEY_1_BYTES, KEY_2_BYTES)) + .allSatisfy(val -> assertThat(val).isGreaterThan(1000L).isLessThanOrEqualTo(5000L)); } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireAtReturnsMinusTwoWhenFieldDoesNotExist() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); long inFiveSeconds = Instant.now().plusSeconds(5L).toEpochMilli(); @@ -1193,14 +1213,18 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hpExpireAdReturnsTwoWhenZeroProvided() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hpExpireAt(KEY_1_BYTES, 0L, KEY_2_BYTES)).contains(2L); } @Test + @EnabledOnCommand("HEXPIRE") public void hPersistReturnsSuccessAndPersistsField() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hExpire(KEY_1_BYTES, 5L, KEY_2_BYTES)).contains(1L); assertThat(clusterConnection.hashCommands().hPersist(KEY_1_BYTES, KEY_2_BYTES)).contains(1L); @@ -1208,12 +1232,15 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hPersistReturnsMinusOneWhenFieldDoesNotHaveExpiration() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hPersist(KEY_1_BYTES, KEY_2_BYTES)).contains(-1L); } @Test + @EnabledOnCommand("HEXPIRE") public void hPersistReturnsMinusTwoWhenFieldOrKeyMissing() { nativeConnection.hset(KEY_1, KEY_2, VALUE_3); @@ -1223,19 +1250,21 @@ public class LettuceClusterConnectionTests implements ClusterConnectionTests { } @Test + @EnabledOnCommand("HEXPIRE") public void hTtlReturnsMinusOneWhenFieldHasNoExpiration() { + nativeConnection.hset(KEY_1, KEY_2, VALUE_3); assertThat(clusterConnection.hashCommands().hTtl(KEY_1_BYTES, KEY_2_BYTES)).contains(-1L); - + assertThat(clusterConnection.hashCommands().hTtl(KEY_1_BYTES, TimeUnit.HOURS, KEY_2_BYTES)).contains(-1L); } @Test + @EnabledOnCommand("HEXPIRE") public void hTtlReturnsMinusTwoWhenFieldOrKeyMissing() { assertThat(clusterConnection.hashCommands().hTtl(KEY_1_BYTES, KEY_1_BYTES)).contains(-2L); assertThat(clusterConnection.hashCommands().hTtl(KEY_3_BYTES,KEY_2_BYTES)).contains(-2L); - } @Test // DATAREDIS-315 diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveHashCommandsIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveHashCommandsIntegrationTests.java index 86b3ca74f..4ef5fcffe 100644 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveHashCommandsIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveHashCommandsIntegrationTests.java @@ -17,6 +17,7 @@ package org.springframework.data.redis.connection.lettuce; import static org.assertj.core.api.Assertions.*; +import org.springframework.data.redis.test.condition.EnabledOnCommand; import reactor.test.StepVerifier; import java.nio.ByteBuffer; @@ -293,6 +294,7 @@ public class LettuceReactiveHashCommandsIntegrationTests extends LettuceReactive } @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") void hExpireShouldHandleMultipleParametersCorrectly() { assertThat(nativeCommands.hset(KEY_1, FIELD_1, VALUE_1)).isTrue(); assertThat(nativeCommands.hset(KEY_1, FIELD_2, VALUE_2)).isTrue(); @@ -312,6 +314,7 @@ public class LettuceReactiveHashCommandsIntegrationTests extends LettuceReactive } @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") void hExpireAtShouldHandleMultipleParametersCorrectly() { assertThat(nativeCommands.hset(KEY_1, FIELD_1, VALUE_1)).isTrue(); assertThat(nativeCommands.hset(KEY_1, FIELD_2, VALUE_2)).isTrue(); @@ -330,6 +333,7 @@ public class LettuceReactiveHashCommandsIntegrationTests extends LettuceReactive } @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") void hPersistShouldPersistFields() { assertThat(nativeCommands.hset(KEY_1, FIELD_1, VALUE_1)).isTrue(); assertThat(nativeCommands.hset(KEY_1, FIELD_2, VALUE_2)).isTrue(); diff --git a/src/test/java/org/springframework/data/redis/core/DefaultHashOperationsIntegrationTests.java b/src/test/java/org/springframework/data/redis/core/DefaultHashOperationsIntegrationTests.java index ef9737d7a..4abd23dac 100644 --- a/src/test/java/org/springframework/data/redis/core/DefaultHashOperationsIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/core/DefaultHashOperationsIntegrationTests.java @@ -15,8 +15,9 @@ */ package org.springframework.data.redis.core; -import static org.assertj.core.api.Assertions.*; -import static org.assertj.core.api.Assumptions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assumptions.assumeThat; import java.io.IOException; import java.time.Duration; @@ -27,13 +28,18 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.BeforeEach; - +import org.junit.jupiter.api.Test; import org.springframework.data.redis.ObjectFactory; import org.springframework.data.redis.RawObjectFactory; import org.springframework.data.redis.StringObjectFactory; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; import org.springframework.data.redis.connection.jedis.extension.JedisConnectionFactoryExtension; +import org.springframework.data.redis.core.Expirations.Expiration; +import org.springframework.data.redis.core.ExpireChanges.ExpiryChangeState; +import org.springframework.data.redis.test.condition.EnabledOnCommand; import org.springframework.data.redis.test.extension.RedisStanalone; import org.springframework.data.redis.test.extension.parametrized.MethodSource; import org.springframework.data.redis.test.extension.parametrized.ParameterizedRedisTest; @@ -136,7 +142,6 @@ public class DefaultHashOperationsIntegrationTests { hashOps.put(key, key1, val1); hashOps.put(key, key2, val2); - long count = 0; try (Cursor> it = hashOps.scan(key, ScanOptions.scanOptions().count(1).build())) { @@ -208,6 +213,7 @@ public class DefaultHashOperationsIntegrationTests { assertThat(values).hasSize(2).containsEntry(key1, val1).containsEntry(key2, val2); } + @EnabledOnCommand("HEXPIRE") @ParameterizedRedisTest void testExpireAndGetExpireMillis() { @@ -220,13 +226,20 @@ public class DefaultHashOperationsIntegrationTests { hashOps.put(key, key2, val2); assertThat(redisTemplate.opsForHash().expire(key, Duration.ofMillis(500), List.of(key1))) - .containsExactly(1L); + .satisfies(ExpireChanges::allOk); - assertThat(redisTemplate.opsForHash().getExpire(key, List.of(key1))) - .allSatisfy(it -> assertThat(it).isBetween(0L, 500L)); + assertThat(redisTemplate.opsForHash().getExpire(key, List.of(key1))).satisfies(expirations -> { + + assertThat(expirations.missing()).isEmpty(); + assertThat(expirations.precision()).isEqualTo(TimeUnit.SECONDS); + assertThat(expirations.expirationOf(key1)).extracting(Expiration::raw, InstanceOfAssertFactories.LONG) + .isBetween(0L, 1L); + assertThat(expirations.ttlOf(key1)).isBetween(Duration.ZERO, Duration.ofSeconds(1)); + }); } @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") void testExpireAndGetExpireSeconds() { K key = keyFactory.instance(); @@ -238,13 +251,26 @@ public class DefaultHashOperationsIntegrationTests { hashOps.put(key, key2, val2); assertThat(redisTemplate.opsForHash().expire(key, Duration.ofSeconds(5), List.of(key1, key2))) - .containsExactly(1L, 1L); + .satisfies(changes -> { + assertThat(changes.allOk()).isTrue(); + assertThat(changes.stateOf(key1)).isEqualTo(ExpiryChangeState.OK); + assertThat(changes.ok()).containsExactlyInAnyOrder(key1, key2); + assertThat(changes.missed()).isEmpty(); + assertThat(changes.stateChanges()).map(ExpiryChangeState::value).containsExactly(1L, 1L); + }); assertThat(redisTemplate.opsForHash().getExpire(key, TimeUnit.SECONDS, List.of(key1, key2))) - .allSatisfy(it -> assertThat(it).isBetween(0L, 5L)); + .satisfies(expirations -> { + assertThat(expirations.missing()).isEmpty(); + assertThat(expirations.precision()).isEqualTo(TimeUnit.SECONDS); + assertThat(expirations.expirationOf(key1)).extracting(Expiration::raw, InstanceOfAssertFactories.LONG) + .isBetween(0L, 5L); + assertThat(expirations.ttlOf(key1)).isBetween(Duration.ofSeconds(1), Duration.ofSeconds(5)); + }); } @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") void testExpireAtAndGetExpireMillis() { K key = keyFactory.instance(); @@ -256,13 +282,69 @@ public class DefaultHashOperationsIntegrationTests { hashOps.put(key, key2, val2); assertThat(redisTemplate.opsForHash().expireAt(key, Instant.now().plusMillis(500), List.of(key1, key2))) - .containsExactly(1L, 1L); + .satisfies(ExpireChanges::allOk); - assertThat(redisTemplate.opsForHash().getExpire(key, List.of(key1, key2))) - .allSatisfy(it -> assertThat(it).isBetween(0L, 500L)); + assertThat(redisTemplate.opsForHash().getExpire(key, TimeUnit.MILLISECONDS, List.of(key1, key2))) + .satisfies(expirations -> { + assertThat(expirations.missing()).isEmpty(); + assertThat(expirations.precision()).isEqualTo(TimeUnit.MILLISECONDS); + assertThat(expirations.expirationOf(key1)).extracting(Expiration::raw, InstanceOfAssertFactories.LONG) + .isBetween(0L, 500L); + assertThat(expirations.ttlOf(key1)).isBetween(Duration.ZERO, Duration.ofMillis(500)); + }); } @ParameterizedRedisTest + void expireThrowsErrorOfNanoPrecision() { + + K key = keyFactory.instance(); + HK key1 = hashKeyFactory.instance(); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> redisTemplate.opsForHash().getExpire(key, TimeUnit.NANOSECONDS, List.of(key1))); + } + + @ParameterizedRedisTest + void testExpireWithOptionsNone() { + + K key = keyFactory.instance(); + HK key1 = hashKeyFactory.instance(); + HV val1 = hashValueFactory.instance(); + HK key2 = hashKeyFactory.instance(); + HV val2 = hashValueFactory.instance(); + + hashOps.put(key, key1, val1); + hashOps.put(key, key2, val2); + + ExpireChanges expire = redisTemplate.opsForHash().expire(key, org.springframework.data.redis.core.types.Expiration.seconds(20), FieldExpirationOptions.none(), List.of(key1)); + + assertThat(expire.allOk()).isTrue(); + } + + @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") + void testExpireWithOptions() { + + K key = keyFactory.instance(); + HK key1 = hashKeyFactory.instance(); + HV val1 = hashValueFactory.instance(); + HK key2 = hashKeyFactory.instance(); + HV val2 = hashValueFactory.instance(); + + hashOps.put(key, key1, val1); + hashOps.put(key, key2, val2); + + redisTemplate.opsForHash().expire(key, org.springframework.data.redis.core.types.Expiration.seconds(20), FieldExpirationOptions.none(), List.of(key1)); + redisTemplate.opsForHash().expire(key, org.springframework.data.redis.core.types.Expiration.seconds(60), FieldExpirationOptions.none(), List.of(key2)); + + ExpireChanges changes = redisTemplate.opsForHash().expire(key, org.springframework.data.redis.core.types.Expiration.seconds(30), FieldExpirationOptions.builder().gt().build(), List.of(key1, key2)); + + assertThat(changes.ok()).containsExactly(key1); + assertThat(changes.skipped()).containsExactly(key2); + } + + @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") void testPersistAndGetExpireMillis() { K key = keyFactory.instance(); @@ -273,13 +355,14 @@ public class DefaultHashOperationsIntegrationTests { hashOps.put(key, key1, val1); hashOps.put(key, key2, val2); - assertThat(redisTemplate.opsForHash().expireAt(key, Instant.now().plusMillis(500), List.of(key1, key2))) - .containsExactly(1L, 1L); + assertThat(redisTemplate.opsForHash().expireAt(key, Instant.now().plusMillis(800), List.of(key1, key2))) + .satisfies(ExpireChanges::allOk); - assertThat(redisTemplate.opsForHash().persist(key, List.of(key1, key2))) - .allSatisfy(it -> assertThat(it).isEqualTo(1L)); + assertThat(redisTemplate.opsForHash().persist(key, List.of(key2))).satisfies(ExpireChanges::allOk); - assertThat(redisTemplate.opsForHash().getExpire(key, List.of(key1, key2))) - .allSatisfy(it -> assertThat(it).isEqualTo(-1L)); + assertThat(redisTemplate.opsForHash().getExpire(key, List.of(key1, key2))).satisfies(expirations -> { + assertThat(expirations.expirationOf(key1).isPersistent()).isFalse(); + assertThat(expirations.expirationOf(key2).isPersistent()).isTrue(); + }); } } diff --git a/src/test/java/org/springframework/data/redis/core/DefaultReactiveHashOperationsIntegrationTests.java b/src/test/java/org/springframework/data/redis/core/DefaultReactiveHashOperationsIntegrationTests.java index dd1e1287e..a128a2929 100644 --- a/src/test/java/org/springframework/data/redis/core/DefaultReactiveHashOperationsIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/core/DefaultReactiveHashOperationsIntegrationTests.java @@ -15,28 +15,32 @@ */ package org.springframework.data.redis.core; -import static org.assertj.core.api.Assertions.*; -import static org.assertj.core.api.Assumptions.*; -import static org.junit.jupiter.api.condition.OS.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.junit.jupiter.api.condition.OS.MAC; -import org.junit.jupiter.api.condition.DisabledOnOs; -import org.springframework.data.redis.connection.convert.Converters; +import org.springframework.data.redis.connection.Hash.FieldExpirationOptions; import reactor.test.StepVerifier; +import java.time.Duration; +import java.time.Instant; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; - +import org.junit.jupiter.api.condition.DisabledOnOs; import org.springframework.data.redis.ObjectFactory; import org.springframework.data.redis.RawObjectFactory; import org.springframework.data.redis.SettingsUtils; import org.springframework.data.redis.StringObjectFactory; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.convert.Converters; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -501,6 +505,141 @@ public class DefaultReactiveHashOperationsIntegrationTests { .verifyComplete(); } + @EnabledOnCommand("HEXPIRE") + @ParameterizedRedisTest + void testExpireAndGetExpireMillis() { + + K key = keyFactory.instance(); + HK key1 = hashKeyFactory.instance(); + HV val1 = hashValueFactory.instance(); + HK key2 = hashKeyFactory.instance(); + HV val2 = hashValueFactory.instance(); + + putAll(key, key1, val1, key2, val2); + + hashOperations.expire(key, Duration.ofMillis(1500), List.of(key1)) // + .as(StepVerifier::create)// + .assertNext(changes -> { + assertThat(changes.allOk()).isTrue(); + }).verifyComplete(); + + hashOperations.getExpire(key, List.of(key1)) // + .as(StepVerifier::create) // + .assertNext(it -> { + assertThat(it.expirationOf(key1).raw()).isBetween(0L, 2L); + }).verifyComplete(); + } + + @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") + void testExpireWithOptions() { + + K key = keyFactory.instance(); + HK key1 = hashKeyFactory.instance(); + HV val1 = hashValueFactory.instance(); + HK key2 = hashKeyFactory.instance(); + HV val2 = hashValueFactory.instance(); + + putAll(key, key1, val1, key2, val2); + + hashOperations.expire(key, org.springframework.data.redis.core.types.Expiration.seconds(20), FieldExpirationOptions.none(), List.of(key1)).as(StepVerifier::create)// + .assertNext(changes -> { + assertThat(changes.allOk()).isTrue(); + }).verifyComplete(); + hashOperations.expire(key, org.springframework.data.redis.core.types.Expiration.seconds(60), FieldExpirationOptions.none(), List.of(key2)).as(StepVerifier::create)// + .assertNext(changes -> { + assertThat(changes.allOk()).isTrue(); + }).verifyComplete(); + + hashOperations.expire(key, org.springframework.data.redis.core.types.Expiration.seconds(30), FieldExpirationOptions.builder().gt().build(), List.of(key1, key2)).as(StepVerifier::create)// + .assertNext(changes -> { + assertThat(changes.ok()).containsExactly(key1); + assertThat(changes.skipped()).containsExactly(key2); + }).verifyComplete(); + } + + @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") + void testExpireAndGetExpireSeconds() { + + K key = keyFactory.instance(); + HK key1 = hashKeyFactory.instance(); + HV val1 = hashValueFactory.instance(); + HK key2 = hashKeyFactory.instance(); + HV val2 = hashValueFactory.instance(); + + putAll(key, key1, val1, key2, val2); + + hashOperations.expire(key, Duration.ofSeconds(5), List.of(key1, key2)) // + .as(StepVerifier::create)// + .assertNext(changes -> { + assertThat(changes.allOk()).isTrue(); + }).verifyComplete(); + + hashOperations.getExpire(key, TimeUnit.SECONDS, List.of(key1, key2)) // + .as(StepVerifier::create) // + .assertNext(it -> { + assertThat(it.expirationOf(key1).raw()).isBetween(0L, 5L); + assertThat(it.expirationOf(key2).raw()).isBetween(0L, 5L); + }).verifyComplete(); + + } + + @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") + void testExpireAtAndGetExpireMillis() { + + K key = keyFactory.instance(); + HK key1 = hashKeyFactory.instance(); + HV val1 = hashValueFactory.instance(); + HK key2 = hashKeyFactory.instance(); + HV val2 = hashValueFactory.instance(); + + putAll(key, key1, val1, key2, val2); + + redisTemplate.opsForHash().expireAt(key, Instant.now().plusMillis(1500), List.of(key1, key2)) + .as(StepVerifier::create)// + .assertNext(changes -> { + assertThat(changes.allOk()).isTrue(); + }).verifyComplete(); + + redisTemplate.opsForHash().getExpire(key, List.of(key1, key2)).as(StepVerifier::create)// + .assertNext(it -> { + assertThat(it.expirationOf(key1).raw()).isBetween(0L, 2L); + assertThat(it.expirationOf(key2).raw()).isBetween(0L, 2L); + }).verifyComplete(); + } + + @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") + void testPersistAndGetExpireMillis() { + + K key = keyFactory.instance(); + HK key1 = hashKeyFactory.instance(); + HV val1 = hashValueFactory.instance(); + HK key2 = hashKeyFactory.instance(); + HV val2 = hashValueFactory.instance(); + + putAll(key, key1, val1, key2, val2); + + redisTemplate.opsForHash().expireAt(key, Instant.now().plusMillis(1500), List.of(key1, key2)) + .as(StepVerifier::create)// + .assertNext(changes -> { + assertThat(changes.allOk()).isTrue(); + }).verifyComplete(); + + redisTemplate.opsForHash().persist(key, List.of(key1, key2)).as(StepVerifier::create)// + .assertNext(changes -> { + assertThat(changes.allOk()).isTrue(); + }).verifyComplete(); + + redisTemplate.opsForHash().getExpire(key, List.of(key1, key2)).as(StepVerifier::create)// + .assertNext(expirations -> { + assertThat(expirations.persistent()).contains(key1, key2); + }).verifyComplete(); + + } + @ParameterizedRedisTest // DATAREDIS-602 void delete() { diff --git a/src/test/java/org/springframework/data/redis/core/ExpirationsUnitTest.java b/src/test/java/org/springframework/data/redis/core/ExpirationsUnitTest.java new file mode 100644 index 000000000..5fc1953d3 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/ExpirationsUnitTest.java @@ -0,0 +1,94 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.data.redis.core.Expirations.Timeouts; + +/** + * @author Christoph Strobl + * @since 2025/02 + */ +class ExpirationsUnitTest { + + static final String KEY_1 = "key-1"; + static final String KEY_2 = "key-2"; + static final String KEY_3 = "key-3"; + + @ParameterizedTest + @EnumSource(TimeUnit.class) + void expirationMemorizesSourceUnit(TimeUnit targetUnit) { + + Expirations exp = Expirations.of(targetUnit, List.of(KEY_1), new Timeouts(TimeUnit.SECONDS, List.of(120L))); + + assertThat(exp.expirations().get(0)).satisfies(expiration -> { + assertThat(expiration.raw()).isEqualTo(120L); + assertThat(expiration.value()).isEqualTo(targetUnit.convert(120, TimeUnit.SECONDS)); + }); + } + + @Test + void expirationsCategorizesElements() { + + Expirations exp = createExpirations(new Timeouts(TimeUnit.SECONDS, List.of(-2L, -1L, 120L))); + + assertThat(exp.persistent()).containsExactly(KEY_2); + assertThat(exp.missing()).containsExactly(KEY_1); + assertThat(exp.expiring()).containsExactly(Map.entry(KEY_3, Duration.ofMinutes(2))); + } + + @Test + void returnsNullForMissingElements() { + + Expirations exp = createExpirations(new Timeouts(TimeUnit.SECONDS, List.of(-2L, -1L, 120L))); + + assertThat(exp.expirationOf("missing")).isNull(); + assertThat(exp.ttlOf("missing")).isNull(); + } + + @Test + void ttlReturnsDurationForEntriesWithTimeout() { + + Expirations exp = createExpirations(new Timeouts(TimeUnit.SECONDS, List.of(-2L, -1L, 120L))); + + assertThat(exp.ttlOf(KEY_3)).isEqualTo(Duration.ofMinutes(2)); + } + + @Test + void ttlReturnsNullForPersistentAndMissingEntries() { + + Expirations exp = createExpirations(new Timeouts(TimeUnit.SECONDS, List.of(-2L, -1L, 120L))); + + assertThat(exp.ttlOf(KEY_1)).isNull(); + assertThat(exp.ttlOf(KEY_2)).isNull(); + } + + static Expirations createExpirations(Timeouts timeouts) { + + List keys = IntStream.range(1, timeouts.raw().size() + 1).mapToObj("key-%s"::formatted).toList(); + return Expirations.of(timeouts.timeUnit(), keys, timeouts); + } +} diff --git a/src/test/java/org/springframework/data/redis/support/collections/AbstractRedisMapIntegrationTests.java b/src/test/java/org/springframework/data/redis/support/collections/AbstractRedisMapIntegrationTests.java index 0f364c45c..5ce8e5441 100644 --- a/src/test/java/org/springframework/data/redis/support/collections/AbstractRedisMapIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/support/collections/AbstractRedisMapIntegrationTests.java @@ -15,8 +15,9 @@ */ package org.springframework.data.redis.support.collections; -import static org.assertj.core.api.Assertions.*; -import static org.assertj.core.api.Assumptions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assumptions.assumeThat; import java.io.IOException; import java.text.DecimalFormat; @@ -41,6 +42,7 @@ import org.springframework.data.redis.LongAsStringObjectFactory; import org.springframework.data.redis.ObjectFactory; import org.springframework.data.redis.RawObjectFactory; import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.core.ExpireChanges; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisOperations; @@ -195,31 +197,41 @@ public abstract class AbstractRedisMapIntegrationTests { } @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") void testExpire() { + K k1 = getKey(); V v1 = getValue(); assertThat(map.put(k1, v1)).isEqualTo(null); Collection keys = Collections.singletonList(k1); - assertThat(map.expire(Duration.ofSeconds(5), keys)).contains(1L); - assertThat(map.getExpire(keys)).allSatisfy(expiration -> assertThat(expiration).isBetween(1L, 5L)); - assertThat(map.getExpire(TimeUnit.MILLISECONDS, keys)) - .allSatisfy(expiration -> assertThat(expiration).isBetween(1000L, 5000L)); - assertThat(map.persist(keys)).contains(1L); + assertThat(map.expire(Duration.ofSeconds(5), keys)).satisfies(ExpireChanges::allOk); + assertThat(map.getExpire(keys)).satisfies(expiration -> { + assertThat(expiration.expirationOf(k1).raw()).isBetween(1L, 5L); + }); + assertThat(map.getExpire(TimeUnit.MILLISECONDS, keys)).satisfies(expiration -> { + assertThat(expiration.expirationOf(k1).raw()).isBetween(1000L, 5000L); + }); + assertThat(map.persist(keys)).satisfies(ExpireChanges::allOk); } @ParameterizedRedisTest + @EnabledOnCommand("HEXPIRE") void testExpireAt() { + K k1 = getKey(); V v1 = getValue(); assertThat(map.put(k1, v1)).isEqualTo(null); Collection keys = Collections.singletonList(k1); - assertThat(map.expireAt(Instant.now().plusSeconds(5), keys)).contains(1L); - assertThat(map.getExpire(keys)).allSatisfy(expiration -> assertThat(expiration).isBetween(1L, 5L)); - assertThat(map.getExpire(TimeUnit.MILLISECONDS, keys)) - .allSatisfy(expiration -> assertThat(expiration).isBetween(1000L, 5000L)); - assertThat(map.persist(keys)).contains(1L); + assertThat(map.expireAt(Instant.now().plusSeconds(5), keys)).satisfies(ExpireChanges::allOk); + assertThat(map.getExpire(keys)).satisfies(expiration -> { + assertThat(expiration.expirationOf(k1).raw()).isBetween(1L, 5L); + }); + assertThat(map.getExpire(TimeUnit.MILLISECONDS, keys)).satisfies(expiration -> { + assertThat(expiration.expirationOf(k1).raw()).isBetween(1000L, 5000L); + }); + assertThat(map.persist(keys)).satisfies(ExpireChanges::allOk); } @ParameterizedRedisTest