diff --git a/build.sbt b/build.sbt index ea39342..f2b1602 100644 --- a/build.sbt +++ b/build.sbt @@ -13,10 +13,6 @@ ThisBuild / licenses := Seq( "MIT" -> url("https://git.garrity.co/garrity-software/gs-predicate/LICENSE") ) -val noPublishSettings = Seq( - publish := {} -) - val sharedSettings = Seq( scalaVersion := scala3, version := semVerSelected.value, @@ -31,14 +27,14 @@ val Deps = new { } val Circe = new { - val Core: ModuleID = "io.circe" %% "circe-core" % "0.14.15" - val Parser: ModuleID = "io.circe" %% "circe-parser" % "0.14.15" - val Optics: ModuleID = "io.circe" %% "circe-optics" % "0.15.1" + val Core: ModuleID = "io.circe" %% "circe-core" % "0.14.15" + val Generic: ModuleID = "io.circe" %% "circe-generic" % "0.14.15" + val Optics: ModuleID = "io.circe" %% "circe-optics" % "0.15.1" + val Parser: ModuleID = "io.circe" %% "circe-parser" % "0.14.15" } val Gs = new { val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.3" - val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.1" } val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.1.1" @@ -53,86 +49,16 @@ lazy val testSettings = Seq( lazy val `gs-predicate` = project .in(file(".")) - .aggregate( - `test-support`, - api, - keyValue, - json - ) - .settings(noPublishSettings) + .settings(sharedSettings) + .settings(testSettings) .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") - -/** Internal project used for unit tests. - */ -lazy val `test-support` = project - .in(file("modules/test-support")) - .settings(sharedSettings) - .settings(testSettings) - .settings(noPublishSettings) - .settings( - name := s"${gsProjectName.value}-test-support" - ) - .settings( - libraryDependencies ++= Seq( - Deps.Cats.Core, - Deps.Cats.Effect - ) - ) - -/** Core API - Defines fundamental predicates. - */ -lazy val api = project - .in(file("modules/api")) - .dependsOn(`test-support` % "test->test") - .settings(sharedSettings) - .settings(testSettings) - .settings( - name := s"${gsProjectName.value}-api-v${semVerMajor.value}" - ) - .settings( - libraryDependencies ++= Seq( - Deps.Cats.Core, - Deps.Gs.Uuid - ) - ) - -/** Key-Value - Defines predicates that can match on string keys and values. - */ -lazy val keyValue = project - .in(file("modules/kv")) - .dependsOn(`test-support` % "test->test") - .dependsOn(api) - .settings(sharedSettings) - .settings(testSettings) - .settings( - name := s"${gsProjectName.value}-kv-v${semVerMajor.value}" - ) - .settings( - libraryDependencies ++= Seq( - Deps.Cats.Core, - Deps.Cats.Effect, - Deps.Gs.Uuid - ) - ) - -/** JSON - Defines predicates that can match on JSON values. - */ -lazy val json = project - .in(file("modules/json")) - .dependsOn(`test-support` % "test->test") - .dependsOn(api) - .settings(sharedSettings) - .settings(testSettings) - .settings( - name := s"${gsProjectName.value}-json-v${semVerMajor.value}" - ) .settings( libraryDependencies ++= Seq( Deps.Cats.Core, Deps.Cats.Effect, Deps.Circe.Core, - Deps.Circe.Parser, + Deps.Circe.Generic, Deps.Circe.Optics, - Deps.Gs.Uuid + Deps.Circe.Parser ) ) diff --git a/modules/api/src/main/scala/gs/predicate/v0/api/And.scala b/modules/api/src/main/scala/gs/predicate/v0/api/And.scala deleted file mode 100644 index c8fcf12..0000000 --- a/modules/api/src/main/scala/gs/predicate/v0/api/And.scala +++ /dev/null @@ -1,41 +0,0 @@ -package gs.predicate.v0.api - -import cats.Applicative -import cats.syntax.all.* -import gs.uuid.v0.UUID - -/** Implements logical AND. - * - * @param ps - * The predicates to evaluate. - */ -final class And[F[_]: Applicative, -A]( - val id: UUID, - private val ps: List[Predicate[F, A]] -) extends Predicate[F, A]: - - /** @inheritDocs - */ - override def eval(input: A): F[Predicate.Result] = - ps.map(_.eval(input)).sequence.map(_.allMatch()) - -object And: - - def empty[F[_]: Applicative]: Predicate[F, Any] = False[F] - - def apply[F[_]: Applicative, A](ps: Predicate[F, A]*): Predicate[F, A] = - ps.toList match - case Nil => False[F] - case p :: Nil => p - case list => new And(UUID.v7(), list) - - def apply[F[_]: Applicative, A]( - id: UUID, - ps: Predicate[F, A]* - ): Predicate[F, A] = - ps.toList match - case Nil => False[F] - case p :: Nil => p - case list => new And(id, list) - -end And diff --git a/modules/api/src/main/scala/gs/predicate/v0/api/False.scala b/modules/api/src/main/scala/gs/predicate/v0/api/False.scala deleted file mode 100644 index 8a6cb78..0000000 --- a/modules/api/src/main/scala/gs/predicate/v0/api/False.scala +++ /dev/null @@ -1,23 +0,0 @@ -package gs.predicate.v0.api - -import cats.Applicative -import gs.uuid.v0.UUID - -/** Always returns a miss. - */ -final class False[F[_]: Applicative]( - val id: UUID -) extends Predicate[F, Any]: - - /** @inheritDocs - */ - override def eval(input: Any): F[Predicate.Result] = - Applicative[F].pure(Predicate.Result.missed()) - -object False: - - def apply[F[_]: Applicative]: False[F] = new False[F](UUID.v7()) - - def apply[F[_]: Applicative](id: UUID): False[F] = new False[F](id) - -end False diff --git a/modules/api/src/main/scala/gs/predicate/v0/api/Or.scala b/modules/api/src/main/scala/gs/predicate/v0/api/Or.scala deleted file mode 100644 index 7e7b2c3..0000000 --- a/modules/api/src/main/scala/gs/predicate/v0/api/Or.scala +++ /dev/null @@ -1,41 +0,0 @@ -package gs.predicate.v0.api - -import cats.Applicative -import cats.syntax.all.* -import gs.uuid.v0.UUID - -/** Implements logical OR. - * - * @param ps - * The predicates to evaluate. - */ -final class Or[F[_]: Applicative, -A]( - val id: UUID, - private val ps: List[Predicate[F, A]] -) extends Predicate[F, A]: - - /** @inheritDocs - */ - override def eval(input: A): F[Predicate.Result] = - ps.map(_.eval(input)).sequence.map(_.anyMatch()) - -object Or: - - def empty[F[_]: Applicative]: Predicate[F, Any] = False[F] - - def apply[F[_]: Applicative, A](ps: Predicate[F, A]*): Predicate[F, A] = - ps.toList match - case Nil => False[F] - case p :: Nil => p - case list => new Or(UUID.v7(), list) - - def apply[F[_]: Applicative, A]( - id: UUID, - ps: Predicate[F, A]* - ): Predicate[F, A] = - ps.toList match - case Nil => False[F] - case p :: Nil => p - case list => new Or(id, list) - -end Or diff --git a/modules/api/src/main/scala/gs/predicate/v0/api/True.scala b/modules/api/src/main/scala/gs/predicate/v0/api/True.scala deleted file mode 100644 index 851d29e..0000000 --- a/modules/api/src/main/scala/gs/predicate/v0/api/True.scala +++ /dev/null @@ -1,23 +0,0 @@ -package gs.predicate.v0.api - -import cats.Applicative -import gs.uuid.v0.UUID - -/** Always returns a match. - */ -final class True[F[_]: Applicative]( - val id: UUID -) extends Predicate[F, Any]: - - /** @inheritDocs - */ - override def eval(input: Any): F[Predicate.Result] = - Applicative[F].pure(Predicate.Result.matched()) - -object True: - - def apply[F[_]: Applicative]: True[F] = new True[F](UUID.v7()) - - def apply[F[_]: Applicative](id: UUID): True[F] = new True[F](id) - -end True diff --git a/modules/api/src/test/scala/gs/predicate/v0/api/FalseTests.scala b/modules/api/src/test/scala/gs/predicate/v0/api/FalseTests.scala deleted file mode 100644 index f502468..0000000 --- a/modules/api/src/test/scala/gs/predicate/v0/api/FalseTests.scala +++ /dev/null @@ -1,35 +0,0 @@ -package gs.predicate.v0.api - -import cats.effect.IO -import gs.uuid.v0.UUID -import support.IOSuite - -class FalseTests extends IOSuite: - - iotest("should return false") { - val predicate = False[IO] - - for result <- predicate.eval(()) - yield assertEquals(result.unwrap(), false) - } - - iotest("should provide a unique identifier") { - val id = UUID.v7() - val predicate = False[IO](id) - - for result <- predicate.eval(()) - yield - assertEquals(result.unwrap(), false) - assertEquals(predicate.id, id) - assertEquals(predicate.hashCode(), id.hashCode()) - assertEquals(predicate.toString(), s"predicate:${id.withoutDashes()}") - } - - test("should support equality") { - val predicate = False[IO] - val p2 = False[IO](predicate.id) - val p3 = False[IO] - assertEquals(predicate, predicate) - assertEquals(predicate, p2) - assertNotEquals(predicate, p3) - } diff --git a/modules/api/src/test/scala/gs/predicate/v0/api/PredicateTests.scala b/modules/api/src/test/scala/gs/predicate/v0/api/PredicateTests.scala deleted file mode 100644 index 4d656f8..0000000 --- a/modules/api/src/test/scala/gs/predicate/v0/api/PredicateTests.scala +++ /dev/null @@ -1,17 +0,0 @@ -package gs.predicate.v0.api - -import munit.FunSuite - -class PredicateTests extends FunSuite: - - test("should not equal non-predicate objects") { - val x = "other" - val p = Predicate.alwaysTrue[cats.Id] - assertEquals(p.equals(x), false) - assertNotEquals(p, null) - } - - test("should provide a default toString implementation") { - val p = Predicate.alwaysFalse[cats.Id] - assertEquals(p.toString(), s"predicate:${p.id.withoutDashes()}") - } diff --git a/modules/api/src/test/scala/gs/predicate/v0/api/TrueTests.scala b/modules/api/src/test/scala/gs/predicate/v0/api/TrueTests.scala deleted file mode 100644 index 0ed014c..0000000 --- a/modules/api/src/test/scala/gs/predicate/v0/api/TrueTests.scala +++ /dev/null @@ -1,35 +0,0 @@ -package gs.predicate.v0.api - -import cats.effect.IO -import gs.uuid.v0.UUID -import support.IOSuite - -class TrueTests extends IOSuite: - - iotest("should return true") { - val predicate = True[IO] - - for result <- predicate.eval(()) - yield assertEquals(result.unwrap(), true) - } - - iotest("should provide a unique identifier") { - val id = UUID.v7() - val predicate = True[IO](id) - - for result <- predicate.eval(()) - yield - assertEquals(result.unwrap(), true) - assertEquals(predicate.id, id) - assertEquals(predicate.hashCode(), id.hashCode()) - assertEquals(predicate.toString(), s"predicate:${id.withoutDashes()}") - } - - test("should support equality") { - val predicate = True[IO] - val p2 = True[IO](predicate.id) - val p3 = True[IO] - assertEquals(predicate, predicate) - assertEquals(predicate, p2) - assertNotEquals(predicate, p3) - } diff --git a/modules/json/src/main/scala/gs/predicate/v0/json/JsonKeyExists.scala b/modules/json/src/main/scala/gs/predicate/v0/json/JsonKeyExists.scala deleted file mode 100644 index 7334ebe..0000000 --- a/modules/json/src/main/scala/gs/predicate/v0/json/JsonKeyExists.scala +++ /dev/null @@ -1,31 +0,0 @@ -package gs.predicate.v0.json - -import cats.Applicative -import gs.predicate.v0.api.Predicate -import gs.uuid.v0.UUID -import io.circe.Json - -/** Predicate that matches if JSON blob is an object that contains the given - * key. - * - * @param id - * The unique identifier of this [[Predicate]]. - * @param key - * The key that should exist. - */ -final class JsonKeyExists[F[_]: Applicative]( - val id: UUID, - val queryString: String -) extends Predicate[F, Json]: - - /** @inheritDocs - */ - override def eval(input: Json): F[Predicate.Result] = - Applicative[F].pure(Predicate.Result(false)) - -object JsonKeyExists: - - def apply[F[_]: Applicative](key: String): JsonKeyExists[F] = - new JsonKeyExists[F](UUID.v7(), key) - -end JsonKeyExists diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/KeyExists.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/KeyExists.scala deleted file mode 100644 index 786b54e..0000000 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/KeyExists.scala +++ /dev/null @@ -1,30 +0,0 @@ -package gs.predicate.v0.kv - -import cats.Functor -import cats.syntax.all.* -import gs.predicate.v0.api.Predicate -import gs.uuid.v0.UUID - -/** Predicate that matches if some [[KeyValueProvider]] contains the given key. - * - * @param id - * The unique identifier of this [[Predicate]]. - * @param key - * The key that should exist. - */ -final class KeyExists[F[_]: Functor, K, V]( - val id: UUID, - val key: K -) extends Predicate[F, KeyValueProvider[F, K, V]]: - - /** @inheritDocs - */ - override def eval(input: KeyValueProvider[F, K, V]): F[Predicate.Result] = - input.exists(key).map(Predicate.Result.apply) - -object KeyExists: - - def apply[F[_]: Functor, K, V](key: K): KeyExists[F, K, V] = - new KeyExists[F, K, V](UUID.v7(), key) - -end KeyExists diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/KeyValueStringProvider.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/KeyValueStringProvider.scala deleted file mode 100644 index 7f7a7b2..0000000 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/KeyValueStringProvider.scala +++ /dev/null @@ -1,6 +0,0 @@ -package gs.predicate.v0.kv - -/** Type alias for a [[KeyValueProvider]] that associates string values with - * string keys. - */ -type KeyValueStringProvider[F[_]] = KeyValueProvider[F, String, String] diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringContains.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/StringContains.scala deleted file mode 100644 index f285eb0..0000000 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringContains.scala +++ /dev/null @@ -1,42 +0,0 @@ -package gs.predicate.v0.kv - -import cats.Functor -import cats.syntax.all.* -import gs.predicate.v0.api.Predicate -import gs.uuid.v0.UUID - -/** Predicate that matches if some (string-valued) [[KeyValueProvider]] contains - * the given key, and the string value associated with that key contains some - * other string. - * - * @param id - * The unique identifier of this [[Predicate]]. - * @param key - * The key that should exist. - * @param containedValue - * The substring that must be contained in the value. - */ -final class StringContains[F[_]: Functor, K]( - val id: UUID, - val key: K, - val containedValue: String -) extends Predicate[F, KeyValueProvider[F, K, String]]: - - /** @inheritDocs - */ - override def eval(input: KeyValueProvider[F, K, String]) - : F[Predicate.Result] = - input.get(key).map { - case Some(value) => Predicate.Result(value.contains(containedValue)) - case _ => Predicate.Result.missed() - } - -object StringContains: - - def apply[F[_]: Functor, K]( - key: K, - containedValue: String - ): StringContains[F, K] = - new StringContains[F, K](UUID.v7(), key, containedValue) - -end StringContains diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringEndsWith.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/StringEndsWith.scala deleted file mode 100644 index 0369ac2..0000000 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringEndsWith.scala +++ /dev/null @@ -1,42 +0,0 @@ -package gs.predicate.v0.kv - -import cats.Functor -import cats.syntax.all.* -import gs.predicate.v0.api.Predicate -import gs.uuid.v0.UUID - -/** Predicate that matches if some (string-valued) [[KeyValueProvider]] contains - * the given key, and the string value associated with that key ends with some - * other string. - * - * @param id - * The unique identifier of this [[Predicate]]. - * @param key - * The key that should exist. - * @param suffix - * The substring that must be the suffix of the value. - */ -final class StringEndsWith[F[_]: Functor, K]( - val id: UUID, - val key: K, - val suffix: String -) extends Predicate[F, KeyValueProvider[F, K, String]]: - - /** @inheritDocs - */ - override def eval(input: KeyValueProvider[F, K, String]) - : F[Predicate.Result] = - input.get(key).map { - case Some(value) => Predicate.Result(value.endsWith(suffix)) - case _ => Predicate.Result.missed() - } - -object StringEndsWith: - - def apply[F[_]: Functor, K]( - key: K, - suffix: String - ): StringEndsWith[F, K] = - new StringEndsWith[F, K](UUID.v7(), key, suffix) - -end StringEndsWith diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringStartsWith.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/StringStartsWith.scala deleted file mode 100644 index 93c556e..0000000 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringStartsWith.scala +++ /dev/null @@ -1,42 +0,0 @@ -package gs.predicate.v0.kv - -import cats.Functor -import cats.syntax.all.* -import gs.predicate.v0.api.Predicate -import gs.uuid.v0.UUID - -/** Predicate that matches if some (string-valued) [[KeyValueProvider]] contains - * the given key, and the string value associated with that key starts with - * some other string. - * - * @param id - * The unique identifier of this [[Predicate]]. - * @param key - * The key that should exist. - * @param prefix - * The substring that must be the prefix of the value. - */ -final class StringStartsWith[F[_]: Functor, K]( - val id: UUID, - val key: K, - val prefix: String -) extends Predicate[F, KeyValueProvider[F, K, String]]: - - /** @inheritDocs - */ - override def eval(input: KeyValueProvider[F, K, String]) - : F[Predicate.Result] = - input.get(key).map { - case Some(value) => Predicate.Result(value.startsWith(prefix)) - case _ => Predicate.Result.missed() - } - -object StringStartsWith: - - def apply[F[_]: Functor, K]( - key: K, - prefix: String - ): StringStartsWith[F, K] = - new StringStartsWith[F, K](UUID.v7(), key, prefix) - -end StringStartsWith diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueEquals.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueEquals.scala deleted file mode 100644 index bd42ac5..0000000 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueEquals.scala +++ /dev/null @@ -1,46 +0,0 @@ -package gs.predicate.v0.kv - -import cats.Functor -import cats.syntax.all.* -import gs.predicate.v0.api.Predicate -import gs.uuid.v0.UUID - -/** Predicate that matches if some [[KeyValueProvider]] contains the given key, - * and the value associated with that key equals the given value. - * - * @param id - * The unique identifier of this [[Predicate]]. - * @param key - * The key that should exist. - * @param value - * The value that must be associated with the key. - */ -final class ValueEquals[F[_]: Functor, K, V]( - val id: UUID, - val key: K, - val expectedValue: V -)( - using - CanEqual[V, V] -) extends Predicate[F, KeyValueProvider[F, K, V]]: - - /** @inheritDocs - */ - override def eval(input: KeyValueProvider[F, K, V]): F[Predicate.Result] = - input.get(key).map { - case Some(value) => Predicate.Result(value == expectedValue) - case _ => Predicate.Result.missed() - } - -object ValueEquals: - - def apply[F[_]: Functor, K, V]( - key: K, - value: V - )( - using - CanEqual[V, V] - ): ValueEquals[F, K, V] = - new ValueEquals[F, K, V](UUID.v7(), key, value) - -end ValueEquals diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala deleted file mode 100644 index bee2e6b..0000000 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala +++ /dev/null @@ -1,46 +0,0 @@ -package gs.predicate.v0.kv - -import cats.Functor -import cats.syntax.all.* -import gs.predicate.v0.api.Predicate -import gs.uuid.v0.UUID - -/** Predicate that matches if some [[KeyValueProvider]] contains the given key, - * and the value associated with that key is not equal to the given value. - * - * @param id - * The unique identifier of this [[Predicate]]. - * @param key - * The key that should exist. - * @param value - * The value that must not be associated with the key. - */ -final class ValueNotEquals[F[_]: Functor, K, V]( - val id: UUID, - val key: K, - val value: V -)( - using - CanEqual[V, V] -) extends Predicate[F, KeyValueProvider[F, K, V]]: - - /** @inheritDocs - */ - override def eval(input: KeyValueProvider[F, K, V]): F[Predicate.Result] = - input.get(key).map { - case Some(v) => Predicate.Result(v != value) - case _ => Predicate.Result.missed() - } - -object ValueNotEquals: - - def apply[F[_]: Functor, K, V]( - key: K, - value: V - )( - using - CanEqual[V, V] - ): ValueNotEquals[F, K, V] = - new ValueNotEquals[F, K, V](UUID.v7(), key, value) - -end ValueNotEquals diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala b/modules/kv/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala deleted file mode 100644 index ac4be8a..0000000 --- a/modules/kv/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala +++ /dev/null @@ -1,84 +0,0 @@ -package gs.predicate.v0.kv - -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.kv.StringContainsTests.Data.Substring1 -import support.IOSuite - -class StringContainsTests extends IOSuite: - - import StringContainsTests.Data - - iotest("should find a contained string in any position") { - val p1 = StringContains[IO, String](Data.PassingKey, Data.Substring1) - val p2 = StringContains[IO, String](Data.PassingKey, Data.Substring2) - val p3 = StringContains[IO, String](Data.PassingKey, Data.Substring3) - for - provider <- StringContainsTests.newProvider(Data.KeyValues) - r1 <- p1.eval(provider) - r2 <- p2.eval(provider) - r3 <- p3.eval(provider) - yield - assertEquals(r1, Predicate.Result.matched()) - assertEquals(r2, Predicate.Result.matched()) - assertEquals(r3, Predicate.Result.matched()) - } - - iotest("should not find a key that does not exist within some provider") { - val p = StringContains[IO, String](Data.NotExistingKey, "") - for - provider <- StringContainsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.missed()) - } - - iotest("should match if an empty substring is provided") { - val p1 = StringContains[IO, String](Data.EmptyStringKey, "") - val p2 = StringContains[IO, String](Data.PassingKey, "") - for - provider <- StringContainsTests.newProvider(Data.KeyValues) - r1 <- p1.eval(provider) - r2 <- p2.eval(provider) - yield - assertEquals(r1, Predicate.Result.matched()) - assertEquals(r2, Predicate.Result.matched()) - } - - iotest("should not match if the target value is not contained in the input") { - val p = StringContains[IO, String](Data.PassingKey, Substring1.reverse) - for - provider <- StringContainsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.missed()) - } - -object StringContainsTests: - - object Data: - - val PassingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() - val PassingValue: String = "abcdefhij" - val Substring1: String = "abc" - val Substring2: String = "def" - val Substring3: String = "hij" - - val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() - val NotExistingValue: String = Gen.string.alphaNumeric(Size.Fixed(4)).gen() - - val EmptyStringKey: String = "empty" - - val KeyValues: Map[String, String] = Map( - PassingKey -> PassingValue, - EmptyStringKey -> "" - ) - - end Data - - def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = - for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) - yield new MemoryMapStringProvider(map) - -end StringContainsTests diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala b/modules/kv/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala deleted file mode 100644 index 89eda3a..0000000 --- a/modules/kv/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala +++ /dev/null @@ -1,93 +0,0 @@ -package gs.predicate.v0.kv - -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 support.IOSuite - -class StringEndsWithTests extends IOSuite: - - import StringEndsWithTests.Data - - iotest("should find a string as a prefix of the input") { - val p1 = StringEndsWith[IO, String](Data.PassingKey, Data.Substring1) - val p2 = StringEndsWith[IO, String](Data.PassingKey, Data.Substring2) - val p3 = StringEndsWith[IO, String](Data.PassingKey, Data.Substring3) - for - provider <- StringEndsWithTests.newProvider(Data.KeyValues) - r1 <- p1.eval(provider) - r2 <- p2.eval(provider) - r3 <- p3.eval(provider) - yield - assertEquals(r1, Predicate.Result.matched()) - assertEquals(r2, Predicate.Result.matched()) - assertEquals(r3, Predicate.Result.matched()) - } - - iotest("should not find a key that does not exist within some provider") { - val p = StringEndsWith[IO, String](Data.NotExistingKey, "") - for - provider <- StringEndsWithTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.missed()) - } - - iotest("should match if an empty substring is provided") { - val p1 = StringEndsWith[IO, String](Data.EmptyStringKey, "") - val p2 = StringEndsWith[IO, String](Data.PassingKey, "") - for - provider <- StringEndsWithTests.newProvider(Data.KeyValues) - r1 <- p1.eval(provider) - r2 <- p2.eval(provider) - yield - assertEquals(r1, Predicate.Result.matched()) - assertEquals(r2, Predicate.Result.matched()) - } - - iotest( - "should not match if the target value is not the suffix of the input" - ) { - val p1 = StringEndsWith[IO, String](Data.PassingKey, Data.Substring3 + "z") - val p2 = - StringEndsWith[IO, String](Data.PassingKey, Data.Substring3.reverse) - val p3 = - StringEndsWith[IO, String](Data.PassingKey, Data.Substring2.reverse) - for - provider <- StringEndsWithTests.newProvider(Data.KeyValues) - r1 <- p1.eval(provider) - r2 <- p2.eval(provider) - r3 <- p3.eval(provider) - yield - assertEquals(r1, Predicate.Result.missed()) - assertEquals(r2, Predicate.Result.missed()) - assertEquals(r3, Predicate.Result.missed()) - } - -object StringEndsWithTests: - - object Data: - - val PassingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() - val PassingValue: String = "abcdefghi" - val Substring1: String = "i" - val Substring2: String = "hi" - val Substring3: String = "abcdefghi" - - val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() - - val EmptyStringKey: String = "empty" - - val KeyValues: Map[String, String] = Map( - PassingKey -> PassingValue, - EmptyStringKey -> "" - ) - - end Data - - def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = - for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) - yield new MemoryMapStringProvider(map) - -end StringEndsWithTests diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala b/modules/kv/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala deleted file mode 100644 index b2ccf65..0000000 --- a/modules/kv/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala +++ /dev/null @@ -1,94 +0,0 @@ -package gs.predicate.v0.kv - -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 support.IOSuite - -class StringStartsWithTests extends IOSuite: - - import StringStartsWithTests.Data - - iotest("should find a string as a prefix of the input") { - val p1 = StringStartsWith[IO, String](Data.PassingKey, Data.Substring1) - val p2 = StringStartsWith[IO, String](Data.PassingKey, Data.Substring2) - val p3 = StringStartsWith[IO, String](Data.PassingKey, Data.Substring3) - for - provider <- StringStartsWithTests.newProvider(Data.KeyValues) - r1 <- p1.eval(provider) - r2 <- p2.eval(provider) - r3 <- p3.eval(provider) - yield - assertEquals(r1, Predicate.Result.matched()) - assertEquals(r2, Predicate.Result.matched()) - assertEquals(r3, Predicate.Result.matched()) - } - - iotest("should not find a key that does not exist within some provider") { - val p = StringStartsWith[IO, String](Data.NotExistingKey, "") - for - provider <- StringStartsWithTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.missed()) - } - - iotest("should match if an empty substring is provided") { - val p1 = StringStartsWith[IO, String](Data.EmptyStringKey, "") - val p2 = StringStartsWith[IO, String](Data.PassingKey, "") - for - provider <- StringStartsWithTests.newProvider(Data.KeyValues) - r1 <- p1.eval(provider) - r2 <- p2.eval(provider) - yield - assertEquals(r1, Predicate.Result.matched()) - assertEquals(r2, Predicate.Result.matched()) - } - - iotest( - "should not match if the target value is not the prefix of the input" - ) { - val p1 = - StringStartsWith[IO, String](Data.PassingKey, Data.Substring3 + "k") - val p2 = - StringStartsWith[IO, String](Data.PassingKey, Data.Substring3.reverse) - val p3 = - StringStartsWith[IO, String](Data.PassingKey, Data.Substring2.reverse) - for - provider <- StringStartsWithTests.newProvider(Data.KeyValues) - r1 <- p1.eval(provider) - r2 <- p2.eval(provider) - r3 <- p3.eval(provider) - yield - assertEquals(r1, Predicate.Result.missed()) - assertEquals(r2, Predicate.Result.missed()) - assertEquals(r3, Predicate.Result.missed()) - } - -object StringStartsWithTests: - - object Data: - - val PassingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() - val PassingValue: String = "abcdefghi" - val Substring1: String = "a" - val Substring2: String = "ab" - val Substring3: String = "abcdefghi" - - val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() - - val EmptyStringKey: String = "empty" - - val KeyValues: Map[String, String] = Map( - PassingKey -> PassingValue, - EmptyStringKey -> "" - ) - - end Data - - def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = - for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) - yield new MemoryMapStringProvider(map) - -end StringStartsWithTests diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala b/modules/kv/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala deleted file mode 100644 index 71c5d28..0000000 --- a/modules/kv/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala +++ /dev/null @@ -1,59 +0,0 @@ -package gs.predicate.v0.kv - -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 support.IOSuite - -class ValueNotEqualsTests extends IOSuite: - - import ValueNotEqualsTests.Data - - iotest("should NOT find an exact match against some value") { - val p = - ValueNotEquals[IO, String, String](Data.ExistingKey, Data.ExistingValue) - for - provider <- ValueNotEqualsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.missed()) - } - - iotest("should match a value if that value is not equal to the target") { - val p = - ValueNotEquals[IO, String, String]( - Data.ExistingKey, - Data.NotExistingValue - ) - for - provider <- ValueNotEqualsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.matched()) - } - - iotest("should not find a key that does not exist within some provider") { - val p = ValueNotEquals[IO, String, String](Data.NotExistingKey, "") - for - provider <- ValueNotEqualsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.missed()) - } - -object ValueNotEqualsTests: - - object Data: - - val ExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() - val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() - val ExistingValue: String = Gen.string.alphaNumeric(Size.Fixed(10)).gen() - val NotExistingValue: String = Gen.string.alphaNumeric(Size.Fixed(4)).gen() - val KeyValues: Map[String, String] = Map(ExistingKey -> ExistingValue) - - end Data - - def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = - for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) - yield new MemoryMapStringProvider(map) - -end ValueNotEqualsTests diff --git a/src/main/scala/gs/predicate/v0/api/And.scala b/src/main/scala/gs/predicate/v0/api/And.scala new file mode 100644 index 0000000..8151b63 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/api/And.scala @@ -0,0 +1,80 @@ +package gs.predicate.v0.api + +import cats.Applicative +import cats.syntax.all.* +import gs.predicate.v0.serde.json.JsonKeys +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Encoder +import io.circe.HCursor +import io.circe.Json +import io.circe.syntax._ + +/** Implements logical AND. + * + * @param ps + * The predicates to evaluate. + */ +final class And[F[_]: Applicative]( + val ps: List[Predicate[F]] +) extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = And.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + ps.map(_.eval()).sequence.map(_.allMatch()) + +object And: + + final val PredicateType: String = "and" + + def empty[F[_]: Applicative]: Predicate[F] = False[F] + + def apply[F[_]: Applicative, A](ps: Predicate[F]*): Predicate[F] = + ps.toList match + case Nil => False[F] + case p :: Nil => p + case list => new And(list) + + given andEncoder[F[_]]( + using + Encoder[Predicate[F]] + ): Encoder[And[F]] = + Encoder.instance[And[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.predicates, p.ps.asJson) + ) + } + + private def consumeCursor[F[_]: Applicative]( + cursor: HCursor + )( + using + Decoder[Predicate[F]] + ): Either[DecodingFailure, And[F]] = + for ps <- cursor.downField(JsonKeys.predicates).as[List[Predicate[F]]] + yield new And(ps) + + given andDecoder[F[_]: Applicative]( + using + Decoder[Predicate[F]] + ): Decoder[And[F]] = + Decoder.instance[And[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => consumeCursor(cursor) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end And diff --git a/src/main/scala/gs/predicate/v0/api/False.scala b/src/main/scala/gs/predicate/v0/api/False.scala new file mode 100644 index 0000000..92733b4 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/api/False.scala @@ -0,0 +1,50 @@ +package gs.predicate.v0.api + +import cats.Applicative +import gs.predicate.v0.serde.json.JsonKeys +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Encoder +import io.circe.Json + +/** Always returns a miss. + */ +final class False[F[_]: Applicative] extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = False.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + Applicative[F].pure(Predicate.Result.missed()) + +end False + +object False: + + final val PredicateType: String = "false" + + def apply[F[_]: Applicative]: False[F] = new False[F] + + given falseEncoder[F[_]]: Encoder[False[F]] = + Encoder.instance[False[F]] { p => + Json.obj((JsonKeys.predicateType, Json.fromString(p.predicateType))) + } + + given falseDecoder[F[_]: Applicative]: Decoder[False[F]] = + Decoder.instance[False[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => Right(False[F]) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end False diff --git a/src/main/scala/gs/predicate/v0/api/Messages.scala b/src/main/scala/gs/predicate/v0/api/Messages.scala new file mode 100644 index 0000000..20160fb --- /dev/null +++ b/src/main/scala/gs/predicate/v0/api/Messages.scala @@ -0,0 +1,11 @@ +package gs.predicate.v0.api + +object Messages: + + def invalidPredicateType( + candidate: String, + expected: String + ): String = + s"Received predicate type '$candidate' but expected '$expected'." + +end Messages diff --git a/src/main/scala/gs/predicate/v0/api/Or.scala b/src/main/scala/gs/predicate/v0/api/Or.scala new file mode 100644 index 0000000..59cfd74 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/api/Or.scala @@ -0,0 +1,72 @@ +package gs.predicate.v0.api + +import cats.Applicative +import cats.syntax.all.* +import gs.predicate.v0.serde.json.JsonKeys +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Encoder +import io.circe.Json +import io.circe.syntax.* + +/** Implements logical OR. + * + * @param ps + * The predicates to evaluate. + */ +final class Or[F[_]: Applicative]( + val ps: List[Predicate[F]] +) extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = Or.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + ps.map(_.eval()).sequence.map(_.anyMatch()) + +object Or: + + final val PredicateType: String = "or" + + def empty[F[_]: Applicative]: Predicate[F] = False[F] + + def apply[F[_]: Applicative, A](ps: Predicate[F]*): Predicate[F] = + ps.toList match + case Nil => False[F] + case p :: Nil => p + case list => new Or(list) + + given orEncoder[F[_]]( + using + Encoder[Predicate[F]] + ): Encoder[Or[F]] = + Encoder.instance[Or[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.predicates, p.ps.asJson) + ) + } + + given orDecoder[F[_]: Applicative]( + using + Decoder[Predicate[F]] + ): Decoder[Or[F]] = + Decoder.instance[Or[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for ps <- cursor.downField(JsonKeys.predicates).as[List[Predicate[F]]] + yield new Or(ps) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end Or diff --git a/modules/api/src/main/scala/gs/predicate/v0/api/Predicate.scala b/src/main/scala/gs/predicate/v0/api/Predicate.scala similarity index 68% rename from modules/api/src/main/scala/gs/predicate/v0/api/Predicate.scala rename to src/main/scala/gs/predicate/v0/api/Predicate.scala index 98c9bc5..50c9721 100644 --- a/modules/api/src/main/scala/gs/predicate/v0/api/Predicate.scala +++ b/src/main/scala/gs/predicate/v0/api/Predicate.scala @@ -1,59 +1,34 @@ package gs.predicate.v0.api import cats.Applicative -import gs.uuid.v0.UUID /** A _Predicate_ is some function that accepts any input and emits some * [[Predicate.Result]] (whether the predicate matched). * - * Predicates evaluate input to see if the predicate matches that input. + * Predicates evaluate input extracted from context to see if the predicate + * matches that input. */ -trait Predicate[F[_], -A]: +trait Predicate[F[_]]: /** @return - * The unique identifier of this Predicate. + * The serializable predicate type. */ - def id: UUID + def predicateType: String /** Evaluate this predicate against the given input. * - * @param input - * The input to evaluate this predicate against. * @return * Some [[Predicate.Result]] that describes whether the input matched the * predicate. */ - def eval(input: A): F[Predicate.Result] + def eval(): F[Predicate.Result] - /** Predicate equality is based on the _unique identifier_. Two predicates - * with the same ID are considered equal. - * - * @param that - * The other object. - * @return - * True if the other object is a predicate with the same ID, false - * otherwise. - */ - override def equals(that: Any): Boolean = - that match - case p: Predicate[?, ?] => p.id == id - case _ => false - - /** @return - * The hash code of the unique identifier. - */ - override def hashCode(): Int = id.hashCode() - - /** @inheritDocs - */ - override def toString(): String = s"predicate:${id.withoutDashes()}" +end Predicate object Predicate: - given CanEqual[Predicate[?, ?], Predicate[?, ?]] = CanEqual.derived + def alwaysTrue[F[_]: Applicative]: Predicate[F] = True[F] - def alwaysTrue[F[_]: Applicative]: Predicate[F, Any] = True[F] - - def alwaysFalse[F[_]: Applicative]: Predicate[F, Any] = False[F] + def alwaysFalse[F[_]: Applicative]: Predicate[F] = False[F] /** The result of evaluating a [[Predicate]] is a Boolean value where: * @@ -132,3 +107,5 @@ object Predicate: def isMiss: Boolean = !result end Result + +end Predicate diff --git a/src/main/scala/gs/predicate/v0/api/True.scala b/src/main/scala/gs/predicate/v0/api/True.scala new file mode 100644 index 0000000..f848981 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/api/True.scala @@ -0,0 +1,50 @@ +package gs.predicate.v0.api + +import cats.Applicative +import gs.predicate.v0.serde.json.JsonKeys +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Encoder +import io.circe.Json + +/** Always returns a match. + */ +final class True[F[_]: Applicative] extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = True.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + Applicative[F].pure(Predicate.Result.matched()) + +end True + +object True: + + final val PredicateType: String = "true" + + def apply[F[_]: Applicative]: True[F] = new True[F] + + given trueEncoder[F[_]]: Encoder[True[F]] = + Encoder.instance[True[F]] { p => + Json.obj((JsonKeys.predicateType, Json.fromString(p.predicateType))) + } + + given trueDecoder[F[_]: Applicative]: Decoder[True[F]] = + Decoder.instance[True[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => Right(True[F]) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end True diff --git a/src/main/scala/gs/predicate/v0/json/JsonExists.scala b/src/main/scala/gs/predicate/v0/json/JsonExists.scala new file mode 100644 index 0000000..f68f9ae --- /dev/null +++ b/src/main/scala/gs/predicate/v0/json/JsonExists.scala @@ -0,0 +1,63 @@ +package gs.predicate.v0.json + +import cats.Functor +import cats.syntax.all.* +import gs.predicate.v0.api.Messages +import gs.predicate.v0.api.Predicate +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 + * given key. + * + * @param key + * The name of the JSON value that should exist. + */ +final class JsonExists[F[_]: Functor: JsonProvider]( + val key: String +) extends JsonPredicate[F]: + + /** @inheritDocs + */ + final override val predicateType: String = JsonExists.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + getJson(key).map(_.isDefined).map(Predicate.Result.apply) + +object JsonExists: + + final val PredicateType: String = "json-exists" + + def apply[F[_]: Functor: JsonProvider](key: String): JsonExists[F] = + new JsonExists[F](key) + + given jsonExistsEncoder[F[_]]: Encoder[JsonExists[F]] = + Encoder.instance[JsonExists[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)) + ) + } + + given jsonExistsDecoder[F[_]: Functor: JsonProvider]: Decoder[JsonExists[F]] = + Decoder.instance[JsonExists[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for key <- cursor.downField(JsonKeys.key).as[String] + yield new JsonExists(key) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end JsonExists diff --git a/src/main/scala/gs/predicate/v0/json/JsonPredicate.scala b/src/main/scala/gs/predicate/v0/json/JsonPredicate.scala new file mode 100644 index 0000000..b223c21 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/json/JsonPredicate.scala @@ -0,0 +1,7 @@ +package gs.predicate.v0.json + +import gs.predicate.v0.api.Predicate +import io.circe.Json + +abstract class JsonPredicate[F[_]: JsonProvider] extends Predicate[F]: + def getJson(key: String): F[Option[Json]] = JsonProvider[F].get(key) diff --git a/src/main/scala/gs/predicate/v0/json/JsonProvider.scala b/src/main/scala/gs/predicate/v0/json/JsonProvider.scala new file mode 100644 index 0000000..8256fc8 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/json/JsonProvider.scala @@ -0,0 +1,37 @@ +package gs.predicate.v0.json + +import cats.Applicative +import io.circe.Json + +/** Interface for anything that can fetch values for stored keys. + */ +trait JsonProvider[F[_]]: + /** Get the JSON value associated with some key. + * + * @param key + * The key to fetch. + * @return + * The value stored for the key, or `None` if no such value exists. + */ + def get(key: String): F[Option[Json]] + +object JsonProvider: + + def apply[F[_]]( + using + jp: JsonProvider[F] + ): JsonProvider[F] = jp + + /** @return + * New instance of the no-op [[JsonProvider]] implementation. + */ + def noop[F[_]: Applicative]: JsonProvider[F] = new Noop[F] + + /** No-op implementation that never contains data. + */ + final class Noop[F[_]: Applicative] extends JsonProvider[F]: + /** @inheritDocs + */ + override def get(key: String): F[Option[Json]] = Applicative[F].pure(None) + +end JsonProvider diff --git a/src/main/scala/gs/predicate/v0/json/JsonQueryEquals.scala b/src/main/scala/gs/predicate/v0/json/JsonQueryEquals.scala new file mode 100644 index 0000000..706abb5 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/json/JsonQueryEquals.scala @@ -0,0 +1,49 @@ +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 JsonQueryEquals[F[_]: Applicative: JsonProvider]( + val key: String, + val query: JsonQuery, + val value: Json +) extends Predicate[F]: + + /** @inheritDocs + */ + final override val predicateType: String = JsonQueryEquals.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, _.equals(value))) + } + +object JsonQueryEquals: + + final val PredicateType: String = "json-query-equals" + + def apply[F[_]: Applicative: JsonProvider]( + key: String, + query: JsonQuery, + value: Json + ): JsonQueryEquals[F] = + new JsonQueryEquals[F](key, query, value) + +end JsonQueryEquals diff --git a/src/main/scala/gs/predicate/v0/json/JsonQueryExists.scala b/src/main/scala/gs/predicate/v0/json/JsonQueryExists.scala new file mode 100644 index 0000000..861b795 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/json/JsonQueryExists.scala @@ -0,0 +1,41 @@ +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. + * + * @param key + * The name of the JSON value that must satisfy the given query. + */ +final class JsonQueryExists[F[_]: Applicative: JsonProvider]( + val key: String, + val query: JsonQuery +) extends Predicate[F]: + + /** @inheritDocs + */ + final override val predicateType: String = JsonQueryExists.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, _ => true)) + } + +object JsonQueryExists: + + final val PredicateType: String = "json-exists" + + def apply[F[_]: Applicative: JsonProvider]( + key: String, + query: JsonQuery + ): JsonQueryExists[F] = + new JsonQueryExists[F](key, query) + +end JsonQueryExists diff --git a/modules/json/src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala b/src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala similarity index 100% rename from modules/json/src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala rename to src/main/scala/gs/predicate/v0/json/query/CompiledQuery.scala diff --git a/modules/json/src/main/scala/gs/predicate/v0/json/query/EmptyQuery.scala b/src/main/scala/gs/predicate/v0/json/query/EmptyQuery.scala similarity index 100% rename from modules/json/src/main/scala/gs/predicate/v0/json/query/EmptyQuery.scala rename to src/main/scala/gs/predicate/v0/json/query/EmptyQuery.scala diff --git a/modules/json/src/main/scala/gs/predicate/v0/json/query/JsonQuery.scala b/src/main/scala/gs/predicate/v0/json/query/JsonQuery.scala similarity index 100% rename from modules/json/src/main/scala/gs/predicate/v0/json/query/JsonQuery.scala rename to src/main/scala/gs/predicate/v0/json/query/JsonQuery.scala diff --git a/modules/json/src/main/scala/gs/predicate/v0/json/query/QueryCompilationException.scala b/src/main/scala/gs/predicate/v0/json/query/QueryCompilationException.scala similarity index 100% rename from modules/json/src/main/scala/gs/predicate/v0/json/query/QueryCompilationException.scala rename to src/main/scala/gs/predicate/v0/json/query/QueryCompilationException.scala diff --git a/src/main/scala/gs/predicate/v0/kv/KeyExists.scala b/src/main/scala/gs/predicate/v0/kv/KeyExists.scala new file mode 100644 index 0000000..380c52a --- /dev/null +++ b/src/main/scala/gs/predicate/v0/kv/KeyExists.scala @@ -0,0 +1,65 @@ +package gs.predicate.v0.kv + +import cats.Functor +import cats.syntax.all.* +import gs.predicate.v0.api.Messages +import gs.predicate.v0.api.Predicate +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 some [[KeyValueProvider]] contains the given key. + * + * @param id + * The unique identifier of this [[Predicate]]. + * @param key + * The key that should exist. + */ +final class KeyExists[F[_]: Functor: KeyValueProvider]( + val key: String +) extends KeyValuePredicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = KeyExists.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + KeyValueProvider[F].exists(key).map(Predicate.Result.apply) + +object KeyExists: + + final val PredicateType: String = "kv-key-exists" + + def apply[F[_]: Functor: KeyValueProvider](key: String): KeyExists[F] = + new KeyExists[F](key) + + given keyExistsEncoder[F[_]]: Encoder[KeyExists[F]] = + Encoder.instance[KeyExists[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)) + ) + } + + given keyExistsDecoder[F[_]: Functor: KeyValueProvider] + : Decoder[KeyExists[F]] = + Decoder.instance[KeyExists[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for key <- cursor.downField(JsonKeys.key).as[String] + yield new KeyExists(key) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end KeyExists diff --git a/src/main/scala/gs/predicate/v0/kv/KeyValuePredicate.scala b/src/main/scala/gs/predicate/v0/kv/KeyValuePredicate.scala new file mode 100644 index 0000000..abbbb8c --- /dev/null +++ b/src/main/scala/gs/predicate/v0/kv/KeyValuePredicate.scala @@ -0,0 +1,8 @@ +package gs.predicate.v0.kv + +import gs.predicate.v0.api.Predicate + +abstract class KeyValuePredicate[F[_]: KeyValueProvider] extends Predicate[F]: + def keyExists(key: String): F[Boolean] = KeyValueProvider[F].exists(key) + + def getValue(key: String): F[Option[String]] = KeyValueProvider[F].get(key) diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/KeyValueProvider.scala b/src/main/scala/gs/predicate/v0/kv/KeyValueProvider.scala similarity index 65% rename from modules/kv/src/main/scala/gs/predicate/v0/kv/KeyValueProvider.scala rename to src/main/scala/gs/predicate/v0/kv/KeyValueProvider.scala index ad3d11d..1b88075 100644 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/KeyValueProvider.scala +++ b/src/main/scala/gs/predicate/v0/kv/KeyValueProvider.scala @@ -4,7 +4,7 @@ import cats.Applicative /** Interface for anything that can fetch values for stored keys. */ -trait KeyValueProvider[F[_], -K, V]: +trait KeyValueProvider[F[_]]: /** Determine if some key exists. * * @param key @@ -12,7 +12,7 @@ trait KeyValueProvider[F[_], -K, V]: * @return * True if the key exists, false otherwise. */ - def exists(key: K): F[Boolean] + def exists(key: String): F[Boolean] /** Get the value associated with some key. * @@ -21,24 +21,29 @@ trait KeyValueProvider[F[_], -K, V]: * @return * The value stored for the key, or `None` if no such value exists. */ - def get(key: K): F[Option[V]] + def get(key: String): F[Option[String]] object KeyValueProvider: + def apply[F[_]]( + using + kvp: KeyValueProvider[F] + ): KeyValueProvider[F] = kvp + /** @return * New instance of the no-op [[KeyValueProvider]] implementation. */ - def noop[F[_]: Applicative]: KeyValueProvider[F, Any, Any] = new Noop[F] + def noop[F[_]: Applicative]: KeyValueProvider[F] = new Noop[F] /** No-op implementation that never contains data. */ - final class Noop[F[_]: Applicative] extends KeyValueProvider[F, Any, Any]: + final class Noop[F[_]: Applicative] extends KeyValueProvider[F]: /** @inheritDocs */ - override def exists(key: Any): F[Boolean] = Applicative[F].pure(false) + override def exists(key: String): F[Boolean] = Applicative[F].pure(false) /** @inheritDocs */ - override def get(key: Any): F[Option[Any]] = Applicative[F].pure(None) + override def get(key: String): F[Option[String]] = Applicative[F].pure(None) end KeyValueProvider diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/MemoryMapStringProvider.scala b/src/main/scala/gs/predicate/v0/kv/MemoryMapKeyValueProvider.scala similarity index 84% rename from modules/kv/src/main/scala/gs/predicate/v0/kv/MemoryMapStringProvider.scala rename to src/main/scala/gs/predicate/v0/kv/MemoryMapKeyValueProvider.scala index 929a44f..6fa09e8 100644 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/MemoryMapStringProvider.scala +++ b/src/main/scala/gs/predicate/v0/kv/MemoryMapKeyValueProvider.scala @@ -9,9 +9,9 @@ import cats.syntax.all.* * @param map * The underlying map. */ -final class MemoryMapStringProvider[F[_]: Sync]( +final class MemoryMapKeyValueProvider[F[_]: Sync]( private val map: MapRef[F, String, Option[String]] -) extends KeyValueStringProvider[F]: +) extends KeyValueProvider[F]: /** @inheritDocs */ diff --git a/src/main/scala/gs/predicate/v0/kv/ValueContains.scala b/src/main/scala/gs/predicate/v0/kv/ValueContains.scala new file mode 100644 index 0000000..bace9e8 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/kv/ValueContains.scala @@ -0,0 +1,76 @@ +package gs.predicate.v0.kv + +import cats.Functor +import cats.syntax.all.* +import gs.predicate.v0.api.Messages +import gs.predicate.v0.api.Predicate +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 some [[KeyValueProvider]] contains the given key, + * and the string value associated with that key contains some other string. + * + * @param key + * The key that should exist. + * @param containedValue + * The substring that must be contained in the value. + */ +final class ValueContains[F[_]: Functor: KeyValueProvider]( + val key: String, + val containedValue: String +) extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = ValueContains.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + KeyValueProvider[F].get(key).map { + case Some(value) => Predicate.Result(value.contains(containedValue)) + case _ => Predicate.Result.missed() + } + +object ValueContains: + + final val PredicateType: String = "kv-string-contains" + + def apply[F[_]: Functor: KeyValueProvider]( + key: String, + containedValue: String + ): ValueContains[F] = + new ValueContains[F](key, containedValue) + + given valueContainsEncoder[F[_]]: Encoder[ValueContains[F]] = + Encoder.instance[ValueContains[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.value, Json.fromString(p.containedValue)) + ) + } + + given valueContainsDecoder[F[_]: Functor: KeyValueProvider] + : Decoder[ValueContains[F]] = + Decoder.instance[ValueContains[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + value <- cursor.downField(JsonKeys.value).as[String] + yield new ValueContains(key, value) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end ValueContains diff --git a/src/main/scala/gs/predicate/v0/kv/ValueEndsWith.scala b/src/main/scala/gs/predicate/v0/kv/ValueEndsWith.scala new file mode 100644 index 0000000..702a4f5 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/kv/ValueEndsWith.scala @@ -0,0 +1,79 @@ +package gs.predicate.v0.kv + +import cats.Functor +import cats.syntax.all.* +import gs.predicate.v0.api.Messages +import gs.predicate.v0.api.Predicate +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 some (string-valued) [[KeyValueProvider]] contains + * the given key, and the string value associated with that key ends with some + * other string. + * + * @param id + * The unique identifier of this [[Predicate]]. + * @param key + * The key that should exist. + * @param suffix + * The substring that must be the suffix of the value. + */ +final class ValueEndsWith[F[_]: Functor: KeyValueProvider]( + val key: String, + val suffix: String +) extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = ValueEndsWith.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + KeyValueProvider[F].get(key).map { + case Some(value) => Predicate.Result(value.endsWith(suffix)) + case _ => Predicate.Result.missed() + } + +object ValueEndsWith: + + final val PredicateType: String = "kv-string-ends-with" + + def apply[F[_]: Functor: KeyValueProvider]( + key: String, + suffix: String + ): ValueEndsWith[F] = + new ValueEndsWith[F](key, suffix) + + given valueEndsWithEncoder[F[_]]: Encoder[ValueEndsWith[F]] = + Encoder.instance[ValueEndsWith[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.value, Json.fromString(p.suffix)) + ) + } + + given valueEndsWithDecoder[F[_]: Functor: KeyValueProvider] + : Decoder[ValueEndsWith[F]] = + Decoder.instance[ValueEndsWith[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + value <- cursor.downField(JsonKeys.value).as[String] + yield new ValueEndsWith(key, value) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end ValueEndsWith diff --git a/src/main/scala/gs/predicate/v0/kv/ValueEquals.scala b/src/main/scala/gs/predicate/v0/kv/ValueEquals.scala new file mode 100644 index 0000000..2ed87a0 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/kv/ValueEquals.scala @@ -0,0 +1,76 @@ +package gs.predicate.v0.kv + +import cats.Functor +import cats.syntax.all.* +import gs.predicate.v0.api.Messages +import gs.predicate.v0.api.Predicate +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 some [[KeyValueProvider]] contains the given key, + * and the value associated with that key equals the given value. + * + * @param key + * The key that should exist. + * @param value + * The value that must be associated with the key. + */ +final class ValueEquals[F[_]: Functor: KeyValueProvider]( + val key: String, + val value: String +) extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = ValueEquals.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + KeyValueProvider[F].get(key).map { + case Some(value) => Predicate.Result(this.value == value) + case _ => Predicate.Result.missed() + } + +object ValueEquals: + + final val PredicateType: String = "kv-value-equals" + + def apply[F[_]: Functor: KeyValueProvider]( + key: String, + value: String + ): ValueEquals[F] = + new ValueEquals[F](key, value) + + given valueEqualsEncoder[F[_]]: Encoder[ValueEquals[F]] = + Encoder.instance[ValueEquals[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.value, Json.fromString(p.value)) + ) + } + + given valueEqualsDecoder[F[_]: Functor: KeyValueProvider] + : Decoder[ValueEquals[F]] = + Decoder.instance[ValueEquals[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + value <- cursor.downField(JsonKeys.value).as[String] + yield new ValueEquals(key, value) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end ValueEquals diff --git a/src/main/scala/gs/predicate/v0/kv/ValueIn.scala b/src/main/scala/gs/predicate/v0/kv/ValueIn.scala new file mode 100644 index 0000000..12fd24a --- /dev/null +++ b/src/main/scala/gs/predicate/v0/kv/ValueIn.scala @@ -0,0 +1,76 @@ +package gs.predicate.v0.kv + +import cats.Functor +import cats.syntax.all.* +import gs.predicate.v0.api.Messages +import gs.predicate.v0.api.Predicate +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 some [[KeyValueProvider]] contains the given key, + * and the value associated with that key is contained in the set of given + * values. + * + * @param key + * The key that should exist. + * @param values + * The list of values, such that one value should match the input. + */ +final class ValueIn[F[_]: Functor: KeyValueProvider]( + val key: String, + val values: Set[String] +) extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = ValueIn.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + KeyValueProvider[F].get(key).map { + case Some(value) => Predicate.Result(values.contains(value)) + case _ => Predicate.Result.missed() + } + +object ValueIn: + + final val PredicateType: String = "kv-value-in" + + def apply[F[_]: Functor: KeyValueProvider]( + key: String, + values: Set[String] + ): ValueIn[F] = + new ValueIn[F](key, values) + + given valueInEncoder[F[_]]: Encoder[ValueIn[F]] = + Encoder.instance[ValueIn[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.values, Json.fromValues(p.values.map(Json.fromString))) + ) + } + + given valueInDecoder[F[_]: Functor: KeyValueProvider]: Decoder[ValueIn[F]] = + Decoder.instance[ValueIn[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + values <- cursor.downField(JsonKeys.value).as[Set[String]] + yield new ValueIn(key, values) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end ValueIn diff --git a/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala b/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala new file mode 100644 index 0000000..7cc6f0f --- /dev/null +++ b/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala @@ -0,0 +1,78 @@ +package gs.predicate.v0.kv + +import cats.Functor +import cats.syntax.all.* +import gs.predicate.v0.api.Messages +import gs.predicate.v0.api.Predicate +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 some [[KeyValueProvider]] contains the given key, + * and the value associated with that key is not equal to the given value. + * + * @param id + * The unique identifier of this [[Predicate]]. + * @param key + * The key that should exist. + * @param value + * The value that must not be associated with the key. + */ +final class ValueNotEquals[F[_]: Functor: KeyValueProvider]( + val key: String, + val value: String +) extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = ValueNotEquals.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + KeyValueProvider[F].get(key).map { + case Some(v) => Predicate.Result(v != value) + case _ => Predicate.Result.missed() + } + +object ValueNotEquals: + + final val PredicateType: String = "kv-value-not-equals" + + def apply[F[_]: Functor: KeyValueProvider]( + key: String, + value: String + ): ValueNotEquals[F] = + new ValueNotEquals[F](key, value) + + given valueNotEqualsEncoder[F[_]]: Encoder[ValueNotEquals[F]] = + Encoder.instance[ValueNotEquals[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.value, Json.fromString(p.value)) + ) + } + + given valueNotEqualsDecoder[F[_]: Functor: KeyValueProvider] + : Decoder[ValueNotEquals[F]] = + Decoder.instance[ValueNotEquals[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + value <- cursor.downField(JsonKeys.value).as[String] + yield new ValueNotEquals(key, value) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end ValueNotEquals diff --git a/src/main/scala/gs/predicate/v0/kv/ValueStartsWith.scala b/src/main/scala/gs/predicate/v0/kv/ValueStartsWith.scala new file mode 100644 index 0000000..de134c0 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/kv/ValueStartsWith.scala @@ -0,0 +1,76 @@ +package gs.predicate.v0.kv + +import cats.Functor +import cats.syntax.all.* +import gs.predicate.v0.api.Messages +import gs.predicate.v0.api.Predicate +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 some [[KeyValueProvider]] contains the given key, + * and the string value associated with that key starts with some other string. + * + * @param key + * The key that should exist. + * @param prefix + * The substring that must be the prefix of the value. + */ +final class ValueStartsWith[F[_]: Functor: KeyValueProvider]( + val key: String, + val prefix: String +) extends Predicate[F]: + + /** @inheritDocs + */ + override def predicateType: String = ValueStartsWith.PredicateType + + /** @inheritDocs + */ + override def eval(): F[Predicate.Result] = + KeyValueProvider[F].get(key).map { + case Some(value) => Predicate.Result(value.startsWith(prefix)) + case _ => Predicate.Result.missed() + } + +object ValueStartsWith: + + final val PredicateType: String = "kv-string-starts-with" + + def apply[F[_]: Functor: KeyValueProvider]( + key: String, + prefix: String + ): ValueStartsWith[F] = + new ValueStartsWith[F](key, prefix) + + given valueStartsWithEncoder[F[_]]: Encoder[ValueStartsWith[F]] = + Encoder.instance[ValueStartsWith[F]] { p => + Json.obj( + (JsonKeys.predicateType, Json.fromString(PredicateType)), + (JsonKeys.key, Json.fromString(p.key)), + (JsonKeys.value, Json.fromString(p.prefix)) + ) + } + + given valueStartsWithDecoder[F[_]: Functor: KeyValueProvider] + : Decoder[ValueStartsWith[F]] = + Decoder.instance[ValueStartsWith[F]] { cursor => + cursor.downField(JsonKeys.predicateType).as[String].flatMap { + case PredicateType => + for + key <- cursor.downField(JsonKeys.key).as[String] + value <- cursor.downField(JsonKeys.value).as[String] + yield new ValueStartsWith(key, value) + case candidate => + Left( + DecodingFailure( + Messages.invalidPredicateType(candidate, PredicateType), + Nil + ) + ) + } + } + +end ValueStartsWith diff --git a/src/main/scala/gs/predicate/v0/serde/json/JsonKeys.scala b/src/main/scala/gs/predicate/v0/serde/json/JsonKeys.scala new file mode 100644 index 0000000..11f5ce9 --- /dev/null +++ b/src/main/scala/gs/predicate/v0/serde/json/JsonKeys.scala @@ -0,0 +1,27 @@ +package gs.predicate.v0.serde.json + +/** Standard keys for JSON serialization and deserialization. + */ +object JsonKeys: + + /** Designates the type of predicate. + */ + val predicateType: String = "predicateType" + + /** Used to collect contained predicates for composites such as AND and OR. + */ + val predicates: String = "predicates" + + /** Used to represent some "key" (e.g. a key/value or JSON key). + */ + val key: String = "key" + + /** Used to represent any value. + */ + val value: String = "value" + + /** Used to represent any collection of values. + */ + val values: String = "values" + +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 new file mode 100644 index 0000000..d6afb9b --- /dev/null +++ b/src/main/scala/gs/predicate/v0/serde/json/codecs.scala @@ -0,0 +1,127 @@ +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.Or +import gs.predicate.v0.api.Predicate +import gs.predicate.v0.api.True +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.ValueStartsWith +import io.circe.* +import io.circe.syntax.* + +/** Given some [[And]] predicate, encode it as a JSON blob. + * + * @param and + * The [[And]] predicate. + * @return + * The JSON blob representing the [[And]]. + */ +def encodeAnd[F[_]](and: And[F]): Json = + Json.obj( + (JsonKeys.predicateType, Json.fromString(And.PredicateType)), + (JsonKeys.predicates, and.ps.asJson) + ) + +/** Given some [[Or]] predicate, encode it as a JSON blob. + * + * @param and + * The [[Or]] predicate. + * @return + * The JSON blob representing the [[Or]]. + */ +def encodeOr[F[_]](and: Or[F]): Json = + Json.obj( + (JsonKeys.predicateType, Json.fromString(Or.PredicateType)), + (JsonKeys.predicates, and.ps.asJson) + ) + +/** @return + * Generic encoder for any [[Predicate]]. + */ +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 => + throw new IllegalArgumentException( + s"Unsupported predicate type: ${p.predicateType}" + ) + } + +/** Given some JSON cursor, decode a logical [[And]] predicate. + * + * TODO: Recursive depth limitations. + * + * @param cursor + * The cursor that points to some JSON value. + * @return + * The [[And]] predicate or a decoding failure. + */ +def decodeAnd[F[_]: Applicative: KeyValueProvider](cursor: HCursor) + : Either[DecodingFailure, And[F]] = + for ps <- cursor.downField(JsonKeys.predicates).as[List[Predicate[F]]] + yield new And(ps) + +/** Given some JSON cursor, decode a logical [[Or]] predicate. + * + * TODO: Recursive depth limitations. + * + * @param cursor + * The cursor that points to some JSON value. + * @return + * The [[Or]] predicate or a decoding failure. + */ +def decodeOr[F[_]: Applicative: KeyValueProvider](cursor: HCursor) + : Either[DecodingFailure, Or[F]] = + for ps <- cursor.downField(JsonKeys.predicates).as[List[Predicate[F]]] + yield new Or(ps) + +/** Given some JSON cursor, decode any known [[Predicate]]. + * + * @param cursor + * The cursor that points to some JSON value. + * @return + * The decoded [[Predicate]] or some decoding failure. + */ +def decodePredicate[F[_]: Applicative: KeyValueProvider](cursor: HCursor) + : Either[DecodingFailure, Predicate[F]] = + 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 ValueContains.PredicateType => + Decoder[ValueContains[F]].apply(cursor) + case ValueStartsWith.PredicateType => + Decoder[ValueStartsWith[F]].apply(cursor) + case ValueEndsWith.PredicateType => + Decoder[ValueEndsWith[F]].apply(cursor) + case predicateType => + Left( + DecodingFailure(s"Unrecognized predicate type: '$predicateType'", Nil) + ) + yield predicate + +/** @return + * Generic decoder for any [[Predicate]]. + */ +given predicateDecoder[F[_]: Applicative: KeyValueProvider] + : Decoder[Predicate[F]] = + Decoder.instance[Predicate[F]](cursor => decodePredicate[F](cursor)) diff --git a/modules/test-support/src/test/scala/support/IOSuite.scala b/src/test/scala/gs/predicate/v0/IOSuite.scala similarity index 100% rename from modules/test-support/src/test/scala/support/IOSuite.scala rename to src/test/scala/gs/predicate/v0/IOSuite.scala diff --git a/modules/api/src/test/scala/gs/predicate/v0/api/AndTests.scala b/src/test/scala/gs/predicate/v0/api/AndTests.scala similarity index 67% rename from modules/api/src/test/scala/gs/predicate/v0/api/AndTests.scala rename to src/test/scala/gs/predicate/v0/api/AndTests.scala index a505d50..b985fc8 100644 --- a/modules/api/src/test/scala/gs/predicate/v0/api/AndTests.scala +++ b/src/test/scala/gs/predicate/v0/api/AndTests.scala @@ -1,32 +1,29 @@ package gs.predicate.v0.api import cats.effect.IO -import gs.uuid.v0.UUID import support.IOSuite class AndTests extends IOSuite: iotest("should return true if all are true") { - val id = UUID.v7() val and = And(True[IO], True[IO], True[IO]) - val and2 = And(id, True[IO], True[IO], True[IO]) + val and2 = And(True[IO], True[IO], True[IO]) for - result <- and.eval(()) - result2 <- and2.eval(()) + result <- and.eval() + result2 <- and2.eval() yield assertEquals(result.unwrap(), true) assertEquals(result2.unwrap(), true) } iotest("should return false if any are false") { - val id = UUID.v7() val and = And(True[IO], False[IO], True[IO]) - val and2 = And(id, True[IO], False[IO], True[IO]) + val and2 = And(True[IO], False[IO], True[IO]) for - result <- and.eval(()) - result2 <- and2.eval(()) + result <- and.eval() + result2 <- and2.eval() yield assertEquals(result.unwrap(), false) assertEquals(result2.unwrap(), false) @@ -35,12 +32,12 @@ class AndTests extends IOSuite: iotest("should return false for an empty list") { val and = And.empty[IO] val and2 = And[IO, Any]() - val and3 = And[IO, Any](UUID.v7()) + val and3 = And[IO, Any]() for - result <- and.eval(()) - result2 <- and2.eval(()) - result3 <- and3.eval(()) + result <- and.eval() + result2 <- and2.eval() + result3 <- and3.eval() yield assertEquals(result.unwrap(), false) assertEquals(result2.unwrap(), false) @@ -50,11 +47,11 @@ class AndTests extends IOSuite: iotest("should return the underlying predicate for a singular entry") { val p = True[IO] val and = And(p) - val and2 = And(UUID.v7(), p) + val and2 = And(p) for - result <- and.eval(()) - result2 <- and2.eval(()) + result <- and.eval() + result2 <- and2.eval() yield assertEquals(result.unwrap(), true) assertEquals(result2.unwrap(), true) diff --git a/src/test/scala/gs/predicate/v0/api/FalseTests.scala b/src/test/scala/gs/predicate/v0/api/FalseTests.scala new file mode 100644 index 0000000..5b8fdd9 --- /dev/null +++ b/src/test/scala/gs/predicate/v0/api/FalseTests.scala @@ -0,0 +1,13 @@ +package gs.predicate.v0.api + +import cats.effect.IO +import support.IOSuite + +class FalseTests extends IOSuite: + + iotest("should return false") { + val predicate = False[IO] + + for result <- predicate.eval() + yield assertEquals(result.unwrap(), false) + } diff --git a/modules/api/src/test/scala/gs/predicate/v0/api/OrTests.scala b/src/test/scala/gs/predicate/v0/api/OrTests.scala similarity index 67% rename from modules/api/src/test/scala/gs/predicate/v0/api/OrTests.scala rename to src/test/scala/gs/predicate/v0/api/OrTests.scala index d5ff2f6..d12b9dc 100644 --- a/modules/api/src/test/scala/gs/predicate/v0/api/OrTests.scala +++ b/src/test/scala/gs/predicate/v0/api/OrTests.scala @@ -1,32 +1,29 @@ package gs.predicate.v0.api import cats.effect.IO -import gs.uuid.v0.UUID import support.IOSuite class OrTests extends IOSuite: iotest("should return true if any are true") { - val id = UUID.v7() val or = Or(False[IO], True[IO], False[IO]) - val or2 = Or(id, False[IO], True[IO], False[IO]) + val or2 = Or(False[IO], True[IO], False[IO]) for - result <- or.eval(()) - result2 <- or2.eval(()) + result <- or.eval() + result2 <- or2.eval() yield assertEquals(result.unwrap(), true) assertEquals(result2.unwrap(), true) } iotest("should return false if all are false") { - val id = UUID.v7() val or = Or(False[IO], False[IO], False[IO]) - val or2 = Or(id, False[IO], False[IO], False[IO]) + val or2 = Or(False[IO], False[IO], False[IO]) for - result <- or.eval(()) - result2 <- or2.eval(()) + result <- or.eval() + result2 <- or2.eval() yield assertEquals(result.unwrap(), false) assertEquals(result2.unwrap(), false) @@ -35,12 +32,12 @@ class OrTests extends IOSuite: iotest("should return false for an empty list") { val or = Or.empty[IO] val or2 = Or[IO, Any]() - val or3 = Or[IO, Any](UUID.v7()) + val or3 = Or[IO, Any]() for - result <- or.eval(()) - result2 <- or2.eval(()) - result3 <- or3.eval(()) + result <- or.eval() + result2 <- or2.eval() + result3 <- or3.eval() yield assertEquals(result.unwrap(), false) assertEquals(result2.unwrap(), false) @@ -50,11 +47,11 @@ class OrTests extends IOSuite: iotest("should return the underlying predicate for a singular entry") { val p = True[IO] val or = Or(p) - val or2 = Or(UUID.v7(), p) + val or2 = Or(p) for - result <- or.eval(()) - result2 <- or2.eval(()) + result <- or.eval() + result2 <- or2.eval() yield assertEquals(result.unwrap(), true) assertEquals(result2.unwrap(), true) diff --git a/modules/api/src/test/scala/gs/predicate/v0/api/PredicateResultTests.scala b/src/test/scala/gs/predicate/v0/api/PredicateResultTests.scala similarity index 100% rename from modules/api/src/test/scala/gs/predicate/v0/api/PredicateResultTests.scala rename to src/test/scala/gs/predicate/v0/api/PredicateResultTests.scala diff --git a/src/test/scala/gs/predicate/v0/api/TrueTests.scala b/src/test/scala/gs/predicate/v0/api/TrueTests.scala new file mode 100644 index 0000000..8038481 --- /dev/null +++ b/src/test/scala/gs/predicate/v0/api/TrueTests.scala @@ -0,0 +1,13 @@ +package gs.predicate.v0.api + +import cats.effect.IO +import support.IOSuite + +class TrueTests extends IOSuite: + + iotest("should return true") { + val predicate = True[IO] + + for result <- predicate.eval() + yield assertEquals(result.unwrap(), true) + } diff --git a/modules/json/src/test/scala/gs/predicate/v0/json/query/CompiledQueryEvalTests.scala b/src/test/scala/gs/predicate/v0/json/query/CompiledQueryEvalTests.scala similarity index 100% rename from modules/json/src/test/scala/gs/predicate/v0/json/query/CompiledQueryEvalTests.scala rename to src/test/scala/gs/predicate/v0/json/query/CompiledQueryEvalTests.scala diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/KeyExistsTests.scala b/src/test/scala/gs/predicate/v0/kv/KeyExistsTests.scala similarity index 54% rename from modules/kv/src/test/scala/gs/predicate/v0/kv/KeyExistsTests.scala rename to src/test/scala/gs/predicate/v0/kv/KeyExistsTests.scala index 8622c90..7327ed6 100644 --- a/modules/kv/src/test/scala/gs/predicate/v0/kv/KeyExistsTests.scala +++ b/src/test/scala/gs/predicate/v0/kv/KeyExistsTests.scala @@ -12,21 +12,23 @@ class KeyExistsTests extends IOSuite: import KeyExistsTests.Data iotest("should find a key that exists within some provider") { - val p = KeyExists[IO, String, String](Data.ExistingKey) - for - provider <- KeyExistsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield - // TODO: assertPredicateMatched(result) - assertEquals(result, Predicate.Result.matched()) + KeyExistsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = KeyExists[IO](Data.ExistingKey) + for result <- p.eval() + yield + // TODO: assertPredicateMatched(result) + assertEquals(result, Predicate.Result.matched()) + } } iotest("should not find a key that does not exist within some provider") { - val p = KeyExists[IO, String, String](Data.NotExistingKey) - for - provider <- KeyExistsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.missed()) + KeyExistsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = KeyExists[IO](Data.NotExistingKey) + for result <- p.eval() + yield assertEquals(result, Predicate.Result.missed()) + } } object KeyExistsTests: @@ -40,8 +42,8 @@ object KeyExistsTests: end Data - def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = + def newProvider(data: Map[String, String]): IO[KeyValueProvider[IO]] = for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) - yield new MemoryMapStringProvider(map) + yield new MemoryMapKeyValueProvider(map) end KeyExistsTests diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/KeyValueProviderTests.scala b/src/test/scala/gs/predicate/v0/kv/KeyValueProviderTests.scala similarity index 100% rename from modules/kv/src/test/scala/gs/predicate/v0/kv/KeyValueProviderTests.scala rename to src/test/scala/gs/predicate/v0/kv/KeyValueProviderTests.scala diff --git a/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala b/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala new file mode 100644 index 0000000..f60776c --- /dev/null +++ b/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala @@ -0,0 +1,90 @@ +package gs.predicate.v0.kv + +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.kv.StringContainsTests.Data.Substring1 +import support.IOSuite + +class StringContainsTests extends IOSuite: + + import StringContainsTests.Data + + iotest("should find a contained string in any position") { + StringContainsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p1 = ValueContains[IO](Data.PassingKey, Data.Substring1) + val p2 = ValueContains[IO](Data.PassingKey, Data.Substring2) + val p3 = ValueContains[IO](Data.PassingKey, Data.Substring3) + for + r1 <- p1.eval() + r2 <- p2.eval() + r3 <- p3.eval() + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + assertEquals(r3, Predicate.Result.matched()) + } + } + + iotest("should not find a key that does not exist within some provider") { + StringContainsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = ValueContains[IO](Data.NotExistingKey, "") + for result <- p.eval() + yield assertEquals(result, Predicate.Result.missed()) + } + } + + iotest("should match if an empty substring is provided") { + StringContainsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p1 = ValueContains[IO](Data.EmptyStringKey, "") + val p2 = ValueContains[IO](Data.PassingKey, "") + for + r1 <- p1.eval() + r2 <- p2.eval() + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + } + } + + iotest("should not match if the target value is not contained in the input") { + StringContainsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = ValueContains[IO](Data.PassingKey, Substring1.reverse) + for result <- p.eval() + yield assertEquals(result, Predicate.Result.missed()) + } + } + +object StringContainsTests: + + object Data: + + val PassingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() + val PassingValue: String = "abcdefhij" + val Substring1: String = "abc" + val Substring2: String = "def" + val Substring3: String = "hij" + + val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() + val NotExistingValue: String = Gen.string.alphaNumeric(Size.Fixed(4)).gen() + + val EmptyStringKey: String = "empty" + + val KeyValues: Map[String, String] = Map( + PassingKey -> PassingValue, + EmptyStringKey -> "" + ) + + end Data + + def newProvider(data: Map[String, String]): IO[KeyValueProvider[IO]] = + for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) + yield new MemoryMapKeyValueProvider(map) + +end StringContainsTests diff --git a/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala b/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala new file mode 100644 index 0000000..b452977 --- /dev/null +++ b/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala @@ -0,0 +1,100 @@ +package gs.predicate.v0.kv + +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 support.IOSuite + +class ValueEndsWithTests extends IOSuite: + + import ValueEndsWithTests.Data + + iotest("should find a string as a prefix of the input") { + ValueEndsWithTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p1 = ValueEndsWith[IO](Data.PassingKey, Data.Substring1) + val p2 = ValueEndsWith[IO](Data.PassingKey, Data.Substring2) + val p3 = ValueEndsWith[IO](Data.PassingKey, Data.Substring3) + for + r1 <- p1.eval() + r2 <- p2.eval() + r3 <- p3.eval() + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + assertEquals(r3, Predicate.Result.matched()) + } + } + + iotest("should not find a key that does not exist within some provider") { + ValueEndsWithTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = ValueEndsWith[IO](Data.NotExistingKey, "") + for result <- p.eval() + yield assertEquals(result, Predicate.Result.missed()) + } + } + + iotest("should match if an empty substring is provided") { + ValueEndsWithTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p1 = ValueEndsWith[IO](Data.EmptyStringKey, "") + val p2 = ValueEndsWith[IO](Data.PassingKey, "") + for + r1 <- p1.eval() + r2 <- p2.eval() + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + } + } + + iotest( + "should not match if the target value is not the suffix of the input" + ) { + ValueEndsWithTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p1 = ValueEndsWith[IO](Data.PassingKey, Data.Substring3 + "z") + val p2 = + ValueEndsWith[IO](Data.PassingKey, Data.Substring3.reverse) + val p3 = + ValueEndsWith[IO](Data.PassingKey, Data.Substring2.reverse) + for + r1 <- p1.eval() + r2 <- p2.eval() + r3 <- p3.eval() + yield + assertEquals(r1, Predicate.Result.missed()) + assertEquals(r2, Predicate.Result.missed()) + assertEquals(r3, Predicate.Result.missed()) + } + } + +object ValueEndsWithTests: + + object Data: + + val PassingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() + val PassingValue: String = "abcdefghi" + val Substring1: String = "i" + val Substring2: String = "hi" + val Substring3: String = "abcdefghi" + + val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() + + val EmptyStringKey: String = "empty" + + val KeyValues: Map[String, String] = Map( + PassingKey -> PassingValue, + EmptyStringKey -> "" + ) + + end Data + + def newProvider(data: Map[String, String]): IO[KeyValueProvider[IO]] = + for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) + yield new MemoryMapKeyValueProvider(map) + +end ValueEndsWithTests diff --git a/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala b/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala new file mode 100644 index 0000000..91626a7 --- /dev/null +++ b/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala @@ -0,0 +1,101 @@ +package gs.predicate.v0.kv + +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 support.IOSuite + +class ValueStartsWithTests extends IOSuite: + + import ValueStartsWithTests.Data + + iotest("should find a string as a prefix of the input") { + ValueStartsWithTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p1 = ValueStartsWith[IO](Data.PassingKey, Data.Substring1) + val p2 = ValueStartsWith[IO](Data.PassingKey, Data.Substring2) + val p3 = ValueStartsWith[IO](Data.PassingKey, Data.Substring3) + for + r1 <- p1.eval() + r2 <- p2.eval() + r3 <- p3.eval() + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + assertEquals(r3, Predicate.Result.matched()) + } + } + + iotest("should not find a key that does not exist within some provider") { + ValueStartsWithTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = ValueStartsWith[IO](Data.NotExistingKey, "") + for result <- p.eval() + yield assertEquals(result, Predicate.Result.missed()) + } + } + + iotest("should match if an empty substring is provided") { + ValueStartsWithTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p1 = ValueStartsWith[IO](Data.EmptyStringKey, "") + val p2 = ValueStartsWith[IO](Data.PassingKey, "") + for + r1 <- p1.eval() + r2 <- p2.eval() + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + } + } + + iotest( + "should not match if the target value is not the prefix of the input" + ) { + ValueStartsWithTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p1 = + ValueStartsWith[IO](Data.PassingKey, Data.Substring3 + "k") + val p2 = + ValueStartsWith[IO](Data.PassingKey, Data.Substring3.reverse) + val p3 = + ValueStartsWith[IO](Data.PassingKey, Data.Substring2.reverse) + for + r1 <- p1.eval() + r2 <- p2.eval() + r3 <- p3.eval() + yield + assertEquals(r1, Predicate.Result.missed()) + assertEquals(r2, Predicate.Result.missed()) + assertEquals(r3, Predicate.Result.missed()) + } + } + +object ValueStartsWithTests: + + object Data: + + val PassingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() + val PassingValue: String = "abcdefghi" + val Substring1: String = "a" + val Substring2: String = "ab" + val Substring3: String = "abcdefghi" + + val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() + + val EmptyStringKey: String = "empty" + + val KeyValues: Map[String, String] = Map( + PassingKey -> PassingValue, + EmptyStringKey -> "" + ) + + end Data + + def newProvider(data: Map[String, String]): IO[KeyValueProvider[IO]] = + for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) + yield new MemoryMapKeyValueProvider(map) + +end ValueStartsWithTests diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/ValueEqualsTests.scala b/src/test/scala/gs/predicate/v0/kv/ValueEqualsTests.scala similarity index 50% rename from modules/kv/src/test/scala/gs/predicate/v0/kv/ValueEqualsTests.scala rename to src/test/scala/gs/predicate/v0/kv/ValueEqualsTests.scala index c96a110..9df3238 100644 --- a/modules/kv/src/test/scala/gs/predicate/v0/kv/ValueEqualsTests.scala +++ b/src/test/scala/gs/predicate/v0/kv/ValueEqualsTests.scala @@ -12,29 +12,32 @@ class ValueEqualsTests extends IOSuite: import ValueEqualsTests.Data iotest("should find an exact match against some value") { - val p = - ValueEquals[IO, String, String](Data.ExistingKey, Data.ExistingValue) - for - provider <- ValueEqualsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.matched()) + ValueEqualsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = + ValueEquals[IO](Data.ExistingKey, Data.ExistingValue) + for result <- p.eval() + yield assertEquals(result, Predicate.Result.matched()) + } } iotest("should not find a value if it is not associated to a key") { - val p = - ValueEquals[IO, String, String](Data.ExistingKey, Data.NotExistingValue) - for - provider <- ValueEqualsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.missed()) + ValueEqualsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = + ValueEquals[IO](Data.ExistingKey, Data.NotExistingValue) + for result <- p.eval() + yield assertEquals(result, Predicate.Result.missed()) + } } iotest("should not find a key that does not exist within some provider") { - val p = ValueEquals[IO, String, String](Data.NotExistingKey, "") - for - provider <- ValueEqualsTests.newProvider(Data.KeyValues) - result <- p.eval(provider) - yield assertEquals(result, Predicate.Result.missed()) + ValueEqualsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = ValueEquals[IO](Data.NotExistingKey, "") + for result <- p.eval() + yield assertEquals(result, Predicate.Result.missed()) + } } object ValueEqualsTests: @@ -49,8 +52,8 @@ object ValueEqualsTests: end Data - def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = + def newProvider(data: Map[String, String]): IO[KeyValueProvider[IO]] = for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) - yield new MemoryMapStringProvider(map) + yield new MemoryMapKeyValueProvider(map) end ValueEqualsTests diff --git a/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala b/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala new file mode 100644 index 0000000..c55c2db --- /dev/null +++ b/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala @@ -0,0 +1,62 @@ +package gs.predicate.v0.kv + +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 support.IOSuite + +class ValueNotEqualsTests extends IOSuite: + + import ValueNotEqualsTests.Data + + iotest("should NOT find an exact match against some value") { + ValueNotEqualsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = + ValueNotEquals[IO](Data.ExistingKey, Data.ExistingValue) + for result <- p.eval() + yield assertEquals(result, Predicate.Result.missed()) + } + } + + iotest("should match a value if that value is not equal to the target") { + ValueNotEqualsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = + ValueNotEquals[IO]( + Data.ExistingKey, + Data.NotExistingValue + ) + for result <- p.eval() + yield assertEquals(result, Predicate.Result.matched()) + } + } + + iotest("should not find a key that does not exist within some provider") { + ValueNotEqualsTests.newProvider(Data.KeyValues).flatMap { provider => + given KeyValueProvider[IO] = provider + val p = ValueNotEquals[IO](Data.NotExistingKey, "") + for result <- p.eval() + yield assertEquals(result, Predicate.Result.missed()) + } + } + +object ValueNotEqualsTests: + + object Data: + + val ExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() + val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() + val ExistingValue: String = Gen.string.alphaNumeric(Size.Fixed(10)).gen() + val NotExistingValue: String = Gen.string.alphaNumeric(Size.Fixed(4)).gen() + val KeyValues: Map[String, String] = Map(ExistingKey -> ExistingValue) + + end Data + + def newProvider(data: Map[String, String]): IO[KeyValueProvider[IO]] = + for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) + yield new MemoryMapKeyValueProvider(map) + +end ValueNotEqualsTests