From c417bb23a59db3206d1b1b9ac54169c62fc17873 Mon Sep 17 00:00:00 2001 From: John Loehrer Date: Mon, 29 Jul 2024 08:51:23 -0700 Subject: [PATCH] #12 implement string range and string multi related commands --- .../commands/RedisStringAsyncCommands.scala | 74 ++++++++-- .../LettuceRedisStringAsyncCommands.scala | 40 ++++++ .../RedisCommandsIntegrationSpec.scala | 61 +++++++- .../LettuceRedisCommandsClientSpec.scala | 135 +++++++++++++++++- 4 files changed, 299 insertions(+), 11 deletions(-) diff --git a/modules/api/src/main/scala/com/github/scoquelin/arugula/commands/RedisStringAsyncCommands.scala b/modules/api/src/main/scala/com/github/scoquelin/arugula/commands/RedisStringAsyncCommands.scala index d32cef5..517e49b 100644 --- a/modules/api/src/main/scala/com/github/scoquelin/arugula/commands/RedisStringAsyncCommands.scala +++ b/modules/api/src/main/scala/com/github/scoquelin/arugula/commands/RedisStringAsyncCommands.scala @@ -1,5 +1,6 @@ package com.github.scoquelin.arugula.commands +import scala.collection.immutable.ListMap import scala.concurrent.Future import scala.concurrent.duration.FiniteDuration @@ -10,6 +11,15 @@ import scala.concurrent.duration.FiniteDuration * @tparam V The value type */ trait RedisStringAsyncCommands[K, V] { + + /** + * Append a value to a key. + * @param key The key + * @param value The value + * @return The length of the string after the append operation + */ + def append(key: K, value: V): Future[Long] + /** * Get the value of a key. * @param key The key @@ -24,6 +34,24 @@ trait RedisStringAsyncCommands[K, V] { */ def getDel(key: K): Future[Option[V]] + + /** + * Get the value of a key and set its expiration. + * @param key The key + * @param expiresIn The expiration time + * @return + */ + def getEx(key: K, expiresIn: FiniteDuration): Future[Option[V]] + + /** + * Get the value of a key and set a new value. + * @param key The key + * @param start The start index + * @param end The end index + * @return The value of the key + */ + def getRange(key: K, start: Long, end: Long): Future[Option[V]] + /** * Get the value of a key and set a new value. * @param key The key @@ -32,6 +60,28 @@ trait RedisStringAsyncCommands[K, V] { */ def getSet(key: K, value: V): Future[Option[V]] + /** + * Get the values of all the given keys. + * @param keys The keys + * @return The values of the keys, in the same order as the keys + */ + def mGet(keys: K*): Future[ListMap[K, Option[V]]] + + /** + * Set multiple keys to multiple values. + * @param keyValues The key-value pairs + * @return Unit + */ + def mSet(keyValues: Map[K, V]): Future[Unit] + + + /** + * Set multiple keys to multiple values, only if none of the keys exist. + * @param keyValues The key-value pairs + * @return true if all keys were set, false otherwise + */ + def mSetNx(keyValues: Map[K, V]): Future[Boolean] + /** * Set the value of a key. * @param key The key @@ -56,6 +106,22 @@ trait RedisStringAsyncCommands[K, V] { */ def setNx(key: K, value: V): Future[Boolean] + /** + * Overwrite part of a string at key starting at the specified offset. + * @param key The key + * @param offset The offset + * @param value The value + * @return The length of the string after the append operation + */ + def setRange(key: K, offset: Long, value: V): Future[Long] + + /** + * Get the length of the value stored in a key. + * @param key The key + * @return The length of the string at key, or 0 if the key does not exist + */ + def strLen(key: K): Future[Long] + /** * Increment the integer value of a key by one. * @param key The key @@ -96,13 +162,6 @@ trait RedisStringAsyncCommands[K, V] { /*** commands that are not yet implemented ***/ - // def append(key: K, value: V): Future[Long] - // def getRange(key: K, start: Long, end: Long): Future[Option[V]] - // def setRange(key: K, offset: Long, value: V): Future[Long] - // def getEx(key: K, expiresIn: FiniteDuration): Future[Option[V]] - // def mGet(keys: K*): Future[Seq[Option[V]]] - // def mSet(keyValues: Map[K, V]): Future[Unit] - // def mSetNx(keyValues: Map[K, V]): Future[Boolean] // def bitCount(key: K, start: Option[Long] = None, end: Option[Long] = None): Future[Long] // def bitOpAnd(destination: K, keys: K*): Future[Long] // def bitOpOr(destination: K, keys: K*): Future[Long] @@ -111,7 +170,6 @@ trait RedisStringAsyncCommands[K, V] { // def bitPos(key: K, bit: Boolean, start: Option[Long] = None, end: Option[Long] = None): Future[Long] // def bitField(key: K, command: String, offset: Long, value: Option[Long] = None): Future[Long] // def strAlgoLcs(keys: K*): Future[Option[V]] - // def strLen(key: K): Future[Long] // def getBit(key: K, offset: Long): Future[Boolean] // def setBit(key: K, offset: Long, value: Boolean): Future[Boolean] diff --git a/modules/core/src/main/scala/com/github/scoquelin/arugula/commands/LettuceRedisStringAsyncCommands.scala b/modules/core/src/main/scala/com/github/scoquelin/arugula/commands/LettuceRedisStringAsyncCommands.scala index cc3aed7..353743f 100644 --- a/modules/core/src/main/scala/com/github/scoquelin/arugula/commands/LettuceRedisStringAsyncCommands.scala +++ b/modules/core/src/main/scala/com/github/scoquelin/arugula/commands/LettuceRedisStringAsyncCommands.scala @@ -1,21 +1,55 @@ package com.github.scoquelin.arugula.commands +import scala.collection.immutable.ListMap import scala.concurrent.Future import scala.concurrent.duration.FiniteDuration +import scala.jdk.CollectionConverters._ + import com.github.scoquelin.arugula.internal.LettuceRedisCommandDelegation +import io.lettuce.core.{GetExArgs, KeyValue} import java.util.concurrent.TimeUnit private[arugula] trait LettuceRedisStringAsyncCommands[K, V] extends RedisStringAsyncCommands[K, V] with LettuceRedisCommandDelegation[K, V] { + + override def append(key: K, value: V): Future[Long] = + delegateRedisClusterCommandAndLift(_.append(key, value)).map(Long2long) + override def get(key: K): Future[Option[V]] = delegateRedisClusterCommandAndLift(_.get(key)).map(Option.apply) override def getDel(key: K): Future[Option[V]] = delegateRedisClusterCommandAndLift(_.getdel(key)).map(Option.apply) + override def getEx(key: K, expiresIn: FiniteDuration): Future[Option[V]] = + (expiresIn.unit match { + case TimeUnit.MILLISECONDS | TimeUnit.MICROSECONDS | TimeUnit.NANOSECONDS => + delegateRedisClusterCommandAndLift(_.getex(key, GetExArgs.Builder.ex(expiresIn.toMillis))) + case _ => + delegateRedisClusterCommandAndLift(_.getex(key, GetExArgs.Builder.ex(java.time.Duration.ofSeconds(expiresIn.toSeconds)))) + }).map(Option.apply) + + override def getRange(key: K, start: Long, end: Long): Future[Option[V]] = + delegateRedisClusterCommandAndLift(_.getrange(key, start, end)).map(Option.apply) + override def getSet(key: K, value: V): Future[Option[V]] = delegateRedisClusterCommandAndLift(_.getset(key, value)).map(Option.apply) + override def mGet(keys: K*): Future[ListMap[K, Option[V]]] = + delegateRedisClusterCommandAndLift(_.mget(keys: _*)).map { + case null => ListMap.empty + case kvs => ListMap(kvs.toArray.map{ + case kv: KeyValue[K, V] if kv.hasValue => kv.getKey -> Some(kv.getValue) + case kv: KeyValue[K, V] => kv.getKey -> None + }: _*) + } + + override def mSet(keyValues: Map[K, V]): Future[Unit] = + delegateRedisClusterCommandAndLift(_.mset(keyValues.asJava)).map(_ => ()) + + override def mSetNx(keyValues: Map[K, V]): Future[Boolean] = + delegateRedisClusterCommandAndLift(_.msetnx(keyValues.asJava)).map(Boolean2boolean) + override def set(key: K, value: V): Future[Unit] = delegateRedisClusterCommandAndLift(_.set(key, value)).map(_ => ()) @@ -30,6 +64,12 @@ private[arugula] trait LettuceRedisStringAsyncCommands[K, V] extends RedisString override def setNx(key: K, value: V): Future[Boolean] = delegateRedisClusterCommandAndLift(_.setnx(key, value)).map(Boolean2boolean) + override def setRange(key: K, offset: Long, value: V): Future[Long] = + delegateRedisClusterCommandAndLift(_.setrange(key, offset, value)).map(Long2long) + + override def strLen(key: K): Future[Long] = + delegateRedisClusterCommandAndLift(_.strlen(key)).map(Long2long) + override def incr(key: K): Future[Long] = delegateRedisClusterCommandAndLift(_.incr(key)).map(Long2long) diff --git a/modules/tests/it/src/test/scala/com/github/scoquelin/arugula/RedisCommandsIntegrationSpec.scala b/modules/tests/it/src/test/scala/com/github/scoquelin/arugula/RedisCommandsIntegrationSpec.scala index ce8ac9b..85271d8 100644 --- a/modules/tests/it/src/test/scala/com/github/scoquelin/arugula/RedisCommandsIntegrationSpec.scala +++ b/modules/tests/it/src/test/scala/com/github/scoquelin/arugula/RedisCommandsIntegrationSpec.scala @@ -1,9 +1,13 @@ package com.github.scoquelin.arugula +import scala.collection.immutable.ListMap + import com.github.scoquelin.arugula.commands.RedisSortedSetAsyncCommands.{RangeLimit, ScoreWithValue, ZAddOptions, ZRange} import org.scalatest.matchers.should.Matchers import scala.concurrent.duration._ +import java.util.concurrent.TimeUnit + class RedisCommandsIntegrationSpec extends BaseRedisCommandsIntegrationSpec with Matchers { import RedisCommandsIntegrationSpec.randomKey @@ -118,12 +122,23 @@ class RedisCommandsIntegrationSpec extends BaseRedisCommandsIntegrationSpec with keyValue <- client.get(key) _ <- keyValue match { case Some(expectedValue) => expectedValue shouldBe value - case None => fail() + case None => fail("Expected value not found") } ttl <- client.ttl(key) _ <- ttl match { case Some(timeToLive) => assert(timeToLive > (expireIn - 1.minute) && timeToLive <= expireIn) - case None => fail() + case None => fail("Expected time to live not found") + } + longDuration = FiniteDuration(3, TimeUnit.DAYS) + getExp <- client.getEx(key, longDuration) + _ <- getExp match { + case Some(expectedValue) => expectedValue shouldBe value + case None => fail("Expected value not found") + } + getTtl <- client.ttl(key) + _ <- getTtl match { + case Some(timeToLive) => assert(timeToLive > (longDuration - 1.minute) && timeToLive <= longDuration) + case None => fail("Expected time to live not found") } deleted <- client.del(key) _ <- deleted shouldBe 1L @@ -133,6 +148,48 @@ class RedisCommandsIntegrationSpec extends BaseRedisCommandsIntegrationSpec with } } + "support string range operations" in { + withRedisSingleNodeAndCluster { client => + val key = randomKey("range-key") + for { + lenResult <- client.append(key, "Hello") + _ = lenResult shouldBe 5L + lenResult <- client.append(key, ", World!") + _ = lenResult shouldBe 13L + range <- client.getRange(key, 0, 4) + _ = range shouldBe Some("Hello") + range <- client.getRange(key, -6, -1) + _ = range shouldBe Some("World!") + _ = client.setRange(key, 7, "Redis") + updatedValue <- client.get(key) + _ = updatedValue shouldBe Some("Hello, Redis!") + strLen <- client.strLen(key) + _ = strLen shouldBe 13L + } yield succeed + + } + } + + "support multiple key operations" in { + withRedisSingleNodeAndCluster { client => + val suffix = "{user1}" + val key1 = randomKey("k1") + suffix + val key2 = randomKey("k2") + suffix + val key3 = randomKey("k3") + suffix + val key4 = randomKey("k4") + suffix + for { + _ <- client.mSet(Map(key1 -> "value1", key2 -> "value2", key3 -> "value3")) + values <- client.mGet(key1, key2, key3, key4) + _ <- values shouldBe ListMap(key1 -> Some("value1"), key2 -> Some("value2"), key3 -> Some("value3"), key4 -> None) + nxResult <- client.mSetNx(Map(key4 -> "value4")) + _ = nxResult shouldBe true + values <- client.mGet(key1, key2, key3, key4) + _ = values shouldBe ListMap(key1 -> Some("value1"), key2 -> Some("value2"), key3 -> Some("value3"), key4 -> Some("value4")) + } yield succeed + + } + } + } "leveraging RedisListAsyncCommands" should { diff --git a/modules/tests/test/src/test/scala/com/github/scoquelin/arugula/LettuceRedisCommandsClientSpec.scala b/modules/tests/test/src/test/scala/com/github/scoquelin/arugula/LettuceRedisCommandsClientSpec.scala index 5eba50a..3c72808 100644 --- a/modules/tests/test/src/test/scala/com/github/scoquelin/arugula/LettuceRedisCommandsClientSpec.scala +++ b/modules/tests/test/src/test/scala/com/github/scoquelin/arugula/LettuceRedisCommandsClientSpec.scala @@ -1,12 +1,13 @@ package com.github.scoquelin.arugula +import scala.collection.immutable.ListMap import scala.concurrent.Future import scala.concurrent.duration.{DurationInt, DurationLong} import scala.jdk.CollectionConverters._ import com.github.scoquelin.arugula.commands.RedisSortedSetAsyncCommands.{RangeLimit, ScoreWithValue, ZRange} import com.github.scoquelin.arugula.connection.RedisConnection -import io.lettuce.core.{RedisFuture, ScoredValue, ScoredValueScanCursor} +import io.lettuce.core.{GetExArgs, KeyValue, RedisFuture, ScoredValue, ScoredValueScanCursor} import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatchers.{any, anyLong, anyString, eq => meq} import org.mockito.Mockito.{verify, when} @@ -26,6 +27,21 @@ class LettuceRedisCommandsClientSpec extends wordspec.FixtureAsyncWordSpec with } "LettuceRedisAsyncCommands" should { + + "delegate APPEND command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = 5L + val mockRedisFuture: RedisFuture[java.lang.Long] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.append(anyString, anyString)).thenReturn(mockRedisFuture) + + testClass.append("key", "value").map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).append("key", "value") + succeed + } + } + "delegate GET command to Lettuce and lift result into a Future" in { testContext => import testContext._ @@ -58,6 +74,22 @@ class LettuceRedisCommandsClientSpec extends wordspec.FixtureAsyncWordSpec with } } + "delegate GETEX command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = "value" + val mockRedisFuture: RedisFuture[String] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.getex(anyString, any[GetExArgs])).thenReturn(mockRedisFuture) + + testClass.getEx("key", 1.second).map { + case Some(value) => + value mustBe expectedValue + verify(lettuceAsyncCommands).getex(meq("key"), any[GetExArgs]) + succeed + case None => fail(s"Value for GETEX(key, 1.second) should be \"$expectedValue\"") + } + } + "delegate GETSET command to Lettuce and lift result into a Future" in { testContext => import testContext._ @@ -74,6 +106,107 @@ class LettuceRedisCommandsClientSpec extends wordspec.FixtureAsyncWordSpec with } } + "delegate GETRANGE command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = "value" + val mockRedisFuture: RedisFuture[String] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.getrange(anyString, anyLong, anyLong)).thenReturn(mockRedisFuture) + + testClass.getRange("key", 0, 1).map { + case Some(value) => + value mustBe expectedValue + verify(lettuceAsyncCommands).getrange("key", 0, 1) + succeed + case None => fail(s"Value for GETRANGE(key, 0, 1) should be \"$expectedValue\"") + } + } + + "delegate MGET command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + + val expectedValue: List[KeyValue[String, String]] = List(KeyValue.fromNullable("key1", "value1"), KeyValue.fromNullable("key2", "value2")) + val mockRedisFuture: RedisFuture[java.util.List[KeyValue[String, String]]] = mockRedisFutureToReturn(expectedValue.asJava) + when(lettuceAsyncCommands.mget(anyString, anyString)).thenReturn(mockRedisFuture) + + testClass.mGet("key1", "key2").map { result => + result mustBe ListMap("key1" -> Some("value1"), "key2" -> Some("value2")) + verify(lettuceAsyncCommands).mget("key1", "key2") + succeed + } + } + + "delegate MSET command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val mockRedisFuture: RedisFuture[String] = mockRedisFutureToReturn("OK") + when(lettuceAsyncCommands.mset(any[java.util.Map[String, String]]())).thenReturn(mockRedisFuture) + + testClass.mSet(Map("key1" -> "value1", "key2" -> "value2")).map { _ => + verify(lettuceAsyncCommands).mset(Map("key1" -> "value1", "key2" -> "value2").asJava) + succeed + } + } + + "delegate MSETNX command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = true + val mockRedisFuture: RedisFuture[java.lang.Boolean] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.msetnx(any[java.util.Map[String, String]]())).thenReturn(mockRedisFuture) + + testClass.mSetNx(Map("key1" -> "value1", "key2" -> "value2")).map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).msetnx(Map("key1" -> "value1", "key2" -> "value2").asJava) + succeed + } + } + + "delegate SET command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = "OK" + val mockRedisFuture: RedisFuture[String] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.set(anyString, anyString)).thenReturn(mockRedisFuture) + + testClass.set("key", "value").map { result => + result mustBe () + verify(lettuceAsyncCommands).set("key", "value") + succeed + } + } + + "delegate SETRANGE command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = 5L + val mockRedisFuture: RedisFuture[java.lang.Long] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.setrange(anyString, anyLong, anyString)).thenReturn(mockRedisFuture) + + testClass.setRange("key", 0, "value").map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).setrange("key", 0, "value") + succeed + } + } + + "delegate STRLEN command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = 5L + val mockRedisFuture: RedisFuture[java.lang.Long] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.strlen(anyString)).thenReturn(mockRedisFuture) + + testClass.strLen("key").map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).strlen("key") + succeed + } + } + + + "delegate HGET command to Lettuce and lift result into a Future" in { testContext => import testContext._