From 0744a308779b5d41101d58e5129b56364d7e66de Mon Sep 17 00:00:00 2001 From: James Duong Date: Sat, 29 Jun 2024 11:47:56 -0700 Subject: [PATCH] Java: Add SSCAN and ZSCAN commands (#1705) * Java: Add `SSCAN` command (#394) * Add ScanOptions base class for scan-family options. * Expose the cursor as a String to support unsigned 64-bit cursor values. Co-authored-by: James Duong * Java: Add `ZSCAN` command (#397) --------- Co-authored-by: James Duong * WIP TODO: support transactions, docs, and more IT * Added more tests * Added tests and javadocs * Improved examples and tests * Correct use of SScanOptions instead of ScanOptions for SScan * Remove plumbing for SCAN command * Sleep after sadd() calls before sscan() calls Due to eventual consistency * Change sscan cursor to be a String Also fix bug in SharedCommandTests * WIP with todos # Conflicts: # glide-core/src/protobuf/redis_request.proto # glide-core/src/request_type.rs # java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java * Add ZScan to TransactionTestUtilities * Spotless cleanup * Test fixes * Cleanup test code * Apply IntelliJ suggestions * Use String.valueOf() instead of concatenating empty string * Added better error info for set comparison failures * More logging for test failures * Add sleeps after zadd() calls To help make sure data is consistent without WAIT * Longer sleeps * Reduce wait time * Experiment with unsigned 64-bit cursors * Fix rebase error * WIP TODO: support transactions, docs, and more IT * Added more tests * Added tests and javadocs * Improved examples and tests * Apply PR comments * Fix method ordering in BaseTransaction * Fix broken line breaks within code tags in ScanOptions * More thoroughly test results in SharedCommandTests * Add better logging for set comparisons * Spotless * Sleep after sadd() calls before sscan() calls Due to eventual consistency * Change sscan cursor to be a String Also fix bug in SharedCommandTests * Update java/integTest/src/test/java/glide/SharedCommandTests.java Co-authored-by: Guian Gumpac * Update java/integTest/src/test/java/glide/SharedCommandTests.java Co-authored-by: Guian Gumpac * Fix rebase conflicts * Fix another rebase conflict * Spotless * Update java/client/src/main/java/glide/api/models/BaseTransaction.java Co-authored-by: Andrew Carbonetto * Update java/client/src/main/java/glide/api/models/BaseTransaction.java Co-authored-by: Andrew Carbonetto * Update java/client/src/main/java/glide/api/models/BaseTransaction.java Co-authored-by: Andrew Carbonetto * Update java/client/src/main/java/glide/api/models/BaseTransaction.java Co-authored-by: Andrew Carbonetto * Correctly use constants in TransactionTests * Rename ScanOptions to BaseScanOptions * Doc PR fixes * Treat end of cursor as failure * Spotless * Fixes * Update java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java Co-authored-by: Andrew Carbonetto * Update java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java Co-authored-by: Andrew Carbonetto * Update java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java Co-authored-by: Andrew Carbonetto * Update java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java Co-authored-by: Andrew Carbonetto * Minor doc changes --------- Co-authored-by: Guian Gumpac Co-authored-by: Andrew Carbonetto --- glide-core/src/protobuf/redis_request.proto | 2 + glide-core/src/request_type.rs | 6 + .../src/main/java/glide/api/BaseClient.java | 30 ++ .../glide/api/commands/SetBaseCommands.java | 58 ++++ .../api/commands/SortedSetBaseCommands.java | 74 ++++ .../glide/api/models/BaseTransaction.java | 184 +++++++--- .../models/commands/scan/BaseScanOptions.java | 59 ++++ .../models/commands/scan/SScanOptions.java | 13 + .../models/commands/scan/ZScanOptions.java | 13 + java/client/src/main/java/module-info.java | 1 + .../test/java/glide/api/RedisClientTest.java | 110 ++++++ .../glide/api/models/TransactionTests.java | 34 ++ .../test/java/glide/SharedCommandTests.java | 327 ++++++++++++++++++ .../java/glide/TransactionTestUtilities.java | 12 + 14 files changed, 872 insertions(+), 51 deletions(-) create mode 100644 java/client/src/main/java/glide/api/models/commands/scan/BaseScanOptions.java create mode 100644 java/client/src/main/java/glide/api/models/commands/scan/SScanOptions.java create mode 100644 java/client/src/main/java/glide/api/models/commands/scan/ZScanOptions.java diff --git a/glide-core/src/protobuf/redis_request.proto b/glide-core/src/protobuf/redis_request.proto index b3330959ec..1394e95721 100644 --- a/glide-core/src/protobuf/redis_request.proto +++ b/glide-core/src/protobuf/redis_request.proto @@ -238,6 +238,8 @@ enum RequestType { FunctionRestore = 197; XPending = 198; XGroupSetId = 199; + SScan = 200; + ZScan = 201; } message Command { diff --git a/glide-core/src/request_type.rs b/glide-core/src/request_type.rs index 943721bfd3..d73c6576f8 100644 --- a/glide-core/src/request_type.rs +++ b/glide-core/src/request_type.rs @@ -208,6 +208,8 @@ pub enum RequestType { FunctionRestore = 197, XPending = 198, XGroupSetId = 199, + SScan = 200, + ZScan = 201, } fn get_two_word_command(first: &str, second: &str) -> Cmd { @@ -419,6 +421,8 @@ impl From<::protobuf::EnumOrUnknown> for RequestType { ProtobufRequestType::FunctionRestore => RequestType::FunctionRestore, ProtobufRequestType::XPending => RequestType::XPending, ProtobufRequestType::XGroupSetId => RequestType::XGroupSetId, + ProtobufRequestType::SScan => RequestType::SScan, + ProtobufRequestType::ZScan => RequestType::ZScan, } } } @@ -628,6 +632,8 @@ 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")), + RequestType::ZScan => Some(cmd("ZSCAN")), } } } diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index 2252a9261d..463b0564ae 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -117,6 +117,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; @@ -167,6 +168,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByRank; import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByScore; import static redis_request.RedisRequestOuterClass.RequestType.ZRevRank; +import static redis_request.RedisRequestOuterClass.RequestType.ZScan; import static redis_request.RedisRequestOuterClass.RequestType.ZScore; import static redis_request.RedisRequestOuterClass.RequestType.ZUnion; import static redis_request.RedisRequestOuterClass.RequestType.ZUnionStore; @@ -213,6 +215,8 @@ import glide.api.models.commands.geospatial.GeoSearchStoreOptions; 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.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamPendingOptions; @@ -2905,4 +2909,30 @@ public CompletableFuture geosearchstore( resultOptions.toArgs()); return commandManager.submitNewCommand(GeoSearchStore, arguments, 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); + } + + @Override + public CompletableFuture zscan(@NonNull String key, @NonNull String cursor) { + String[] arguments = new String[] {key, cursor}; + return commandManager.submitNewCommand(ZScan, arguments, this::handleArrayResponse); + } + + @Override + public CompletableFuture zscan( + @NonNull String key, @NonNull String cursor, @NonNull ZScanOptions zScanOptions) { + String[] arguments = concatenateArrays(new String[] {key, cursor}, zScanOptions.toArgs()); + return commandManager.submitNewCommand(ZScan, 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..bb9ec55f79 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,61 @@ 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. A value of 0 indicates + * the start of the search. + * @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. A value of 0 indicates + * the start of the search. + * @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/commands/SortedSetBaseCommands.java b/java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java index 0da26317d4..7a35c84250 100644 --- a/java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java @@ -19,6 +19,7 @@ import glide.api.models.commands.WeightAggregateOptions.KeysOrWeightedKeys; import glide.api.models.commands.WeightAggregateOptions.WeightedKeys; import glide.api.models.commands.ZAddOptions; +import glide.api.models.commands.scan.ZScanOptions; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -1577,4 +1578,77 @@ CompletableFuture> zinterWithScores( * } */ CompletableFuture zintercard(GlideString[] keys, long limit); + + /** + * Iterates incrementally over a sorted set. + * + * @see valkey.io for details. + * @param key The key of the sorted set. + * @param cursor The cursor that points to the next iteration of results. A value of 0 indicates + * the start of the search. + * @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 sorted set. The second element is always an + * + * Array of the subset of the sorted set held in key. The array in the + * second element is always a flattened series of String pairs, where the value + * is at even indices and the score is at odd indices. + * @example + *
{@code
+     * // Assume key contains a set with 200 member-score pairs
+     * String cursor = "0";
+     * Object[] result;
+     * do {
+     *   result = client.zscan(key1, cursor).get();
+     *   cursor = result[0].toString();
+     *   Object[] stringResults = (Object[]) result[1];
+     *
+     *   System.out.println("\nZSCAN iteration:");
+     *   for (int i = 0; i < stringResults.length; i += 2) {
+     *     System.out.printf("{%s=%s}", stringResults[i], stringResults[i + 1]);
+     *     if (i + 2 < stringResults.length) {
+     *       System.out.print(", ");
+     *     }
+     *   }
+     * } while (!cursor.equals("0"));
+     * }
+ */ + CompletableFuture zscan(String key, String cursor); + + /** + * Iterates incrementally over a sorted set. + * + * @see valkey.io for details. + * @param key The key of the sorted set. + * @param cursor The cursor that points to the next iteration of results. A value of 0 indicates + * the start of the search. + * @param zScanOptions The {@link ZScanOptions}. + * @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 sorted set. The second element is always an + * + * Array of the subset of the sorted set held in key. The array in the + * second element is always a flattened series of String pairs, where the value + * is at even indices and the score is at odd indices. + * @example + *
{@code
+     * // Assume key contains a set with 200 member-score pairs
+     * String cursor = "0";
+     * Object[] result;
+     * do {
+     *   result = client.zscan(key1, cursor, ZScanOptions.builder().matchPattern("*").count(20L).build()).get();
+     *   cursor = result[0].toString();
+     *   Object[] stringResults = (Object[]) result[1];
+     *
+     *   System.out.println("\nZSCAN iteration:");
+     *   for (int i = 0; i < stringResults.length; i += 2) {
+     *     System.out.printf("{%s=%s}", stringResults[i], stringResults[i + 1]);
+     *     if (i + 2 < stringResults.length) {
+     *       System.out.print(", ");
+     *     }
+     *   }
+     * } while (!cursor.equals("0"));
+     * }
+ */ + CompletableFuture zscan(String key, String cursor, ZScanOptions zScanOptions); } 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 d3901f1d26..6dacc98bc4 100644 --- a/java/client/src/main/java/glide/api/models/BaseTransaction.java +++ b/java/client/src/main/java/glide/api/models/BaseTransaction.java @@ -144,6 +144,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; @@ -194,6 +195,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByRank; import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByScore; import static redis_request.RedisRequestOuterClass.RequestType.ZRevRank; +import static redis_request.RedisRequestOuterClass.RequestType.ZScan; import static redis_request.RedisRequestOuterClass.RequestType.ZScore; import static redis_request.RedisRequestOuterClass.RequestType.ZUnion; import static redis_request.RedisRequestOuterClass.RequestType.ZUnionStore; @@ -247,6 +249,8 @@ import glide.api.models.commands.geospatial.GeoSearchStoreOptions; 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.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamAddOptions.StreamAddOptionsBuilder; import glide.api.models.commands.stream.StreamGroupOptions; @@ -4883,57 +4887,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. @@ -5133,6 +5086,57 @@ 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(); + } + /** * Returns the members of a sorted set populated with geospatial information using {@link * #geoadd(String, Map)}, which are within the borders of the area specified by a given shape. @@ -5500,6 +5504,84 @@ public T geosearchstore( 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. A value of 0 indicates + * the start of the search. + * @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. A value of 0 indicates + * the start of the search. + * @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(); + } + + /** + * Iterates incrementally over a sorted set. + * + * @see valkey.io for details. + * @param key The key of the sorted set. + * @param cursor The cursor that points to the next iteration of results. A value of 0 indicates + * the start of the search. + * @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 sorted set. The second + * element is always an Array of the subset of the sorted set held in key + * . The array in the second element is always a flattened series of String + * pairs, where the value is at even indices and the score is at odd indices. + */ + public T zscan(@NonNull String key, @NonNull String cursor) { + protobufTransaction.addCommands(buildCommand(ZScan, buildArgs(key, cursor))); + return getThis(); + } + + /** + * Iterates incrementally over a sorted set. + * + * @see valkey.io for details. + * @param key The key of the sorted set. + * @param cursor The cursor that points to the next iteration of results. A value of 0 indicates + * the start of the search. + * @param zScanOptions The {@link ZScanOptions}. + * @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 sorted set. The second + * element is always an Array of the subset of the sorted set held in key + * . The array in the second element is always a flattened series of String + * pairs, where the value is at even indices and the score is at odd indices. + */ + public T zscan(@NonNull String key, @NonNull String cursor, @NonNull ZScanOptions zScanOptions) { + ArgsArray commandArgs = + buildArgs(concatenateArrays(new String[] {key, cursor}, zScanOptions.toArgs())); + protobufTransaction.addCommands(buildCommand(ZScan, 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/BaseScanOptions.java b/java/client/src/main/java/glide/api/models/commands/scan/BaseScanOptions.java new file mode 100644 index 0000000000..e97560fd3d --- /dev/null +++ b/java/client/src/main/java/glide/api/models/commands/scan/BaseScanOptions.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 BaseScanOptions { + /** 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/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..ecea070450 --- /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 BaseScanOptions {} diff --git a/java/client/src/main/java/glide/api/models/commands/scan/ZScanOptions.java b/java/client/src/main/java/glide/api/models/commands/scan/ZScanOptions.java new file mode 100644 index 0000000000..dbda89106b --- /dev/null +++ b/java/client/src/main/java/glide/api/models/commands/scan/ZScanOptions.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.SortedSetBaseCommands; +import lombok.experimental.SuperBuilder; + +/** + * Optional arguments for {@link SortedSetBaseCommands#zscan(String, String, ZScanOptions)}. + * + * @see valkey.io + */ +@SuperBuilder +public class ZScanOptions extends BaseScanOptions {} 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 71dd044661..1eabe0776f 100644 --- a/java/client/src/test/java/glide/api/RedisClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClientTest.java @@ -40,6 +40,8 @@ import static glide.api.models.commands.geospatial.GeoAddOptions.CHANGED_REDIS_API; import static glide.api.models.commands.geospatial.GeoSearchOrigin.FROMLONLAT_VALKEY_API; import static glide.api.models.commands.geospatial.GeoSearchOrigin.FROMMEMBER_VALKEY_API; +import static glide.api.models.commands.scan.BaseScanOptions.COUNT_OPTION_STRING; +import static glide.api.models.commands.scan.BaseScanOptions.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; @@ -198,6 +200,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; @@ -250,6 +253,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByRank; import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByScore; import static redis_request.RedisRequestOuterClass.RequestType.ZRevRank; +import static redis_request.RedisRequestOuterClass.RequestType.ZScan; import static redis_request.RedisRequestOuterClass.RequestType.ZScore; import static redis_request.RedisRequestOuterClass.RequestType.ZUnion; import static redis_request.RedisRequestOuterClass.RequestType.ZUnionStore; @@ -305,6 +309,8 @@ import glide.api.models.commands.geospatial.GeoSearchStoreOptions; 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.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamPendingOptions; @@ -8890,6 +8896,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() { @@ -8969,6 +9000,85 @@ public void sortStore_with_options_returns_success() { 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); + } + + @SneakyThrows + @Test + public void zscan_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(ZScan), eq(arguments), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = service.zscan(key, cursor); + Object[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void zscan_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(ZScan), eq(arguments), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = + service.zscan(key, cursor, ZScanOptions.builder().matchPattern("*").count(1L).build()); + Object[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + private static List getGeoSearchArguments() { return List.of( Arguments.of( 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 f32beb17ef..d139b50905 100644 --- a/java/client/src/test/java/glide/api/models/TransactionTests.java +++ b/java/client/src/test/java/glide/api/models/TransactionTests.java @@ -165,6 +165,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; @@ -215,6 +216,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByRank; import static redis_request.RedisRequestOuterClass.RequestType.ZRemRangeByScore; import static redis_request.RedisRequestOuterClass.RequestType.ZRevRank; +import static redis_request.RedisRequestOuterClass.RequestType.ZScan; import static redis_request.RedisRequestOuterClass.RequestType.ZScore; import static redis_request.RedisRequestOuterClass.RequestType.ZUnion; import static redis_request.RedisRequestOuterClass.RequestType.ZUnionStore; @@ -258,6 +260,8 @@ import glide.api.models.commands.geospatial.GeoSearchStoreOptions; 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.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamPendingOptions; @@ -1336,6 +1340,36 @@ InfScoreBound.NEGATIVE_INFINITY, new ScoreBoundary(3, false), new Limit(1, 2)), "ANY", "ASC"))); + 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", + SScanOptions.MATCH_OPTION_STRING, + "*", + SScanOptions.COUNT_OPTION_STRING, + "10"))); + + transaction.zscan("key1", "0"); + results.add(Pair.of(ZScan, buildArgs("key1", "0"))); + + transaction.zscan("key1", "0", ZScanOptions.builder().matchPattern("*").count(10L).build()); + results.add( + Pair.of( + ZScan, + buildArgs( + "key1", + "0", + ZScanOptions.MATCH_OPTION_STRING, + "*", + ZScanOptions.COUNT_OPTION_STRING, + "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 fbbe76c5d8..74dbac710b 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; @@ -77,6 +78,8 @@ import glide.api.models.commands.geospatial.GeoSearchStoreOptions; 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.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamPendingOptions; @@ -102,6 +105,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; @@ -7576,4 +7580,327 @@ public void geosearchstore(BaseClient client) { .get()); assertInstanceOf(RequestException.class, requestException2.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[50000]; // Use large dataset to force an iterative cursor. + 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]); + + // Negative cursor + result = client.sscan(key1, "-1").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<>(); + boolean isFirstLoop = true; + do { + result = client.sscan(key1, resultCursor).get(); + resultCursor = result[resultCursorIndex].toString(); + secondResultValues.addAll( + Arrays.stream((Object[]) result[resultCollectionIndex]).collect(Collectors.toSet())); + + if (isFirstLoop) { + assertNotEquals("0", resultCursor); + isFirstLoop = false; + } else 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)); + + assertTrue( + secondResultValues.containsAll(numberMembersSet), + String.format( + "secondResultValues: {%s}, numberMembersSet: {%s}", + secondResultValues, numberMembersSet)); + + 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()); + } + + @SneakyThrows + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("getClients") + public void zscan(BaseClient client) { + String key1 = "{key}-1" + UUID.randomUUID(); + String key2 = "{key}-2" + UUID.randomUUID(); + String initialCursor = "0"; + long defaultCount = 20; + int resultCursorIndex = 0; + int resultCollectionIndex = 1; + + // Setup test data - use a large number of entries to force an iterative cursor. + Map numberMap = new HashMap<>(); + for (Double i = 0.0; i < 50000; i++) { + numberMap.put(String.valueOf(i), i); + } + String[] charMembers = new String[] {"a", "b", "c", "d", "e"}; + Map charMap = new HashMap<>(); + for (double i = 0.0; i < 5; i++) { + charMap.put(charMembers[(int) i], i); + } + + // Empty set + Object[] result = client.zscan(key1, initialCursor).get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertDeepEquals(new String[] {}, result[resultCollectionIndex]); + + // Negative cursor + result = client.zscan(key1, "-1").get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertDeepEquals(new String[] {}, result[resultCollectionIndex]); + + // Result contains the whole set + assertEquals(charMembers.length, client.zadd(key1, charMap).get()); + result = client.zscan(key1, initialCursor).get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertEquals( + charMap.size() * 2, + ((Object[]) result[resultCollectionIndex]) + .length); // Length includes the score which is twice the map size + final Object[] resultArray = (Object[]) result[resultCollectionIndex]; + + final Set resultKeys = new HashSet<>(); + final Set resultValues = new HashSet<>(); + for (int i = 0; i < resultArray.length; i += 2) { + resultKeys.add(resultArray[i]); + resultValues.add(resultArray[i + 1]); + } + assertTrue( + resultKeys.containsAll(charMap.keySet()), + String.format("resultKeys: {%s} charMap.keySet(): {%s}", resultKeys, charMap.keySet())); + + // The score comes back as an integer converted to a String when the fraction is zero. + final Set expectedScoresAsStrings = + charMap.values().stream() + .map(v -> String.valueOf(v.intValue())) + .collect(Collectors.toSet()); + + assertTrue( + resultValues.containsAll(expectedScoresAsStrings), + String.format( + "resultValues: {%s} expectedScoresAsStrings: {%s}", + resultValues, expectedScoresAsStrings)); + + result = + client.zscan(key1, initialCursor, ZScanOptions.builder().matchPattern("a").build()).get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertDeepEquals(new String[] {"a", "0"}, result[resultCollectionIndex]); + + // Result contains a subset of the key + assertEquals(numberMap.size(), client.zadd(key1, numberMap).get()); + String resultCursor = "0"; + final Set secondResultAllKeys = new HashSet<>(); + final Set secondResultAllValues = new HashSet<>(); + boolean isFirstLoop = true; + do { + result = client.zscan(key1, resultCursor).get(); + resultCursor = result[resultCursorIndex].toString(); + Object[] resultEntry = (Object[]) result[resultCollectionIndex]; + for (int i = 0; i < resultEntry.length; i += 2) { + secondResultAllKeys.add(resultEntry[i]); + secondResultAllValues.add(resultEntry[i + 1]); + } + + if (isFirstLoop) { + assertNotEquals("0", resultCursor); + isFirstLoop = false; + } else if (resultCursor.equals("0")) { + break; + } + + // Scan with result cursor has a different set + Object[] secondResult = client.zscan(key1, resultCursor).get(); + String newResultCursor = secondResult[resultCursorIndex].toString(); + assertNotEquals(resultCursor, newResultCursor); + resultCursor = newResultCursor; + Object[] secondResultEntry = (Object[]) secondResult[resultCollectionIndex]; + assertFalse( + Arrays.deepEquals( + ArrayUtils.toArray(result[resultCollectionIndex]), + ArrayUtils.toArray(secondResult[resultCollectionIndex]))); + + for (int i = 0; i < secondResultEntry.length; i += 2) { + secondResultAllKeys.add(secondResultEntry[i]); + secondResultAllValues.add(secondResultEntry[i + 1]); + } + } while (!resultCursor.equals("0")); // 0 is returned for the cursor of the last iteration. + + assertTrue( + secondResultAllKeys.containsAll(numberMap.keySet()), + String.format( + "secondResultAllKeys: {%s} numberMap.keySet: {%s}", + secondResultAllKeys, numberMap.keySet())); + + final Set numberMapValuesAsStrings = + numberMap.values().stream() + .map(d -> String.valueOf(d.intValue())) + .collect(Collectors.toSet()); + + assertTrue( + secondResultAllValues.containsAll(numberMapValuesAsStrings), + String.format( + "secondResultAllValues: {%s} numberMapValuesAsStrings: {%s}", + secondResultAllValues, numberMapValuesAsStrings)); + + // Test match pattern + result = + client.zscan(key1, initialCursor, ZScanOptions.builder().matchPattern("*").build()).get(); + assertTrue(Long.parseLong(result[resultCursorIndex].toString()) >= 0); + assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) >= defaultCount); + + // Test count + result = client.zscan(key1, initialCursor, ZScanOptions.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 + .zscan( + key1, initialCursor, ZScanOptions.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.zscan(key2, initialCursor).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + executionException = + assertThrows( + ExecutionException.class, + () -> + client + .zscan( + key2, + initialCursor, + ZScanOptions.builder().matchPattern("test").count(1L).build()) + .get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + // Negative count + executionException = + assertThrows( + ExecutionException.class, + () -> client.zscan(key1, "-1", ZScanOptions.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 ac1c5a795a..f0797b2db8 100644 --- a/java/integTest/src/test/java/glide/TransactionTestUtilities.java +++ b/java/integTest/src/test/java/glide/TransactionTestUtilities.java @@ -42,6 +42,8 @@ import glide.api.models.commands.geospatial.GeoSearchStoreOptions; 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.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamGroupOptions; import glide.api.models.commands.stream.StreamRange; @@ -526,6 +528,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) @@ -557,6 +561,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); @@ -625,6 +631,8 @@ private static Object[] sortedSetCommands(BaseTransaction transaction) { .zrandmember(zSetKey2) .zrandmemberWithCount(zSetKey2, 1) .zrandmemberWithCountWithScores(zSetKey2, 1) + .zscan(zSetKey2, "0") + .zscan(zSetKey2, "0", ZScanOptions.builder().count(20L).build()) .bzpopmin(new String[] {zSetKey2}, .1); // zSetKey2 is now empty @@ -684,6 +692,10 @@ private static Object[] sortedSetCommands(BaseTransaction transaction) { "one", // zrandmember(zSetKey2) new String[] {"one"}, // .zrandmemberWithCount(zSetKey2, 1) new Object[][] {{"one", 1.0}}, // .zrandmemberWithCountWithScores(zSetKey2, 1); + new Object[] {"0", new String[] {"one", "1"}}, // zscan(zSetKey2, 0) + new Object[] { + "0", new String[] {"one", "1"} + }, // zscan(zSetKey2, 0, ZScanOptions.builder().count(20L).build()) new Object[] {zSetKey2, "one", 1.0}, // bzpopmin(new String[] { zsetKey2 }, .1) };