Skip to content

Commit 287d442

Browse files
authored
[json-core] Add JsonExtract helper to ease extracting values and number types from raw Map<String,Object> (#325)
Helps mostly when extracting values out of nested documents, and dealing with the various number types and type conversion
1 parent 4d92dc7 commit 287d442

File tree

4 files changed

+298
-0
lines changed

4 files changed

+298
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package io.avaje.json.simple;
2+
3+
import java.util.Map;
4+
import java.util.Optional;
5+
import java.util.regex.Pattern;
6+
7+
final class DExtract implements JsonExtract {
8+
9+
private static final Pattern PATH_PATTERN = Pattern.compile("\\.");
10+
11+
private final Map<String, Object> map;
12+
13+
DExtract(Map<String, Object> map) {
14+
this.map = map;
15+
}
16+
17+
@SuppressWarnings("unchecked")
18+
private Object find(String path, Map<String, Object> map) {
19+
final String[] paths = PATH_PATTERN.split(path, 2);
20+
final Object child = map.get(paths[0]);
21+
if (child == null || paths.length == 1) {
22+
return child;
23+
}
24+
if (child instanceof Map) {
25+
return find(paths[1], (Map<String, Object>) child);
26+
}
27+
return null;
28+
}
29+
30+
@Override
31+
public String extract(String path) {
32+
final var node = find(path, map);
33+
if (node == null) {
34+
throw new IllegalArgumentException("Node not present for " + path);
35+
}
36+
return node.toString();
37+
}
38+
39+
@Override
40+
public Optional<String> extractOrEmpty(String path) {
41+
final var name = find(path, map);
42+
return name == null ? Optional.empty() : Optional.of(name.toString());
43+
}
44+
45+
@Override
46+
public String extract(String path, String missingValue) {
47+
final var name = find(path, map);
48+
return name == null ? missingValue : name.toString();
49+
}
50+
51+
@Override
52+
public int extract(String path, int missingValue) {
53+
final var node = find(path, map);
54+
return !(node instanceof Number)
55+
? missingValue
56+
: ((Number) node).intValue();
57+
}
58+
59+
@Override
60+
public long extract(String path, long missingValue) {
61+
final var node = find(path, map);
62+
return !(node instanceof Number)
63+
? missingValue
64+
: ((Number) node).longValue();
65+
}
66+
67+
@Override
68+
public double extract(String path, double missingValue) {
69+
final var node = find(path, map);
70+
return !(node instanceof Number)
71+
? missingValue
72+
: ((Number) node).doubleValue();
73+
}
74+
75+
@Override
76+
public boolean extract(String path, boolean missingValue) {
77+
final var node = find(path, map);
78+
return !(node instanceof Boolean)
79+
? missingValue
80+
: (Boolean) node;
81+
}
82+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package io.avaje.json.simple;
2+
3+
import java.util.Map;
4+
import java.util.Optional;
5+
6+
/**
7+
* A helper to extract values from a Map.
8+
* <p>
9+
* The <em>path</em> can be simple like {@code "name"} or a nested path using
10+
* dot notation like {@code "address.city"}.
11+
* <p>
12+
* For extracting numbers there are methods for int, long and double that will
13+
* return the intValue(), longValue() and doubleValue() respectively.
14+
* <p>
15+
* <pre>{@code
16+
*
17+
* String json = "{\"name\":\"Rob\",\"score\":4.5,\"whenActive\":\"2025-10-20\",\"address\":{\"street\":\"Pall Mall\"}}";
18+
* Map<String, Object> mapFromJson = simpleMapper.fromJsonObject(json);
19+
*
20+
* JsonExtract jsonExtract = simpleMapper.extract(mapFromJson);
21+
*
22+
* String name = jsonExtract.extract("name");
23+
* double score = jsonExtract.extract("score", -1D);
24+
* String street = jsonExtract.extract("address.street");
25+
*
26+
* LocalDate activeDate = jsonExtract.extractOrEmpty("whenActive")
27+
* .map(LocalDate::parse)
28+
* .orElseThrow();
29+
*
30+
* }</pre>
31+
*
32+
*/
33+
public interface JsonExtract {
34+
35+
/**
36+
* Return a JsonExtract for the given Map of values.
37+
*/
38+
static JsonExtract of(Map<String, Object> map) {
39+
return new DExtract(map);
40+
}
41+
42+
/**
43+
* Extract the text from the node at the given path.
44+
*
45+
* @throws IllegalArgumentException When the given path is missing.
46+
*/
47+
String extract(String path);
48+
49+
/**
50+
* Extract the text value from the given path if present else empty.
51+
*
52+
* <pre>{@code
53+
*
54+
* LocalDate activeDate = jsonExtract.extractOrEmpty("whenActive")
55+
* .map(LocalDate::parse)
56+
* .orElseThrow();
57+
*
58+
* }</pre>
59+
*/
60+
Optional<String> extractOrEmpty(String path);
61+
62+
/**
63+
* Extract the text value from the given path if present or the given default value.
64+
*
65+
* @param missingValue The value to use when the path is missing.
66+
*/
67+
String extract(String path, String missingValue);
68+
69+
/**
70+
* Extract the int from the given path if present or the given default value.
71+
*
72+
* @param missingValue The value to use when the path is missing.
73+
*/
74+
int extract(String path, int missingValue);
75+
76+
/**
77+
* Extract the long from the given path if present or the given default value.
78+
*
79+
* @param missingValue The value to use when the path is missing.
80+
*/
81+
long extract(String path, long missingValue);
82+
83+
/**
84+
* Extract the double from the given path if present or the given default value.
85+
*
86+
* @param missingValue The value to use when the path is missing.
87+
*/
88+
double extract(String path, double missingValue);
89+
90+
/**
91+
* Extract the boolean from the given path if present or the given default value.
92+
*
93+
* @param missingValue The value to use when the path is missing.
94+
*/
95+
boolean extract(String path, boolean missingValue);
96+
}

json-core/src/main/java/io/avaje/json/simple/SimpleMapper.java

+4
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ static Builder builder() {
136136
*/
137137
<T> Type<T> type(JsonAdapter<T> customAdapter);
138138

139+
default JsonExtract extract(Map<String, Object> map) {
140+
return new DExtract(map);
141+
}
142+
139143
/**
140144
* Build the JsonNodeMapper.
141145
*/

json-core/src/test/java/io/avaje/json/simple/SimpleMapperTest.java

+116
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import io.avaje.json.stream.JsonStream;
77
import org.junit.jupiter.api.Test;
88

9+
import java.time.LocalDate;
910
import java.util.LinkedHashMap;
1011
import java.util.List;
1112
import java.util.Map;
1213

1314
import static org.assertj.core.api.Assertions.assertThat;
15+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
1416

1517
class SimpleMapperTest {
1618

@@ -132,4 +134,118 @@ void arrayToJsonFromJson() {
132134
List<Object> list2 = simpleMapper.list().fromJson(asJson);
133135
assertThat(list2).isEqualTo(listFromJson);
134136
}
137+
138+
@Test
139+
void extract_example() {
140+
String json = "{\"name\":\"Rob\",\"score\":4.5,\"whenActive\":\"2025-10-20\",\"address\":{\"street\":\"Pall Mall\"}}";
141+
Map<String, Object> mapFromJson = simpleMapper.fromJsonObject(json);
142+
143+
JsonExtract extract = simpleMapper.extract(mapFromJson);
144+
145+
String name = extract.extract("name");
146+
double score = extract.extract("score", -1D);
147+
String street = extract.extract("address.street");
148+
LocalDate activeDate = extract.extractOrEmpty("whenActive")
149+
.map(LocalDate::parse)
150+
.orElseThrow();
151+
152+
assertThat(name).isEqualTo("Rob");
153+
assertThat(score).isEqualTo(4.5D);
154+
assertThat(street).isEqualTo("Pall Mall");
155+
assertThat(activeDate).isEqualTo(LocalDate.parse("2025-10-20"));
156+
}
157+
158+
@Test
159+
void extract() {
160+
String json = "{\"one\":1,\"two\":4.5,\"three\":3,\"four\":\"2025-10-20\",\"five\":true}";
161+
Map<String, Object> mapFromJson = simpleMapper.fromJsonObject(json);
162+
163+
JsonExtract extract = simpleMapper.extract(mapFromJson);
164+
assertThat(extract.extract("one", 0)).isEqualTo(1);
165+
assertThat(extract.extract("two", 0D)).isEqualTo(4.5D);
166+
assertThat(extract.extract("three", 0L)).isEqualTo(3L);
167+
assertThat(extract.extract("four")).isEqualTo("2025-10-20");
168+
assertThat(extract.extract("four", "NA")).isEqualTo("2025-10-20");
169+
assertThat(extract.extract("five", false)).isTrue();
170+
171+
LocalDate fourAsLocalDate = extract.extractOrEmpty("four")
172+
.map(LocalDate::parse)
173+
.orElseThrow();
174+
175+
assertThat(fourAsLocalDate)
176+
.isEqualTo(LocalDate.parse("2025-10-20"));
177+
178+
}
179+
180+
@Test
181+
void JsonExtractOf() {
182+
String json = "{\"one\":1}";
183+
Map<String, Object> mapFromJson = simpleMapper.fromJsonObject(json);
184+
185+
JsonExtract extract = JsonExtract.of(mapFromJson);
186+
assertThat(extract.extract("one", 0)).isEqualTo(1);
187+
}
188+
189+
@Test
190+
void extract_whenMissing() {
191+
String json = "{}";
192+
Map<String, Object> mapFromJson = simpleMapper.fromJsonObject(json);
193+
194+
JsonExtract extract = simpleMapper.extract(mapFromJson);
195+
assertThat(extract.extract("one", 0)).isEqualTo(0);
196+
assertThat(extract.extract("two", 0D)).isEqualTo(0D);
197+
assertThat(extract.extract("three", 0L)).isEqualTo(0L);
198+
assertThat(extract.extract("four", "NA")).isEqualTo("NA");
199+
assertThat(extract.extract("five", false)).isFalse();
200+
201+
assertThatThrownBy(() -> extract.extract("four"))
202+
.isInstanceOf(IllegalArgumentException.class);
203+
204+
LocalDate fourAsLocalDate = extract.extractOrEmpty("four")
205+
.map(LocalDate::parse)
206+
.orElse(LocalDate.of(1970, 1, 21));
207+
208+
assertThat(fourAsLocalDate).isEqualTo(LocalDate.parse("1970-01-21"));
209+
}
210+
211+
@Test
212+
void extractNumber_whenNotANumber_expect_missingValue() {
213+
String json = "{\"text\":\"foo\",\"bool\":true,\"isNull\":null}";
214+
Map<String, Object> mapFromJson = simpleMapper.fromJsonObject(json);
215+
216+
JsonExtract extract = simpleMapper.extract(mapFromJson);
217+
assertThat(extract.extract("text", 7)).isEqualTo(7);
218+
assertThat(extract.extract("text", 7L)).isEqualTo(7L);
219+
assertThat(extract.extract("text", 7.4D)).isEqualTo(7.4D);
220+
assertThat(extract.extract("bool", 7)).isEqualTo(7);
221+
assertThat(extract.extract("bool", 7L)).isEqualTo(7L);
222+
assertThat(extract.extract("bool", 7.4D)).isEqualTo(7.4D);
223+
assertThat(extract.extract("isNull", 7)).isEqualTo(7);
224+
assertThat(extract.extract("isNull", 7L)).isEqualTo(7L);
225+
assertThat(extract.extract("isNull", 7.4D)).isEqualTo(7.4D);
226+
}
227+
228+
@Test
229+
void extract_nestedPath() {
230+
String json = "{\"outer\":{\"a\":\"v0\", \"b\":1, \"c\":true,\"d\":{\"x\":\"x0\",\"y\":42,\"date\":\"2025-10-20\"}}}";
231+
Map<String, Object> mapFromJson = simpleMapper.fromJsonObject(json);
232+
233+
JsonExtract extract = simpleMapper.extract(mapFromJson);
234+
assertThat(extract.extract("outer.b", 0)).isEqualTo(1);
235+
assertThat(extract.extract("outer.d.y", 0)).isEqualTo(42);
236+
assertThat(extract.extract("outer.d.y", "junk")).isEqualTo("42");
237+
assertThat(extract.extract("outer.a", "NA")).isEqualTo("v0");
238+
239+
assertThat(extract.extract("outer.d.y", 0L)).isEqualTo(42L);
240+
assertThat(extract.extract("outer.d.y", 0D)).isEqualTo(42D);
241+
assertThat(extract.extract("outer.c", false)).isTrue();
242+
243+
assertThat(extract.extract("outer.c")).isEqualTo("true");
244+
245+
LocalDate fourAsLocalDate = extract.extractOrEmpty("outer.d.date")
246+
.map(LocalDate::parse)
247+
.orElse(LocalDate.of(1970, 1, 21));
248+
249+
assertThat(fourAsLocalDate).isEqualTo(LocalDate.parse("2025-10-20"));
250+
}
135251
}

0 commit comments

Comments
 (0)