From 72cc9784783731440cbb73196fc45c0fd77745f2 Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Wed, 6 Nov 2024 19:56:20 -0800 Subject: [PATCH] feat(stdlib): Json value access utils (#2150) --- compiler/test/stdlib/json.test.gr | 135 ++++++++++ stdlib/json.gr | 312 ++++++++++++++++++++++ stdlib/json.md | 416 ++++++++++++++++++++++++++++++ 3 files changed, 863 insertions(+) diff --git a/compiler/test/stdlib/json.test.gr b/compiler/test/stdlib/json.test.gr index 9dd0dbf77..ecf74bd5f 100644 --- a/compiler/test/stdlib/json.test.gr +++ b/compiler/test/stdlib/json.test.gr @@ -35,6 +35,7 @@ from "result" include Result from "buffer" include Buffer from "char" include Char from "list" include List +from "option" include Option use Json.* module Validation { // Valid @@ -1099,3 +1100,137 @@ module ToString { ) == Ok(Ok(comprehensiveJsonObject)) } + +module Lenses { + let num = JsonNumber(123) + let str = JsonString("abc") + let bool = JsonBoolean(true) + let arr = JsonArray([JsonNumber(1), JsonNumber(2), JsonNumber(3)]) + let obj = JsonObject( + [ + ("num", num), + ("string", str), + ("bool", bool), + ("array", arr), + ("null", JsonNull), + ], + ) + let nestedObj = JsonObject([("object", JsonObject([("nested", obj)]))]) + let simpleObj = JsonObject([("property", JsonNumber(10))]) + + use Json.Lenses.* + + assert get(json, num) == Some(num) + assert set(json, JsonString("hi"), num) == Some(JsonString("hi")) + assert map(json, x => JsonArray([x, x]), num) == Some(JsonArray([num, num])) + + assert get(number, num) == Some(123) + assert get(string, num) == None + assert set(string, "hi", num) == Some(JsonString("hi")) + assert map(number, x => x * 2, num) == Some(JsonNumber(246)) + + assert get(string, str) == Some("abc") + assert get(number, str) == None + + assert get(boolean, bool) == Some(true) + assert get(number, bool) == None + + assert get(array, arr) == Some([JsonNumber(1), JsonNumber(2), JsonNumber(3)]) + assert get(number, arr) == None + + assert get(objectProperties, obj) == + Some( + [ + ("num", num), + ("string", str), + ("bool", bool), + ("array", arr), + ("null", JsonNull), + ], + ) + assert get(number, obj) == None + + assert get(property("num"), obj) == Some(num) + assert get(property("string"), obj) == Some(str) + assert get(property("oops"), obj) == None + assert get(property("oops"), num) == None + assert set(property("bool"), JsonBoolean(false), obj) == + Some( + JsonObject( + [ + ("num", num), + ("string", str), + ("bool", JsonBoolean(false)), + ("array", arr), + ("null", JsonNull), + ], + ), + ) + assert set(property("new"), JsonString("newVal"), obj) == + Some( + JsonObject( + [ + ("num", num), + ("string", str), + ("bool", bool), + ("array", arr), + ("null", JsonNull), + ("new", JsonString("newVal")), + ], + ), + ) + assert map(property("property"), x => JsonArray([x, x]), simpleObj) == + Some( + JsonObject([("property", JsonArray([JsonNumber(10), JsonNumber(10)]))]), + ) + + assert get(property("num") ||> number, obj) == Some(123) + assert get(property("null") ||> number, obj) == None + assert set(property("object") ||> number, 3, nestedObj) == + Some(JsonObject([("object", JsonNumber(3))])) + assert set(property("new") ||> number, 1, JsonObject([])) == None + assert map(property("property") ||> number, val => val * 2, simpleObj) == + Some(JsonObject([("property", JsonNumber(20))])) + assert map(property("property") ||> string, val => val ++ val, simpleObj) == + None + + assert get(nullable(number), num) == Some(Some(123)) + assert get(nullable(number), JsonNull) == Some(None) + assert get(nullable(number), str) == None + assert set(nullable(number), Some(3), str) == Some(JsonNumber(3)) + assert get(property("num") ||> nullable(number), obj) == Some(Some(123)) + assert get(property("null") ||> nullable(number), obj) == Some(None) + assert set(property("object") ||> nullable(number), Some(3), nestedObj) == + Some(JsonObject([("object", JsonNumber(3))])) + assert set(property("object") ||> nullable(number), None, nestedObj) == + Some(JsonObject([("object", JsonNull)])) + assert set(property("new") ||> nullable(number), Some(1), JsonObject([])) == + None + assert map( + property("property") ||> nullable(number), + val => Option.map(val => val * 2, val), + simpleObj + ) == + Some(JsonObject([("property", JsonNumber(20))])) + assert map(property("property") ||> nullable(number), val => None, simpleObj) == + Some(JsonObject([("property", JsonNull)])) + + assert get(propertyPath(["object", "nested"]), nestedObj) == Some(obj) + assert get(propertyPath(["object", "nested"]), nestedObj) == + get(property("object") ||> property("nested"), nestedObj) + assert get(propertyPath(["num"]), obj) == Some(num) + assert get(propertyPath([]), obj) == Some(obj) + assert get(propertyPath([]), num) == Some(num) + assert get(propertyPath(["oops"]), num) == None + assert get(propertyPath(["object", "nested", "oops"]), nestedObj) == None + assert map( + propertyPath(["object", "nested"]) ||> objectProperties, + props => List.take(1, props), + nestedObj + ) == + Some( + JsonObject( + [("object", JsonObject([("nested", JsonObject([("num", num)]))]))], + ), + ) +} diff --git a/stdlib/json.gr b/stdlib/json.gr index ad6d40a5c..9a8cbeea6 100644 --- a/stdlib/json.gr +++ b/stdlib/json.gr @@ -2069,3 +2069,315 @@ provide let parse: (str: String) => Result = (str: String) } } } + +/** + * Utilities for accessing and updating JSON data. + * + * @example + * let obj = JsonObject([("x", JsonNumber(123))]) + * assert get(property("x") ||> number, obj) == Some(123) + * @example + * let obj = JsonObject([("x", JsonNumber(123))]) + * assert set(property("x") ||> number, 321, obj) == + * Some(JsonObject([("x", JsonNumber(321))])) + * + * @since v0.7.0 + */ +provide module Lenses { + /** + * A structure which provides functionality for accessing and setting JSON + * data. + * + * @since v0.7.0 + */ + provide record Lens { + /** + * A function which reads a value from the subject. + */ + get: (subject: a) => Option, + /** + * A function which immutably updates a value in the subject. + */ + set: (newValue: b, subject: a) => Option, + } + + /** + * Reads the value focused on by the given lens from the input data. + * + * @param lens: The lens to apply to the subject data + * @param subject: The data which will have the lens applied to it + * @returns `Some(data)` containing the data read by the lens if the lens matches the given data, or `None` if the data cannot be matched to the lens + * + * @example assert get(number, JsonNumber(123)) == Some(123) + * @example assert get(string, JsonString("abc")) == Some("abc") + * @example assert get(number, JsonString("abc")) == None + */ + provide let get = (lens, subject) => lens.get(subject) + + /** + * Sets the value focused on by the given lens from the input data to the + * desired new value. + * + * @param lens: The lens to apply to the subject data + * @param newValue: The new value to set at the focus of the lens + * @param subject: The data which will have the lens applied to it + * @returns `Some(data)` containing the new data after the lens substitution if the lens matches the given data, or `None` if the data cannot be matched to the lens + * + * @example assert set(number, 123, JsonBoolean(true)) == Some(JsonNumber(123)) + * @example assert set(property("a"), JsonNumber(123), JsonObject([("a", JsonNull)])) == Some(JsonObject([("a", JsonNumber(123))])) + * @example assert set(property("a"), JsonNumber(123), JsonBoolean(true)) == None + */ + provide let set = (lens, newValue, subject) => lens.set(newValue, subject) + + /** + * Updates the value focused on by the given lens from the input data by + * applying a function to it and setting the focus to the result of the function + * + * @param lens: The lens to apply to the subject data + * @param fn: The function to apply to the matched data at the lens if matched + * @param subject: The data which will have the lens applied to it + * @returns `Some(data)` containing the new data after the lens mapping has been applied if the lens matches the given data, or `None` if the data cannot be matched to the lens + * + * @example assert map(number, x => x * 2, JsonNumber(5)) == Some(JsonNumber(10)) + * @example + * assert map(property("x"), x => JsonArray([x, x]), JsonObject([("x", JsonNumber(1))])) == + * Some(JsonObject([("x", JsonArray([JsonNumber(1), JsonNumber(1)]))])) + * @example assert map(number, x => x * 2, JsonString("abc")) == None + */ + provide let map = (lens, fn, subject) => { + match (lens.get(subject)) { + Some(lensVal) => lens.set(fn(lensVal), subject), + None => None, + } + } + + /** + * A lens whose focus is a JSON value. + * + * @example assert get(json, JsonString("abc")) == Some(JsonString("abc")) + * + * @since v0.7.0 + */ + provide let json = { + get: json => Some(json), + set: (newValue, _) => Some(newValue), + } + + /** + * A lens whose focus is a JSON boolean value. + * + * @example assert get(boolean, JsonBoolean(true)) == Some(true) + * + * @since v0.7.0 + */ + provide let boolean = { + get: json => { + match (json) { + JsonBoolean(val) => Some(val), + _ => None, + } + }, + set: (newValue, _) => Some(JsonBoolean(newValue)), + } + + /** + * A lens whose focus is a JSON string value. + * + * @example assert get(string, JsonString("abc")) == Some("abc") + * + * @since v0.7.0 + */ + provide let string = { + get: json => { + match (json) { + JsonString(val) => Some(val), + _ => None, + } + }, + set: (newValue, _) => Some(JsonString(newValue)), + } + + /** + * A lens whose focus is a JSON number value. + * + * @example assert get(number, JsonNumber(123)) == Some(123) + * + * @since v0.7.0 + */ + provide let number = { + get: json => { + match (json) { + JsonNumber(val) => Some(val), + _ => None, + } + }, + set: (newValue, _) => Some(JsonNumber(newValue)), + } + + /** + * A lens whose focus is a JSON array. + * + * @example assert get(array, JsonArray([JsonNumber(123)])) == Some([JsonNumber(123)]) + * + * @since v0.7.0 + */ + provide let array = { + get: json => { + match (json) { + JsonArray(val) => Some(val), + _ => None, + } + }, + set: (newValue, _) => Some(JsonArray(newValue)), + } + + /** + * A lens whose focus is the property pair list of a JSON object. + * + * @example assert get(objectProperties, JsonObject([("a", JsonNumber(123))])) == Some([("a", JsonNumber(123))]) + * + * @since v0.7.0 + */ + provide let objectProperties = { + get: json => { + match (json) { + JsonObject(val) => Some(val), + _ => None, + } + }, + set: (newValue, _) => Some(JsonObject(newValue)), + } + + let rec replaceFirst = (acc, list, propertyName, newValue) => { + match (list) { + [first, ...rest] => { + let (firstKey, firstVal) = first + if (firstKey == propertyName) { + List.append(List.reverse([(firstKey, newValue), ...acc]), rest) + } else { + replaceFirst([first, ...acc], rest, propertyName, newValue) + } + }, + [] => List.reverse([(propertyName, newValue), ...acc]), + } + } + + /** + * Creates a lens whose focus is a given property of a JSON object. + * + * @param propertyName: The property name of the JSON object to focus on + * @returns A lens whose focus is the given property of a JSON object + * + * @example assert get(property("x"), JsonObject([("x", JsonNumber(123))])) == Some(JsonNumber(123)) + * @example + * assert set(property("x"), JsonString("new"), JsonObject([("x", JsonNumber(123))])) == + * Some(JsonObject([("x", JsonString("new"))])) + * + * @since v0.7.0 + */ + provide let property = propertyName => + { get: json => match (json) { + JsonObject(props) => { + match (List.find(((k, _)) => k == propertyName, props)) { + Some((_, v)) => Some(v), + None => None, + } + }, + _ => None, + }, set: (newValue, json) => { + match (json) { + JsonObject(props) => + Some(JsonObject(replaceFirst([], props, propertyName, newValue))), + _ => None, + } + } } + + /** + * Wraps a lens to permit nullable values in addition to the original value + * type of the given lens. During a `get` operation if the lens matches then + * the result will be enclosed in `Some`; if the lens does not match but the + * value focused is null, then the lens will still successfully match and + * `None` will be returned. + * + * @example assert get(nullable(number), JsonNumber(123)) == Some(Some(123)) + * @example assert get(nullable(number), JsonNull) == Some(None) + * @example assert get(nullable(number), JsonString("abc")) == None + * @example assert set(nullable(number), Some(123), JsonString("abc")) == Some(JsonNumber(123)) + * + * @since v0.7.0 + */ + provide let nullable = lens => + { get: json => { + match (get(lens, json)) { + Some(x) => Some(Some(x)), + None => { + match (json) { + JsonNull => Some(None), + _ => { + match (get(lens, json)) { + Some(x) => Some(Some(x)), + None => None, + } + }, + } + }, + } + }, set: (newValue, json) => { + match (newValue) { + Some(x) => lens.set(x, json), + None => Some(JsonNull), + } + } } + + /** + * Reverse lens composition. + * + * @param lens1: The lens which will be applied first + * @param lens2: The lens which will be applied second + * @returns A lens which combines the two given lenses, passing through the first and then the second + * + * @example assert get(property("x") ||> number, JsonObject([("x", JsonNumber(123))])) == Some(123) + * @example + * assert set(property("x") ||> string, "new", JsonObject([("x", JsonNumber(123))])) == + * Some(JsonObject([("x", JsonString("new"))])) + * + * @since v0.7.0 + */ + let (||>) = (lens1, lens2) => + { get: json => { + match (lens1.get(json)) { + Some(x) => lens2.get(x), + None => None, + } + }, set: (newValue, target) => { + match (lens1.get(target)) { + Some(x) => match (lens2.set(newValue, x)) { + Some(y) => lens1.set(y, target), + None => None, + }, + None => None, + } + } } + + /** + * Creates a lens whose focus is a given property path within a JSON object tree. + * + * @param propertyNames: The property path of the JSON object to create a focus on + * @returns A lens whose focus is the given property path of a JSON object + * + * @example + * let nestedObj = JsonObject([("a", JsonObject([("b", JsonNumber(123))]))]) + * assert get(propertyPath(["a", "b"]), nestedObj) == Some(JsonNumber(123)) + * + * @since v0.7.0 + */ + provide let propertyPath = propertyNames => { + List.reduce( + (lensAcc, propertyName) => lensAcc ||> property(propertyName), + json, + propertyNames + ) + } + + provide { (||>) } +} diff --git a/stdlib/json.md b/stdlib/json.md index 982704318..11968878c 100644 --- a/stdlib/json.md +++ b/stdlib/json.md @@ -644,3 +644,419 @@ assert parse("{\"currency\":\"$\",\"price\":119}") == Ok( ) ``` +## Json.Lenses + +Utilities for accessing and updating JSON data. + +
+Added in next +No other changes yet. +
+ +```grain +let obj = JsonObject([("x", JsonNumber(123))]) +assert get(property("x") ||> number, obj) == Some(123) +``` + +```grain +let obj = JsonObject([("x", JsonNumber(123))]) +assert set(property("x") ||> number, 321, obj) == + Some(JsonObject([("x", JsonNumber(321))])) +``` + +### Types + +Type declarations included in the Json.Lenses module. + +#### Json.Lenses.**Lens** + +
+Added in next +No other changes yet. +
+ +```grain +record Lens { + get: (subject: a) => Option, + set: (newValue: b, subject: a) => Option
, +} +``` + +A structure which provides functionality for accessing and setting JSON +data. + +Fields: + +|name|type|description| +|----|----|-----------| +|`get`|`(subject: a0) => Option`|A function which reads a value from the subject.| +|`set`|`(newValue: b0, subject: a0) => Option`|A function which immutably updates a value in the subject.| + +### Values + +Functions and constants included in the Json.Lenses module. + +#### Json.Lenses.**get** + +```grain +get : (lens: Lens, subject: a) => Option +``` + +Reads the value focused on by the given lens from the input data. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`lens`|`Lens`|The lens to apply to the subject data| +|`subject`|`a`|The data which will have the lens applied to it| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(data)` containing the data read by the lens if the lens matches the given data, or `None` if the data cannot be matched to the lens| + +Examples: + +```grain +assert get(number, JsonNumber(123)) == Some(123) +``` + +```grain +assert get(string, JsonString("abc")) == Some("abc") +``` + +```grain +assert get(number, JsonString("abc")) == None +``` + +#### Json.Lenses.**set** + +```grain +set : (lens: Lens, newValue: b, subject: a) => Option +``` + +Sets the value focused on by the given lens from the input data to the +desired new value. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`lens`|`Lens`|The lens to apply to the subject data| +|`newValue`|`b`|The new value to set at the focus of the lens| +|`subject`|`a`|The data which will have the lens applied to it| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(data)` containing the new data after the lens substitution if the lens matches the given data, or `None` if the data cannot be matched to the lens| + +Examples: + +```grain +assert set(number, 123, JsonBoolean(true)) == Some(JsonNumber(123)) +``` + +```grain +assert set(property("a"), JsonNumber(123), JsonObject([("a", JsonNull)])) == Some(JsonObject([("a", JsonNumber(123))])) +``` + +```grain +assert set(property("a"), JsonNumber(123), JsonBoolean(true)) == None +``` + +#### Json.Lenses.**map** + +```grain +map : (lens: Lens, fn: (b => b), subject: a) => Option +``` + +Updates the value focused on by the given lens from the input data by +applying a function to it and setting the focus to the result of the function + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`lens`|`Lens`|The lens to apply to the subject data| +|`fn`|`b => b`|The function to apply to the matched data at the lens if matched| +|`subject`|`a`|The data which will have the lens applied to it| + +Returns: + +|type|description| +|----|-----------| +|`Option`|`Some(data)` containing the new data after the lens mapping has been applied if the lens matches the given data, or `None` if the data cannot be matched to the lens| + +Examples: + +```grain +assert map(number, x => x * 2, JsonNumber(5)) == Some(JsonNumber(10)) +``` + +```grain +assert map(property("x"), x => JsonArray([x, x]), JsonObject([("x", JsonNumber(1))])) == + Some(JsonObject([("x", JsonArray([JsonNumber(1), JsonNumber(1)]))])) +``` + +```grain +assert map(number, x => x * 2, JsonString("abc")) == None +``` + +#### Json.Lenses.**json** + +
+Added in next +No other changes yet. +
+ +```grain +json : Lens +``` + +A lens whose focus is a JSON value. + +Examples: + +```grain +assert get(json, JsonString("abc")) == Some(JsonString("abc")) +``` + +#### Json.Lenses.**boolean** + +
+Added in next +No other changes yet. +
+ +```grain +boolean : Lens +``` + +A lens whose focus is a JSON boolean value. + +Examples: + +```grain +assert get(boolean, JsonBoolean(true)) == Some(true) +``` + +#### Json.Lenses.**string** + +
+Added in next +No other changes yet. +
+ +```grain +string : Lens +``` + +A lens whose focus is a JSON string value. + +Examples: + +```grain +assert get(string, JsonString("abc")) == Some("abc") +``` + +#### Json.Lenses.**number** + +
+Added in next +No other changes yet. +
+ +```grain +number : Lens +``` + +A lens whose focus is a JSON number value. + +Examples: + +```grain +assert get(number, JsonNumber(123)) == Some(123) +``` + +#### Json.Lenses.**array** + +
+Added in next +No other changes yet. +
+ +```grain +array : Lens> +``` + +A lens whose focus is a JSON array. + +Examples: + +```grain +assert get(array, JsonArray([JsonNumber(123)])) == Some([JsonNumber(123)]) +``` + +#### Json.Lenses.**objectProperties** + +
+Added in next +No other changes yet. +
+ +```grain +objectProperties : Lens> +``` + +A lens whose focus is the property pair list of a JSON object. + +Examples: + +```grain +assert get(objectProperties, JsonObject([("a", JsonNumber(123))])) == Some([("a", JsonNumber(123))]) +``` + +#### Json.Lenses.**property** + +
+Added in next +No other changes yet. +
+ +```grain +property : (propertyName: String) => Lens +``` + +Creates a lens whose focus is a given property of a JSON object. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`propertyName`|`String`|The property name of the JSON object to focus on| + +Returns: + +|type|description| +|----|-----------| +|`Lens`|A lens whose focus is the given property of a JSON object| + +Examples: + +```grain +assert get(property("x"), JsonObject([("x", JsonNumber(123))])) == Some(JsonNumber(123)) +``` + +```grain +assert set(property("x"), JsonString("new"), JsonObject([("x", JsonNumber(123))])) == + Some(JsonObject([("x", JsonString("new"))])) +``` + +#### Json.Lenses.**nullable** + +
+Added in next +No other changes yet. +
+ +```grain +nullable : (lens: Lens) => Lens> +``` + +Wraps a lens to permit nullable values in addition to the original value +type of the given lens. During a `get` operation if the lens matches then +the result will be enclosed in `Some`; if the lens does not match but the +value focused is null, then the lens will still successfully match and +`None` will be returned. + +Examples: + +```grain +assert get(nullable(number), JsonNumber(123)) == Some(Some(123)) +``` + +```grain +assert get(nullable(number), JsonNull) == Some(None) +``` + +```grain +assert get(nullable(number), JsonString("abc")) == None +``` + +```grain +assert set(nullable(number), Some(123), JsonString("abc")) == Some(JsonNumber(123)) +``` + +#### Json.Lenses.**propertyPath** + +
+Added in next +No other changes yet. +
+ +```grain +propertyPath : (propertyNames: List) => Lens +``` + +Creates a lens whose focus is a given property path within a JSON object tree. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`propertyNames`|`List`|The property path of the JSON object to create a focus on| + +Returns: + +|type|description| +|----|-----------| +|`Lens`|A lens whose focus is the given property path of a JSON object| + +Examples: + +```grain +let nestedObj = JsonObject([("a", JsonObject([("b", JsonNumber(123))]))]) +assert get(propertyPath(["a", "b"]), nestedObj) == Some(JsonNumber(123)) +``` + +#### Json.Lenses.**(||>)** + +
+Added in next +No other changes yet. +
+ +```grain +(||>) : (lens1: Lens, lens2: Lens) => Lens +``` + +Reverse lens composition. + +Parameters: + +|param|type|description| +|-----|----|-----------| +|`lens1`|`Lens`|The lens which will be applied first| +|`lens2`|`Lens`|The lens which will be applied second| + +Returns: + +|type|description| +|----|-----------| +|`Lens`|A lens which combines the two given lenses, passing through the first and then the second| + +Examples: + +```grain +assert get(property("x") ||> number, JsonObject([("x", JsonNumber(123))])) == Some(123) +``` + +```grain +assert set(property("x") ||> string, "new", JsonObject([("x", JsonNumber(123))])) == + Some(JsonObject([("x", JsonString("new"))])) +``` +