diff --git a/src/main/scala/gs/predicate/v0/json/JsonComparison.scala b/src/main/scala/gs/predicate/v0/json/JsonComparison.scala new file mode 100644 index 0000000..219e5d0 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/json/JsonComparison.scala @@ -0,0 +1,313 @@ +package gs.predicate.v0.json + +import gs.predicate.v0.serde.json.JsonKeys +import io.circe.Encoder +import io.circe.Json +import java.time.LocalDate +import scala.util.Try + +/** Serializable comparisons against single JSON objects. + * + * @param name + * The name of the comparison - used for serialization. + */ +abstract class JsonComparison(val name: String): + def compare(input: Json): Boolean + +object JsonComparison: + + case class Eq(target: Json) extends JsonComparison(Eq.Name): + def compare(input: Json): Boolean = target.equals(input) + + object Eq: + final val Name: String = "=" + + given Encoder[Eq] = Encoder.instance[Eq] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> jc.target + ) + } + + case class Neq(target: Json) extends JsonComparison(Neq.Name): + def compare(input: Json): Boolean = !target.equals(input) + + object Neq: + final val Name: String = "!=" + + given Encoder[Neq] = Encoder.instance[Neq] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> jc.target + ) + } + + case class StringContains(target: String) + extends JsonComparison(StringContains.Name): + + def compare(input: Json): Boolean = + input.asString.exists(_.contains(target)) + + object StringContains: + final val Name: String = "contains" + + given Encoder[StringContains] = Encoder.instance[StringContains] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromString(jc.target) + ) + } + + case class StringPrefix(target: String) + extends JsonComparison(StringPrefix.Name): + + def compare(input: Json): Boolean = + input.asString.exists(_.startsWith(target)) + + object StringPrefix: + final val Name: String = "prefix" + + given Encoder[StringPrefix] = Encoder.instance[StringPrefix] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromString(jc.target) + ) + } + + case class StringSuffix(target: String) + extends JsonComparison(StringSuffix.Name): + + def compare(input: Json): Boolean = + input.asString.exists(_.endsWith(target)) + + object StringSuffix: + final val Name: String = "suffix" + + given Encoder[StringSuffix] = Encoder.instance[StringSuffix] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromString(jc.target) + ) + } + + case class IntLessThan(target: Int) extends JsonComparison(IntLessThan.Name): + + def compare(input: Json): Boolean = + input.asNumber.flatMap(_.toInt).exists(_ < target) + + object IntLessThan: + final val Name: String = "<" + + given Encoder[IntLessThan] = Encoder.instance[IntLessThan] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromInt(jc.target) + ) + } + + case class IntLessThanOrEqualTo(target: Int) + extends JsonComparison(IntLessThanOrEqualTo.Name): + + def compare(input: Json): Boolean = + input.asNumber.flatMap(_.toInt).exists(_ <= target) + + object IntLessThanOrEqualTo: + final val Name: String = "<=" + + given Encoder[IntLessThanOrEqualTo] = + Encoder.instance[IntLessThanOrEqualTo] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromInt(jc.target) + ) + } + + case class IntGreaterThan(target: Int) + extends JsonComparison(IntGreaterThan.Name): + + def compare(input: Json): Boolean = + input.asNumber.flatMap(_.toInt).exists(_ > target) + + object IntGreaterThan: + final val Name: String = ">" + + given Encoder[IntGreaterThan] = Encoder.instance[IntGreaterThan] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromInt(jc.target) + ) + } + + case class IntGreaterThanOrEqualTo(target: Int) + extends JsonComparison(IntGreaterThanOrEqualTo.Name): + + def compare(input: Json): Boolean = + input.asNumber.flatMap(_.toInt).exists(_ >= target) + + object IntGreaterThanOrEqualTo: + final val Name: String = ">=" + + given Encoder[IntGreaterThanOrEqualTo] = + Encoder.instance[IntGreaterThanOrEqualTo] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromInt(jc.target) + ) + } + + case class IntBetweenInclusive( + lower: Int, + upper: Int + ) extends JsonComparison(IntBetweenInclusive.Name): + + def compare(input: Json): Boolean = + input.asNumber + .flatMap(_.toInt) + .exists(value => value >= lower && value <= upper) + + object IntBetweenInclusive: + final val Name: String = "[]" + + given Encoder[IntBetweenInclusive] = + Encoder.instance[IntBetweenInclusive] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.lower -> Json.fromInt(jc.lower), + JsonKeys.upper -> Json.fromInt(jc.upper) + ) + } + + case class IntBetweenExclusive( + lower: Int, + upper: Int + ) extends JsonComparison(IntBetweenExclusive.Name): + + def compare(input: Json): Boolean = + input.asNumber + .flatMap(_.toInt) + .exists(value => value > lower && value < upper) + + object IntBetweenExclusive: + final val Name: String = "()" + + given Encoder[IntBetweenExclusive] = + Encoder.instance[IntBetweenExclusive] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.lower -> Json.fromInt(jc.lower), + JsonKeys.upper -> Json.fromInt(jc.upper) + ) + } + + case class DateEq(target: LocalDate) extends JsonComparison(DateEq.Name): + + def compare(input: Json): Boolean = + input.asString.flatMap(asDate).exists(_.isEqual(target)) + + object DateEq: + final val Name: String = "date=" + + given Encoder[DateEq] = Encoder.instance[DateEq] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromString(jc.target.toString()) + ) + } + + case class DateBefore(target: LocalDate) + extends JsonComparison(DateBefore.Name): + + def compare(input: Json): Boolean = + input.asString.flatMap(asDate).exists(_.isBefore(target)) + + object DateBefore: + final val Name: String = "date<" + + given Encoder[DateBefore] = Encoder.instance[DateBefore] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromString(jc.target.toString()) + ) + } + + case class DateAfter(target: LocalDate) + extends JsonComparison(DateAfter.Name): + + def compare(input: Json): Boolean = + input.asString.flatMap(asDate).exists(_.isAfter(target)) + + object DateAfter: + final val Name: String = "date>" + + given Encoder[DateAfter] = Encoder.instance[DateAfter] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.value -> Json.fromString(jc.target.toString()) + ) + } + + case class DateBetweenInclusive( + lower: LocalDate, + upper: LocalDate + ) extends JsonComparison(DateBetweenInclusive.Name): + + def compare(input: Json): Boolean = + input.asString + .flatMap(asDate) + .exists(value => + dateLessThanOrEqualTo(value, upper) && dateGreaterThanOrEqualTo( + value, + lower + ) + ) + + object DateBetweenInclusive: + final val Name: String = "date[]" + + given Encoder[DateBetweenInclusive] = + Encoder.instance[DateBetweenInclusive] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.lower -> Json.fromString(jc.lower.toString()), + JsonKeys.upper -> Json.fromString(jc.upper.toString()) + ) + } + + case class DateBetweenExclusive( + lower: LocalDate, + upper: LocalDate + ) extends JsonComparison(DateBetweenExclusive.Name): + + def compare(input: Json): Boolean = + input.asString + .flatMap(asDate) + .exists(value => value.isAfter(lower) && value.isBefore(upper)) + + object DateBetweenExclusive: + final val Name: String = "date()" + + given Encoder[DateBetweenExclusive] = + Encoder.instance[DateBetweenExclusive] { jc => + Json.obj( + JsonKeys.name -> Json.fromString(Name), + JsonKeys.lower -> Json.fromString(jc.lower.toString()), + JsonKeys.upper -> Json.fromString(jc.upper.toString()) + ) + } + + private def asDate(input: String): Option[LocalDate] = + Try(LocalDate.parse(input)).toOption + + private def dateLessThanOrEqualTo( + input: LocalDate, + target: LocalDate + ): Boolean = + input.isBefore(target) || input.isEqual(target) + + private def dateGreaterThanOrEqualTo( + input: LocalDate, + target: LocalDate + ): Boolean = + input.isAfter(target) || input.isEqual(target) + +end JsonComparison diff --git a/src/main/scala/gs/predicate/v0/json/JsonQueryComparison.scala b/src/main/scala/gs/predicate/v0/json/JsonQueryComparison.scala new file mode 100644 index 0000000..133a187 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/json/JsonQueryComparison.scala @@ -0,0 +1,50 @@ +package gs.predicate.v0.json + +import cats.Applicative +import cats.syntax.all.* +import gs.predicate.v0.api.Predicate +import gs.predicate.v0.json.query.JsonQuery + +/** 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 + * query matches the given comparison function. + * + * This is the most general JSON predicate. + * + * @param key + * The name of the JSON value that must satisfy the given query. + * @param query + * The [[JsonQuery]] that must be satisfied. + * @param comparison + * The JSON comparison that must match the query. + */ +final class JsonQueryComparison[F[_]: Applicative: JsonProvider]( + val key: String, + val query: JsonQuery, + val comparison: JsonComparison +) extends Predicate[F]: + + /** @inheritDocs + */ + final override val predicateType: String = JsonQueryComparison.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + JsonProvider[F].get(key).map { + case None => Predicate.Result.missed() + case Some(json) => Predicate.Result(query.eval(json, comparison.compare)) + } + +object JsonQueryComparison: + + final val PredicateType: String = "json-query-comparison" + + def apply[F[_]: Applicative: JsonProvider]( + key: String, + query: JsonQuery, + comparison: JsonComparison + ): JsonQueryComparison[F] = + new JsonQueryComparison[F](key, query, comparison) + +end JsonQueryComparison diff --git a/src/main/scala/gs/predicate/v0/json/JsonQueryNotEquals.scala b/src/main/scala/gs/predicate/v0/json/JsonQueryNotEquals.scala new file mode 100644 index 0000000..f336d96 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/json/JsonQueryNotEquals.scala @@ -0,0 +1,50 @@ +package gs.predicate.v0.json + +import cats.Applicative +import cats.syntax.all.* +import gs.predicate.v0.api.Predicate +import gs.predicate.v0.json.query.JsonQuery +import io.circe.Json + +/** 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 + * query matches the given value. + * + * @param key + * The name of the JSON value that must satisfy the given query. + * @param query + * The [[JsonQuery]] that must be satisfied. + * @param value + * The JSON value that must match the query. + */ +final class JsonQueryNotEquals[F[_]: Applicative: JsonProvider]( + val key: String, + val query: JsonQuery, + val value: Json +) extends Predicate[F]: + + /** @inheritDocs + */ + final override val predicateType: String = JsonQueryNotEquals.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + JsonProvider[F].get(key).map { + case None => Predicate.Result.missed() + case Some(json) => + Predicate.Result(query.eval(json, v => !v.equals(value))) + } + +object JsonQueryNotEquals: + + final val PredicateType: String = "json-query-not-equals" + + def apply[F[_]: Applicative: JsonProvider]( + key: String, + query: JsonQuery, + value: Json + ): JsonQueryNotEquals[F] = + new JsonQueryNotEquals[F](key, query, value) + +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 b3ce080..26c9210 100644 --- a/src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala +++ b/src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala @@ -151,8 +151,12 @@ final class CompiledQuery private ( else ps match case Nil => + // If we are at the end of some query, we reason about the selected + // value. Note that this could be ANY JSON value. If the key is an + // array, for example, this will get the entire array. path.json.getOption(json).map(p).getOrElse(false) case Single(key) :: rest => + // Selecting a key could lead to `Nil` (evaluate the value at the key) evalRec(rest, json, path.selectDynamic(key), p, depth + 1) case ArrayAny(key) :: rest => path 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 11f5ce9..1f3b177 100644 --- a/src/main/scala/gs/predicate/v0/serde/json/JsonKeys.scala +++ b/src/main/scala/gs/predicate/v0/serde/json/JsonKeys.scala @@ -24,4 +24,16 @@ object JsonKeys: */ val values: String = "values" + /** Captures the name of comparison operations. + */ + val name: String = "name" + + /** Lower bound of some range. + */ + val lower: String = "lower" + + /** Upper bound of some range. + */ + val upper: String = "upper" + 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 ee1aae9..7c0637e 100644 --- a/src/main/scala/gs/predicate/v0/serde/json/codecs.scala +++ b/src/main/scala/gs/predicate/v0/serde/json/codecs.scala @@ -12,6 +12,7 @@ import gs.predicate.v0.kv.KeyValueProvider import gs.predicate.v0.kv.ValueContains import gs.predicate.v0.kv.ValueEndsWith import gs.predicate.v0.kv.ValueEquals +import gs.predicate.v0.kv.ValueNotEquals import gs.predicate.v0.kv.ValueStartsWith import io.circe.* import io.circe.syntax.* @@ -100,12 +101,14 @@ def decodePredicate[F[_]: Applicative: KeyValueProvider: JsonProvider]( for predicateType <- cursor.downField(JsonKeys.predicateType).as[String] predicate <- predicateType match - case True.PredicateType => Right(True[F]) - case False.PredicateType => Right(False[F]) - case And.PredicateType => decodeAnd[F](cursor) - case Or.PredicateType => decodeOr[F](cursor) - case KeyExists.PredicateType => Decoder[KeyExists[F]].apply(cursor) - case ValueEquals.PredicateType => Decoder[ValueEquals[F]].apply(cursor) + case True.PredicateType => Right(True[F]) + case False.PredicateType => Right(False[F]) + case And.PredicateType => decodeAnd[F](cursor) + case Or.PredicateType => decodeOr[F](cursor) + case KeyExists.PredicateType => Decoder[KeyExists[F]].apply(cursor) + case ValueEquals.PredicateType => Decoder[ValueEquals[F]].apply(cursor) + case ValueNotEquals.PredicateType => + Decoder[ValueNotEquals[F]].apply(cursor) case ValueContains.PredicateType => Decoder[ValueContains[F]].apply(cursor) case ValueStartsWith.PredicateType => diff --git a/src/test/scala/gs/predicate/v0/json/JsonComparisonTests.scala b/src/test/scala/gs/predicate/v0/json/JsonComparisonTests.scala new file mode 100644 index 0000000..db1929f --- /dev/null +++ b/src/test/scala/gs/predicate/v0/json/JsonComparisonTests.scala @@ -0,0 +1,210 @@ +package gs.predicate.v0.json + +import gs.datagen.v0.Gen +import gs.datagen.v0.generators.MinMax +import gs.datagen.v0.generators.Size +import io.circe.Json +import java.time.LocalDate +import munit.FunSuite + +class JsonComparisonTests extends FunSuite: + import JsonComparisonTests.* + + test("should support Eq") { + val key = keyGen.gen() + val value = jsonStringGen.gen() + val json = Json.obj(key -> value) + val other = Json.obj("x" -> value) + val jc = JsonComparison.Eq(json) + assert(jc.compare(json)) + assert(!jc.compare(other)) + } + + test("should support Neq") { + val key = keyGen.gen() + val value = jsonStringGen.gen() + val json = Json.obj(key -> value) + val other = Json.obj("x" -> value) + val jc = JsonComparison.Neq(json) + assert(!jc.compare(json)) + assert(jc.compare(other)) + } + + test("should support StringContains") { + val str = strGen.gen() + val substr = str.substring(3, 7) + val json = Json.fromString(str) + val jc1 = JsonComparison.StringContains(substr) + val jc2 = JsonComparison.StringContains(str) + val jc3 = JsonComparison.StringContains(longStrGen.gen()) + assert(jc1.compare(json)) + assert(jc2.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support StringPrefix") { + val str = strGen.gen() + val substr = str.take(4) + val json = Json.fromString(str) + val jc1 = JsonComparison.StringPrefix(substr) + val jc2 = JsonComparison.StringPrefix(str) + val jc3 = JsonComparison.StringPrefix(longStrGen.gen()) + assert(jc1.compare(json)) + assert(jc2.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support StringSuffix") { + val str = strGen.gen() + val substr = str.takeRight(4) + val json = Json.fromString(str) + val jc1 = JsonComparison.StringSuffix(substr) + val jc2 = JsonComparison.StringSuffix(str) + val jc3 = JsonComparison.StringSuffix(longStrGen.gen()) + assert(jc1.compare(json)) + assert(jc2.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support IntLessThan") { + val int = intGen.gen() + val target = 101 + val json = Json.fromInt(int) + val jc1 = JsonComparison.IntLessThan(target) + val jc3 = JsonComparison.IntLessThan(-1) + assert(jc1.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support IntLessThanOrEqualTo") { + val int = intGen.gen() + val target = 101 + val json = Json.fromInt(int) + val jc1 = JsonComparison.IntLessThanOrEqualTo(target) + val jc2 = JsonComparison.IntLessThanOrEqualTo(int) + val jc3 = JsonComparison.IntLessThan(-1) + assert(jc1.compare(json)) + assert(jc2.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support IntGreaterThan") { + val int = intGen.gen() + val target = -1 + val json = Json.fromInt(int) + val jc1 = JsonComparison.IntGreaterThan(target) + val jc3 = JsonComparison.IntGreaterThan(101) + assert(jc1.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support IntGreaterThanOrEqualTo") { + val int = intGen.gen() + val target = -1 + val json = Json.fromInt(int) + val jc1 = JsonComparison.IntGreaterThanOrEqualTo(target) + val jc2 = JsonComparison.IntGreaterThanOrEqualTo(int) + val jc3 = JsonComparison.IntGreaterThan(101) + assert(jc1.compare(json)) + assert(jc2.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support IntBetweenInclusive") { + val int = intGen.gen() + val lower = 0 + val upper = 100 + val json = Json.fromInt(int) + val jc1 = JsonComparison.IntBetweenInclusive(lower, upper) + val jc2 = JsonComparison.IntBetweenInclusive(int, int) + val jc3 = JsonComparison.IntBetweenInclusive(1000, 10000) + assert(jc1.compare(json)) + assert(jc2.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support IntBetweenExclusive") { + val int = intGen.gen() + val lower = -1 + val upper = 101 + val json = Json.fromInt(int) + val jc1 = JsonComparison.IntBetweenExclusive(lower, upper) + val jc2 = JsonComparison.IntBetweenExclusive(int, int) + val jc3 = JsonComparison.IntBetweenExclusive(1000, 10000) + assert(jc1.compare(json)) + assert(!jc2.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support DateEq") { + val date = dateGen.gen() + val target = date + val json = Json.fromString(date.toString()) + val jc1 = JsonComparison.DateEq(target) + val jc2 = JsonComparison.DateEq(target.plusDays(1L)) + assert(jc1.compare(json)) + assert(!jc2.compare(json)) + } + + test("should support DateBefore") { + val date = dateGen.gen() + val target = date.plusDays(1L) + val json = Json.fromString(date.toString()) + val jc1 = JsonComparison.DateBefore(target) + val jc2 = JsonComparison.DateBefore(date) + assert(jc1.compare(json)) + assert(!jc2.compare(json)) + } + + test("should support DateAfter") { + val date = dateGen.gen() + val target = date.minusDays(1L) + val json = Json.fromString(date.toString()) + val jc1 = JsonComparison.DateAfter(target) + val jc2 = JsonComparison.DateAfter(date) + assert(jc1.compare(json)) + assert(!jc2.compare(json)) + } + + test("should support DateBetweenInclusive") { + val date = dateGen.gen() + val lower = date.minusDays(1L) + val upper = date.plusDays(1L) + val json = Json.fromString(date.toString()) + val jc1 = JsonComparison.DateBetweenInclusive(lower, upper) + val jc2 = JsonComparison.DateBetweenInclusive(date, date) + val jc3 = JsonComparison.DateBetweenInclusive( + date.plusDays(1L), + date.plusDays(1000L) + ) + assert(jc1.compare(json)) + assert(jc2.compare(json)) + assert(!jc3.compare(json)) + } + + test("should support DateBetweenExclusive") { + val date = dateGen.gen() + val lower = date.minusDays(1L) + val upper = date.plusDays(1L) + val json = Json.fromString(date.toString()) + val jc1 = JsonComparison.DateBetweenExclusive(lower, upper) + val jc2 = JsonComparison.DateBetweenExclusive(date, date) + val jc3 = JsonComparison.DateBetweenExclusive( + date.plusDays(1L), + date.plusDays(1000L) + ) + assert(jc1.compare(json)) + assert(!jc2.compare(json)) + assert(!jc3.compare(json)) + } + +object JsonComparisonTests: + + val keyGen: Gen[String] = Gen.string.alphaNumeric(Size.between(8, 16)) + val strGen: Gen[String] = Gen.string.alphaNumeric(Size.between(8, 16)) + val longStrGen: Gen[String] = Gen.string.alphaNumeric(Size.fixed(32)) + val jsonStringGen: Gen[Json] = strGen.map(Json.fromString) + val intGen: Gen[Int] = Gen.integer.inRange(0, 100) + val dateGen: Gen[LocalDate] = Gen.date.aroundToday(MinMax(1, 3)) + +end JsonComparisonTests diff --git a/src/test/scala/gs/predicate/v0/json/JsonQueryExistsTests.scala b/src/test/scala/gs/predicate/v0/json/JsonQueryExistsTests.scala new file mode 100644 index 0000000..7e4b25c --- /dev/null +++ b/src/test/scala/gs/predicate/v0/json/JsonQueryExistsTests.scala @@ -0,0 +1,62 @@ +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 JsonQueryExistsTests extends IOSuite: + + import JsonQueryExistsTests.Data + import JsonQueryExistsTests.newProvider + + iotest("should find a match for an existing key") { + val key = Data.keyGen.gen() + val value = Data.strValGen.gen() + val query = key + val blob = Json.obj( + key -> value + ) + + newProvider(Map(key -> blob)).flatMap { provider => + given JsonProvider[IO] = provider + val p = JsonQueryExists[IO](key, JsonQuery.compile(query)) + p.eval().map(result => assertEquals(result, Predicate.Result.matched())) + } + } + + iotest("should fail to match against some non-existing key") { + val key = Data.keyGen.gen() + val value = Data.strValGen.gen() + val query = "somethingelse" + val blob = Json.obj( + key -> value + ) + + newProvider(Map(key -> blob)).flatMap { provider => + given JsonProvider[IO] = provider + val p = JsonQueryExists[IO](key, JsonQuery.compile(query)) + p.eval().map(result => assertEquals(result, Predicate.Result.missed())) + } + } + +object JsonQueryExistsTests: + + 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 JsonQueryExistsTests diff --git a/src/test/scala/gs/predicate/v0/serde/json/KeyValueCodecTests.scala b/src/test/scala/gs/predicate/v0/serde/json/KeyValueCodecTests.scala new file mode 100644 index 0000000..2621b3d --- /dev/null +++ b/src/test/scala/gs/predicate/v0/serde/json/KeyValueCodecTests.scala @@ -0,0 +1,183 @@ +package gs.predicate.v0.serde.json + +import cats.effect.IO +import gs.datagen.v0.Gen +import gs.datagen.v0.generators.Size +import gs.predicate.v0.api.Predicate +import gs.predicate.v0.json.JsonProvider +import gs.predicate.v0.kv.KeyExists +import gs.predicate.v0.kv.KeyValueProvider +import gs.predicate.v0.kv.ValueContains +import gs.predicate.v0.kv.ValueEndsWith +import gs.predicate.v0.kv.ValueEquals +import gs.predicate.v0.kv.ValueNotEquals +import gs.predicate.v0.kv.ValueStartsWith +import io.circe.Decoder +import io.circe.Encoder +import io.circe.Json +import munit.FunSuite + +class KeyValueCodecTests extends FunSuite: + + test("should serialize and deserialize a KeyExists predicate") { + given JsonProvider[IO] = JsonProvider.noop[IO] + given KeyValueProvider[IO] = KeyValueProvider.noop[IO] + + val key = Gen.string.alphaNumeric(Size.fixed(8)).gen() + val p = KeyExists.apply[IO](key) + val encoded = Encoder[KeyExists[IO]].apply(p) + val decoded = Decoder[Predicate[IO]].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.predicateType, Json.fromString(KeyExists.PredicateType)), + (JsonKeys.key, Json.fromString(key)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case parsed: KeyExists[?] => parsed.key + case _ => "" + }, + Right(key) + ) + } + + test("should serialize and deserialize a ValueEquals predicate") { + given JsonProvider[IO] = JsonProvider.noop[IO] + given KeyValueProvider[IO] = KeyValueProvider.noop[IO] + + val key = Gen.string.alphaNumeric(Size.fixed(8)).gen() + val value = Gen.string.alphaNumeric(Size.between(8, 16)).gen() + val p = ValueEquals.apply[IO](key, value) + val encoded = Encoder[ValueEquals[IO]].apply(p) + val decoded = Decoder[Predicate[IO]].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.predicateType, Json.fromString(ValueEquals.PredicateType)), + (JsonKeys.key, Json.fromString(key)), + (JsonKeys.value, Json.fromString(value)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case parsed: ValueEquals[?] => parsed.key -> parsed.value + case _ => "" + }, + Right(key -> value) + ) + } + + test("should serialize and deserialize a ValueNotEquals predicate") { + given JsonProvider[IO] = JsonProvider.noop[IO] + given KeyValueProvider[IO] = KeyValueProvider.noop[IO] + + val key = Gen.string.alphaNumeric(Size.fixed(8)).gen() + val value = Gen.string.alphaNumeric(Size.between(8, 16)).gen() + val p = ValueNotEquals.apply[IO](key, value) + val encoded = Encoder[ValueNotEquals[IO]].apply(p) + val decoded = Decoder[Predicate[IO]].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.predicateType, Json.fromString(ValueNotEquals.PredicateType)), + (JsonKeys.key, Json.fromString(key)), + (JsonKeys.value, Json.fromString(value)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case parsed: ValueNotEquals[?] => parsed.key -> parsed.value + case _ => "" + }, + Right(key -> value) + ) + } + + test("should serialize and deserialize a ValueContains predicate") { + given JsonProvider[IO] = JsonProvider.noop[IO] + given KeyValueProvider[IO] = KeyValueProvider.noop[IO] + + val key = Gen.string.alphaNumeric(Size.fixed(8)).gen() + val value = Gen.string.alphaNumeric(Size.between(8, 16)).gen() + val p = ValueContains.apply[IO](key, value) + val encoded = Encoder[ValueContains[IO]].apply(p) + val decoded = Decoder[Predicate[IO]].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.predicateType, Json.fromString(ValueContains.PredicateType)), + (JsonKeys.key, Json.fromString(key)), + (JsonKeys.value, Json.fromString(value)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case parsed: ValueContains[?] => parsed.key -> parsed.containedValue + case _ => "" + }, + Right(key -> value) + ) + } + + test("should serialize and deserialize a ValueStartsWith predicate") { + given JsonProvider[IO] = JsonProvider.noop[IO] + given KeyValueProvider[IO] = KeyValueProvider.noop[IO] + + val key = Gen.string.alphaNumeric(Size.fixed(8)).gen() + val value = Gen.string.alphaNumeric(Size.between(8, 16)).gen() + val p = ValueStartsWith.apply[IO](key, value) + val encoded = Encoder[ValueStartsWith[IO]].apply(p) + val decoded = Decoder[Predicate[IO]].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + ( + JsonKeys.predicateType, + Json.fromString(ValueStartsWith.PredicateType) + ), + (JsonKeys.key, Json.fromString(key)), + (JsonKeys.value, Json.fromString(value)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case parsed: ValueStartsWith[?] => parsed.key -> parsed.prefix + case _ => "" + }, + Right(key -> value) + ) + } + + test("should serialize and deserialize a ValueEndsWith predicate") { + given JsonProvider[IO] = JsonProvider.noop[IO] + given KeyValueProvider[IO] = KeyValueProvider.noop[IO] + + val key = Gen.string.alphaNumeric(Size.fixed(8)).gen() + val value = Gen.string.alphaNumeric(Size.between(8, 16)).gen() + val p = ValueEndsWith.apply[IO](key, value) + val encoded = Encoder[ValueEndsWith[IO]].apply(p) + val decoded = Decoder[Predicate[IO]].decodeJson(encoded) + assertEquals( + encoded, + Json.obj( + (JsonKeys.predicateType, Json.fromString(ValueEndsWith.PredicateType)), + (JsonKeys.key, Json.fromString(key)), + (JsonKeys.value, Json.fromString(value)) + ) + ) + assertEquals(decoded.isRight, true) + assertEquals( + decoded.map { + case parsed: ValueEndsWith[?] => parsed.key -> parsed.suffix + case _ => "" + }, + Right(key -> value) + ) + }