diff --git a/glide-core/src/protobuf/redis_request.proto b/glide-core/src/protobuf/redis_request.proto index 1394e95721..0c09cf9650 100644 --- a/glide-core/src/protobuf/redis_request.proto +++ b/glide-core/src/protobuf/redis_request.proto @@ -240,6 +240,7 @@ enum RequestType { XGroupSetId = 199; SScan = 200; ZScan = 201; + HScan = 202; } message Command { diff --git a/glide-core/src/request_type.rs b/glide-core/src/request_type.rs index d73c6576f8..ba6285f8ff 100644 --- a/glide-core/src/request_type.rs +++ b/glide-core/src/request_type.rs @@ -210,6 +210,7 @@ pub enum RequestType { XGroupSetId = 199, SScan = 200, ZScan = 201, + HScan = 202, } fn get_two_word_command(first: &str, second: &str) -> Cmd { @@ -423,6 +424,7 @@ impl From<::protobuf::EnumOrUnknown> for RequestType { ProtobufRequestType::XGroupSetId => RequestType::XGroupSetId, ProtobufRequestType::SScan => RequestType::SScan, ProtobufRequestType::ZScan => RequestType::ZScan, + ProtobufRequestType::HScan => RequestType::HScan, } } } @@ -634,6 +636,7 @@ impl RequestType { RequestType::XGroupSetId => Some(get_two_word_command("XGROUP", "SETID")), RequestType::SScan => Some(cmd("SSCAN")), RequestType::ZScan => Some(cmd("ZSCAN")), + RequestType::HScan => Some(cmd("HSCAN")), } } } diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index 463b0564ae..ea4cb995c4 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -61,6 +61,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.HLen; import static redis_request.RedisRequestOuterClass.RequestType.HMGet; import static redis_request.RedisRequestOuterClass.RequestType.HRandField; +import static redis_request.RedisRequestOuterClass.RequestType.HScan; import static redis_request.RedisRequestOuterClass.RequestType.HSet; import static redis_request.RedisRequestOuterClass.RequestType.HSetNX; import static redis_request.RedisRequestOuterClass.RequestType.HStrlen; @@ -215,6 +216,7 @@ 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.HScanOptions; import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; @@ -2935,4 +2937,17 @@ public CompletableFuture zscan( String[] arguments = concatenateArrays(new String[] {key, cursor}, zScanOptions.toArgs()); return commandManager.submitNewCommand(ZScan, arguments, this::handleArrayResponse); } + + @Override + public CompletableFuture hscan(@NonNull String key, @NonNull String cursor) { + String[] arguments = new String[] {key, cursor}; + return commandManager.submitNewCommand(HScan, arguments, this::handleArrayResponse); + } + + @Override + public CompletableFuture hscan( + @NonNull String key, @NonNull String cursor, @NonNull HScanOptions hScanOptions) { + String[] arguments = concatenateArrays(new String[] {key, cursor}, hScanOptions.toArgs()); + return commandManager.submitNewCommand(HScan, arguments, this::handleArrayResponse); + } } diff --git a/java/client/src/main/java/glide/api/commands/HashBaseCommands.java b/java/client/src/main/java/glide/api/commands/HashBaseCommands.java index c5b3939aa7..5691629ca0 100644 --- a/java/client/src/main/java/glide/api/commands/HashBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/HashBaseCommands.java @@ -2,6 +2,7 @@ package glide.api.commands; import glide.api.models.GlideString; +import glide.api.models.commands.scan.HScanOptions; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -432,4 +433,75 @@ public interface HashBaseCommands { * } */ CompletableFuture hrandfieldWithCountWithValues(String key, long count); + + /** + * Iterates fields of Hash types and their associated values. + * + * @see valkey.io for details. + * @param key The key of the hash. + * @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 result. The second element is always an + * Array of the subset of the hash held in key. The array in the + * second element is always a flattened series of String pairs, where the key is + * at even indices and the value is at odd indices. + * @example + *
{@code
+     * // Assume key contains a set with 200 member-score pairs
+     * String cursor = "0";
+     * Object[] result;
+     * do {
+     *   result = client.hscan(key1, cursor).get();
+     *   cursor = result[0].toString();
+     *   Object[] stringResults = (Object[]) result[1];
+     *
+     *   System.out.println("\nHSCAN 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 hscan(String key, String cursor); + + /** + * Iterates fields of Hash types and their associated values. + * + * @see valkey.io for details. + * @param key The key of the hash. + * @param cursor The cursor that points to the next iteration of results. A value of "0" + * indicates the start of the search. + * @param hScanOptions The {@link HScanOptions}. + * @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 result. The second element is always an + * Array of the subset of the hash held in key. The array in the + * second element is always a flattened series of String pairs, where the key is + * at even indices and the value is at odd indices. + * @example + *
{@code
+     * // Assume key contains a set with 200 member-score pairs
+     * String cursor = "0";
+     * Object[] result;
+     * do {
+     *   result = client.hscan(key1, cursor, HScanOptions.builder().matchPattern("*").count(20L).build()).get();
+     *   cursor = result[0].toString();
+     *   Object[] stringResults = (Object[]) result[1];
+     *
+     *   System.out.println("\nHSCAN 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 hscan(String key, String cursor, HScanOptions hScanOptions); } 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 bb9ec55f79..a5fa16d05c 100644 --- a/java/client/src/main/java/glide/api/commands/SetBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/SetBaseCommands.java @@ -560,10 +560,10 @@ public interface SetBaseCommands { * * @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 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 + * 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 @@ -588,11 +588,11 @@ public interface SetBaseCommands { * * @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 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 + * 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 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 7a35c84250..f6051cdb32 100644 --- a/java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/SortedSetBaseCommands.java @@ -1584,10 +1584,10 @@ CompletableFuture> zinterWithScores( * * @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 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 + * 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 @@ -1620,11 +1620,11 @@ CompletableFuture> zinterWithScores( * * @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 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 + * 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 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 6dacc98bc4..6bfef677fa 100644 --- a/java/client/src/main/java/glide/api/models/BaseTransaction.java +++ b/java/client/src/main/java/glide/api/models/BaseTransaction.java @@ -84,6 +84,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.HLen; import static redis_request.RedisRequestOuterClass.RequestType.HMGet; import static redis_request.RedisRequestOuterClass.RequestType.HRandField; +import static redis_request.RedisRequestOuterClass.RequestType.HScan; import static redis_request.RedisRequestOuterClass.RequestType.HSet; import static redis_request.RedisRequestOuterClass.RequestType.HSetNX; import static redis_request.RedisRequestOuterClass.RequestType.HStrlen; @@ -249,6 +250,7 @@ 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.HScanOptions; import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; @@ -5509,10 +5511,10 @@ public T geosearchstore( * * @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 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 + * 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. */ @@ -5526,11 +5528,11 @@ public T sscan(@NonNull String key, @NonNull String cursor) { * * @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 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 + * 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. */ @@ -5546,10 +5548,10 @@ public T sscan(@NonNull String key, @NonNull String cursor, @NonNull SScanOption * * @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 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 + * 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 @@ -5565,11 +5567,11 @@ public T zscan(@NonNull String key, @NonNull String cursor) { * * @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 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 + * 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 @@ -5582,6 +5584,47 @@ public T zscan(@NonNull String key, @NonNull String cursor, @NonNull ZScanOption return getThis(); } + /** + * Iterates fields of Hash types and their associated values. + * + * @see valkey.io for details. + * @param key The key of the hash. + * @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 result. The second element is + * always an Array of the subset of the hash held in key. The array + * in the second element is always a flattened series of String pairs, where the + * key is at even indices and the value is at odd indices. + */ + public T hscan(@NonNull String key, @NonNull String cursor) { + protobufTransaction.addCommands(buildCommand(HScan, buildArgs(key, cursor))); + return getThis(); + } + + /** + * Iterates fields of Hash types and their associated values. + * + * @see valkey.io for details. + * @param key The key of the hash. + * @param cursor The cursor that points to the next iteration of results. A value of "0" + * indicates the start of the search. + * @param hScanOptions The {@link HScanOptions}. + * @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 result. The second element is + * always an Array of the subset of the hash held in key. The array + * in the second element is always a flattened series of String pairs, where the + * key is at even indices and the value is at odd indices. + */ + public T hscan(@NonNull String key, @NonNull String cursor, @NonNull HScanOptions hScanOptions) { + final ArgsArray commandArgs = + buildArgs(concatenateArrays(new String[] {key, cursor}, hScanOptions.toArgs())); + protobufTransaction.addCommands(buildCommand(HScan, 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/HScanOptions.java b/java/client/src/main/java/glide/api/models/commands/scan/HScanOptions.java new file mode 100644 index 0000000000..1f03a00e6d --- /dev/null +++ b/java/client/src/main/java/glide/api/models/commands/scan/HScanOptions.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.HashBaseCommands; +import lombok.experimental.SuperBuilder; + +/** + * Optional arguments for {@link HashBaseCommands#hscan(String, String, HScanOptions)}. + * + * @see valkey.io + */ +@SuperBuilder +public class HScanOptions extends BaseScanOptions {} diff --git a/java/client/src/main/java/module-info.java b/java/client/src/main/java/module-info.java index 1cff595006..2dbaca04f7 100644 --- a/java/client/src/main/java/module-info.java +++ b/java/client/src/main/java/module-info.java @@ -6,10 +6,10 @@ exports glide.api.models.commands.bitmap; exports glide.api.models.commands.geospatial; exports glide.api.models.commands.function; + exports glide.api.models.commands.scan; 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 1eabe0776f..03a6a0f181 100644 --- a/java/client/src/test/java/glide/api/RedisClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClientTest.java @@ -138,6 +138,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.HLen; import static redis_request.RedisRequestOuterClass.RequestType.HMGet; import static redis_request.RedisRequestOuterClass.RequestType.HRandField; +import static redis_request.RedisRequestOuterClass.RequestType.HScan; import static redis_request.RedisRequestOuterClass.RequestType.HSet; import static redis_request.RedisRequestOuterClass.RequestType.HSetNX; import static redis_request.RedisRequestOuterClass.RequestType.HStrlen; @@ -309,6 +310,7 @@ 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.HScanOptions; import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; @@ -9079,6 +9081,58 @@ public void zscan_with_options_returns_success() { assertEquals(value, payload); } + @SneakyThrows + @Test + public void hscan_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(HScan), eq(arguments), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = service.hscan(key, cursor); + Object[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void hscan_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(HScan), eq(arguments), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = + service.hscan(key, cursor, HScanOptions.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 d139b50905..7ff892095b 100644 --- a/java/client/src/test/java/glide/api/models/TransactionTests.java +++ b/java/client/src/test/java/glide/api/models/TransactionTests.java @@ -105,6 +105,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.HLen; import static redis_request.RedisRequestOuterClass.RequestType.HMGet; import static redis_request.RedisRequestOuterClass.RequestType.HRandField; +import static redis_request.RedisRequestOuterClass.RequestType.HScan; import static redis_request.RedisRequestOuterClass.RequestType.HSet; import static redis_request.RedisRequestOuterClass.RequestType.HSetNX; import static redis_request.RedisRequestOuterClass.RequestType.HStrlen; @@ -260,6 +261,7 @@ 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.HScanOptions; import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; @@ -1370,6 +1372,21 @@ InfScoreBound.NEGATIVE_INFINITY, new ScoreBoundary(3, false), new Limit(1, 2)), ZScanOptions.COUNT_OPTION_STRING, "10"))); + transaction.hscan("key1", "0"); + results.add(Pair.of(HScan, buildArgs("key1", "0"))); + + transaction.hscan("key1", "0", HScanOptions.builder().matchPattern("*").count(10L).build()); + results.add( + Pair.of( + HScan, + buildArgs( + "key1", + "0", + HScanOptions.MATCH_OPTION_STRING, + "*", + HScanOptions.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 6112bb08a5..8d651fb393 100644 --- a/java/integTest/src/test/java/glide/SharedCommandTests.java +++ b/java/integTest/src/test/java/glide/SharedCommandTests.java @@ -78,6 +78,7 @@ 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.HScanOptions; import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; @@ -7609,16 +7610,6 @@ public void sscan(BaseClient client) { 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(); @@ -7679,12 +7670,6 @@ public void sscan(BaseClient client) { "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(); @@ -7903,4 +7888,168 @@ public void zscan(BaseClient client) { () -> client.zscan(key1, "-1", ZScanOptions.builder().count(-1L).build()).get()); assertInstanceOf(RequestException.class, executionException.getCause()); } + + @SneakyThrows + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("getClients") + public void hscan(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 + Map numberMap = new HashMap<>(); + // This is an unusually large dataset because the server can ignore the COUNT option + // if the dataset is small enough that it is more efficient to transfer its entire contents + // at once. + for (int i = 0; i < 50000; i++) { + numberMap.put(String.valueOf(i), "num" + i); + } + String[] charMembers = new String[] {"a", "b", "c", "d", "e"}; + Map charMap = new HashMap<>(); + for (int i = 0; i < 5; i++) { + charMap.put(charMembers[i], String.valueOf(i)); + } + + // Empty set + Object[] result = client.hscan(key1, initialCursor).get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertDeepEquals(new String[] {}, result[resultCollectionIndex]); + + // Negative cursor + result = client.hscan(key1, "-1").get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertDeepEquals(new String[] {}, result[resultCollectionIndex]); + + // Result contains the whole set + assertEquals(charMembers.length, client.hset(key1, charMap).get()); + result = client.hscan(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())); + + assertTrue( + resultValues.containsAll(charMap.values()), + String.format("resultValues: {%s} charMap.values(): {%s}", resultValues, charMap.values())); + + result = + client.hscan(key1, initialCursor, HScanOptions.builder().matchPattern("a").build()).get(); + assertEquals(initialCursor, result[resultCursorIndex]); + assertDeepEquals(new String[] {"a", "0"}, result[resultCollectionIndex]); + + // Result contains a subset of the key + final HashMap combinedMap = new HashMap<>(numberMap); + combinedMap.putAll(charMap); + assertEquals(numberMap.size(), client.hset(key1, combinedMap).get()); + String resultCursor = "0"; + final Set secondResultAllKeys = new HashSet<>(); + final Set secondResultAllValues = new HashSet<>(); + boolean isFirstLoop = true; + do { + result = client.hscan(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.hscan(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())); + + assertTrue( + secondResultAllValues.containsAll(numberMap.values()), + String.format( + "secondResultAllValues: {%s} numberMap.values(): {%s}", + secondResultAllValues, numberMap.values())); + + // Test match pattern + result = + client.hscan(key1, initialCursor, HScanOptions.builder().matchPattern("*").build()).get(); + assertTrue(Long.parseLong(result[resultCursorIndex].toString()) >= 0); + assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) >= defaultCount); + + // Test count + result = client.hscan(key1, initialCursor, HScanOptions.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 + .hscan( + key1, initialCursor, HScanOptions.builder().matchPattern("1*").count(20L).build()) + .get(); + assertTrue(Long.parseLong(result[resultCursorIndex].toString()) >= 0); + assertTrue(ArrayUtils.getLength(result[resultCollectionIndex]) >= 0); + + // Exceptions + // Non-hash key + assertEquals(OK, client.set(key2, "test").get()); + ExecutionException executionException = + assertThrows(ExecutionException.class, () -> client.hscan(key2, initialCursor).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + executionException = + assertThrows( + ExecutionException.class, + () -> + client + .hscan( + key2, + initialCursor, + HScanOptions.builder().matchPattern("test").count(1L).build()) + .get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + // Negative count + executionException = + assertThrows( + ExecutionException.class, + () -> client.hscan(key1, "-1", HScanOptions.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 f0797b2db8..87edb4e6c2 100644 --- a/java/integTest/src/test/java/glide/TransactionTestUtilities.java +++ b/java/integTest/src/test/java/glide/TransactionTestUtilities.java @@ -42,6 +42,7 @@ 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.HScanOptions; import glide.api.models.commands.scan.SScanOptions; import glide.api.models.commands.scan.ZScanOptions; import glide.api.models.commands.stream.StreamAddOptions; @@ -351,6 +352,10 @@ private static Object[] stringCommands(BaseTransaction transaction) { private static Object[] hashCommands(BaseTransaction transaction) { String hashKey1 = "{HashKey}-1-" + UUID.randomUUID(); + // This extra key is for HScan testing. It is a key with only one field. HScan doesn't guarantee + // a return order but this test compares arrays so order is significant. + String hashKey2 = "{HashKey}-2-" + UUID.randomUUID(); + transaction .hset(hashKey1, Map.of(field1, value1, field2, value2)) .hget(hashKey1, field1) @@ -369,7 +374,10 @@ private static Object[] hashCommands(BaseTransaction transaction) { .hincrBy(hashKey1, field3, 5) .hincrByFloat(hashKey1, field3, 5.5) .hkeys(hashKey1) - .hstrlen(hashKey1, field2); + .hstrlen(hashKey1, field2) + .hset(hashKey2, Map.of(field1, value1)) + .hscan(hashKey2, "0") + .hscan(hashKey2, "0", HScanOptions.builder().count(20L).build()); return new Object[] { 2L, // hset(hashKey1, Map.of(field1, value1, field2, value2)) @@ -392,6 +400,11 @@ private static Object[] hashCommands(BaseTransaction transaction) { 10.5, // hincrByFloat(hashKey1, field3, 5.5) new String[] {field2, field3}, // hkeys(hashKey1) (long) value2.length(), // hstrlen(hashKey1, field2) + 1L, // hset(hashKey2, Map.of(field1, value1)) + new Object[] {"0", new Object[] {field1, value1}}, // hscan(hashKey2, "0") + new Object[] { + "0", new Object[] {field1, value1} + }, // hscan(hashKey2, "0", HScanOptions.builder().count(20L).build()); }; }