diff --git a/build.sbt b/build.sbt index 541e739..381de67 100644 --- a/build.sbt +++ b/build.sbt @@ -30,6 +30,11 @@ val Deps = new { val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.6.3" } + val Circe = new { + val Core: ModuleID = "io.circe" %% "circe-core" % "0.14.15" + 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" @@ -50,7 +55,8 @@ lazy val `gs-predicate` = project .aggregate( `test-support`, api, - keyValue + keyValue, + json ) .settings(noPublishSettings) .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") @@ -107,3 +113,24 @@ lazy val keyValue = project 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.Gs.Uuid + ) + ) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..97ec101 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,6 @@ +# Library Documentation + +- [Terminology](./terminology.md) +- [Predicates: Standard](./predicates-standard.md) +- [Predicates: Key-Value](./predicates-key-value.md) +- [Predicates: JSON](#) diff --git a/docs/predicates-json.md b/docs/predicates-json.md new file mode 100644 index 0000000..c71a799 --- /dev/null +++ b/docs/predicates-json.md @@ -0,0 +1,187 @@ +# Predicates: JSON + +Refer to [Terminology](./terminology.md) for information about specific terms. + +[[_TOC_]] + +## Description + +These predicates are focused on reasoning about JSON values. + +## Input + +All key-value predicates accept a single input: some JSON value. + +## JSON Query Strings + +This library may query JSON values using query strings. These are deliberately +_not_ powerful and are not intended for `jq`-like functionality. + +These query strings directly interact with the query system. + +### Empty Query + +An empty query string indicates that the predicate should address the value +verbatim. + +#### Example: Plain String + +**Input**: + +This example will match. + +```json +"some string" +``` + +**Predicate**: + +```json +{ + "predicate": "json-value-equals", + "parameters": { + "queryString": "", + "value": "some string" + } +} +``` + +### Value (`.`) + +The `.` operator accesses a named value by key within the current object. This +will not match if the current value is not an object. + +#### Example: Mismatched Types + +**Input**: + +This example will not match, because the value at `x` is a string, so no key can +possibly be accessed through it. + +```json +{ "x": "some string" } +``` + +**Predicate**: + +```json +{ + "predicate": "json-value-equals", + "parameters": { + "queryString": "x.y", + "value": "some string" + } +} +``` + +### JSON Key + +Any text aside from `.` and the contents of `[]` is considered a key. These keys +refer to named elements of the JSON structure. + +```json +{ + "x": { "y": { "z": 100 } } +} +``` + +`x`, `y`, and `z` are all keys. + +### Array Operator + +The `[any|all|]` syntax is used to reason about arrays: + +- `[any]` indicates that the predicate will match if _any_ of the selected + values based on traversing the array match. +- `[all]` indicates that the predicate will match only if _all_ of the selected + values based on traversing the array match. +- `[]` indicates that the predicate will match only if the specified + index exists within the array and the selected value at that index matches. + +#### Example: Value Equals (Any) + +The following scenario will _match_: + +**Input**: + +```json +{ + "data": { + "things": [ + { "x": 100, "y": 200 } + ] + } +} +``` + +**Predicate**: + +```json +{ + "predicate": "json-value-equals", + "parameters": { + "queryString": "data.things[any].x", + "value": 100 + } +} +``` + +#### Example: Value Equals (All) + +The following scenario will _match_: + +**Input**: + +```json +{ + "data": { + "things": [ + { "x": 100, "y": 200 }, + { "x": 100, "y": 300 }, + { "x": 100, "y": 400 } + ] + } +} +``` + +**Predicate**: + +```json +{ + "predicate": "json-value-equals", + "parameters": { + "queryString": "data.things[all].x", + "value": 100 + } +} +``` + +#### Example: Value Equals (Index) + +The following scenario will _match_: + +**Input**: + +```json +{ + "data": { + "things": [ + { "x": 100, "y": 200 }, + { "x": 999, "y": 300 }, + { "x": 100, "y": 400 } + ] + } +} +``` + +**Predicate**: + +```json +{ + "predicate": "json-value-equals", + "parameters": { + "queryString": "data.things[1].x", + "value": 999 + } +} +``` diff --git a/docs/predicates-key-value.md b/docs/predicates-key-value.md new file mode 100644 index 0000000..b3be72f --- /dev/null +++ b/docs/predicates-key-value.md @@ -0,0 +1,109 @@ +# Predicates: Key-Value + +Refer to [Terminology](./terminology.md) for information about specific terms. + +[[_TOC_]] + +## Description + +These predicates are focused on reasoning about keys and their associated +(or not associated) values. + +## Input + +All key-value predicates accept a single input: some key-value provider. This +is the state referenced by the predicates. + +This provider can answer two questions: + +- Does the given key exist? +- What is the value associated with a given key? + +## Predicate: Key Exists + +Matches if the key-value provider contains the given key. + +## Predicate: Value Equals + +Given some key `K` and expected value `EV`, this predicate matches if: + +- There is some value `V` associated with `K`. +- `V` is equal to `EV`. + +## Predicate: Value Not Equals + +Given some key `K` and unexpected value `UV`, this predicate matches if: + +- There is some value `V` associated with `K`. +- `V` is not equal to `UV`. + +## Predicate: String Contains + +> [!important] +> This predicate is only supported for string values. + +Given some key `K` and string `S`, this predicate matches if: + +- There is some value `V` associated with `K`. +- `V` contains the substring `S`. + +### Examples + +| `S` | `V` | Result | +| ------- | ------- | ------- | +| `""` | `""` | `match` | +| `""` | `"a"` | `match` | +| `"a"` | `""` | `miss` | +| `"a"` | `"abc"` | `match` | +| `"ab"` | `"abc"` | `match` | +| `"b"` | `"abc"` | `match` | +| `"bc"` | `"abc"` | `match` | +| `"c"` | `"abc"` | `match` | +| `"d"` | `"abc"` | `miss` | +| `"abc"` | `"abc"` | `match` | + +## Predicate: String Starts With + +> [!important] +> This predicate is only supported for string values. + +Given some key `K` and prefix `P`, this predicate matches if: + +- There is some value `V` associated with `K`. +- `V` starts with the prefix `P`. + +### Examples + +| `P` | `V` | Result | +| ------- | ------- | ------- | +| `""` | `""` | `match` | +| `""` | `"a"` | `match` | +| `"a"` | `""` | `miss` | +| `"a"` | `"abc"` | `match` | +| `"ab"` | `"abc"` | `match` | +| `"b"` | `"abc"` | `miss` | +| `"d"` | `"abc"` | `miss` | +| `"abc"` | `"abc"` | `match` | + +## Predicate: String Ends With + +> [!important] +> This predicate is only supported for string values. + +Given some key `K` and suffix `S`, this predicate matches if: + +- There is some value `V` associated with `K`. +- `V` ends with the suffix `S`. + +### Examples + +| `S` | `V` | Result | +| ------- | ------- | ------- | +| `""` | `""` | `match` | +| `""` | `"a"` | `match` | +| `"a"` | `""` | `miss` | +| `"a"` | `"abc"` | `miss` | +| `"bc"` | `"abc"` | `match` | +| `"c"` | `"abc"` | `match` | +| `"d"` | `"abc"` | `miss` | +| `"abc"` | `"abc"` | `match` | diff --git a/docs/predicates-standard.md b/docs/predicates-standard.md new file mode 100644 index 0000000..349041f --- /dev/null +++ b/docs/predicates-standard.md @@ -0,0 +1,57 @@ +# Predicates: Standard + +Refer to [Terminology](./terminology.md) for information about specific terms. + +[[_TOC_]] + +## And + +The `and` predicate calculates the logical AND of some list of predicates. For +this predicate, a `match` corresponds to `true` and a `miss` corresponds to +`false`. + +> Only match if all contained predicates match. + +### Case: Empty List + +The predicate always misses. + +Since there are no predicates, nothing can possibly match. + +### Case: Single Predicate + +Identiical to that single predicate. + +### Case: Multiple Predicates + +The logical AND of the predicate results. + +## Or + +The `or` predicate calculates the logical OR of some list of predicates. For +this predicate, a `match` corresponds to `true` and a `miss` corresponds to +`false`. + +> Only match if any contained predicate matches. + +### Case: Empty List + +The predicate always misses. + +Since there are no predicates, nothing can possibly match. + +### Case: Single Predicate + +Identiical to that single predicate. + +### Case: Multiple Predicates + +The logical OR of the predicate results. + +## True + +This predicate always matches. + +## False + +This predicate always misses. diff --git a/docs/terminology.md b/docs/terminology.md new file mode 100644 index 0000000..18cca40 --- /dev/null +++ b/docs/terminology.md @@ -0,0 +1,33 @@ +# Predicate Terminology + +[[_TOC_]] + +## Definition: Predicate + +Any function that accepts exactly one input and produces a +[Predicate Result](#definition-predicate-result) (aka a Boolean output). + +Predicates are ways to express logical conditions. + +## Definition: Predicate Result + +The result of evaluating a [Predicate](#definition-predicate). Predicates may: + +- [Match](#definition-match) +- [Miss](#definition-miss) + +## Definition: Match + +The term **match** is a specific [Result](#definition-predicate-result). +Semantically, it means that the predicate ran successfully against the given +input. The conditions within were met. + +If the predicate were expressed as a Boolean, this would correspond to `true`. + +## Definition: Miss + +The term **miss** is a specific [Result](#definition-predicate-result). +Semantically, it means that the predicate did not run successfully against the +given input. The conditions within were not met. + +If the predicate were expressed as a Boolean, this would correspond to `false`. 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 new file mode 100644 index 0000000..7eb9ae1 --- /dev/null +++ b/modules/json/src/main/scala/gs/predicate/v0/json/JsonKeyExists.scala @@ -0,0 +1,32 @@ +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 key: String +) extends Predicate[F, Json]: + + /** @inheritDocs + */ + override def eval(input: Json): F[Predicate.Result] = + // \\ is recursive, we need a path-based lookup or something + Applicative[F].pure(Predicate.Result(input.\\(key).nonEmpty)) + +object JsonKeyExists: + + def apply[F[_]: Applicative](key: String): JsonKeyExists[F] = + new JsonKeyExists[F](UUID.v7(), key) + +end JsonKeyExists