diff --git a/pom.xml b/pom.xml index 8657b1992a..5b4fbf15be 100644 --- a/pom.xml +++ b/pom.xml @@ -1,11 +1,13 @@ - + 4.0.0 org.springframework.data spring-data-redis - 2.6.0-SNAPSHOT + 2.6.0-2089-SNAPSHOT Spring Data Redis diff --git a/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java b/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java index bac73b442b..e01c7cf982 100644 --- a/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java @@ -30,6 +30,7 @@ import org.springframework.data.redis.connection.ReactiveRedisConnection.KeyCommand; import org.springframework.data.redis.connection.ReactiveRedisConnection.MultiValueResponse; import org.springframework.data.redis.connection.ReactiveRedisConnection.NumericResponse; +import org.springframework.data.redis.core.KeyScanOptions; import org.springframework.data.redis.core.ScanOptions; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -268,6 +269,20 @@ default Flux scan() { return scan(ScanOptions.NONE); } + /** + * Use a {@link Flux} to iterate over keys. The resulting {@link Flux} acts as a cursor and issues {@code SCAN} + * commands itself as long as the subscriber signals demand. + * + * @param options must not be {@literal null}. + * @return the {@link Flux} emitting {@link ByteBuffer keys} one by one. + * @throws IllegalArgumentException when options is {@literal null}. + * @see Redis Documentation: SCAN + * @since 2.6 + */ + default Flux scan(KeyScanOptions options) { + return scan((ScanOptions) options); + } + /** * Use a {@link Flux} to iterate over keys. The resulting {@link Flux} acts as a cursor and issues {@code SCAN} * commands itself as long as the subscriber signals demand. 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 d13c18e285..185e143039 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java @@ -21,6 +21,7 @@ import java.util.concurrent.TimeUnit; import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.KeyScanOptions; import org.springframework.data.redis.core.ScanOptions; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -128,6 +129,18 @@ default Boolean exists(byte[] key) { @Nullable Set keys(byte[] pattern); + /** + * Use a {@link Cursor} to iterate over keys. + * + * @param options must not be {@literal null}. + * @return never {@literal null}. + * @since 2.4 + * @see Redis Documentation: SCAN + */ + default Cursor scan(KeyScanOptions options) { + return scan((ScanOptions) options); + } + /** * Use a {@link Cursor} to iterate over keys. * diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisKeyCommands.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisKeyCommands.java index e7acd67ef0..d44525c555 100644 --- a/src/main/java/org/springframework/data/redis/connection/jedis/JedisKeyCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisKeyCommands.java @@ -32,6 +32,7 @@ import org.springframework.data.redis.connection.ValueEncoding.RedisValueEncoding; import org.springframework.data.redis.connection.convert.Converters; import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.KeyScanOptions; import org.springframework.data.redis.core.ScanCursor; import org.springframework.data.redis.core.ScanIteration; import org.springframework.data.redis.core.ScanOptions; @@ -171,6 +172,10 @@ public Cursor scan(ScanOptions options) { */ public Cursor scan(long cursorId, ScanOptions options) { + if (options instanceof KeyScanOptions && ((KeyScanOptions) options).getType() != null) { + throw new UnsupportedOperationException("'SCAN' with type is not yet supported using the Jedis driver"); + } + return new ScanCursor(cursorId, options) { @Override diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java index f986127d21..de6e9be316 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java @@ -64,6 +64,7 @@ import org.springframework.data.redis.connection.convert.ListConverter; import org.springframework.data.redis.connection.convert.LongToBooleanConverter; import org.springframework.data.redis.connection.convert.StringToRedisClientInfoConverter; +import org.springframework.data.redis.core.KeyScanOptions; import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.types.Expiration; import org.springframework.data.redis.core.types.RedisClientInfo; @@ -905,7 +906,7 @@ static ScanArgs toScanArgs(@Nullable ScanOptions options) { return null; } - ScanArgs scanArgs = new ScanArgs(); + KeyScanArgs scanArgs = new KeyScanArgs(); byte[] pattern = options.getBytePattern(); if (pattern != null) { @@ -916,6 +917,10 @@ static ScanArgs toScanArgs(@Nullable ScanOptions options) { scanArgs.limit(options.getCount()); } + if (options instanceof KeyScanOptions) { + scanArgs.type(((KeyScanOptions) options).getType()); + } + return scanArgs; } diff --git a/src/main/java/org/springframework/data/redis/core/KeyScanOptions.java b/src/main/java/org/springframework/data/redis/core/KeyScanOptions.java new file mode 100644 index 0000000000..85f2abe8f6 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/KeyScanOptions.java @@ -0,0 +1,138 @@ +/* + * Copyright 2014-2021 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.StringJoiner; + +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +/** + * Options to be used for with {@literal SCAN} commands. + * + * @author Mark Paluch + * @since 2.6 + */ +public class KeyScanOptions extends ScanOptions { + + /** + * Constant to apply default {@link KeyScanOptions} without setting a limit or matching a pattern. + */ + public static KeyScanOptions NONE = new KeyScanOptions(null, null, null, null); + + private final @Nullable String type; + + private KeyScanOptions(@Nullable Long count, @Nullable String pattern, @Nullable byte[] bytePattern, + @Nullable String type) { + + super(count, pattern, bytePattern); + this.type = type; + } + + /** + * Static factory method that returns a new {@link KeyScanOptionsBuilder}. + * + * @return + */ + public static KeyScanOptionsBuilder scanOptions() { + return new KeyScanOptionsBuilder(); + } + + @Nullable + public String getType() { + return type; + } + + @Override + public String toOptionString() { + + if (this.equals(KeyScanOptions.NONE)) { + return ""; + } + + StringJoiner joiner = new StringJoiner(", ").add(super.toOptionString()); + + if (StringUtils.hasText(type)) { + joiner.add("'type' '" + type + "'"); + } + + return joiner.toString(); + } + + public static class KeyScanOptionsBuilder extends ScanOptionsBuilder { + + private @Nullable String type; + + private KeyScanOptionsBuilder() {} + + /** + * Returns the current {@link KeyScanOptionsBuilder} configured with the given {@code count}. + * + * @param count + * @return + */ + @Override + public KeyScanOptionsBuilder count(long count) { + super.count(count); + return this; + } + + /** + * Returns the current {@link KeyScanOptionsBuilder} configured with the given {@code pattern}. + * + * @param pattern + * @return + */ + @Override + public KeyScanOptionsBuilder match(String pattern) { + super.match(pattern); + return this; + } + + /** + * Returns the current {@link KeyScanOptionsBuilder} configured with the given {@code pattern}. + * + * @param pattern + * @return + */ + @Override + public KeyScanOptionsBuilder match(byte[] pattern) { + super.match(pattern); + return this; + } + + /** + * Returns the current {@link KeyScanOptionsBuilder} configured with the given {@code type}. + * + * @param type + * @return + */ + public KeyScanOptionsBuilder type(String type) { + this.type = type; + return this; + } + + /** + * Builds a new {@link KeyScanOptions} objects. + * + * @return a new {@link KeyScanOptions} objects. + */ + @Override + public KeyScanOptions build() { + return new KeyScanOptions(count, pattern, bytePattern, type); + } + } +} diff --git a/src/main/java/org/springframework/data/redis/core/ScanOptions.java b/src/main/java/org/springframework/data/redis/core/ScanOptions.java index 56a8973928..e368498793 100644 --- a/src/main/java/org/springframework/data/redis/core/ScanOptions.java +++ b/src/main/java/org/springframework/data/redis/core/ScanOptions.java @@ -15,6 +15,8 @@ */ package org.springframework.data.redis.core; +import java.util.StringJoiner; + import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; @@ -25,6 +27,7 @@ * @author Thomas Darimont * @author Mark Paluch * @since 1.4 + * @see KeyScanOptions */ public class ScanOptions { @@ -37,14 +40,14 @@ public class ScanOptions { private final @Nullable String pattern; private final @Nullable byte[] bytePattern; - private ScanOptions(@Nullable Long count, @Nullable String pattern, @Nullable byte[] bytePattern) { + ScanOptions(@Nullable Long count, @Nullable String pattern, @Nullable byte[] bytePattern) { this.count = count; this.pattern = pattern; this.bytePattern = bytePattern; } /** - * Static factory method that returns a new {@link ScanOptionsBuilder}. + * Static factory method that returns a new {@link ScanOptionsBuilder}. * * @return */ @@ -83,17 +86,18 @@ public String toOptionString() { return ""; } - String params = ""; + StringJoiner joiner = new StringJoiner(", "); - if (this.count != null) { - params += (", 'count', " + count); + if (this.getCount() != null) { + joiner.add("'count' " + this.getCount()); } + String pattern = getPattern(); if (StringUtils.hasText(pattern)) { - params += (", 'match' , '" + this.pattern + "'"); + joiner.add("'match' '" + pattern + "'"); } - return params; + return joiner.toString(); } /** @@ -103,10 +107,11 @@ public String toOptionString() { */ public static class ScanOptionsBuilder { - private @Nullable Long count; - private @Nullable String pattern; - private @Nullable byte[] bytePattern; + @Nullable Long count; + @Nullable String pattern; + @Nullable byte[] bytePattern; + ScanOptionsBuilder() {} /** * Returns the current {@link ScanOptionsBuilder} configured with the given {@code count}. 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 1e663e83c1..62387b3a2a 100644 --- a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java @@ -16,6 +16,7 @@ package org.springframework.data.redis.connection; import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assumptions.*; import static org.springframework.data.redis.SpinBarrier.*; import static org.springframework.data.redis.connection.BitFieldSubCommands.*; import static org.springframework.data.redis.connection.BitFieldSubCommands.BitFieldIncrBy.Overflow.*; @@ -73,6 +74,7 @@ import org.springframework.data.redis.connection.stream.StreamInfo.XInfoStream; import org.springframework.data.redis.connection.stream.StreamOffset; import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.KeyScanOptions; import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.types.Expiration; @@ -84,6 +86,7 @@ import org.springframework.data.redis.test.condition.LongRunningTest; import org.springframework.data.redis.test.condition.RedisDriver; import org.springframework.data.redis.test.util.HexStringUtils; +import org.springframework.data.util.Streamable; /** * Base test class for AbstractConnection integration tests @@ -2561,6 +2564,39 @@ void scanShouldReadEntireValueRange() { assertThat(i).isEqualTo(itemCount); } + @Test // GH-2089 + @EnabledOnRedisDriver(RedisDriver.LETTUCE) + @EnabledOnRedisVersion("6.2") + void scanWithType() { + + assumeThat(connection.isPipelined() || connection.isQueueing()) + .describedAs("SCAN is only available in non pipeline | queue mode.").isFalse(); + + connection.set("key", "data"); + connection.lPush("list", "foo"); + connection.sAdd("set", "foo"); + + try (Cursor cursor = connection.scan(KeyScanOptions.scanOptions().type("set").build())) { + assertThat(toList(cursor)).hasSize(1).contains("set"); + } + + try (Cursor cursor = connection.scan(KeyScanOptions.scanOptions().type("string").match("k*").build())) { + assertThat(toList(cursor)).hasSize(1).contains("key"); + } + + try (Cursor cursor = connection.scan(KeyScanOptions.scanOptions().match("k*").build())) { + assertThat(toList(cursor)).hasSize(1).contains("key"); + } + + try (Cursor cursor = connection.scan(KeyScanOptions.scanOptions().build())) { + assertThat(toList(cursor)).contains("key", "list", "set"); + } + } + + private static List toList(Cursor cursor) { + return Streamable.of(() -> cursor).map(String::new).toList(); + } + @Test // DATAREDIS-417 public void scanShouldReadEntireValueRangeWhenIdividualScanIterationsReturnEmptyCollection() {