Skip to content

Commit

Permalink
[json-core] Add JsonExtract helper to ease extracting values and numb…
Browse files Browse the repository at this point in the history
…er types from raw Map<String,Object>

Helps mostly when extracting values out of nested documents, and dealing with the various number types and type conversion
  • Loading branch information
rbygrave committed Jan 8, 2025
1 parent 1c41b4d commit c7c2e83
Show file tree
Hide file tree
Showing 4 changed files with 298 additions and 0 deletions.
82 changes: 82 additions & 0 deletions json-core/src/main/java/io/avaje/json/simple/DExtract.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> map;

DExtract(Map<String, Object> map) {
this.map = map;
}

@SuppressWarnings("unchecked")
private Object find(String path, Map<String, Object> 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<String, Object>) 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<String> 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;
}
}
96 changes: 96 additions & 0 deletions json-core/src/main/java/io/avaje/json/simple/JsonExtract.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* The <em>path</em> can be simple like {@code "name"} or a nested path using
* dot notation like {@code "address.city"}.
* <p>
* For extracting numbers there are methods for int, long and double that will
* return the intValue(), longValue() and doubleValue() respectively.
* <p>
* <pre>{@code
*
* String json = "{\"name\":\"Rob\",\"score\":4.5,\"whenActive\":\"2025-10-20\",\"address\":{\"street\":\"Pall Mall\"}}";
* Map<String, Object> 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();
*
* }</pre>
*
*/
public interface JsonExtract {

/**
* Return a JsonExtract for the given Map of values.
*/
static JsonExtract of(Map<String, Object> 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.
*
* <pre>{@code
*
* LocalDate activeDate = jsonExtract.extractOrEmpty("whenActive")
* .map(LocalDate::parse)
* .orElseThrow();
*
* }</pre>
*/
Optional<String> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ static Builder builder() {
*/
<T> Type<T> type(JsonAdapter<T> customAdapter);

default JsonExtract extract(Map<String, Object> map) {
return new DExtract(map);
}

/**
* Build the JsonNodeMapper.
*/
Expand Down
116 changes: 116 additions & 0 deletions json-core/src/test/java/io/avaje/json/simple/SimpleMapperTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -132,4 +134,118 @@ void arrayToJsonFromJson() {
List<Object> 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<String, Object> 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<String, Object> 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<String, Object> mapFromJson = simpleMapper.fromJsonObject(json);

JsonExtract extract = JsonExtract.of(mapFromJson);
assertThat(extract.extract("one", 0)).isEqualTo(1);
}

@Test
void extract_whenMissing() {
String json = "{}";
Map<String, Object> 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<String, Object> 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<String, Object> 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"));
}
}

0 comments on commit c7c2e83

Please sign in to comment.