diff --git a/java/client/src/main/java/babushka/api/RedisClient.java b/java/client/src/main/java/babushka/api/RedisClient.java index 34c35fbbfd..63e844b22a 100644 --- a/java/client/src/main/java/babushka/api/RedisClient.java +++ b/java/client/src/main/java/babushka/api/RedisClient.java @@ -3,16 +3,20 @@ import static babushka.api.commands.Command.RequestType.CUSTOM_COMMAND; import static babushka.api.commands.Command.RequestType.GETSTRING; import static babushka.api.commands.Command.RequestType.SETSTRING; +import static babushka.api.models.commands.SetOptions.createSetOptions; import babushka.api.commands.BaseCommands; import babushka.api.commands.Command; import babushka.api.commands.StringCommands; import babushka.api.commands.Transaction; import babushka.api.commands.VoidCommands; +import babushka.api.models.commands.SetOptions; import babushka.api.models.configuration.RedisClientConfiguration; import babushka.managers.CommandManager; import connection_request.ConnectionRequestOuterClass; import java.lang.reflect.Array; +import java.util.LinkedList; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.function.Function; import response.ResponseOuterClass; @@ -45,7 +49,7 @@ public RedisClient(CommandManager commandManager) { } /** - * Close the client if it's open + * Close the Redis client * * @return */ @@ -68,6 +72,14 @@ public CompletableFuture exec(Transaction transaction) { return new CompletableFuture<>(); } + /** + * Executes a single custom command, without checking inputs. Every part of the command, including subcommands, + * should be added as a separate value in args. + * + * @param cmd to be executed + * @param args arguments for the command + * @return CompletableFuture with the response + */ public CompletableFuture customCommand(String cmd, String[] args) { String[] commandArguments = (String[]) Array.newInstance(String.class, args.length + 1); commandArguments[0] = cmd; @@ -78,15 +90,51 @@ public CompletableFuture customCommand(String cmd, String[] args) { return exec(command, BaseCommands::handleResponse); } + /** + * Get the value associated with the given key, or null if no such value exists. + * See https://redis.io/commands/set/ for details. + * + * @param key - The key to retrieve from the database. + * @return If `key` exists, returns the value of `key` as a string. Otherwise, return null + */ public CompletableFuture get(String key) { Command command = Command.builder().requestType(GETSTRING).arguments(new String[] {key}).build(); return exec(command, StringCommands::handleStringResponse); } + /** + * Set the given key with the given value. Return value is dependent on the passed options. + * See https://redis.io/commands/set/ for details. + * + * @param key - The key to store. + * @param value - The value to store with the given key. + * @return null + */ public CompletableFuture set(String key, String value) { Command command = Command.builder().requestType(SETSTRING).arguments(new String[] {key, value}).build(); return exec(command, VoidCommands::handleVoidResponse); } + + /** + * Set the given key with the given value. Return value is dependent on the passed options. + * See https://redis.io/commands/set/ for details. + * + * @param key - The key to store. + * @param value - The value to store with the given key. + * @param options - The Set options + * @return string or null + * If value isn't set because of `onlyIfExists` or `onlyIfDoesNotExist` conditions, return null. + * If `returnOldValue` is set, return the old value as a string. + */ + public CompletableFuture set(String key, String value, SetOptions options) { + LinkedList args = new LinkedList<>(); + args.add(key); + args.add(value); + args.addAll(createSetOptions(options)); + Command command = + Command.builder().requestType(SETSTRING).arguments(args.toArray(new String[0])).build(); + return exec(command, StringCommands::handleStringResponse); + } } diff --git a/java/client/src/main/java/babushka/api/commands/Command.java b/java/client/src/main/java/babushka/api/commands/Command.java index 1ea7309265..d4f3c1682e 100644 --- a/java/client/src/main/java/babushka/api/commands/Command.java +++ b/java/client/src/main/java/babushka/api/commands/Command.java @@ -1,5 +1,6 @@ package babushka.api.commands; +import java.util.Arrays; import lombok.Builder; @Builder @@ -13,4 +14,19 @@ public enum RequestType { GETSTRING, SETSTRING } + + @Override + public boolean equals(Object o) { + if (o instanceof Command) { + Command otherCommand = (Command) o; + if (this.requestType != otherCommand.requestType) + return false; + + if (!Arrays.equals(this.arguments, otherCommand.arguments)) + return false; + + return true; + } + return false; + } } diff --git a/java/client/src/main/java/babushka/api/commands/StringCommands.java b/java/client/src/main/java/babushka/api/commands/StringCommands.java index b23ea2777c..9374541ef4 100644 --- a/java/client/src/main/java/babushka/api/commands/StringCommands.java +++ b/java/client/src/main/java/babushka/api/commands/StringCommands.java @@ -8,7 +8,8 @@ public interface StringCommands { public static String handleStringResponse(Response response) { // return function to convert protobuf.Response into the response object by // calling valueFromPointer - return (String) BaseCommands.handleResponse(response); + Object value = BaseCommands.handleResponse(response); + return value == null ? null : (String) value; } CompletableFuture get(String key); diff --git a/java/client/src/main/java/babushka/api/models/commands/SetOptions.java b/java/client/src/main/java/babushka/api/models/commands/SetOptions.java new file mode 100644 index 0000000000..c7bdfcca2e --- /dev/null +++ b/java/client/src/main/java/babushka/api/models/commands/SetOptions.java @@ -0,0 +1,104 @@ +package babushka.api.models.commands; + +import java.util.LinkedList; +import java.util.List; +import lombok.Builder; +import lombok.NonNull; + +@Builder +@NonNull +public class SetOptions { + + /** + * `onlyIfDoesNotExist` - Only set the key if it does not already exist. Equivalent to `NX` in the Redis API. + * `onlyIfExists` - Only set the key if it already exist. Equivalent to `EX` in the Redis API. + * if `conditional` is not set the value will be set regardless of prior value existence. + * If value isn't set because of the condition, return null. + */ + private ConditionalSet conditionalSet; + + /** + * Return the old string stored at key, or nil if key did not exist. An error is returned and SET aborted if the value stored at key is not a string. + * Equivalent to `GET` in the Redis API. + */ + private boolean returnOldValue; + + /** + * If not set, no expiry time will be set for the value. + */ + private TimeToLive expiry; + + public enum ConditionalSet { + ONLY_IF_EXISTS, + ONLY_IF_DOES_NOT_EXIST + } + + @Builder + public static class TimeToLive { + private TimeToLiveType type; + private int count; + } + + public enum TimeToLiveType { + /** + * Retain the time to live associated with the key. Equivalent to `KEEPTTL` in the Redis API. + */ + KEEP_EXISTING, + /** + * Set the specified expire time, in seconds. Equivalent to `EX` in the Redis API. + */ + SECONDS, + /** + * Set the specified expire time, in milliseconds. Equivalent to `PX` in the Redis API. + */ + MILLISECONDS, + /** + * Set the specified Unix time at which the key will expire, in seconds. Equivalent to `EXAT` in the Redis API. + */ + UNIX_SECONDS, + /** + * Set the specified Unix time at which the key will expire, in milliseconds. Equivalent to `PXAT` in the Redis API. + */ + UNIX_MILLISECONDS + } + + public static String CONDITIONAL_SET_ONLY_IF_EXISTS = "XX"; + public static String CONDITIONAL_SET_ONLY_IF_DOES_NOT_EXIST = "NX"; + public static String RETURN_OLD_VALUE = "GET"; + public static String TIME_TO_LIVE_KEEP_EXISTING = "KEEPTTL"; + public static String TIME_TO_LIVE_SECONDS = "EX"; + public static String TIME_TO_LIVE_MILLISECONDS = "PX"; + public static String TIME_TO_LIVE_UNIX_SECONDS = "EXAT"; + public static String TIME_TO_LIVE_UNIX_MILLISECONDS = "PXAT"; + + public static List createSetOptions(SetOptions options) { + List optionArgs = new LinkedList(); + if (options.conditionalSet != null) { + if (options.conditionalSet == ConditionalSet.ONLY_IF_EXISTS) { + optionArgs.add(CONDITIONAL_SET_ONLY_IF_EXISTS); + } else if (options.conditionalSet == ConditionalSet.ONLY_IF_DOES_NOT_EXIST) { + optionArgs.add(CONDITIONAL_SET_ONLY_IF_DOES_NOT_EXIST); + } + } + + if (options.returnOldValue) { + optionArgs.add(RETURN_OLD_VALUE); + } + + if (options.expiry != null) { + if (options.expiry.type == TimeToLiveType.KEEP_EXISTING) { + optionArgs.add(TIME_TO_LIVE_KEEP_EXISTING); + } else if (options.expiry.type == TimeToLiveType.SECONDS) { + optionArgs.add(TIME_TO_LIVE_SECONDS + " " + options.expiry.count); + } else if (options.expiry.type == TimeToLiveType.MILLISECONDS) { + optionArgs.add(TIME_TO_LIVE_MILLISECONDS + " " + options.expiry.count); + } else if (options.expiry.type == TimeToLiveType.UNIX_SECONDS) { + optionArgs.add(TIME_TO_LIVE_UNIX_SECONDS + " " + options.expiry.count); + } else if (options.expiry.type == TimeToLiveType.UNIX_MILLISECONDS) { + optionArgs.add(TIME_TO_LIVE_UNIX_MILLISECONDS + " " + options.expiry.count); + } + } + + return optionArgs; + } +} diff --git a/java/client/src/test/java/babushka/api/RedisClientTest.java b/java/client/src/test/java/babushka/api/RedisClientTest.java index a51d18c047..12dc7cb238 100644 --- a/java/client/src/test/java/babushka/api/RedisClientTest.java +++ b/java/client/src/test/java/babushka/api/RedisClientTest.java @@ -1,12 +1,20 @@ package babushka.api; +import static babushka.api.models.commands.SetOptions.CONDITIONAL_SET_ONLY_IF_DOES_NOT_EXIST; +import static babushka.api.models.commands.SetOptions.CONDITIONAL_SET_ONLY_IF_EXISTS; +import static babushka.api.models.commands.SetOptions.RETURN_OLD_VALUE; +import static babushka.api.models.commands.SetOptions.TIME_TO_LIVE_KEEP_EXISTING; +import static babushka.api.models.commands.SetOptions.TIME_TO_LIVE_UNIX_SECONDS; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import babushka.api.commands.Command; +import babushka.api.models.commands.SetOptions; import babushka.api.models.configuration.RedisClientConfiguration; import babushka.managers.CommandManager; import java.util.concurrent.CompletableFuture; @@ -111,6 +119,80 @@ public void set_success() throws ExecutionException, InterruptedException { // teardown } + @Test + public void set_withOptionsOnlyIfExists_success() throws ExecutionException, InterruptedException { + // setup + String key = "testKey"; + String value = "testValue"; + SetOptions setOptions = SetOptions.builder() + .conditionalSet(SetOptions.ConditionalSet.ONLY_IF_EXISTS) + .returnOldValue(false) + .expiry(SetOptions.TimeToLive.builder() + .type(SetOptions.TimeToLiveType.KEEP_EXISTING) + .build()) + .build(); + Command cmd = + Command.builder() + .requestType(Command.RequestType.SETSTRING) + .arguments(new String[] { + key, + value, + CONDITIONAL_SET_ONLY_IF_EXISTS, + TIME_TO_LIVE_KEEP_EXISTING + }) + .build(); + CompletableFuture testResponse = mock(CompletableFuture.class); + when(testResponse.get()).thenReturn(null); + when(commandManager.submitNewCommand(eq(cmd), any())).thenReturn(testResponse); + + // exercise + CompletableFuture response = service.set(key, value, setOptions); + + // verify + assertNotNull(response); + assertNull(response.get()); + + // teardown + } + + @Test + public void set_withOptionsOnlyIfDoesNotExist_success() throws ExecutionException, InterruptedException { + // setup + String key = "testKey"; + String value = "testValue"; + SetOptions setOptions = SetOptions.builder() + .conditionalSet(SetOptions.ConditionalSet.ONLY_IF_DOES_NOT_EXIST) + .returnOldValue(true) + .expiry(SetOptions.TimeToLive.builder() + .type(SetOptions.TimeToLiveType.UNIX_SECONDS) + .count(60) + .build()) + .build(); + Command cmd = + Command.builder() + .requestType(Command.RequestType.SETSTRING) + .arguments(new String[] { + key, + value, + CONDITIONAL_SET_ONLY_IF_DOES_NOT_EXIST, + RETURN_OLD_VALUE, + TIME_TO_LIVE_UNIX_SECONDS + " 60" + }) + .build(); + CompletableFuture testResponse = mock(CompletableFuture.class); + when(testResponse.get()).thenReturn(value); + when(commandManager.submitNewCommand(eq(cmd), any())).thenReturn(testResponse); + + // exercise + CompletableFuture response = service.set(key, value, setOptions); + + // verify + assertNotNull(response); + assertEquals(value, response.get()); + + // teardown + } + @Test public void test_ping_success() { // setup