diff --git a/modules/effects/src/main/scala/dev/profunktor/redis4cats/algebra/json.scala b/modules/effects/src/main/scala/dev/profunktor/redis4cats/algebra/json.scala new file mode 100644 index 00000000..d910182c --- /dev/null +++ b/modules/effects/src/main/scala/dev/profunktor/redis4cats/algebra/json.scala @@ -0,0 +1,99 @@ +/* + * Copyright 2018-2025 ProfunKtor + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.profunktor.redis4cats.algebra + +import dev.profunktor.redis4cats.algebra.json.JsonGetArgs +import io.lettuce.core.json.arguments.{ JsonGetArgs => LJsonGetArgs, JsonRangeArgs } +import io.lettuce.core.json.{ JsonPath, JsonType, JsonValue } + +trait JsonCommands[F[_], K, V] + extends JsonArray[F, K, V] + with JsonGet[F, K, V] + with JsonSet[F, K, V] + with JsonNumber[F, K, V] + with JsonString[F, K, V] + with JsonBoolean[F, K, V] { + + /** Clear container values (arrays/objects) and set numeric values to 0 + * @return + * Long the number of values removed plus all the matching JSON numerical values that are zeroed. + */ + def jClear(key: K, path: JsonPath): F[Long] + def jClear(key: K): F[Long] + + /** Deletes a value inside the JSON document at a given JsonPath + * @return + * Long the number of values removed (0 or more). + */ + def jDel(key: K, path: JsonPath): F[Long] + def jDel(key: K): F[Long] + + def jsonType(key: K, path: JsonPath): F[List[JsonType]] + def jsonType(key: K): F[List[JsonType]] +} +trait JsonGet[F[_], K, V] { + def jGet(key: K, path: JsonPath, paths: JsonPath*): F[List[JsonValue]] + def jGet(key: K, arg: JsonGetArgs, path: JsonPath, paths: JsonPath*): F[List[JsonValue]] + def jMget(path: JsonPath, key: K, keys: K*): F[List[JsonValue]] + def jObjKeys(key: K, path: JsonPath): F[List[V]] + def jObjLen(key: K, path: JsonPath): F[Long] +} +trait JsonSet[F[_], K, V] { + def jMset(key: K, values: (JsonPath, JsonValue)*): F[Boolean] + def jSet(key: K, path: JsonPath, value: JsonValue): F[Boolean] + def jSetnx(key: K, path: JsonPath, value: JsonValue): F[Boolean] + def jSetxx(key: K, path: JsonPath, value: JsonValue): F[Boolean] + def jsonMerge(key: K, jsonPath: JsonPath, value: JsonValue): F[String]; +} +trait JsonNumber[F[_], K, V] { + def numIncrBy(key: K, path: JsonPath, number: Number): F[List[Number]] +} +trait JsonString[F[_], K, V] { + def strAppend(key: K, path: JsonPath, value: JsonValue): F[List[Long]] + def strLen(key: K, path: JsonPath): F[Long] +} +trait JsonBoolean[F[_], K, V] { + def toggle(key: K, path: JsonPath): F[List[Long]] +} + +trait JsonArray[F[_], K, V] { + def arrAppend(key: K, path: JsonPath, value: JsonValue*): F[Unit] + def arrIndex(key: K, path: JsonPath, value: JsonValue, range: JsonRangeArgs): F[List[Long]] + def arrInsert(key: K, path: JsonPath, index: Int, value: JsonValue*): F[List[Long]] + def arrLen(key: K, path: JsonPath): F[List[Long]] + def arrPop(key: K, path: JsonPath, index: Int): F[List[JsonValue]] + def arrTrim(key: K, path: JsonPath, range: JsonRangeArgs): F[List[Long]] + +} + +object json { + final case class JsonGetArgs( + indent: Option[String], + newline: Option[String], + space: Option[String] + ) { + def underlying: LJsonGetArgs = { + val args = new LJsonGetArgs() + indent.foreach(args.indent) + newline.foreach(args.newline) + space.foreach(args.space) + args + } + } + + object JsonGetArgs +} diff --git a/modules/effects/src/main/scala/dev/profunktor/redis4cats/commands.scala b/modules/effects/src/main/scala/dev/profunktor/redis4cats/commands.scala index 9e8c3826..2b6446ba 100644 --- a/modules/effects/src/main/scala/dev/profunktor/redis4cats/commands.scala +++ b/modules/effects/src/main/scala/dev/profunktor/redis4cats/commands.scala @@ -22,6 +22,7 @@ import dev.profunktor.redis4cats.effect.Log trait RedisCommands[F[_], K, V] extends StringCommands[F, K, V] + with JsonCommands[F, K, V] with HashCommands[F, K, V] with SetCommands[F, K, V] with SortedSetCommands[F, K, V] diff --git a/modules/effects/src/main/scala/dev/profunktor/redis4cats/redis.scala b/modules/effects/src/main/scala/dev/profunktor/redis4cats/redis.scala index ccbc89ea..c8f3da04 100644 --- a/modules/effects/src/main/scala/dev/profunktor/redis4cats/redis.scala +++ b/modules/effects/src/main/scala/dev/profunktor/redis4cats/redis.scala @@ -20,7 +20,7 @@ import cats._ import cats.data.NonEmptyList import cats.effect.kernel._ import cats.syntax.all._ -import dev.profunktor.redis4cats.algebra.BitCommandOperation +import dev.profunktor.redis4cats.algebra.{ json, BitCommandOperation } import dev.profunktor.redis4cats.algebra.BitCommandOperation.Overflows import dev.profunktor.redis4cats.config.Redis4CatsConfig import dev.profunktor.redis4cats.connection._ @@ -34,6 +34,8 @@ import io.lettuce.core.XReadArgs.StreamOffset import io.lettuce.core.api.async.RedisAsyncCommands import io.lettuce.core.cluster.api.async.RedisClusterAsyncCommands import io.lettuce.core.cluster.api.sync.{ RedisClusterCommands => RedisClusterSyncCommands } +import io.lettuce.core.json.arguments.{ JsonMsetArgs, JsonRangeArgs, JsonSetArgs } +import io.lettuce.core.json.{ JsonPath, JsonType, JsonValue } import io.lettuce.core.{ BitFieldArgs, ClientOptions, @@ -693,7 +695,7 @@ private[redis4cats] class BaseRedis[F[_]: FutureLift: MonadThrow: Log, K, V]( case SetArg.Ttl.Keep => jSetArgs.keepttl() } - async.flatMap(_.set(key, value, jSetArgs).futureLift.map(_ == "OK")) + async.flatMap(_.set(key, value, jSetArgs).futureLift.map(_.isSuccess)) } override def setNx(key: K, value: V): F[Boolean] = @@ -759,6 +761,101 @@ private[redis4cats] class BaseRedis[F[_]: FutureLift: MonadThrow: Log, K, V]( override def mSetNx(keyValues: Map[K, V]): F[Boolean] = async.flatMap(_.msetnx(keyValues.asJava).futureLift.map(x => Boolean.box(x))) + /** ***************************** JSON API ********************************* + */ + override def jsonType(key: K, path: JsonPath): F[List[JsonType]] = + async.flatMap(_.jsonType(key, path).futureLift.map(_.asScala.toList)) + + override def jsonType(key: K): F[List[JsonType]] = async.flatMap(_.jsonType(key).futureLift.map(_.asScala.toList)) + + override def jClear(key: K, path: JsonPath): F[Long] = + async.flatMap(_.jsonClear(key, path).futureLift.map(x => Long.box(x))) + + override def jClear(key: K): F[Long] = + async.flatMap(_.jsonClear(key).futureLift.map(x => Long.box(x))) + + override def jDel(key: K, path: JsonPath): F[Long] = + async.flatMap(_.jsonDel(key, path).futureLift.map(x => Long.box(x))) + + override def jDel(key: K): F[Long] = async.flatMap(_.jsonDel(key).futureLift.map(x => Long.box(x))) + + /** * JSON GETTERS ** + */ + override def jGet(key: K, path: JsonPath, paths: JsonPath*): F[List[JsonValue]] = { + val all = path +: paths + async.flatMap(_.jsonGet(key, all: _*).futureLift.map(_.asScala.toList)) + } + + override def jGet(key: K, arg: json.JsonGetArgs, path: JsonPath, paths: JsonPath*): F[List[JsonValue]] = { + val all = path +: paths + val options = arg.underlying + async.flatMap(_.jsonGet(key, options, all: _*).futureLift.map(_.asScala.toList)) + } + + override def jMget(path: JsonPath, key: K, keys: K*): F[List[JsonValue]] = { + val all = key +: keys + async.flatMap(_.jsonMGet(path, all: _*).futureLift.map(_.asScala.toList)) + } + + override def jObjKeys(key: K, path: JsonPath): F[List[V]] = + async.flatMap(_.jsonObjkeys(key, path).futureLift.map(_.asScala.toList)) + + override def jObjLen(key: K, path: JsonPath): F[Long] = + async.flatMap(_.jsonObjlen(key, path).futureLift.map(x => Long.unbox(x))) + + /** * JSON ARRAY ** + */ + override def arrAppend(key: K, path: JsonPath, value: JsonValue*): F[Unit] = + async.flatMap(_.jsonArrappend(key, path, value: _*).futureLift.void) + + override def arrIndex(key: K, path: JsonPath, value: JsonValue, range: JsonRangeArgs): F[List[Long]] = + async.flatMap(_.jsonArrindex(key, path, value, range).futureLift.map(_.asScala.toList.map(Long.unbox(_)))) + + override def arrInsert(key: K, path: JsonPath, index: Int, value: JsonValue*): F[List[Long]] = + async.flatMap(_.jsonArrinsert(key, path, index, value: _*).futureLift.map(_.asScala.toList.map(Long.unbox(_)))) + + override def arrLen(key: K, path: JsonPath): F[List[Long]] = + async.flatMap(_.jsonArrlen(key, path).futureLift.map(_.asScala.toList.map(Long.unbox(_)))) + + override def arrPop(key: K, path: JsonPath, index: Int): F[List[JsonValue]] = + async.flatMap(_.jsonArrpop(key, path, index).futureLift.map(_.asScala.toList)) + + override def arrTrim(key: K, path: JsonPath, range: JsonRangeArgs): F[List[Long]] = + async.flatMap(_.jsonArrtrim(key, path, range).futureLift.map(_.asScala.toList.map(Long.unbox(_)))) + + override def toggle(key: K, path: JsonPath): F[List[Long]] = + async.flatMap(_.jsonToggle(key, path).futureLift.map(_.asScala.toList.map(Long.unbox(_)))) + + override def numIncrBy(key: K, path: JsonPath, number: Number): F[List[Number]] = + async.flatMap(_.jsonNumincrby(key, path, number).futureLift.map(_.asScala.toList)) + + override def jMset(key: K, values: (JsonPath, JsonValue)*): F[Boolean] = { + val jValues: util.List[JsonMsetArgs[K, V]] = + values + .map { case (path, value) => new JsonMsetArgs(key, path, value) } + .asJava + .asInstanceOf[util.List[JsonMsetArgs[K, V]]] + async.flatMap(_.jsonMSet(jValues).futureLift.map(_.isSuccess)) + } + + override def jSet(key: K, path: JsonPath, value: JsonValue): F[Boolean] = + async.flatMap(_.jsonSet(key, path, value).futureLift).map(Option(_).exists(_.isSuccess)) + + override def jSetnx(key: K, path: JsonPath, value: JsonValue): F[Boolean] = + async.flatMap(_.jsonSet(key, path, value, new JsonSetArgs().nx()).futureLift.map(_.isSuccess)) + + override def jSetxx(key: K, path: JsonPath, value: JsonValue): F[Boolean] = + async.flatMap(_.jsonSet(key, path, value, new JsonSetArgs().xx()).futureLift.map(_.isSuccess)) + + override def jsonMerge(key: K, jsonPath: JsonPath, value: JsonValue): F[String] = + async.flatMap(_.jsonMerge(key, jsonPath, value).futureLift) + + override def strAppend(key: K, path: JsonPath, value: JsonValue): F[List[Long]] = + async.flatMap(_.jsonStrappend(key, path, value).futureLift.map(_.asScala.toList.map(x => Long.unbox(x)))) + + override def strLen(key: K, path: JsonPath): F[Long] = + async.flatMap(_.jsonStrlen(key, path).futureLift.map(x => Long.unbox(x))) + // format: off /******************************* Hashes API **********************************/ // format: on @@ -1389,13 +1486,13 @@ private[redis4cats] class BaseRedis[F[_]: FutureLift: MonadThrow: Log, K, V]( conn.async.flatMap(_.select(index).futureLift.void) override def auth(password: CharSequence): F[Boolean] = - async.flatMap(_.auth(password).futureLift.map(_ == "OK")) + async.flatMap(_.auth(password).futureLift.map(_.isSuccess)) override def auth(username: String, password: CharSequence): F[Boolean] = - async.flatMap(_.auth(username, password).futureLift.map(_ == "OK")) + async.flatMap(_.auth(username, password).futureLift.map(_.isSuccess)) override def setClientName(name: K): F[Boolean] = - async.flatMap(_.clientSetname(name).futureLift.map(_ == "OK")) + async.flatMap(_.clientSetname(name).futureLift.map(_.isSuccess)) override def getClientName(): F[Option[K]] = async.flatMap(_.clientGetname().futureLift).map(Option.apply) @@ -1404,10 +1501,10 @@ private[redis4cats] class BaseRedis[F[_]: FutureLift: MonadThrow: Log, K, V]( async.flatMap(_.clientId().futureLift.map(Long.unbox)) override def setLibName(name: String): F[Boolean] = - async.flatMap(_.clientSetinfo("LIB-NAME", name).futureLift.map(_ == "OK")) + async.flatMap(_.clientSetinfo("LIB-NAME", name).futureLift.map(_.isSuccess)) override def setLibVersion(version: String): F[Boolean] = - async.flatMap(_.clientSetinfo("LIB-VER", version).futureLift.map(_ == "OK")) + async.flatMap(_.clientSetinfo("LIB-VER", version).futureLift.map(_.isSuccess)) override def getClientInfo: F[Map[String, String]] = async.flatMap( @@ -1924,6 +2021,10 @@ private[redis4cats] trait RedisConversionOps { } } + private[redis4cats] implicit class ResponseOps(str: String) { + def isSuccess: Boolean = str == "OK" + } + } private[redis4cats] class Redis[F[_]: FutureLift: MonadThrow: Log, K, V]( diff --git a/modules/tests/src/test/scala/dev/profunktor/redis4cats/Redis4CatsFunSuite.scala b/modules/tests/src/test/scala/dev/profunktor/redis4cats/Redis4CatsFunSuite.scala index 4a2360b8..49a94d17 100644 --- a/modules/tests/src/test/scala/dev/profunktor/redis4cats/Redis4CatsFunSuite.scala +++ b/modules/tests/src/test/scala/dev/profunktor/redis4cats/Redis4CatsFunSuite.scala @@ -18,18 +18,19 @@ package dev.profunktor.redis4cats import cats.effect._ import cats.syntax.all._ -import dev.profunktor.redis4cats.Redis4CatsFunSuite.{ Fs2PubSub, Fs2Streaming } +import dev.profunktor.redis4cats.Redis4CatsFunSuite.Fs2Streaming import dev.profunktor.redis4cats.connection._ import dev.profunktor.redis4cats.data.{ RedisChannel, RedisCodec } import dev.profunktor.redis4cats.effect.Log.NoOp._ import dev.profunktor.redis4cats.pubsub.data.Subscription -import dev.profunktor.redis4cats.pubsub.{ PubSub, PubSubCommands } import dev.profunktor.redis4cats.streams.{ RedisStream, Streaming } import io.lettuce.core.{ ClientOptions, TimeoutOptions } import munit.{ Compare, Location } import scala.concurrent.duration.{ Duration, DurationInt, FiniteDuration } import scala.concurrent.{ Await, Future } +import dev.profunktor.redis4cats.pubsub.{ PubSub, PubSubCommands } +import dev.profunktor.redis4cats.Redis4CatsFunSuite.Fs2PubSub abstract class Redis4CatsFunSuite(isCluster: Boolean) extends IOSuite {