From c7c2e83cf82f195dba970aa84a9e3668382e0a5c Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Wed, 8 Jan 2025 21:44:27 +1300 Subject: [PATCH] [json-core] Add JsonExtract helper to ease extracting values and number types from raw Map Helps mostly when extracting values out of nested documents, and dealing with the various number types and type conversion --- .../java/io/avaje/json/simple/DExtract.java | 82 +++++++++++++ .../io/avaje/json/simple/JsonExtract.java | 96 +++++++++++++++ .../io/avaje/json/simple/SimpleMapper.java | 4 + .../avaje/json/simple/SimpleMapperTest.java | 116 ++++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 json-core/src/main/java/io/avaje/json/simple/DExtract.java create mode 100644 json-core/src/main/java/io/avaje/json/simple/JsonExtract.java diff --git a/json-core/src/main/java/io/avaje/json/simple/DExtract.java b/json-core/src/main/java/io/avaje/json/simple/DExtract.java new file mode 100644 index 00000000..af96a9b0 --- /dev/null +++ b/json-core/src/main/java/io/avaje/json/simple/DExtract.java @@ -0,0 +1,82 @@ +package io.avaje.json.simple; + +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; + +final class DExtract implements JsonExtract { + + private static final Pattern PATH_PATTERN = Pattern.compile("\\."); + + private final Map map; + + DExtract(Map map) { + this.map = map; + } + + @SuppressWarnings("unchecked") + private Object find(String path, Map map) { + final String[] paths = PATH_PATTERN.split(path, 2); + final Object child = map.get(paths[0]); + if (child == null || paths.length == 1) { + return child; + } + if (child instanceof Map) { + return find(paths[1], (Map) child); + } + return null; + } + + @Override + public String extract(String path) { + final var node = find(path, map); + if (node == null) { + throw new IllegalArgumentException("Node not present for " + path); + } + return node.toString(); + } + + @Override + public Optional extractOrEmpty(String path) { + final var name = find(path, map); + return name == null ? Optional.empty() : Optional.of(name.toString()); + } + + @Override + public String extract(String path, String missingValue) { + final var name = find(path, map); + return name == null ? missingValue : name.toString(); + } + + @Override + public int extract(String path, int missingValue) { + final var node = find(path, map); + return !(node instanceof Number) + ? missingValue + : ((Number) node).intValue(); + } + + @Override + public long extract(String path, long missingValue) { + final var node = find(path, map); + return !(node instanceof Number) + ? missingValue + : ((Number) node).longValue(); + } + + @Override + public double extract(String path, double missingValue) { + final var node = find(path, map); + return !(node instanceof Number) + ? missingValue + : ((Number) node).doubleValue(); + } + + @Override + public boolean extract(String path, boolean missingValue) { + final var node = find(path, map); + return !(node instanceof Boolean) + ? missingValue + : (Boolean) node; + } +} diff --git a/json-core/src/main/java/io/avaje/json/simple/JsonExtract.java b/json-core/src/main/java/io/avaje/json/simple/JsonExtract.java new file mode 100644 index 00000000..d55126a2 --- /dev/null +++ b/json-core/src/main/java/io/avaje/json/simple/JsonExtract.java @@ -0,0 +1,96 @@ +package io.avaje.json.simple; + +import java.util.Map; +import java.util.Optional; + +/** + * A helper to extract values from a Map. + *

+ * The path can be simple like {@code "name"} or a nested path using + * dot notation like {@code "address.city"}. + *

+ * For extracting numbers there are methods for int, long and double that will + * return the intValue(), longValue() and doubleValue() respectively. + *

+ *

{@code
+ *
+ *   String json = "{\"name\":\"Rob\",\"score\":4.5,\"whenActive\":\"2025-10-20\",\"address\":{\"street\":\"Pall Mall\"}}";
+ *   Map mapFromJson = simpleMapper.fromJsonObject(json);
+ *
+ *   JsonExtract jsonExtract = simpleMapper.extract(mapFromJson);
+ *
+ *   String name = jsonExtract.extract("name");
+ *   double score = jsonExtract.extract("score", -1D);
+ *   String street = jsonExtract.extract("address.street");
+ *
+ *   LocalDate activeDate = jsonExtract.extractOrEmpty("whenActive")
+ *     .map(LocalDate::parse)
+ *     .orElseThrow();
+ *
+ * }
+ * + */ +public interface JsonExtract { + + /** + * Return a JsonExtract for the given Map of values. + */ + static JsonExtract of(Map map) { + return new DExtract(map); + } + + /** + * Extract the text from the node at the given path. + * + * @throws IllegalArgumentException When the given path is missing. + */ + String extract(String path); + + /** + * Extract the text value from the given path if present else empty. + * + *
{@code
+   *
+   *   LocalDate activeDate = jsonExtract.extractOrEmpty("whenActive")
+   *     .map(LocalDate::parse)
+   *     .orElseThrow();
+   *
+   * }
+ */ + Optional extractOrEmpty(String path); + + /** + * Extract the text value from the given path if present or the given default value. + * + * @param missingValue The value to use when the path is missing. + */ + String extract(String path, String missingValue); + + /** + * Extract the int from the given path if present or the given default value. + * + * @param missingValue The value to use when the path is missing. + */ + int extract(String path, int missingValue); + + /** + * Extract the long from the given path if present or the given default value. + * + * @param missingValue The value to use when the path is missing. + */ + long extract(String path, long missingValue); + + /** + * Extract the double from the given path if present or the given default value. + * + * @param missingValue The value to use when the path is missing. + */ + double extract(String path, double missingValue); + + /** + * Extract the boolean from the given path if present or the given default value. + * + * @param missingValue The value to use when the path is missing. + */ + boolean extract(String path, boolean missingValue); +} diff --git a/json-core/src/main/java/io/avaje/json/simple/SimpleMapper.java b/json-core/src/main/java/io/avaje/json/simple/SimpleMapper.java index ed0246cb..d3a3629f 100644 --- a/json-core/src/main/java/io/avaje/json/simple/SimpleMapper.java +++ b/json-core/src/main/java/io/avaje/json/simple/SimpleMapper.java @@ -136,6 +136,10 @@ static Builder builder() { */ Type type(JsonAdapter customAdapter); + default JsonExtract extract(Map map) { + return new DExtract(map); + } + /** * Build the JsonNodeMapper. */ diff --git a/json-core/src/test/java/io/avaje/json/simple/SimpleMapperTest.java b/json-core/src/test/java/io/avaje/json/simple/SimpleMapperTest.java index cb2ba8eb..606dc543 100644 --- a/json-core/src/test/java/io/avaje/json/simple/SimpleMapperTest.java +++ b/json-core/src/test/java/io/avaje/json/simple/SimpleMapperTest.java @@ -6,11 +6,13 @@ import io.avaje.json.stream.JsonStream; import org.junit.jupiter.api.Test; +import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class SimpleMapperTest { @@ -132,4 +134,118 @@ void arrayToJsonFromJson() { List list2 = simpleMapper.list().fromJson(asJson); assertThat(list2).isEqualTo(listFromJson); } + + @Test + void extract_example() { + String json = "{\"name\":\"Rob\",\"score\":4.5,\"whenActive\":\"2025-10-20\",\"address\":{\"street\":\"Pall Mall\"}}"; + Map mapFromJson = simpleMapper.fromJsonObject(json); + + JsonExtract extract = simpleMapper.extract(mapFromJson); + + String name = extract.extract("name"); + double score = extract.extract("score", -1D); + String street = extract.extract("address.street"); + LocalDate activeDate = extract.extractOrEmpty("whenActive") + .map(LocalDate::parse) + .orElseThrow(); + + assertThat(name).isEqualTo("Rob"); + assertThat(score).isEqualTo(4.5D); + assertThat(street).isEqualTo("Pall Mall"); + assertThat(activeDate).isEqualTo(LocalDate.parse("2025-10-20")); + } + + @Test + void extract() { + String json = "{\"one\":1,\"two\":4.5,\"three\":3,\"four\":\"2025-10-20\",\"five\":true}"; + Map mapFromJson = simpleMapper.fromJsonObject(json); + + JsonExtract extract = simpleMapper.extract(mapFromJson); + assertThat(extract.extract("one", 0)).isEqualTo(1); + assertThat(extract.extract("two", 0D)).isEqualTo(4.5D); + assertThat(extract.extract("three", 0L)).isEqualTo(3L); + assertThat(extract.extract("four")).isEqualTo("2025-10-20"); + assertThat(extract.extract("four", "NA")).isEqualTo("2025-10-20"); + assertThat(extract.extract("five", false)).isTrue(); + + LocalDate fourAsLocalDate = extract.extractOrEmpty("four") + .map(LocalDate::parse) + .orElseThrow(); + + assertThat(fourAsLocalDate) + .isEqualTo(LocalDate.parse("2025-10-20")); + + } + + @Test + void JsonExtractOf() { + String json = "{\"one\":1}"; + Map mapFromJson = simpleMapper.fromJsonObject(json); + + JsonExtract extract = JsonExtract.of(mapFromJson); + assertThat(extract.extract("one", 0)).isEqualTo(1); + } + + @Test + void extract_whenMissing() { + String json = "{}"; + Map mapFromJson = simpleMapper.fromJsonObject(json); + + JsonExtract extract = simpleMapper.extract(mapFromJson); + assertThat(extract.extract("one", 0)).isEqualTo(0); + assertThat(extract.extract("two", 0D)).isEqualTo(0D); + assertThat(extract.extract("three", 0L)).isEqualTo(0L); + assertThat(extract.extract("four", "NA")).isEqualTo("NA"); + assertThat(extract.extract("five", false)).isFalse(); + + assertThatThrownBy(() -> extract.extract("four")) + .isInstanceOf(IllegalArgumentException.class); + + LocalDate fourAsLocalDate = extract.extractOrEmpty("four") + .map(LocalDate::parse) + .orElse(LocalDate.of(1970, 1, 21)); + + assertThat(fourAsLocalDate).isEqualTo(LocalDate.parse("1970-01-21")); + } + + @Test + void extractNumber_whenNotANumber_expect_missingValue() { + String json = "{\"text\":\"foo\",\"bool\":true,\"isNull\":null}"; + Map mapFromJson = simpleMapper.fromJsonObject(json); + + JsonExtract extract = simpleMapper.extract(mapFromJson); + assertThat(extract.extract("text", 7)).isEqualTo(7); + assertThat(extract.extract("text", 7L)).isEqualTo(7L); + assertThat(extract.extract("text", 7.4D)).isEqualTo(7.4D); + assertThat(extract.extract("bool", 7)).isEqualTo(7); + assertThat(extract.extract("bool", 7L)).isEqualTo(7L); + assertThat(extract.extract("bool", 7.4D)).isEqualTo(7.4D); + assertThat(extract.extract("isNull", 7)).isEqualTo(7); + assertThat(extract.extract("isNull", 7L)).isEqualTo(7L); + assertThat(extract.extract("isNull", 7.4D)).isEqualTo(7.4D); + } + + @Test + void extract_nestedPath() { + String json = "{\"outer\":{\"a\":\"v0\", \"b\":1, \"c\":true,\"d\":{\"x\":\"x0\",\"y\":42,\"date\":\"2025-10-20\"}}}"; + Map mapFromJson = simpleMapper.fromJsonObject(json); + + JsonExtract extract = simpleMapper.extract(mapFromJson); + assertThat(extract.extract("outer.b", 0)).isEqualTo(1); + assertThat(extract.extract("outer.d.y", 0)).isEqualTo(42); + assertThat(extract.extract("outer.d.y", "junk")).isEqualTo("42"); + assertThat(extract.extract("outer.a", "NA")).isEqualTo("v0"); + + assertThat(extract.extract("outer.d.y", 0L)).isEqualTo(42L); + assertThat(extract.extract("outer.d.y", 0D)).isEqualTo(42D); + assertThat(extract.extract("outer.c", false)).isTrue(); + + assertThat(extract.extract("outer.c")).isEqualTo("true"); + + LocalDate fourAsLocalDate = extract.extractOrEmpty("outer.d.date") + .map(LocalDate::parse) + .orElse(LocalDate.of(1970, 1, 21)); + + assertThat(fourAsLocalDate).isEqualTo(LocalDate.parse("2025-10-20")); + } }