diff --git a/src/main/scala/gs/predicate/v0/api/Messages.scala b/src/main/scala/gs/predicate/v0/api/Messages.scala index 20160fb..75e83ca 100644 --- a/src/main/scala/gs/predicate/v0/api/Messages.scala +++ b/src/main/scala/gs/predicate/v0/api/Messages.scala @@ -2,10 +2,24 @@ package gs.predicate.v0.api object Messages: + def unrecognizedPredicateType( + candidate: String + ): String = s"Unrecognized predicate type: '$candidate'" + def invalidPredicateType( candidate: String, expected: String ): String = s"Received predicate type '$candidate' but expected '$expected'." + def unrecognizedJsonComparison( + candidate: String + ): String = s"Unrecognized JSON comparison: '$candidate'" + + def invalidJsonComparison( + candidate: String, + expected: String + ): String = + s"Received JSON comparison name '$candidate' but expected '$expected'." + end Messages diff --git a/src/main/scala/gs/predicate/v0/json/JsonComparison.scala b/src/main/scala/gs/predicate/v0/json/JsonComparison.scala index 219e5d0..87f55c5 100644 --- a/src/main/scala/gs/predicate/v0/json/JsonComparison.scala +++ b/src/main/scala/gs/predicate/v0/json/JsonComparison.scala @@ -1,6 +1,9 @@ package gs.predicate.v0.json +import gs.predicate.v0.api.Messages import gs.predicate.v0.serde.json.JsonKeys +import io.circe.Decoder +import io.circe.DecodingFailure import io.circe.Encoder import io.circe.Json import java.time.LocalDate @@ -29,6 +32,21 @@ object JsonComparison: ) } + given Decoder[Eq] = Decoder.instance[Eq] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[Json] + yield Eq(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class Neq(target: Json) extends JsonComparison(Neq.Name): def compare(input: Json): Boolean = !target.equals(input) @@ -42,6 +60,21 @@ object JsonComparison: ) } + given Decoder[Neq] = Decoder.instance[Neq] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[Json] + yield Neq(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class StringContains(target: String) extends JsonComparison(StringContains.Name): @@ -58,6 +91,21 @@ object JsonComparison: ) } + given Decoder[StringContains] = Decoder.instance[StringContains] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[String] + yield StringContains(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class StringPrefix(target: String) extends JsonComparison(StringPrefix.Name): @@ -74,6 +122,21 @@ object JsonComparison: ) } + given Decoder[StringPrefix] = Decoder.instance[StringPrefix] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[String] + yield StringPrefix(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class StringSuffix(target: String) extends JsonComparison(StringSuffix.Name): @@ -90,6 +153,21 @@ object JsonComparison: ) } + given Decoder[StringSuffix] = Decoder.instance[StringSuffix] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[String] + yield StringSuffix(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class IntLessThan(target: Int) extends JsonComparison(IntLessThan.Name): def compare(input: Json): Boolean = @@ -105,6 +183,21 @@ object JsonComparison: ) } + given Decoder[IntLessThan] = Decoder.instance[IntLessThan] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[Int] + yield IntLessThan(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class IntLessThanOrEqualTo(target: Int) extends JsonComparison(IntLessThanOrEqualTo.Name): @@ -122,6 +215,22 @@ object JsonComparison: ) } + given Decoder[IntLessThanOrEqualTo] = + Decoder.instance[IntLessThanOrEqualTo] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[Int] + yield IntLessThanOrEqualTo(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class IntGreaterThan(target: Int) extends JsonComparison(IntGreaterThan.Name): @@ -138,6 +247,21 @@ object JsonComparison: ) } + given Decoder[IntGreaterThan] = Decoder.instance[IntGreaterThan] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[Int] + yield IntGreaterThan(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class IntGreaterThanOrEqualTo(target: Int) extends JsonComparison(IntGreaterThanOrEqualTo.Name): @@ -155,6 +279,22 @@ object JsonComparison: ) } + given Decoder[IntGreaterThanOrEqualTo] = + Decoder.instance[IntGreaterThanOrEqualTo] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[Int] + yield IntGreaterThanOrEqualTo(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class IntBetweenInclusive( lower: Int, upper: Int @@ -177,6 +317,24 @@ object JsonComparison: ) } + given Decoder[IntBetweenInclusive] = + Decoder.instance[IntBetweenInclusive] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for + lower <- cursor.downField(JsonKeys.lower).as[Int] + upper <- cursor.downField(JsonKeys.upper).as[Int] + yield IntBetweenInclusive(lower, upper) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class IntBetweenExclusive( lower: Int, upper: Int @@ -199,6 +357,24 @@ object JsonComparison: ) } + given Decoder[IntBetweenExclusive] = + Decoder.instance[IntBetweenExclusive] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for + lower <- cursor.downField(JsonKeys.lower).as[Int] + upper <- cursor.downField(JsonKeys.upper).as[Int] + yield IntBetweenExclusive(lower, upper) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class DateEq(target: LocalDate) extends JsonComparison(DateEq.Name): def compare(input: Json): Boolean = @@ -214,6 +390,21 @@ object JsonComparison: ) } + given Decoder[DateEq] = Decoder.instance[DateEq] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[LocalDate] + yield DateEq(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class DateBefore(target: LocalDate) extends JsonComparison(DateBefore.Name): @@ -230,6 +421,21 @@ object JsonComparison: ) } + given Decoder[DateBefore] = Decoder.instance[DateBefore] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[LocalDate] + yield DateBefore(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class DateAfter(target: LocalDate) extends JsonComparison(DateAfter.Name): @@ -246,6 +452,21 @@ object JsonComparison: ) } + given Decoder[DateAfter] = Decoder.instance[DateAfter] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for target <- cursor.downField(JsonKeys.value).as[LocalDate] + yield DateAfter(target) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class DateBetweenInclusive( lower: LocalDate, upper: LocalDate @@ -273,6 +494,24 @@ object JsonComparison: ) } + given Decoder[DateBetweenInclusive] = + Decoder.instance[DateBetweenInclusive] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for + lower <- cursor.downField(JsonKeys.lower).as[LocalDate] + upper <- cursor.downField(JsonKeys.upper).as[LocalDate] + yield DateBetweenInclusive(lower, upper) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + case class DateBetweenExclusive( lower: LocalDate, upper: LocalDate @@ -295,6 +534,24 @@ object JsonComparison: ) } + given Decoder[DateBetweenExclusive] = + Decoder.instance[DateBetweenExclusive] { cursor => + cursor.downField(JsonKeys.name).as[String].flatMap { + case Name => + for + lower <- cursor.downField(JsonKeys.lower).as[LocalDate] + upper <- cursor.downField(JsonKeys.upper).as[LocalDate] + yield DateBetweenExclusive(lower, upper) + case candidate => + Left( + DecodingFailure( + Messages.invalidJsonComparison(candidate, Name), + Nil + ) + ) + } + } + private def asDate(input: String): Option[LocalDate] = Try(LocalDate.parse(input)).toOption diff --git a/src/main/scala/gs/predicate/v0/json/JsonQueryComparison.scala b/src/main/scala/gs/predicate/v0/json/JsonQueryComparison.scala index 133a187..f8a563e 100644 --- a/src/main/scala/gs/predicate/v0/json/JsonQueryComparison.scala +++ b/src/main/scala/gs/predicate/v0/json/JsonQueryComparison.scala @@ -2,8 +2,17 @@ package gs.predicate.v0.json import cats.Applicative import cats.syntax.all.* +import gs.predicate.v0.api.Messages import gs.predicate.v0.api.Predicate import gs.predicate.v0.json.query.JsonQuery +import gs.predicate.v0.serde.json.JsonKeys +import gs.predicate.v0.serde.json.jsonComparisonDecoder +import gs.predicate.v0.serde.json.jsonComparisonEncoder +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Encoder +import io.circe.Json +import io.circe.syntax.* /** Predicate that matches if the JSON provider contains a JSON blob with the * given key, and that blob contains the given query, and the result of the @@ -47,4 +56,36 @@ object JsonQueryComparison: ): JsonQueryComparison[F] = new JsonQueryComparison[F](key, query, comparison) + given jsonQueryComparisonEncoder[F[_]]: Encoder[JsonQueryComparison[F]] = + Encoder.instance[JsonQueryComparison[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.query, Json.fromString(p.query.toString())), + (JsonKeys.comparison, p.comparison.asJson) + ) + } + + given jsonQueryComparisonDecoder[F[_]: Applicative: JsonProvider] + : Decoder[JsonQueryComparison[F]] = + Decoder.instance[JsonQueryComparison[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + query <- cursor.downField(JsonKeys.query).as[JsonQuery] + comparison <- cursor + .downField(JsonKeys.comparison) + .as[JsonComparison] + yield new JsonQueryComparison(key, query, comparison) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + end JsonQueryComparison diff --git a/src/main/scala/gs/predicate/v0/json/JsonQueryEquals.scala b/src/main/scala/gs/predicate/v0/json/JsonQueryEquals.scala index 706abb5..eaaa9c2 100644 --- a/src/main/scala/gs/predicate/v0/json/JsonQueryEquals.scala +++ b/src/main/scala/gs/predicate/v0/json/JsonQueryEquals.scala @@ -2,8 +2,13 @@ package gs.predicate.v0.json import cats.Applicative import cats.syntax.all.* +import gs.predicate.v0.api.Messages import gs.predicate.v0.api.Predicate import gs.predicate.v0.json.query.JsonQuery +import gs.predicate.v0.serde.json.JsonKeys +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Encoder import io.circe.Json /** Predicate that matches if the JSON provider contains a JSON blob with the @@ -46,4 +51,34 @@ object JsonQueryEquals: ): JsonQueryEquals[F] = new JsonQueryEquals[F](key, query, value) + given jsonQueryEqualsEncoder[F[_]]: Encoder[JsonQueryEquals[F]] = + Encoder.instance[JsonQueryEquals[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.query, Json.fromString(p.query.toString())), + (JsonKeys.value, p.value) + ) + } + + given jsonQueryEqualsDecoder[F[_]: Applicative: JsonProvider] + : Decoder[JsonQueryEquals[F]] = + Decoder.instance[JsonQueryEquals[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + query <- cursor.downField(JsonKeys.query).as[JsonQuery] + value <- cursor.downField(JsonKeys.value).as[Json] + yield new JsonQueryEquals(key, query, value) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + end JsonQueryEquals diff --git a/src/main/scala/gs/predicate/v0/json/JsonQueryIn.scala b/src/main/scala/gs/predicate/v0/json/JsonQueryIn.scala index 46d93fd..28c61af 100644 --- a/src/main/scala/gs/predicate/v0/json/JsonQueryIn.scala +++ b/src/main/scala/gs/predicate/v0/json/JsonQueryIn.scala @@ -2,8 +2,13 @@ package gs.predicate.v0.json import cats.Applicative import cats.syntax.all.* +import gs.predicate.v0.api.Messages import gs.predicate.v0.api.Predicate import gs.predicate.v0.json.query.JsonQuery +import gs.predicate.v0.serde.json.JsonKeys +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Encoder import io.circe.Json /** Predicate that matches if the JSON provider contains a JSON blob with the @@ -46,4 +51,34 @@ object JsonQueryIn: ): JsonQueryIn[F] = new JsonQueryIn[F](key, query, values) + given jsonQueryInEncoder[F[_]]: Encoder[JsonQueryIn[F]] = + Encoder.instance[JsonQueryIn[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.query, Json.fromString(p.query.toString())), + (JsonKeys.values, Json.arr(p.values.toSeq*)) + ) + } + + given jsonQueryInDecoder[F[_]: Applicative: JsonProvider] + : Decoder[JsonQueryIn[F]] = + Decoder.instance[JsonQueryIn[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + query <- cursor.downField(JsonKeys.query).as[JsonQuery] + values <- cursor.downField(JsonKeys.value).as[List[Json]] + yield new JsonQueryIn(key, query, values.toSet) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + end JsonQueryIn diff --git a/src/main/scala/gs/predicate/v0/json/JsonQueryNotEquals.scala b/src/main/scala/gs/predicate/v0/json/JsonQueryNotEquals.scala index f336d96..93dff41 100644 --- a/src/main/scala/gs/predicate/v0/json/JsonQueryNotEquals.scala +++ b/src/main/scala/gs/predicate/v0/json/JsonQueryNotEquals.scala @@ -2,8 +2,13 @@ package gs.predicate.v0.json import cats.Applicative import cats.syntax.all.* +import gs.predicate.v0.api.Messages import gs.predicate.v0.api.Predicate import gs.predicate.v0.json.query.JsonQuery +import gs.predicate.v0.serde.json.JsonKeys +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Encoder import io.circe.Json /** Predicate that matches if the JSON provider contains a JSON blob with the @@ -47,4 +52,34 @@ object JsonQueryNotEquals: ): JsonQueryNotEquals[F] = new JsonQueryNotEquals[F](key, query, value) + given jsonQueryNotEqualsEncoder[F[_]]: Encoder[JsonQueryNotEquals[F]] = + Encoder.instance[JsonQueryNotEquals[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.query, Json.fromString(p.query.toString())), + (JsonKeys.value, p.value) + ) + } + + given jsonQueryNotEqualsDecoder[F[_]: Applicative: JsonProvider] + : Decoder[JsonQueryNotEquals[F]] = + Decoder.instance[JsonQueryNotEquals[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + query <- cursor.downField(JsonKeys.query).as[JsonQuery] + value <- cursor.downField(JsonKeys.value).as[Json] + yield new JsonQueryNotEquals(key, query, value) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + end JsonQueryNotEquals diff --git a/src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala b/src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala index 26c9210..6aeb7d5 100644 --- a/src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala +++ b/src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala @@ -122,6 +122,7 @@ import scala.util.matching.Regex * Maximum allowed query depth. */ final class CompiledQuery private ( + val raw: String, private val parts: List[CompiledQuery.QueryPart], private val reader: Json => List[Json], private val maxQueryDepth: Int @@ -237,12 +238,14 @@ object CompiledQuery: ) else if queryParts.exists(_.hasListTraversal) then new CompiledQuery( + query, queryParts, readerWithTraversal(queryParts), maxQueryDepth ) else new CompiledQuery( + query, queryParts, readerWithoutTraversal(queryParts), maxQueryDepth diff --git a/src/main/scala/gs/predicate/v0/json/query/EmptyQuery.scala b/src/main/scala/gs/predicate/v0/json/query/EmptyQuery.scala index 0564a4f..b055f6c 100644 --- a/src/main/scala/gs/predicate/v0/json/query/EmptyQuery.scala +++ b/src/main/scala/gs/predicate/v0/json/query/EmptyQuery.scala @@ -5,6 +5,9 @@ import io.circe.Json /** Implementation of [[JsonQuery]] that always refers to the input value as-is. */ object EmptyQuery extends JsonQuery: + /** @inheritDocs + */ + final val raw: String = "" /** @inheritDocs */ diff --git a/src/main/scala/gs/predicate/v0/json/query/JsonQuery.scala b/src/main/scala/gs/predicate/v0/json/query/JsonQuery.scala index d5f9356..b3a0f49 100644 --- a/src/main/scala/gs/predicate/v0/json/query/JsonQuery.scala +++ b/src/main/scala/gs/predicate/v0/json/query/JsonQuery.scala @@ -1,10 +1,20 @@ package gs.predicate.v0.json.query +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Encoder import io.circe.Json +import scala.util.Failure +import scala.util.Success +import scala.util.Try /** Predicate-integrated queries for JSON objects. */ trait JsonQuery: + /** @return + * The raw query string. + */ + def raw: String /** Query the given JSON value and evaluate the predicate as part of that * query. @@ -48,4 +58,21 @@ object JsonQuery: */ def compile(query: String): JsonQuery = CompiledQuery.compile(query) + given Encoder[JsonQuery] = + Encoder.instance[JsonQuery](jq => Json.fromString(jq.raw)) + + given Decoder[JsonQuery] = Decoder.instance[JsonQuery] { cursor => + cursor.as[String].flatMap { raw => + Try(compile(raw)) match + case Success(query) => Right(query) + case Failure(exception) => + Left( + DecodingFailure( + s"Failed to parse JsonQuery - could not compile the query: '${exception.getMessage()}'", + Nil + ) + ) + } + } + end JsonQuery diff --git a/src/main/scala/gs/predicate/v0/serde/json/JsonKeys.scala b/src/main/scala/gs/predicate/v0/serde/json/JsonKeys.scala index 1f3b177..3cbc83a 100644 --- a/src/main/scala/gs/predicate/v0/serde/json/JsonKeys.scala +++ b/src/main/scala/gs/predicate/v0/serde/json/JsonKeys.scala @@ -36,4 +36,12 @@ object JsonKeys: */ val upper: String = "upper" + /** Represents JSON queries. + */ + val query: String = "query" + + /** Represents JSON comparisons. + */ + val comparison: String = "comparison" + end JsonKeys diff --git a/src/main/scala/gs/predicate/v0/serde/json/codecs.scala b/src/main/scala/gs/predicate/v0/serde/json/codecs.scala index 7c0637e..06310e4 100644 --- a/src/main/scala/gs/predicate/v0/serde/json/codecs.scala +++ b/src/main/scala/gs/predicate/v0/serde/json/codecs.scala @@ -3,10 +3,13 @@ package gs.predicate.v0.serde.json import cats.Applicative import gs.predicate.v0.api.And import gs.predicate.v0.api.False +import gs.predicate.v0.api.Messages import gs.predicate.v0.api.Or import gs.predicate.v0.api.Predicate import gs.predicate.v0.api.True +import gs.predicate.v0.json.JsonComparison import gs.predicate.v0.json.JsonProvider +import gs.predicate.v0.json.JsonQueryComparison import gs.predicate.v0.kv.KeyExists import gs.predicate.v0.kv.KeyValueProvider import gs.predicate.v0.kv.ValueContains @@ -48,18 +51,56 @@ def encodeOr[F[_]](and: Or[F]): Json = */ given predicateEncoder[F[_]]: Encoder[Predicate[F]] = Encoder.instance { - case p: True[F] => Encoder[True[F]].apply(p) - case p: False[F] => Encoder[False[F]].apply(p) - case p: And[F] => encodeAnd[F](p) - case p: Or[F] => encodeOr[F](p) - case p: KeyExists[F] => Encoder[KeyExists[F]].apply(p) - case p: ValueEquals[F] => Encoder[ValueEquals[F]].apply(p) - case p: ValueContains[F] => Encoder[ValueContains[F]].apply(p) - case p: ValueStartsWith[F] => Encoder[ValueStartsWith[F]].apply(p) - case p: ValueEndsWith[F] => Encoder[ValueEndsWith[F]].apply(p) - case p => + case p: True[F] => Encoder[True[F]].apply(p) + case p: False[F] => Encoder[False[F]].apply(p) + case p: And[F] => encodeAnd[F](p) + case p: Or[F] => encodeOr[F](p) + case p: KeyExists[F] => Encoder[KeyExists[F]].apply(p) + case p: ValueEquals[F] => Encoder[ValueEquals[F]].apply(p) + case p: ValueContains[F] => Encoder[ValueContains[F]].apply(p) + case p: ValueStartsWith[F] => Encoder[ValueStartsWith[F]].apply(p) + case p: ValueEndsWith[F] => Encoder[ValueEndsWith[F]].apply(p) + case p: JsonQueryComparison[F] => Encoder[JsonQueryComparison[F]].apply(p) + case p => throw new IllegalArgumentException( - s"Unsupported predicate type: ${p.predicateType}" + s"Unsupported predicate type: '${p.predicateType}'" + ) + } + +given jsonComparisonEncoder: Encoder[JsonComparison] = + Encoder.instance[JsonComparison] { + case jc: JsonComparison.Eq => Encoder[JsonComparison.Eq].apply(jc) + case jc: JsonComparison.Neq => Encoder[JsonComparison.Neq].apply(jc) + case jc: JsonComparison.StringContains => + Encoder[JsonComparison.StringContains].apply(jc) + case jc: JsonComparison.StringPrefix => + Encoder[JsonComparison.StringPrefix].apply(jc) + case jc: JsonComparison.StringSuffix => + Encoder[JsonComparison.StringSuffix].apply(jc) + case jc: JsonComparison.IntLessThan => + Encoder[JsonComparison.IntLessThan].apply(jc) + case jc: JsonComparison.IntLessThanOrEqualTo => + Encoder[JsonComparison.IntLessThanOrEqualTo].apply(jc) + case jc: JsonComparison.IntGreaterThan => + Encoder[JsonComparison.IntGreaterThan].apply(jc) + case jc: JsonComparison.IntGreaterThanOrEqualTo => + Encoder[JsonComparison.IntGreaterThanOrEqualTo].apply(jc) + case jc: JsonComparison.IntBetweenInclusive => + Encoder[JsonComparison.IntBetweenInclusive].apply(jc) + case jc: JsonComparison.IntBetweenExclusive => + Encoder[JsonComparison.IntBetweenExclusive].apply(jc) + case jc: JsonComparison.DateEq => Encoder[JsonComparison.DateEq].apply(jc) + case jc: JsonComparison.DateBefore => + Encoder[JsonComparison.DateBefore].apply(jc) + case jc: JsonComparison.DateAfter => + Encoder[JsonComparison.DateAfter].apply(jc) + case jc: JsonComparison.DateBetweenInclusive => + Encoder[JsonComparison.DateBetweenInclusive].apply(jc) + case jc: JsonComparison.DateBetweenExclusive => + Encoder[JsonComparison.DateBetweenExclusive].apply(jc) + case jc => + throw new IllegalArgumentException( + s"Unsupported JSON comparison: '${jc.name}'" ) } @@ -115,9 +156,14 @@ def decodePredicate[F[_]: Applicative: KeyValueProvider: JsonProvider]( Decoder[ValueStartsWith[F]].apply(cursor) case ValueEndsWith.PredicateType => Decoder[ValueEndsWith[F]].apply(cursor) - case predicateType => + case JsonQueryComparison.PredicateType => + Decoder[JsonQueryComparison[F]].apply(cursor) + case _ => Left( - DecodingFailure(s"Unrecognized predicate type: '$predicateType'", Nil) + DecodingFailure( + Messages.unrecognizedPredicateType(predicateType), + Nil + ) ) yield predicate @@ -127,3 +173,56 @@ def decodePredicate[F[_]: Applicative: KeyValueProvider: JsonProvider]( given predicateDecoder[F[_]: Applicative: KeyValueProvider: JsonProvider] : Decoder[Predicate[F]] = Decoder.instance[Predicate[F]](cursor => decodePredicate[F](cursor)) + +/** Given some JSON cursor, decode any known JSON comparison. + * + * @param cursor + * The cursor. + * @return + * The decoded [[JsonComparison]] or a decoding failure. + */ +def decodeJsonComparison(cursor: HCursor) + : Either[DecodingFailure, JsonComparison] = + for + name <- cursor.downField(JsonKeys.name).as[String] + comparison <- name match + case JsonComparison.Eq.Name => Decoder[JsonComparison.Eq].apply(cursor) + case JsonComparison.Neq.Name => Decoder[JsonComparison.Neq].apply(cursor) + case JsonComparison.StringContains.Name => + Decoder[JsonComparison.StringContains].apply(cursor) + case JsonComparison.StringPrefix.Name => + Decoder[JsonComparison.StringPrefix].apply(cursor) + case JsonComparison.StringSuffix.Name => + Decoder[JsonComparison.StringSuffix].apply(cursor) + case JsonComparison.IntLessThan.Name => + Decoder[JsonComparison.IntLessThan].apply(cursor) + case JsonComparison.IntLessThanOrEqualTo.Name => + Decoder[JsonComparison.IntLessThanOrEqualTo].apply(cursor) + case JsonComparison.IntGreaterThan.Name => + Decoder[JsonComparison.IntGreaterThan].apply(cursor) + case JsonComparison.IntGreaterThanOrEqualTo.Name => + Decoder[JsonComparison.IntGreaterThanOrEqualTo].apply(cursor) + case JsonComparison.IntBetweenInclusive.Name => + Decoder[JsonComparison.IntBetweenInclusive].apply(cursor) + case JsonComparison.IntBetweenExclusive.Name => + Decoder[JsonComparison.IntBetweenExclusive].apply(cursor) + case JsonComparison.DateEq.Name => + Decoder[JsonComparison.DateEq].apply(cursor) + case JsonComparison.DateBefore.Name => + Decoder[JsonComparison.DateBefore].apply(cursor) + case JsonComparison.DateAfter.Name => + Decoder[JsonComparison.DateAfter].apply(cursor) + case JsonComparison.DateBetweenInclusive.Name => + Decoder[JsonComparison.DateBetweenInclusive].apply(cursor) + case JsonComparison.DateBetweenExclusive.Name => + Decoder[JsonComparison.DateBetweenExclusive].apply(cursor) + case _ => + Left( + DecodingFailure(Messages.unrecognizedJsonComparison(name), Nil) + ) + yield comparison + +/** Generic decoder for any [[JsonComparison]]. + */ +given jsonComparisonDecoder: Decoder[JsonComparison] = + Decoder.instance[JsonComparison](decodeJsonComparison) diff --git a/src/test/scala/gs/predicate/v0/json/JsonQueryComparisonTests.scala b/src/test/scala/gs/predicate/v0/json/JsonQueryComparisonTests.scala new file mode 100644 index 0000000..276ac5f --- /dev/null +++ b/src/test/scala/gs/predicate/v0/json/JsonQueryComparisonTests.scala @@ -0,0 +1,65 @@ +package gs.predicate.v0.json + +import cats.effect.IO +import cats.effect.std.MapRef +import gs.datagen.v0.Gen +import gs.datagen.v0.generators.Size +import gs.predicate.v0.api.Predicate +import gs.predicate.v0.json.query.JsonQuery +import io.circe.Json +import support.IOSuite + +class JsonQueryComparisonTests extends IOSuite: + + import JsonQueryComparisonTests.Data + import JsonQueryComparisonTests.newProvider + + iotest("should find an exact match against some string value") { + val key = Data.keyGen.gen() + val value = Data.strValGen.gen() + val query = key + val blob = Json.obj( + key -> value + ) + val jc = JsonComparison.Eq(value) + + newProvider(Map(key -> blob)).flatMap { provider => + given JsonProvider[IO] = provider + val p = JsonQueryComparison[IO](key, JsonQuery.compile(query), jc) + p.eval().map(result => assertEquals(result, Predicate.Result.matched())) + } + } + + iotest("should fail to match against some non-equal string value") { + val key = Data.keyGen.gen() + val value = Data.strValGen.gen() + val searchValue = Data.strValGen.gen() + val query = key + val blob = Json.obj( + key -> value + ) + val jc = JsonComparison.Eq(searchValue) + + newProvider(Map(key -> blob)).flatMap { provider => + given JsonProvider[IO] = provider + val p = JsonQueryComparison[IO](key, JsonQuery.compile(query), jc) + p.eval().map(result => assertEquals(result, Predicate.Result.missed())) + } + } + +object JsonQueryComparisonTests: + + object Data: + + val keyGen: Gen[String] = Gen.string.alphaNumeric(Size.between(4, 16)) + + val strValGen: Gen[Json] = + Gen.string.uppercaseAlpha(Size.fixed(8)).map(Json.fromString) + + end Data + + def newProvider(data: Map[String, Json]): IO[JsonProvider[IO]] = + for map <- MapRef.ofSingleImmutableMap[IO, String, Json](data) + yield new MemoryMapJsonProvider(map) + +end JsonQueryComparisonTests diff --git a/src/test/scala/gs/predicate/v0/serde/json/ApiCodecTests.scala b/src/test/scala/gs/predicate/v0/serde/json/ApiCodecTests.scala index 11da434..940fb56 100644 --- a/src/test/scala/gs/predicate/v0/serde/json/ApiCodecTests.scala +++ b/src/test/scala/gs/predicate/v0/serde/json/ApiCodecTests.scala @@ -3,6 +3,7 @@ package gs.predicate.v0.serde.json import cats.effect.IO import gs.predicate.v0.api.And import gs.predicate.v0.api.False +import gs.predicate.v0.api.Messages import gs.predicate.v0.api.Or import gs.predicate.v0.api.Predicate import gs.predicate.v0.api.True @@ -15,6 +16,23 @@ import munit.FunSuite class ApiCodecTests extends FunSuite: + test("should fail to decode an unknown predicate") { + given JsonProvider[IO] = JsonProvider.noop[IO] + given KeyValueProvider[IO] = KeyValueProvider.noop[IO] + + val candidate = "unrecognized" + val json: Json = Json.obj( + JsonKeys.predicateType -> Json.fromString(candidate) + ) + + val decoded = Decoder[Predicate[IO]].decodeJson(json) + assertEquals(decoded.isLeft, true) + assertEquals( + decoded.left.toOption.map(_.message), + Some(Messages.unrecognizedPredicateType(candidate)) + ) + } + test("should serialize and deserialize a True predicate") { val p = True[IO] val encoded = Encoder[True[IO]].apply(p) diff --git a/src/test/scala/gs/predicate/v0/serde/json/JsonCodecTests.scala b/src/test/scala/gs/predicate/v0/serde/json/JsonCodecTests.scala new file mode 100644 index 0000000..81fdb11 --- /dev/null +++ b/src/test/scala/gs/predicate/v0/serde/json/JsonCodecTests.scala @@ -0,0 +1,50 @@ +package gs.predicate.v0.serde.json + +import cats.effect.IO +import gs.predicate.v0.api.Predicate +import gs.predicate.v0.json.JsonComparison +import gs.predicate.v0.json.JsonProvider +import gs.predicate.v0.json.JsonQueryComparison +import gs.predicate.v0.json.query.JsonQuery +import gs.predicate.v0.kv.KeyValueProvider +import io.circe.Decoder +import io.circe.Encoder +import io.circe.Json +import io.circe.syntax.* +import munit.FunSuite + +class JsonCodecTests extends FunSuite: + + test("should serialize and deserialize a predicate: JsonComparison") { + given JsonProvider[IO] = JsonProvider.noop[IO] + given KeyValueProvider[IO] = KeyValueProvider.noop[IO] + + val key = "x" + val value = Json.fromString("y") + val query = JsonQuery.compile(key) + val comparison = JsonComparison.Eq(value) + + val p = JsonQueryComparison[IO](key, query, comparison) + val encoded = Encoder[JsonQueryComparison[IO]].apply(p) + val decoded = Decoder[Predicate[IO]].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + ( + JsonKeys.predicateType, + Json.fromString(JsonQueryComparison.PredicateType) + ), + (JsonKeys.key, Json.fromString(key)), + (JsonKeys.query, Json.fromString(query.raw)), + (JsonKeys.comparison, comparison.asJson) + ) + ) + assertEquals( + decoded.map { + case d: JsonQueryComparison[?] => + (p.key, p.query.raw, p.comparison.asJson) + case _ => fail("Decoded an unexpected predicate.") + }, + Right((key, query.raw, comparison.asJson)) + ) + } diff --git a/src/test/scala/gs/predicate/v0/serde/json/JsonComparisonCodecTests.scala b/src/test/scala/gs/predicate/v0/serde/json/JsonComparisonCodecTests.scala new file mode 100644 index 0000000..71c3e9b --- /dev/null +++ b/src/test/scala/gs/predicate/v0/serde/json/JsonComparisonCodecTests.scala @@ -0,0 +1,403 @@ +package gs.predicate.v0.serde.json + +import gs.predicate.v0.api.Messages +import gs.predicate.v0.json.JsonComparison +import io.circe.Decoder +import io.circe.Json +import io.circe.syntax._ +import java.time.LocalDate +import munit.FunSuite + +class JsonComparisonCodecTests extends FunSuite: + + test("should fail to decode an unknown comparison") { + val candidate = "unrecognized" + val json: Json = Json.obj( + JsonKeys.name -> Json.fromString(candidate) + ) + + val decoded = Decoder[JsonComparison].decodeJson(json) + assertEquals(decoded.isLeft, true) + assertEquals( + decoded.left.toOption.map(_.message), + Some(Messages.unrecognizedJsonComparison(candidate)) + ) + } + + test("should serialize and deserialize comparison: Eq") { + val target: Json = Json.obj("x" -> Json.fromString("y")) + val jc = JsonComparison.Eq(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.Eq.Name)), + (JsonKeys.value, target) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.Eq => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: Neq") { + val target: Json = Json.obj("x" -> Json.fromString("y")) + val jc = JsonComparison.Neq(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.Neq.Name)), + (JsonKeys.value, target) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.Neq => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: StringContains") { + val target = "target" + val jc = JsonComparison.StringContains(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.StringContains.Name)), + (JsonKeys.value, Json.fromString(target)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.StringContains => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: StringPrefix") { + val target = "target" + val jc = JsonComparison.StringPrefix(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.StringPrefix.Name)), + (JsonKeys.value, Json.fromString(target)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.StringPrefix => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: StringSuffix") { + val target = "target" + val jc = JsonComparison.StringSuffix(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.StringSuffix.Name)), + (JsonKeys.value, Json.fromString(target)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.StringSuffix => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: IntLessThan") { + val target = 10 + val jc = JsonComparison.IntLessThan(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.IntLessThan.Name)), + (JsonKeys.value, Json.fromInt(target)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.IntLessThan => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: IntLessThanOrEqualTo") { + val target = 10 + val jc = JsonComparison.IntLessThanOrEqualTo(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + ( + JsonKeys.name, + Json.fromString(JsonComparison.IntLessThanOrEqualTo.Name) + ), + (JsonKeys.value, Json.fromInt(target)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.IntLessThanOrEqualTo => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: IntGreaterThan") { + val target = 10 + val jc = JsonComparison.IntGreaterThan(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.IntGreaterThan.Name)), + (JsonKeys.value, Json.fromInt(target)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.IntGreaterThan => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: IntGreaterThanOrEqualTo") { + val target = 10 + val jc = JsonComparison.IntGreaterThanOrEqualTo(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + ( + JsonKeys.name, + Json.fromString(JsonComparison.IntGreaterThanOrEqualTo.Name) + ), + (JsonKeys.value, Json.fromInt(target)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.IntGreaterThanOrEqualTo => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: IntBetweenInclusive") { + val lower = 1 + val upper = 10 + val jc = JsonComparison.IntBetweenInclusive(lower, upper) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + ( + JsonKeys.name, + Json.fromString(JsonComparison.IntBetweenInclusive.Name) + ), + (JsonKeys.lower, Json.fromInt(lower)), + (JsonKeys.upper, Json.fromInt(upper)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.IntBetweenInclusive => d.lower -> d.upper + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(lower -> upper) + ) + } + + test("should serialize and deserialize comparison: IntBetweenExclusive") { + val lower = 1 + val upper = 10 + val jc = JsonComparison.IntBetweenExclusive(lower, upper) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + ( + JsonKeys.name, + Json.fromString(JsonComparison.IntBetweenExclusive.Name) + ), + (JsonKeys.lower, Json.fromInt(lower)), + (JsonKeys.upper, Json.fromInt(upper)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.IntBetweenExclusive => d.lower -> d.upper + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(lower -> upper) + ) + } + + test("should serialize and deserialize comparison: DateEq") { + val target = LocalDate.now() + val jc = JsonComparison.DateEq(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.DateEq.Name)), + (JsonKeys.value, Json.fromString(target.toString())) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.DateEq => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: DateBefore") { + val target = LocalDate.now() + val jc = JsonComparison.DateBefore(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.DateBefore.Name)), + (JsonKeys.value, Json.fromString(target.toString())) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.DateBefore => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: DateAfter") { + val target = LocalDate.now() + val jc = JsonComparison.DateAfter(target) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.name, Json.fromString(JsonComparison.DateAfter.Name)), + (JsonKeys.value, Json.fromString(target.toString())) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.DateAfter => d.target + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(target) + ) + } + + test("should serialize and deserialize comparison: DateBetweenInclusive") { + val lower = LocalDate.now().minusDays(1L) + val upper = LocalDate.now().plusDays(1L) + val jc = JsonComparison.DateBetweenInclusive(lower, upper) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + ( + JsonKeys.name, + Json.fromString(JsonComparison.DateBetweenInclusive.Name) + ), + (JsonKeys.lower, Json.fromString(lower.toString())), + (JsonKeys.upper, Json.fromString(upper.toString())) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.DateBetweenInclusive => d.lower -> d.upper + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(lower -> upper) + ) + } + + test("should serialize and deserialize comparison: DateBetweenExclusive") { + val lower = LocalDate.now().minusDays(1L) + val upper = LocalDate.now().plusDays(1L) + val jc = JsonComparison.DateBetweenExclusive(lower, upper) + val encoded = jc.asJson + val decoded = Decoder[JsonComparison].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + ( + JsonKeys.name, + Json.fromString(JsonComparison.DateBetweenExclusive.Name) + ), + (JsonKeys.lower, Json.fromString(lower.toString())), + (JsonKeys.upper, Json.fromString(upper.toString())) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case d: JsonComparison.DateBetweenExclusive => d.lower -> d.upper + case _ => fail("Parsed an unexpected JsonComparison.") + }, + Right(lower -> upper) + ) + }