JSON
Sage supports the JSON.* commands, which store documents on the server and address values with JSONPath expressions. Redis 8 includes JSON support. On Valkey, install the valkey-json module or use the valkey/valkey-bundle image. The standard valkey image does not include the module.
Sage does not depend on a JSON library. It sends and receives documents as raw JSON text, and you can use your own codec for typed values.
Paths
Every JSON command locates values with a JsonPath. Sage supports the JSONPath dialect, whose expressions begin with $. The default path is the document root, $.
JsonPath.root // $
JsonPath("$.name") // a field
JsonPath("$.items[0]") // an array element
JsonPath("$..price") // every price, at any depthA JSONPath can match more than one location. Commands such as jsonType, jsonArrLen, jsonStrLen, jsonArrAppend, and jsonToggle return a Vector with one entry for each match. An entry is None when the matched value has the wrong type for the command. A path that matches nothing returns an empty Vector; use .headOption when you expect a single match.
The legacy dot dialect is not modeled. If you need it, send a raw command.
Documents are raw JSON
A value you write is raw JSON text. The built-in String codec passes it through unchanged, so supply valid JSON. A scalar string must include JSON quotes. Objects and arrays use their standard JSON syntax.
client.jsonSet("user:1", JsonPath.root, """{"name":"Ada","age":36,"tags":["a","b"]}""")
val name = client.jsonGet[String]("user:1", JsonPath("$.name")) // Some("[\"Ada\"]")
val age = client.jsonGet[String]("user:1", JsonPath("$.age")) // Some("[36]")for {
_ <- client.jsonSet("user:1", JsonPath.root, """{"name":"Ada","age":36,"tags":["a","b"]}""")
name <- client.jsonGet[String]("user:1", JsonPath("$.name"))
age <- client.jsonGet[String]("user:1", JsonPath("$.age"))
} yield (name, age)jsonGet returns one block of JSON text. Without a path, it returns the complete document for a typed codec to decode. With one JSONPath, it wraps the matches in an array; for example, $.name returns ["Ada"]. With several paths, it returns an object whose keys are the paths.
To decode documents into your own types, define a ValueCodec with your JSON library. The encoder must emit valid JSON, and the decoder must parse it. The following circe codec works for any type with circe Decoder and Encoder instances.
import io.circe.generic.auto.*
import io.circe.parser.decode
import io.circe.syntax.*
import sage.SageException.DecodeError
given [A](using io.circe.Decoder[A], io.circe.Encoder[A]): ValueCodec[A] =
ValueCodec.string.emap(s => decode[A](s).left.map(DecodeError.fromThrowable))(_.asJson.noSpaces)
case class User(name: String, age: Int)
client.jsonSet("user:1", JsonPath.root, User("Ada", 36))
client.jsonGet[User]("user:1") // Some(User("Ada", 36)): whole document, unwrapped
client.jsonGet[Vector[Int]]("user:1", JsonPath("$.age")) // Some(Vector(36)): a JSONPath wraps matches in an arraySage does not add a JSON-library dependency.
jsonSet models the NX and XX conditions; other server options (the jsonGet formatting hints INDENT, NEWLINE, SPACE, and newer JSON.SET storage hints) are not modeled. Reach them with a raw command.
Working with the document
Numbers, strings, booleans, arrays, and objects each have their commands. All of the location commands return per-match Vectors.
client.jsonSet("doc", JsonPath.root, """{"n":1,"s":"ab","flag":false,"xs":[1,2,3]}""")
client.jsonNumIncrBy("doc", JsonPath("$.n"), 5) // Vector(Some(6.0))
client.jsonStrAppend("doc", JsonPath("$.s"), "\"c\"") // Vector(Some(3))
client.jsonToggle("doc", JsonPath("$.flag")) // Vector(Some(true))
client.jsonArrAppend("doc", JsonPath("$.xs"), "4") // Vector(Some(4))
client.jsonArrLen("doc", JsonPath("$.xs")) // Vector(Some(4))
client.jsonType("doc", JsonPath("$.n")) // Vector(Some(JsonType.Integer))for {
_ <- client.jsonSet("doc", JsonPath.root, """{"n":1,"s":"ab","flag":false,"xs":[1,2,3]}""")
_ <- client.jsonNumIncrBy("doc", JsonPath("$.n"), 5)
_ <- client.jsonStrAppend("doc", JsonPath("$.s"), "\"c\"")
_ <- client.jsonToggle("doc", JsonPath("$.flag"))
_ <- client.jsonArrAppend("doc", JsonPath("$.xs"), "4")
n <- client.jsonArrLen("doc", JsonPath("$.xs"))
t <- client.jsonType("doc", JsonPath("$.n"))
} yield (n, t)Multiple keys and the cluster
jsonMGet reads the same path from several documents at once, returning one Option per key:
client.jsonMGet[String](JsonPath("$.name"))("user:1", "user:2", "user:3")
// Vector(Some("[\"Ada\"]"), Some("[\"Lin\"]"), None)jsonMSet sets several documents atomically:
client.jsonMSet(("user:1", JsonPath.root, """{"n":"Ada"}"""), ("user:2", JsonPath.root, """{"n":"Lin"}"""))In a cluster, jsonMGet can read keys from different slots. Sage splits the request and combines the results. jsonMSet is atomic, so all keys must share one slot. Use a hash tag to place related keys in the same slot:
client.jsonMSet(("{acct:9}:profile", JsonPath.root, "{}"), ("{acct:9}:prefs", JsonPath.root, "{}"))Redis and Valkey differences
Most commands behave the same on Redis and Valkey. The known differences are:
jsonMerge(RFC 7386 merge) works on Redis only. Valkey does not shipJSON.MERGEyet.jsonSetinto a missing intermediate path that cannot be created returnsfalseon Redis but fails with a server error on Valkey.