From 64dae097b0d5e25ae8ce36f0e8b8a563265981c6 Mon Sep 17 00:00:00 2001 From: John Loehrer Date: Tue, 27 Aug 2024 10:09:09 -0700 Subject: [PATCH] support some commonly used key methods --- .../commands/RedisKeyAsyncCommands.scala | 166 +++++++++++++ .../LettuceRedisKeyAsyncCommands.scala | 88 ++++++- .../RedisCommandsIntegrationSpec.scala | 223 +++++++++++++++++- .../LettuceRedisKeyAsyncCommandsSpec.scala | 221 ++++++++++++++++- 4 files changed, 688 insertions(+), 10 deletions(-) diff --git a/modules/api/src/main/scala/com/github/scoquelin/arugula/commands/RedisKeyAsyncCommands.scala b/modules/api/src/main/scala/com/github/scoquelin/arugula/commands/RedisKeyAsyncCommands.scala index 8c3a6de..f3be786 100644 --- a/modules/api/src/main/scala/com/github/scoquelin/arugula/commands/RedisKeyAsyncCommands.scala +++ b/modules/api/src/main/scala/com/github/scoquelin/arugula/commands/RedisKeyAsyncCommands.scala @@ -3,6 +3,10 @@ package com.github.scoquelin.arugula.commands import scala.concurrent.Future import scala.concurrent.duration.FiniteDuration +import com.github.scoquelin.arugula.commands.RedisBaseAsyncCommands.{InitialCursor, ScanResults} + +import java.time.Instant + /** * Asynchronous commands for manipulating/querying Keys * @@ -10,8 +14,170 @@ import scala.concurrent.duration.FiniteDuration * @tparam V The value type */ trait RedisKeyAsyncCommands[K, V] { + + /** + * Copy a key to another key + * @param srcKey The key to copy + * @param destKey The key to copy to + * @return True if the key was copied, false otherwise + */ + def copy(srcKey: K, destKey: K): Future[Boolean] + + /** + * Copy a key to another key with additional arguments + * @param srcKey The key to copy + * @param destKey The key to copy to + * @param args Additional arguments for the copy operation + */ + def copy(srcKey: K, destKey: K, args: RedisKeyAsyncCommands.CopyArgs): Future[Unit] + + /** + * Delete one or more keys + * @param key The key(s) to delete + * @return The number of keys that were removed + */ def del(key: K*): Future[Long] + + /** + * Unlink one or more keys. (non-blocking version of DEL) + * @param key The key(s) to unlink + * @return The number of keys that were unlinked + */ + def unlink(key: K*): Future[Long] + + /** + * Serialize a key + * @param key The key to serialize + * @return The serialized value of the key + */ + def dump(key: K): Future[Array[Byte]] + + /** + * Determine if a key exists + * @param key The key to check + * @return True if the key exists, false otherwise + */ def exists(key: K*): Future[Boolean] + + /** + * Set a key's time to live. The key will be automatically deleted after the timeout. + * Implementations may round the timeout to the nearest second if necessary + * but could set a more precise timeout if the underlying Redis client supports it. + * @param key The key to set the expiration for + * @param expiresIn The duration until the key expires + * @return True if the timeout was set, false otherwise + */ def expire(key: K, expiresIn: FiniteDuration): Future[Boolean] + + /** + * Set the expiration for a key as an Instant + * @param key The key to set the expiration for + * @param timestamp The point in time when the key should expire + * @return True if the timeout was set, false otherwise + */ + def expireAt(key: K, timestamp: Instant): Future[Boolean] + + /** + * Get the time to live for a key as an Instant + * @param key The key to get the expiration for + * @return The time to live as a point in time, or None if the key does not exist or does not have an expiration + */ + def expireTime(key: K): Future[Option[Instant]] + + /** + * Find all keys matching the given pattern + * To match all keys, use "*" + * @param pattern The pattern to match + * @return The keys that match the pattern + */ + def keys(pattern: K): Future[List[K]] + + /** + * Move a key to a different database + * @param key The key to move + * @param db The database to move the key to + * @return True if the key was moved, false otherwise + */ + def move(key: K, db: Int): Future[Boolean] + + /** + * Rename a key + * @param key The key to rename + * @param newKey The new name for the key + */ + def rename(key: K, newKey: K): Future[Unit] + + /** + * Rename a key, but only if the new key does not already exist + * @param key The key to rename + * @param newKey The new name for the key + * @return True if the key was renamed, false otherwise + */ + def renameNx(key: K, newKey: K): Future[Boolean] + + /** + * Restore a key from its serialized form + * @param key The key to restore + * @param serializedValue The serialized value of the key + * @param args Additional arguments for the restore operation + */ + def restore(key: K, serializedValue: Array[Byte], args: RedisKeyAsyncCommands.RestoreArgs = RedisKeyAsyncCommands.RestoreArgs()): Future[Unit] + + /** + * Scan the keyspace + * @param cursor The cursor to start scanning from + * @param matchPattern An optional pattern to match keys against + * @param limit An optional limit on the number of keys to return + * @return The keys that were scanned + */ + def scan(cursor: String = InitialCursor, matchPattern: Option[String] = None, limit: Option[Int] = None): Future[ScanResults[List[K]]] + + /** + * Get the time to live for a key. + * Implementations may return a more precise time to live if the underlying Redis client supports it. + * Rather than expose the underlying Redis client's API, this method returns a FiniteDuration which can + * be rounded to the nearest second if necessary. + * @param key The key to get the expiration for + * @return The time to live, or None if the key does not exist or does not have an expiration + */ def ttl(key: K): Future[Option[FiniteDuration]] + + /** + * Alters the last access time of a key(s). A key is ignored if it does not exist. + * @param key The key(s) to touch + * @return The number of keys that were touched + */ + def touch(key: K*): Future[Long] + + /** + * Get the type of a key + * @param key The key to get the type of + * @return The type of the key + */ + def `type`(key: K): Future[String] +} + +object RedisKeyAsyncCommands { + case class CopyArgs(replace: Boolean = false, destinationDb: Option[Int] = None) + + case class RestoreArgs( + replace: Boolean = false, + idleTime: Option[FiniteDuration] = None, + ttl: Option[FiniteDuration] = None, + absTtl: Option[Instant] = None, + frequency: Option[Long] = None, + ){ + def isEmpty: Boolean = !replace && idleTime.isEmpty && frequency.isEmpty && ttl.isEmpty && absTtl.isEmpty + } } + +// Commands to be Implemented: +//migrate +//objectEncoding +//objectFreq +//objectIdletime +//objectRefcount +//randomkey +//sort +//sortReadOnly +//sortStore diff --git a/modules/core/src/main/scala/com/github/scoquelin/arugula/commands/LettuceRedisKeyAsyncCommands.scala b/modules/core/src/main/scala/com/github/scoquelin/arugula/commands/LettuceRedisKeyAsyncCommands.scala index e7dff63..45bc5b9 100644 --- a/modules/core/src/main/scala/com/github/scoquelin/arugula/commands/LettuceRedisKeyAsyncCommands.scala +++ b/modules/core/src/main/scala/com/github/scoquelin/arugula/commands/LettuceRedisKeyAsyncCommands.scala @@ -1,17 +1,37 @@ package com.github.scoquelin.arugula.commands +import scala.collection.convert.ImplicitConversions.`collection AsScalaIterable` import scala.concurrent.Future import scala.concurrent.duration.FiniteDuration + +import com.github.scoquelin.arugula.commands.RedisBaseAsyncCommands.InitialCursor import com.github.scoquelin.arugula.internal.LettuceRedisCommandDelegation +import io.lettuce.core.{CopyArgs, ScanCursor} +import java.time.Instant import java.util.concurrent.TimeUnit private[arugula] trait LettuceRedisKeyAsyncCommands[K, V] extends RedisKeyAsyncCommands[K, V] with LettuceRedisCommandDelegation[K, V] { import LettuceRedisKeyAsyncCommands.toFiniteDuration + override def copy(srcKey: K, destKey: K): Future[Boolean] = + delegateRedisClusterCommandAndLift(_.copy(srcKey, destKey)).map(Boolean2boolean) + + override def copy(srcKey: K, destKey: K, args: RedisKeyAsyncCommands.CopyArgs): Future[Unit] = { + val copyArgs: CopyArgs = CopyArgs.Builder.replace(args.replace) + args.destinationDb.foreach(copyArgs.destinationDb(_)) + delegateRedisClusterCommandAndLift(_.copy(srcKey, destKey, copyArgs)).map(_ => ()) + } + override def del(key: K*): Future[Long] = delegateRedisClusterCommandAndLift(_.del(key: _*)).map(Long2long) + override def unlink(key: K*): Future[Long] = + delegateRedisClusterCommandAndLift(_.unlink(key: _*)).map(Long2long) + + override def dump(key: K): Future[Array[Byte]] = + delegateRedisClusterCommandAndLift(_.dump(key)) + override def exists(key: K*): Future[Boolean] = delegateRedisClusterCommandAndLift(_.exists(key: _*)).map(_ == key.size.toLong) @@ -23,8 +43,74 @@ private[arugula] trait LettuceRedisKeyAsyncCommands[K, V] extends RedisKeyAsyncC delegateRedisClusterCommandAndLift(_.expire(key, expiresIn.toSeconds)) }).map(Boolean2boolean) + + override def expireAt(key: K, timestamp: Instant): Future[Boolean] = + delegateRedisClusterCommandAndLift(_.pexpireat(key, timestamp.toEpochMilli)).map(Boolean2boolean) + + override def expireTime(key: K): Future[Option[Instant]] = { + delegateRedisClusterCommandAndLift(_.pexpiretime(key)).map { + case d if d < 0 => None + case d => Some(Instant.ofEpochMilli(d)) + } + } + + override def keys(pattern: K): Future[List[K]] = + delegateRedisClusterCommandAndLift(_.keys(pattern)).map(_.toList) + + override def move(key: K, db: Int): Future[Boolean] = + delegateRedisClusterCommandAndLift(_.move(key, db)).map(Boolean2boolean) + + override def rename(key: K, newKey: K): Future[Unit] = + delegateRedisClusterCommandAndLift(_.rename(key, newKey)).map(_ => ()) + + override def renameNx(key: K, newKey: K): Future[Boolean] = + delegateRedisClusterCommandAndLift(_.renamenx(key, newKey)).map(Boolean2boolean) + + override def restore(key: K, serializedValue: Array[Byte], args: RedisKeyAsyncCommands.RestoreArgs = RedisKeyAsyncCommands.RestoreArgs()): Future[Unit] = { + val restoreArgs = new io.lettuce.core.RestoreArgs() + args.ttl.foreach { duration => + restoreArgs.ttl(duration.toMillis) + } + args.idleTime.foreach { duration => + restoreArgs.idleTime(duration.toMillis) + } + args.frequency.foreach { frequency => + restoreArgs.frequency(frequency) + } + if(args.replace) restoreArgs.replace() + args.absTtl.foreach{ instant => + restoreArgs.absttl(true) + restoreArgs.ttl(instant.toEpochMilli) + } + delegateRedisClusterCommandAndLift(_.restore(key, serializedValue, restoreArgs)).map(_ => ()) + } + + override def scan(cursor: String = InitialCursor, matchPattern: Option[String] = None, limit: Option[Int] = None): Future[RedisBaseAsyncCommands.ScanResults[List[K]]] = { + val scanArgs = (matchPattern, limit) match { + case (Some(pattern), Some(count)) => Some(io.lettuce.core.ScanArgs.Builder.matches(pattern).limit(count)) + case (Some(pattern), None) => Some(io.lettuce.core.ScanArgs.Builder.matches(pattern)) + case (None, Some(count)) => Some(io.lettuce.core.ScanArgs.Builder.limit(count)) + case _ => None + } + val result = scanArgs match { + case Some(args) => delegateRedisClusterCommandAndLift(_.scan(ScanCursor.of(cursor), args)) + case None => delegateRedisClusterCommandAndLift(_.scan(ScanCursor.of(cursor))) + } + result.map { scanResult => + RedisBaseAsyncCommands.ScanResults(scanResult.getCursor, scanResult.isFinished, scanResult.getKeys.toList) + } + } + override def ttl(key: K): Future[Option[FiniteDuration]] = - delegateRedisClusterCommandAndLift(_.ttl(key)).map(toFiniteDuration(TimeUnit.SECONDS)) + delegateRedisClusterCommandAndLift(_.pttl(key)).map(toFiniteDuration(TimeUnit.MILLISECONDS)) + + override def touch(key: K*): Future[Long] = { + delegateRedisClusterCommandAndLift(_.touch(key: _*)).map(Long2long) + } + + override def `type`(key: K): Future[String] = { + delegateRedisClusterCommandAndLift(_.`type`(key)) + } } private[this] object LettuceRedisKeyAsyncCommands { 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 fda9f02..0bba113 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 @@ -9,9 +9,10 @@ import org.scalatest.matchers.should.Matchers import scala.concurrent.duration._ import com.github.scoquelin.arugula.commands.RedisBaseAsyncCommands.InitialCursor -import com.github.scoquelin.arugula.commands.RedisListAsyncCommands +import com.github.scoquelin.arugula.commands.{RedisKeyAsyncCommands, RedisListAsyncCommands} import com.github.scoquelin.arugula.commands.RedisStringAsyncCommands.{BitFieldCommand, BitFieldDataType} +import java.time.Instant import java.util.concurrent.TimeUnit class RedisCommandsIntegrationSpec extends BaseRedisCommandsIntegrationSpec with Matchers { @@ -31,15 +32,229 @@ class RedisCommandsIntegrationSpec extends BaseRedisCommandsIntegrationSpec with } } + "leveraging RedisKeyAsyncCommands" should { + + "copy a key to another key" in { + withRedisSingleNodeAndCluster(RedisCodec.Utf8WithValueAsStringCodec) { client => + val suffix = "{user1}" + val srcKey = randomKey("src-key") + suffix + val destKey = randomKey("dest-key") + suffix + val value = "value" + + for { + _ <- client.set(srcKey, value) + copied <- client.copy(srcKey, destKey) + _ <- copied shouldBe true + destValue <- client.get(destKey) + _ <- destValue match { + case Some(expectedValue) => expectedValue shouldBe value + case None => fail("Expected value not found") + } + } yield succeed + } + } + + "copy a key to another key with additional arguments" in { + withRedisSingleNodeAndCluster(RedisCodec.Utf8WithValueAsStringCodec) { client => + val suffix = "{user1}" + val srcKey = randomKey("src-key") + suffix + val destKey = randomKey("dest-key") + suffix + val value = "value" + + for { + _ <- client.set(srcKey, value) + _ <- client.set(destKey, "other-value") + _ <- client.copy(srcKey, destKey, RedisKeyAsyncCommands.CopyArgs(replace = true)) + destValue <- client.get(destKey) + _ <- destValue match { + case Some(expectedValue) => expectedValue shouldBe value + case None => fail("Expected value not found") + } + } yield succeed + } + } + + "delete one or more keys" in { + withRedisSingleNodeAndCluster(RedisCodec.Utf8WithValueAsStringCodec) { client => + val key1 = randomKey("key1") + val key2 = randomKey("key2") + val key3 = randomKey("key3") + val value = "value" + + for { + _ <- client.set(key1, value) + _ <- client.set(key2, value) + _ <- client.set(key3, value) + deleted <- client.del(key1, key2, key3) + _ <- deleted shouldBe 3L + key1Exists <- client.exists(key1) + _ <- key1Exists shouldBe false + key2Exists <- client.exists(key2) + _ <- key2Exists shouldBe false + key3Exists <- client.exists(key3) + _ <- key3Exists shouldBe false + _ <- client.set(key1, value) + unlinkResult <- client.unlink(key1) + _ <- unlinkResult shouldBe 1L + key1Exists <- client.exists(key1) + _ <- key1Exists shouldBe false + } yield succeed + } + } + + "determine if a key exists" in { + withRedisSingleNodeAndCluster(RedisCodec.Utf8WithValueAsStringCodec) { client => + val key = randomKey() + val value = "value" + + for { + keyExists <- client.exists(key) + _ <- keyExists shouldBe false + _ <- client.set(key, value) + keyExists <- client.exists(key) + _ <- keyExists shouldBe true + } yield succeed + } + } + + "set a key's time to live in seconds" in { + withRedisSingleNodeAndCluster(RedisCodec.Utf8WithValueAsStringCodec) { client => + val key = randomKey() + val value = "value" + val expireIn = 30.minutes + + for { + _ <- client.set(key, value) + _ <- client.expire(key, expireIn) + ttl <- client.ttl(key) + _ <- ttl match { + case Some(timeToLive) => assert(timeToLive > (expireIn - 1.minute) && timeToLive <= expireIn) + case None => fail("Expected time to live not found") + } + expireAtResult <- client.expireAt(key, Instant.now.plusSeconds(60)) + _ <- expireAtResult shouldBe true + ttl <- client.ttl(key) + _ <- ttl match { + case Some(timeToLive) => assert(timeToLive > 0.seconds && timeToLive <= 60.seconds) + case None => fail("Expected time to live not found") + } + expireTime <- client.expireTime(key) + _ <- expireTime match { + case Some(expiration) => assert(expiration.isAfter(Instant.now.plusSeconds(55)) && expiration.isBefore(Instant.now.plusSeconds(65))) + case None => fail("Expected expiration time not found") + } + } yield succeed + } + } + + "rename a key" in { + withRedisSingleNodeAndCluster(RedisCodec.Utf8WithValueAsStringCodec) { client => + val suffix = "{user1}" + val key = randomKey("key") + suffix + val newKey = randomKey("new") + suffix + val value = "value" + for { + _ <- client.set(key, value) + _ <- client.rename(key, newKey) + keyExists <- client.exists(key) + _ <- keyExists shouldBe false + result <- client.get(newKey) + _ <- result match { + case Some(expectedValue) => expectedValue shouldBe value + case None => fail("Expected value not found") + } + _ <- client.set(key, "other-value") + renamed <- client.renameNx(key, newKey) + _ <- renamed shouldBe false + } yield succeed + } + } + + "list keys with a pattern" in { + withRedisSingleNode(RedisCodec.Utf8WithValueAsStringCodec) { client => + val prefix = randomKey("key") + val key1 = prefix + "1" + val key2 = prefix + "2" + val key3 = prefix + "3" + for { + _ <- client.set(key1, "value") + _ <- client.set(key2, "value") + _ <- client.set(key3, "value") + keys <- client.keys(prefix + "*") + _ <- keys should contain allOf (key1, key2, key3) + } yield succeed + } + } + + "dump and restore a key" in { + withRedisSingleNodeAndCluster(RedisCodec.Utf8WithValueAsStringCodec) { client => + val key = randomKey("dump-key") + val value = "value" + for { + _ <- client.set(key, value) + dumped <- client.dump(key) + _ <- client.del(key) + _ <- client.restore(key, dumped, RedisKeyAsyncCommands.RestoreArgs( + ttl = Some(FiniteDuration(1, TimeUnit.HOURS)) + )) + restored <- client.get(key) + _ <- restored match { + case Some(expectedValue) => expectedValue shouldBe value + case None => fail("Expected value not found") + } + ttl <- client.ttl(key) + _ <- ttl match { + case Some(timeToLive) => assert(timeToLive > 30.minutes && timeToLive <= 65.minutes) + case None => fail("Expected time to live not found") + } + _ <- client.restore(key, dumped, RedisKeyAsyncCommands.RestoreArgs( + replace = true, + absTtl = Some(Instant.now.plusSeconds(60)), + frequency = Some(5) + )) + + ttl <- client.ttl(key) + _ <- ttl match { + case Some(timeToLive) => assert(timeToLive > 30.seconds && timeToLive <= 65.seconds, s"Time to live was $timeToLive") + case None => fail("Expected time to live not found") + } + + } yield succeed + } + } + + "support scanning the keyspace" in { + withRedisSingleNode(RedisCodec.Utf8WithValueAsStringCodec) { client => + val prefix = randomKey("scan-key") + val key1 = prefix + "1" + val key2 = prefix + "2" + val key3 = prefix + "3" + for { + _ <- client.set(key1, "value") + _ <- client.set(key2, "value") + _ <- client.set(key3, "value") + scanResult <- client.scan(matchPattern = Some(prefix + "*")) + _ <- scanResult.values should contain allOf (key1, key2, key3) + _ <- scanResult.finished shouldBe true + } yield succeed + } + } + } + "leveraging RedisStringAsyncCommands" should { "create, check, retrieve, and delete a key holding a Long value" in { withRedisSingleNodeAndCluster(RedisCodec.Utf8WithValueAsLongCodec) { client => - val key = randomKey() + val key = randomKey("long-key") val value = 1L for { _ <- client.set(key, value) + result <- client.get(key) + _ <- result match { + case Some(expectedValue) => expectedValue shouldBe value + case None => fail("Expected value not found") + } keyExists <- client.exists(key) _ <- keyExists shouldBe true existingKeyAdded <- client.setNx(key, value) //noop since key already exists @@ -51,6 +266,10 @@ class RedisCommandsIntegrationSpec extends BaseRedisCommandsIntegrationSpec with case Some(expectedValue) => expectedValue shouldBe value case None => fail("Expected value not found") } + touched <- client.touch(key) + _ <- touched shouldBe 1L + typeResult <- client.`type`(key) + _ <- typeResult shouldBe "string" deleted <- client.del(key) _ <- deleted shouldBe 1L keyExists <- client.exists(key) diff --git a/modules/tests/test/src/test/scala/com/github/scoquelin/arugula/LettuceRedisKeyAsyncCommandsSpec.scala b/modules/tests/test/src/test/scala/com/github/scoquelin/arugula/LettuceRedisKeyAsyncCommandsSpec.scala index 0afc871..6aeb9d2 100644 --- a/modules/tests/test/src/test/scala/com/github/scoquelin/arugula/LettuceRedisKeyAsyncCommandsSpec.scala +++ b/modules/tests/test/src/test/scala/com/github/scoquelin/arugula/LettuceRedisKeyAsyncCommandsSpec.scala @@ -1,13 +1,16 @@ package com.github.scoquelin.arugula import scala.concurrent.duration.{DurationInt, DurationLong} +import scala.jdk.CollectionConverters.{CollectionHasAsScala, IterableHasAsJava} import io.lettuce.core.RedisFuture -import org.mockito.ArgumentMatchers.{anyLong, anyString} +import org.mockito.ArgumentMatchers.{anyInt, anyLong, anyString, any, eq => meq} import org.mockito.Mockito.{verify, when} import org.scalatest.matchers.must.Matchers import org.scalatest.{FutureOutcome, wordspec} +import java.time.Instant + class LettuceRedisKeyAsyncCommandsSpec extends wordspec.FixtureAsyncWordSpec with Matchers { override type FixtureParam = LettuceRedisCommandsClientFixture.TestContext @@ -16,6 +19,19 @@ class LettuceRedisKeyAsyncCommandsSpec extends wordspec.FixtureAsyncWordSpec wit withFixture(test.toNoArgAsyncTest(new LettuceRedisCommandsClientFixture.TestContext)) "LettuceRedisKeyAsyncCommands" should { + "delegate COPY command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val mockRedisFuture: RedisFuture[java.lang.Boolean] = mockRedisFutureToReturn(java.lang.Boolean.TRUE) + when(lettuceAsyncCommands.copy(anyString, anyString)).thenReturn(mockRedisFuture) + + testClass.copy("srcKey", "destKey").map { result => + result mustBe true + verify(lettuceAsyncCommands).copy("srcKey", "destKey") + succeed + } + } + "delegate DEL command to Lettuce and lift result into a Future" in { testContext => import testContext._ @@ -30,6 +46,20 @@ class LettuceRedisKeyAsyncCommandsSpec extends wordspec.FixtureAsyncWordSpec wit } } + "delegate UNLINK command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = 1L + val mockRedisFuture: RedisFuture[java.lang.Long] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.unlink(anyString)).thenReturn(mockRedisFuture) + + testClass.unlink("key").map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).unlink("key") + succeed + } + } + "delegate DEL command with multiple keys to Lettuce and lift result into a Future" in { testContext => import testContext._ @@ -44,6 +74,20 @@ class LettuceRedisKeyAsyncCommandsSpec extends wordspec.FixtureAsyncWordSpec wit } } + "delegate DUMP command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = Array.emptyByteArray + val mockRedisFuture: RedisFuture[Array[Byte]] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.dump(anyString)).thenReturn(mockRedisFuture) + + testClass.dump("key").map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).dump("key") + succeed + } + } + "delegate EXISTS command to Lettuce and lift result into a Future" in { testContext => import testContext._ @@ -101,21 +145,184 @@ class LettuceRedisKeyAsyncCommandsSpec extends wordspec.FixtureAsyncWordSpec wit } } - "delegate TTL command to Lettuce and lift result into a Future" in { testContext => + "delegate PEXPIREAT command to Lettuce and lift result into a Future" in { testContext => import testContext._ - val expectedValue = 1L + val expectedValue = true + val mockRedisFuture: RedisFuture[java.lang.Boolean] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.pexpireat(anyString, anyLong)).thenReturn(mockRedisFuture) + + testClass.expireAt("key", Instant.ofEpochMilli(1)).map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).pexpireat("key", 1L) + succeed + } + } + + "delegate PEXPIRETIME command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = 1000L + val mockRedisFuture: RedisFuture[java.lang.Long] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.pexpiretime(anyString)).thenReturn(mockRedisFuture) + + testClass.expireTime("key").map { + case Some(result) => + result mustBe Instant.ofEpochMilli(expectedValue) + verify(lettuceAsyncCommands).pexpiretime("key") + succeed + case None => + fail(s"PEXPIRETIME for key should be $expectedValue") + } + } + + "delegate KEYS command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = java.util.List.of("key1", "key2") + val mockRedisFuture: RedisFuture[java.util.List[String]] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.keys(anyString)).thenReturn(mockRedisFuture) + testClass.keys("pattern").map { result => + result mustBe expectedValue.asScala.toList + verify(lettuceAsyncCommands).keys("pattern") + succeed + } + } + + "delegate MOVE 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.move(anyString, anyInt)).thenReturn(mockRedisFuture) + + testClass.move("key", 1).map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).move("key", 1) + succeed + } + } + + "delegate RENAME command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val mockRedisFuture: RedisFuture[String] = mockRedisFutureToReturn("OK") + when(lettuceAsyncCommands.rename(anyString, anyString)).thenReturn(mockRedisFuture) + + testClass.rename("key", "newKey").map { result => + result mustBe () + verify(lettuceAsyncCommands).rename("key", "newKey") + succeed + } + } + + "delegate RENAMENX 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.renamenx(anyString, anyString)).thenReturn(mockRedisFuture) + + testClass.renameNx("key", "newKey").map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).renamenx("key", "newKey") + succeed + } + } + + "delegate RESTORE command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val mockRedisFuture: RedisFuture[String] = mockRedisFutureToReturn("OK") + when(lettuceAsyncCommands.restore(anyString, any[Array[Byte]], any[io.lettuce.core.RestoreArgs])).thenReturn(mockRedisFuture) + + testClass.restore("key", Array.emptyByteArray).map { result => + result mustBe () + verify(lettuceAsyncCommands).restore(meq("key"), any[Array[Byte]], any[io.lettuce.core.RestoreArgs]) + succeed + } + } + + "delegate SCAN command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = new io.lettuce.core.KeyScanCursor[String] + expectedValue.setFinished(true) + expectedValue.setCursor("0") + val mockRedisFuture: RedisFuture[io.lettuce.core.KeyScanCursor[String]] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.scan(any[io.lettuce.core.ScanCursor])).thenReturn(mockRedisFuture) + + testClass.scan().map { result => + result.cursor mustBe "0" + result.finished mustBe true + result.values mustBe empty + verify(lettuceAsyncCommands).scan(any[io.lettuce.core.ScanCursor]) + succeed + } + } + + "delegate SCAN command with arguments to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = new io.lettuce.core.KeyScanCursor[String] + expectedValue.setFinished(true) + expectedValue.setCursor("0") + val mockRedisFuture: RedisFuture[io.lettuce.core.KeyScanCursor[String]] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.scan(any[io.lettuce.core.ScanCursor], any[io.lettuce.core.ScanArgs])).thenReturn(mockRedisFuture) + + testClass.scan("0", Some("pattern"), Some(10)).map { result => + result.cursor mustBe "0" + result.finished mustBe true + result.values mustBe empty + verify(lettuceAsyncCommands).scan(any[io.lettuce.core.ScanCursor], any[io.lettuce.core.ScanArgs]) + succeed + } + } + + "delegate PTTL command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = 1000L val mockRedisFuture: RedisFuture[java.lang.Long] = mockRedisFutureToReturn(expectedValue) when(lettuceAsyncCommands.exists(anyString, anyString)).thenReturn(mockRedisFuture) - when(lettuceAsyncCommands.ttl(anyString)).thenReturn(mockRedisFuture) + when(lettuceAsyncCommands.pttl(anyString)).thenReturn(mockRedisFuture) testClass.ttl("key").map { case Some(result) => - result mustBe (expectedValue).seconds - verify(lettuceAsyncCommands).ttl("key") + result mustBe 1.seconds + verify(lettuceAsyncCommands).pttl("key") succeed case None => - fail(s"TTL for key should be $expectedValue second(s)") + fail(s"PTTL for key should be $expectedValue second(s)") + } + } + + "delegate TOUCH command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = 1L + val mockRedisFuture: RedisFuture[java.lang.Long] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.touch(anyString)).thenReturn(mockRedisFuture) + + testClass.touch("key").map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).touch("key") + succeed + } + } + + + "delegate TYPE command to Lettuce and lift result into a Future" in { testContext => + import testContext._ + + val expectedValue = "string" + val mockRedisFuture: RedisFuture[String] = mockRedisFutureToReturn(expectedValue) + when(lettuceAsyncCommands.`type`(anyString)).thenReturn(mockRedisFuture) + + testClass.`type`("key").map { result => + result mustBe expectedValue + verify(lettuceAsyncCommands).`type`("key") + succeed } } }