diff --git a/glide-core/src/protobuf/redis_request.proto b/glide-core/src/protobuf/redis_request.proto index b3330959ec..e2a1fef74e 100644 --- a/glide-core/src/protobuf/redis_request.proto +++ b/glide-core/src/protobuf/redis_request.proto @@ -238,6 +238,7 @@ enum RequestType { FunctionRestore = 197; XPending = 198; XGroupSetId = 199; + SScan = 200; } message Command { diff --git a/glide-core/src/request_type.rs b/glide-core/src/request_type.rs index 943721bfd3..0e46f7800a 100644 --- a/glide-core/src/request_type.rs +++ b/glide-core/src/request_type.rs @@ -208,6 +208,7 @@ pub enum RequestType { FunctionRestore = 197, XPending = 198, XGroupSetId = 199, + SScan = 200, } fn get_two_word_command(first: &str, second: &str) -> Cmd { @@ -419,6 +420,7 @@ impl From<::protobuf::EnumOrUnknown> for RequestType { ProtobufRequestType::FunctionRestore => RequestType::FunctionRestore, ProtobufRequestType::XPending => RequestType::XPending, ProtobufRequestType::XGroupSetId => RequestType::XGroupSetId, + ProtobufRequestType::SScan => RequestType::SScan, } } } @@ -628,6 +630,7 @@ impl RequestType { RequestType::FunctionRestore => Some(get_two_word_command("FUNCTION", "RESTORE")), RequestType::XPending => Some(cmd("XPENDING")), RequestType::XGroupSetId => Some(get_two_word_command("XGROUP", "SETID")), + RequestType::SScan => Some(cmd("SSCAN")), } } } diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index c777e40c30..eeaeab79a1 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -116,6 +116,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.SPop; import static redis_request.RedisRequestOuterClass.RequestType.SRandMember; import static redis_request.RedisRequestOuterClass.RequestType.SRem; +import static redis_request.RedisRequestOuterClass.RequestType.SScan; import static redis_request.RedisRequestOuterClass.RequestType.SUnion; import static redis_request.RedisRequestOuterClass.RequestType.SUnionStore; import static redis_request.RedisRequestOuterClass.RequestType.Set; @@ -207,6 +208,7 @@ import glide.api.models.commands.geospatial.GeoAddOptions; import glide.api.models.commands.geospatial.GeoUnit; import glide.api.models.commands.geospatial.GeospatialData; +import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamPendingOptions; @@ -2784,4 +2786,17 @@ public CompletableFuture sortStore(@NonNull String key, @NonNull String de return commandManager.submitNewCommand( Sort, new String[] {key, STORE_COMMAND_STRING, destination}, this::handleLongResponse); } + + @Override + public CompletableFuture sscan(@NonNull String key, @NonNull String cursor) { + String[] arguments = new String[] {key, cursor}; + return commandManager.submitNewCommand(SScan, arguments, this::handleArrayResponse); + } + + @Override + public CompletableFuture sscan( + @NonNull String key, @NonNull String cursor, @NonNull SScanOptions sScanOptions) { + String[] arguments = concatenateArrays(new String[] {key, cursor}, sScanOptions.toArgs()); + return commandManager.submitNewCommand(SScan, arguments, this::handleArrayResponse); + } } diff --git a/java/client/src/main/java/glide/api/commands/SetBaseCommands.java b/java/client/src/main/java/glide/api/commands/SetBaseCommands.java index 72bea1012f..ae044c975f 100644 --- a/java/client/src/main/java/glide/api/commands/SetBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/SetBaseCommands.java @@ -2,6 +2,7 @@ package glide.api.commands; import glide.api.models.GlideString; +import glide.api.models.commands.scan.SScanOptions; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -553,4 +554,59 @@ public interface SetBaseCommands { * } */ CompletableFuture> sunion(String[] keys); + + /** + * Iterates incrementally over a set. + * + * @see valkey.io for details. + * @param key The key of the set. + * @param cursor The cursor that points to the next iteration of results. + * @return An Array of Objects. The first element is always the + * cursor for the next iteration of results. 0 will be the cursor + * returned on the last iteration of the set. The second element is always an + * Array of the subset of the set held in key. + * @example + *
{@code
+     * // Assume key contains a set with 200 members
+     * String cursor = "0";
+     * Object[] result;
+     * do {
+     *   result = client.sscan(key1, cursor).get();
+     *   cursor = result[0].toString();
+     *   Object[] stringResults = (Object[]) result[1];
+     *
+     *   System.out.println("\nSSCAN iteration:");
+     *   Arrays.asList(stringResults).stream().forEach(i -> System.out.print(i + ", "));
+     * } while (!cursor.equals("0"));
+     * }
+ */ + CompletableFuture sscan(String key, String cursor); + + /** + * Iterates incrementally over a set. + * + * @see valkey.io for details. + * @param key The key of the set. + * @param cursor The cursor that points to the next iteration of results. + * @param sScanOptions The {@link SScanOptions}. + * @return An Array of Objects. The first element is always the + * cursor for the next iteration of results. 0 will be the cursor + * returned on the last iteration of the set. The second element is always an + * Array of the subset of the set held in key. + * @example + *
{@code
+     * // Assume key contains a set with 200 members
+     * String cursor = "0";
+     * Object[] result;
+     * do {
+     *   result = client.sscan(key1, cursor, SScanOptions.builder().matchPattern("*").count(20L).build()).get();
+     *   cursor = result[0].toString();
+     *   Object[] stringResults = (Object[]) result[1];
+     *
+     *   System.out.println("\nSSCAN iteration:");
+     *   Arrays.asList(stringResults).stream().forEach(i -> System.out.print(i + ", "));
+     * } while (!cursor.equals("0"));
+     * }
+ */ + CompletableFuture sscan(String key, String cursor, SScanOptions sScanOptions); } diff --git a/java/client/src/main/java/glide/api/models/BaseTransaction.java b/java/client/src/main/java/glide/api/models/BaseTransaction.java index 3164ecf7a9..0d8974c217 100644 --- a/java/client/src/main/java/glide/api/models/BaseTransaction.java +++ b/java/client/src/main/java/glide/api/models/BaseTransaction.java @@ -142,6 +142,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.SPop; import static redis_request.RedisRequestOuterClass.RequestType.SRandMember; import static redis_request.RedisRequestOuterClass.RequestType.SRem; +import static redis_request.RedisRequestOuterClass.RequestType.SScan; import static redis_request.RedisRequestOuterClass.RequestType.SUnion; import static redis_request.RedisRequestOuterClass.RequestType.SUnionStore; import static redis_request.RedisRequestOuterClass.RequestType.Set; @@ -240,6 +241,7 @@ import glide.api.models.commands.geospatial.GeoAddOptions; import glide.api.models.commands.geospatial.GeoUnit; import glide.api.models.commands.geospatial.GeospatialData; +import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamAddOptions.StreamAddOptionsBuilder; import glide.api.models.commands.stream.StreamGroupOptions; @@ -4876,57 +4878,6 @@ public T sunion(@NonNull String[] keys) { return getThis(); } - /** - * Sorts the elements in the list, set, or sorted set at key and returns the result. - *
- * The sort command can be used to sort elements based on different criteria and - * apply transformations on sorted elements.
- * To store the result into a new key, see {@link #sortStore(String, String)}.
- * - * @param key The key of the list, set, or sorted set to be sorted. - * @return Command Response - An Array of sorted elements. - */ - public T sort(@NonNull String key) { - ArgsArray commandArgs = buildArgs(key); - protobufTransaction.addCommands(buildCommand(Sort, commandArgs)); - return getThis(); - } - - /** - * Sorts the elements in the list, set, or sorted set at key and returns the result. - *
- * The sortReadOnly command can be used to sort elements based on different criteria - * and apply transformations on sorted elements. - * - * @since Redis 7.0 and above. - * @param key The key of the list, set, or sorted set to be sorted. - * @return Command Response - An Array of sorted elements. - */ - public T sortReadOnly(@NonNull String key) { - ArgsArray commandArgs = buildArgs(key); - protobufTransaction.addCommands(buildCommand(SortReadOnly, commandArgs)); - return getThis(); - } - - /** - * Sorts the elements in the list, set, or sorted set at key and stores the result in - * destination. The sort command can be used to sort elements based on - * different criteria, apply transformations on sorted elements, and store the result in a new - * key.
- * To get the sort result without storing it into a key, see {@link #sort(String)} or {@link - * #sortReadOnly(String)}. - * - * @param key The key of the list, set, or sorted set to be sorted. - * @param destination The key where the sorted result will be stored. - * @return Command Response - The number of elements in the sorted key stored at destination - * . - */ - public T sortStore(@NonNull String key, @NonNull String destination) { - ArgsArray commandArgs = buildArgs(new String[] {key, STORE_COMMAND_STRING, destination}); - protobufTransaction.addCommands(buildCommand(Sort, commandArgs)); - return getThis(); - } - /** * Returns the indices and length of the longest common subsequence between strings stored at * key1 and key2. @@ -5126,6 +5077,92 @@ public T lcsIdxWithMatchLen(@NonNull String key1, @NonNull String key2, long min return getThis(); } + /** + * Sorts the elements in the list, set, or sorted set at key and returns the result. + *
+ * The sort command can be used to sort elements based on different criteria and + * apply transformations on sorted elements.
+ * To store the result into a new key, see {@link #sortStore(String, String)}.
+ * + * @param key The key of the list, set, or sorted set to be sorted. + * @return Command Response - An Array of sorted elements. + */ + public T sort(@NonNull String key) { + ArgsArray commandArgs = buildArgs(key); + protobufTransaction.addCommands(buildCommand(Sort, commandArgs)); + return getThis(); + } + + /** + * Sorts the elements in the list, set, or sorted set at key and returns the result. + *
+ * The sortReadOnly command can be used to sort elements based on different criteria + * and apply transformations on sorted elements. + * + * @since Redis 7.0 and above. + * @param key The key of the list, set, or sorted set to be sorted. + * @return Command Response - An Array of sorted elements. + */ + public T sortReadOnly(@NonNull String key) { + ArgsArray commandArgs = buildArgs(key); + protobufTransaction.addCommands(buildCommand(SortReadOnly, commandArgs)); + return getThis(); + } + + /** + * Sorts the elements in the list, set, or sorted set at key and stores the result in + * destination. The sort command can be used to sort elements based on + * different criteria, apply transformations on sorted elements, and store the result in a new + * key.
+ * To get the sort result without storing it into a key, see {@link #sort(String)} or {@link + * #sortReadOnly(String)}. + * + * @param key The key of the list, set, or sorted set to be sorted. + * @param destination The key where the sorted result will be stored. + * @return Command Response - The number of elements in the sorted key stored at destination + * . + */ + public T sortStore(@NonNull String key, @NonNull String destination) { + ArgsArray commandArgs = buildArgs(new String[] {key, STORE_COMMAND_STRING, destination}); + protobufTransaction.addCommands(buildCommand(Sort, commandArgs)); + return getThis(); + } + + /** + * Iterates incrementally over a set. + * + * @see valkey.io for details. + * @param key The key of the set. + * @param cursor The cursor that points to the next iteration of results. + * @return Command Response - An Array of Objects. The first element is + * always the cursor for the next iteration of results. 0 will be + * the cursor returned on the last iteration of the set. The second element is + * always an Array of the subset of the set held in key. + */ + public T sscan(@NonNull String key, @NonNull String cursor) { + protobufTransaction.addCommands(buildCommand(SScan, buildArgs(key, cursor))); + return getThis(); + } + + /** + * Iterates incrementally over a set. + * + * @see valkey.io for details. + * @param key The key of the set. + * @param cursor The cursor that points to the next iteration of results. + * @param sScanOptions The {@link SScanOptions}. + * @return Command Response - An Array of Objects. The first element is + * always the cursor for the next iteration of results. 0 will be + * the cursor returned on the last iteration of the set. The second element is + * always an Array of the subset of the set held in key. + */ + public T sscan(@NonNull String key, @NonNull String cursor, @NonNull SScanOptions sScanOptions) { + ArgsArray commandArgs = + buildArgs(concatenateArrays(new String[] {key, cursor}, sScanOptions.toArgs())); + protobufTransaction.addCommands(buildCommand(SScan, commandArgs)); + return getThis(); + } + /** Build protobuf {@link Command} object for given command and arguments. */ protected Command buildCommand(RequestType requestType) { return buildCommand(requestType, buildArgs()); diff --git a/java/client/src/main/java/glide/api/models/commands/scan/SScanOptions.java b/java/client/src/main/java/glide/api/models/commands/scan/SScanOptions.java new file mode 100644 index 0000000000..ffd7fefc2e --- /dev/null +++ b/java/client/src/main/java/glide/api/models/commands/scan/SScanOptions.java @@ -0,0 +1,13 @@ +/** Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models.commands.scan; + +import glide.api.commands.SetBaseCommands; +import lombok.experimental.SuperBuilder; + +/** + * Optional arguments for {@link SetBaseCommands#sscan(String, String, SScanOptions)}. + * + * @see valkey.io + */ +@SuperBuilder +public class SScanOptions extends ScanOptions {} diff --git a/java/client/src/main/java/glide/api/models/commands/scan/ScanOptions.java b/java/client/src/main/java/glide/api/models/commands/scan/ScanOptions.java new file mode 100644 index 0000000000..f02345f5e3 --- /dev/null +++ b/java/client/src/main/java/glide/api/models/commands/scan/ScanOptions.java @@ -0,0 +1,59 @@ +/** Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models.commands.scan; + +import java.util.ArrayList; +import java.util.List; +import lombok.experimental.SuperBuilder; + +/** + * This base class represents the common set of optional arguments for the SCAN family of commands. + * Concrete implementations of this class are tied to specific SCAN commands (SCAN, HSCAN, SSCAN, + * and ZSCAN). + */ +@SuperBuilder +public abstract class ScanOptions { + /** MATCH option string to include in the SCAN commands. */ + public static final String MATCH_OPTION_STRING = "MATCH"; + + /** COUNT option string to include in the SCAN commands. */ + public static final String COUNT_OPTION_STRING = "COUNT"; + + /** + * The match filter is applied to the result of the command and will only include strings that + * match the pattern specified. If the set, hash, or list is large enough for scan commands to + * return only a subset of the set, hash, or list, then there could be a case where the result is + * empty although there are items that match the pattern specified. This is due to the default + * COUNT being 10 which indicates that it will only fetch and match + * 10 items from the list. + */ + private final String matchPattern; + + /** + * COUNT is a just a hint for the command for how many elements to fetch from the + * set, hash, or list. COUNT could be ignored until the set, hash, or list is large + * enough for the SCAN commands to represent the results as compact single-allocation + * packed encoding. + */ + private final Long count; + + /** + * Creates the arguments to be used in SCAN commands. + * + * @return a String array that holds the options and their arguments. + */ + public String[] toArgs() { + List optionArgs = new ArrayList<>(); + + if (matchPattern != null) { + optionArgs.add(MATCH_OPTION_STRING); + optionArgs.add(matchPattern); + } + + if (count != null) { + optionArgs.add(COUNT_OPTION_STRING); + optionArgs.add(count.toString()); + } + + return optionArgs.toArray(new String[0]); + } +} diff --git a/java/client/src/main/java/module-info.java b/java/client/src/main/java/module-info.java index 95742335da..1cff595006 100644 --- a/java/client/src/main/java/module-info.java +++ b/java/client/src/main/java/module-info.java @@ -9,6 +9,7 @@ exports glide.api.models.commands.stream; exports glide.api.models.configuration; exports glide.api.models.exceptions; + exports glide.api.models.commands.scan; requires com.google.protobuf; requires io.netty.codec; diff --git a/java/client/src/test/java/glide/api/RedisClientTest.java b/java/client/src/test/java/glide/api/RedisClientTest.java index e254d364ba..8a0c18917b 100644 --- a/java/client/src/test/java/glide/api/RedisClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClientTest.java @@ -37,6 +37,8 @@ import static glide.api.models.commands.function.FunctionListOptions.LIBRARY_NAME_REDIS_API; import static glide.api.models.commands.function.FunctionListOptions.WITH_CODE_REDIS_API; import static glide.api.models.commands.geospatial.GeoAddOptions.CHANGED_REDIS_API; +import static glide.api.models.commands.scan.ScanOptions.COUNT_OPTION_STRING; +import static glide.api.models.commands.scan.ScanOptions.MATCH_OPTION_STRING; import static glide.api.models.commands.stream.StreamAddOptions.NO_MAKE_STREAM_REDIS_API; import static glide.api.models.commands.stream.StreamGroupOptions.ENTRIES_READ_REDIS_API; import static glide.api.models.commands.stream.StreamGroupOptions.MAKE_STREAM_REDIS_API; @@ -193,6 +195,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.SPop; import static redis_request.RedisRequestOuterClass.RequestType.SRandMember; import static redis_request.RedisRequestOuterClass.RequestType.SRem; +import static redis_request.RedisRequestOuterClass.RequestType.SScan; import static redis_request.RedisRequestOuterClass.RequestType.SUnion; import static redis_request.RedisRequestOuterClass.RequestType.SUnionStore; import static redis_request.RedisRequestOuterClass.RequestType.Select; @@ -294,6 +297,7 @@ import glide.api.models.commands.geospatial.GeoAddOptions; import glide.api.models.commands.geospatial.GeoUnit; import glide.api.models.commands.geospatial.GeospatialData; +import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamPendingOptions; @@ -8879,6 +8883,31 @@ public void sort_with_options_returns_success() { assertEquals(result, payload); } + @SneakyThrows + @Test + public void sscan_returns_success() { + // setup + String key = "testKey"; + String cursor = "0"; + String[] arguments = new String[] {key, cursor}; + Object[] value = new Object[] {0L, new String[] {"hello", "world"}}; + + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(SScan), eq(arguments), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = service.sscan(key, cursor); + Object[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + @SneakyThrows @Test public void sortReadOnly_with_options_returns_success() { @@ -8957,4 +8986,31 @@ public void sortStore_with_options_returns_success() { assertEquals(testResponse, response); assertEquals(result, payload); } + + @SneakyThrows + @Test + public void sscan_with_options_returns_success() { + // setup + String key = "testKey"; + String cursor = "0"; + String[] arguments = + new String[] {key, cursor, MATCH_OPTION_STRING, "*", COUNT_OPTION_STRING, "1"}; + Object[] value = new Object[] {0L, new String[] {"hello", "world"}}; + + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(SScan), eq(arguments), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = + service.sscan(key, cursor, SScanOptions.builder().matchPattern("*").count(1L).build()); + Object[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } } diff --git a/java/client/src/test/java/glide/api/models/TransactionTests.java b/java/client/src/test/java/glide/api/models/TransactionTests.java index a0512764ec..62e17215bf 100644 --- a/java/client/src/test/java/glide/api/models/TransactionTests.java +++ b/java/client/src/test/java/glide/api/models/TransactionTests.java @@ -160,6 +160,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.SPop; import static redis_request.RedisRequestOuterClass.RequestType.SRandMember; import static redis_request.RedisRequestOuterClass.RequestType.SRem; +import static redis_request.RedisRequestOuterClass.RequestType.SScan; import static redis_request.RedisRequestOuterClass.RequestType.SUnion; import static redis_request.RedisRequestOuterClass.RequestType.SUnionStore; import static redis_request.RedisRequestOuterClass.RequestType.Set; @@ -247,6 +248,7 @@ import glide.api.models.commands.geospatial.GeoAddOptions; import glide.api.models.commands.geospatial.GeoUnit; import glide.api.models.commands.geospatial.GeospatialData; +import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamPendingOptions; @@ -1168,6 +1170,12 @@ InfScoreBound.NEGATIVE_INFINITY, new ScoreBoundary(3, false), new Limit(1, 2)), transaction.sortStore("key1", "key2"); results.add(Pair.of(Sort, buildArgs("key1", STORE_COMMAND_STRING, "key2"))); + transaction.sscan("key1", "0"); + results.add(Pair.of(SScan, buildArgs("key1", "0"))); + + transaction.sscan("key1", "0", SScanOptions.builder().matchPattern("*").count(10L).build()); + results.add(Pair.of(SScan, buildArgs("key1", "0", "MATCH", "*", "COUNT", "10"))); + var protobufTransaction = transaction.getProtobufTransaction().build(); for (int idx = 0; idx < protobufTransaction.getCommandsCount(); idx++) { diff --git a/java/integTest/src/test/java/glide/SharedCommandTests.java b/java/integTest/src/test/java/glide/SharedCommandTests.java index 3ae422c9e0..6f7c738833 100644 --- a/java/integTest/src/test/java/glide/SharedCommandTests.java +++ b/java/integTest/src/test/java/glide/SharedCommandTests.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -69,6 +70,7 @@ import glide.api.models.commands.geospatial.GeoAddOptions; import glide.api.models.commands.geospatial.GeoUnit; import glide.api.models.commands.geospatial.GeospatialData; +import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamPendingOptions; @@ -94,6 +96,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; import lombok.Getter; import lombok.SneakyThrows; import org.apache.commons.lang3.ArrayUtils; @@ -7009,4 +7012,129 @@ public void lcsIdx(BaseClient client) { () -> client.lcsIdxWithMatchLen(nonStringKey, key1, 10L).get()); assertInstanceOf(RequestException.class, executionException.getCause()); } + + @SneakyThrows + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("getClients") + public void sscan(BaseClient client) { + String key1 = "{key}-1" + UUID.randomUUID(); + String key2 = "{key}-2" + UUID.randomUUID(); + String initialCursor = "0"; + long defaultCount = 10; + String[] numberMembers = new String[125]; + for (int i = 0; i < numberMembers.length; i++) { + numberMembers[i] = String.valueOf(i); + } + Set numberMembersSet = Set.of(numberMembers); + String[] charMembers = new String[] {"a", "b", "c", "d", "e"}; + Set charMemberSet = Set.of(charMembers); + int resultCursorIndex = 0; + int resultCollectionIndex = 1; + + // Empty set + Object[] result = client.sscan(key1, initialCursor).get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertDeepEquals(new String[] {}, result[resultCollectionIndex]); + + // Negative cursor + result = client.sscan(key1, "-1").get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertDeepEquals(new String[] {}, result[resultCollectionIndex]); + + // Result contains the whole set + assertEquals(charMembers.length, client.sadd(key1, charMembers).get()); + result = client.sscan(key1, initialCursor).get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertEquals(charMembers.length, ((Object[]) result[resultCollectionIndex]).length); + final Set resultMembers = + Arrays.stream((Object[]) result[resultCollectionIndex]).collect(Collectors.toSet()); + assertTrue( + resultMembers.containsAll(charMemberSet), + String.format("resultMembers: {%s}, charMemberSet: {%s}", resultMembers, charMemberSet)); + + result = + client.sscan(key1, initialCursor, SScanOptions.builder().matchPattern("a").build()).get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertDeepEquals(new String[] {"a"}, result[resultCollectionIndex]); + + // Result contains a subset of the key + assertEquals(numberMembers.length, client.sadd(key1, numberMembers).get()); + String resultCursor = "0"; + final Set secondResultValues = new HashSet<>(); + do { + result = client.sscan(key1, resultCursor).get(); + resultCursor = result[resultCursorIndex].toString(); + secondResultValues.addAll( + Arrays.stream((Object[]) result[resultCollectionIndex]).collect(Collectors.toSet())); + + if (resultCursor.equals("0")) { + break; + } + + // Scan with result cursor has a different set + Object[] secondResult = client.sscan(key1, resultCursor).get(); + String newResultCursor = secondResult[resultCursorIndex].toString(); + assertNotEquals(resultCursor, newResultCursor); + resultCursor = newResultCursor; + assertFalse( + Arrays.deepEquals( + ArrayUtils.toArray(result[resultCollectionIndex]), + ArrayUtils.toArray(secondResult[resultCollectionIndex]))); + secondResultValues.addAll( + Arrays.stream((Object[]) secondResult[resultCollectionIndex]) + .collect(Collectors.toSet())); + } while (!resultCursor.equals("0")); // 0 is returned for the cursor of the last iteration. + + assertTrue( + secondResultValues.containsAll(numberMembersSet), + String.format( + "secondResultValues: {%s}, numberMembersSet: {%s}", + secondResultValues, numberMembersSet)); + + // Test match pattern + result = + client.sscan(key1, initialCursor, SScanOptions.builder().matchPattern("*").build()).get(); + assertTrue(Long.parseLong(result[resultCursorIndex].toString()) > 0); + assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) >= defaultCount); + + // Test count + result = client.sscan(key1, initialCursor, SScanOptions.builder().count(20L).build()).get(); + assertTrue(Long.parseLong(result[resultCursorIndex].toString()) > 0); + assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) >= 20); + + // Test count with match returns a non-empty list + result = + client + .sscan( + key1, initialCursor, SScanOptions.builder().matchPattern("1*").count(20L).build()) + .get(); + assertTrue(Long.parseLong(result[resultCursorIndex].toString()) > 0); + assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) > 0); + + // Exceptions + // Non-set key + assertEquals(OK, client.set(key2, "test").get()); + ExecutionException executionException = + assertThrows(ExecutionException.class, () -> client.sscan(key2, initialCursor).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + executionException = + assertThrows( + ExecutionException.class, + () -> + client + .sscan( + key2, + initialCursor, + SScanOptions.builder().matchPattern("test").count(1L).build()) + .get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + // Negative count + executionException = + assertThrows( + ExecutionException.class, + () -> client.sscan(key1, "-1", SScanOptions.builder().count(-1L).build()).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + } } diff --git a/java/integTest/src/test/java/glide/TransactionTestUtilities.java b/java/integTest/src/test/java/glide/TransactionTestUtilities.java index 7d030bd283..8e4813562b 100644 --- a/java/integTest/src/test/java/glide/TransactionTestUtilities.java +++ b/java/integTest/src/test/java/glide/TransactionTestUtilities.java @@ -36,6 +36,7 @@ import glide.api.models.commands.bitmap.BitwiseOperation; import glide.api.models.commands.geospatial.GeoUnit; import glide.api.models.commands.geospatial.GeospatialData; +import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamRange; @@ -520,6 +521,8 @@ private static Object[] setCommands(BaseTransaction transaction) { transaction .sadd(setKey1, new String[] {"baz", "foo"}) .srem(setKey1, new String[] {"foo"}) + .sscan(setKey1, "0") + .sscan(setKey1, "0", SScanOptions.builder().matchPattern("*").count(10L).build()) .scard(setKey1) .sismember(setKey1, "baz") .smembers(setKey1) @@ -551,6 +554,8 @@ private static Object[] setCommands(BaseTransaction transaction) { new Object[] { 2L, // sadd(setKey1, new String[] {"baz", "foo"}); 1L, // srem(setKey1, new String[] {"foo"}); + new Object[] {"0", new String[] {"baz"}}, // sscan(setKey1, "0") + new Object[] {"0", new String[] {"baz"}}, // sscan(key1, "0", match "*", count(10L)) 1L, // scard(setKey1); true, // sismember(setKey1, "baz") Set.of("baz"), // smembers(setKey1);